Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,20 @@ func flagValue(value string) (string, error) {
return value, nil
}

// logoutWarnings is where `nan auth logout` says the platform did not confirm
// the sign-out. A variable so the tests can read it.
var logoutWarnings io.Writer = os.Stderr

func runLogout(cmd *cobra.Command, args []string) error {
// End the session on the platform first, while session.json still holds
// the token: deleting the file alone leaves any copy of the token working
// until it expires. A platform that cannot be reached does not keep the
// member signed in here; the file still goes, and they are told.
if current, err := session.Load(); err == nil && current.Token != "" {
if err := api.New(current.Token).WithBaseURL(apiBaseURL).Logout(); err != nil {
fmt.Fprintf(logoutWarnings, "Could not end the session on the platform (%v); it stays valid until it expires. To end it now: cloud.nan.builders, Settings > Account > Sign out other sessions.\n", err)
}
}
if err := session.Delete(); err != nil {
return err
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const maxTokenLen = 512
// tests can point it at a server of their own.
var runsBaseURL = api.RunsBaseURL

// apiBaseURL is where the session endpoints (logout) are sent. A variable so
// the tests can point it at a server of their own.
var apiBaseURL = api.BaseURL

// tokenFileFlag is --token-file on `nan run` and `nan runs`.
var tokenFileFlag string

Expand Down
121 changes: 121 additions & 0 deletions cmd/logout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package cmd

import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)

// Spec (prod E2E 2026-10-12): `nan auth logout` ends the session on the
// platform BEFORE it deletes session.json, so the token stops working
// everywhere and not just on this machine. A platform that cannot be reached
// does not keep the member signed in locally: the file still goes, and the
// command says the session may outlive it.

func logoutServer(t *testing.T, status int) (*[]string, string) {
t.Helper()
seen := new([]string)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The file must still be on disk when the platform is asked.
if _, err := os.Stat(filepath.Join(os.Getenv("HOME"), ".config", "nan", "session.json")); err != nil {
t.Error("session.json was deleted before the platform was asked to end the session")
}
*seen = append(*seen, r.Method+" "+r.URL.Path+" "+r.Header.Get("Authorization"))
w.WriteHeader(status)
}))
t.Cleanup(srv.Close)
return seen, srv.URL
}

// captureLogoutWarnings collects what runLogout tells the member on stderr.
func captureLogoutWarnings(t *testing.T) *bytes.Buffer {
t.Helper()
buf := new(bytes.Buffer)
orig := logoutWarnings
logoutWarnings = buf
t.Cleanup(func() { logoutWarnings = orig })
return buf
}

func withAPIBase(t *testing.T, u string) {
t.Helper()
orig := apiBaseURL
apiBaseURL = u
t.Cleanup(func() { apiBaseURL = orig })
}

func sessionFileExists(home string) bool {
_, err := os.Stat(filepath.Join(home, ".config", "nan", "session.json"))
return err == nil
}

func TestLogoutEndsTheSessionOnThePlatformFirst(t *testing.T) {
home := fakeHome(t, `{"token":"session-jwt"}`)
seen, u := logoutServer(t, http.StatusOK)
withAPIBase(t, u)
warnings := captureLogoutWarnings(t)
keepToolsFlag = true
t.Cleanup(func() { keepToolsFlag = false })

if err := runLogout(logoutCmd, nil); err != nil {
t.Fatalf("runLogout: %v", err)
}
if warn := warnings.String(); warn != "" {
t.Errorf("a confirmed logout warns: %q", warn)
}
if len(*seen) != 1 || (*seen)[0] != "POST /auth/logout Bearer session-jwt" {
t.Errorf("platform saw %v, want one POST /auth/logout with the session bearer", *seen)
}
if sessionFileExists(home) {
t.Error("session.json survived the logout")
}
}

func TestLogoutStillSignsOutLocallyWhenThePlatformFails(t *testing.T) {
home := fakeHome(t, `{"token":"session-jwt"}`)
_, u := logoutServer(t, http.StatusBadGateway)
withAPIBase(t, u)
warnings := captureLogoutWarnings(t)
keepToolsFlag = true
t.Cleanup(func() { keepToolsFlag = false })

if err := runLogout(logoutCmd, nil); err != nil {
t.Fatalf("runLogout: %v", err)
}
warn := warnings.String()
for _, want := range []string{"Could not end the session on the platform", "HTTP 502", "stays valid until it expires", "Sign out other sessions"} {
if !strings.Contains(warn, want) {
t.Errorf("warning %q does not say %q", warn, want)
}
}
if strings.Contains(warn, "session-jwt") {
t.Error("the warning prints the session token")
}
if sessionFileExists(home) {
t.Error("a platform failure kept the member signed in locally")
}
}

// A session that only carries a platform token or an API key has no platform
// session to end: nothing is sent.
func TestLogoutWithoutASessionTokenSendsNothing(t *testing.T) {
home := fakeHome(t, `{"platformToken":"`+testPAT+`"}`)
seen, u := logoutServer(t, http.StatusOK)
withAPIBase(t, u)
keepToolsFlag = true
t.Cleanup(func() { keepToolsFlag = false })

if err := runLogout(logoutCmd, nil); err != nil {
t.Fatalf("runLogout: %v", err)
}
if len(*seen) != 0 {
t.Errorf("platform saw %v, want nothing", *seen)
}
if sessionFileExists(home) {
t.Error("session.json survived the logout")
}
}
7 changes: 7 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"
)

Expand Down Expand Up @@ -36,6 +37,12 @@ func New(token string) *Client {
return &Client{token: token, http: &http.Client{Timeout: requestTimeout}, baseURL: BaseURL}
}

// WithBaseURL points the client somewhere else, for tests.
func (c *Client) WithBaseURL(u string) *Client {
c.baseURL = strings.TrimRight(u, "/")
return c
}

// Token is what this client sends. The panel builds a client once, at start,
// and has to build another when a member signs in from inside it - a client
// still holding the token it started with sends an empty cookie and the
Expand Down
67 changes: 67 additions & 0 deletions internal/api/logout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package api

import (
"context"
"fmt"
"io"
"net/http"
"time"
)

// logoutTimeout bounds the sign-out call. Signing out must not hang on a
// platform that accepts the connection and never answers: the local sign-out
// goes ahead either way, this only decides how long it waits to say whether
// the platform got the message.
//
// A variable only so the tests can make a hanging platform time out quickly.
var logoutTimeout = 10 * time.Second

// Logout ends the session on the platform (POST /auth/logout), so the token
// stops working everywhere and not only on this machine. Deleting
// session.json alone leaves any copy of the token valid until it expires.
//
// The session rides as a bearer, which is how the platform recognises a CLI
// session on logout, and also as the nan_session cookie, which is how every
// other call of this client presents it: a platform that predates bearer
// logout still revokes it. Both name the same session; it is revoked once.
//
// A 401 means the platform already refuses the session, which is the outcome
// wanted, so it is not an error. This assumes cloud-api answers 401 here only
// for a session it rejects, which is true today: /auth/logout itself never
// 401s (it is public and answers 200), and the edge in front of it does not
// authenticate. Redirects are not followed: a 3xx did not revoke anything. Anything else that is not 2xx is: nothing was
// revoked, and the caller should say so.
func (c *Client) Logout() error {
if c.token == "" {
return nil
}
base := c.baseURL
if base == "" {
base = BaseURL
}
ctx, cancel := context.WithTimeout(context.Background(), logoutTimeout)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/auth/logout", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Cookie", "nan_session="+c.token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "nan-cli")

hc := *c.http
hc.CheckRedirect = noRedirects
resp, err := hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))

if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode/100 == 2 {
return nil
}
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
128 changes: 128 additions & 0 deletions internal/api/logout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package api

import (
"net/http"
"net/http/httptest"
"testing"
"time"
)

// Spec (prod E2E 2026-10-12): signing out of the CLI must end the session on
// the platform, not only forget it locally. A session token copied off the
// machine before `nan auth logout` otherwise keeps working until it expires.
//
// Logout POSTs /auth/logout carrying the session as a bearer (and as the
// nan_session cookie, which is how every other call of this client presents
// it, so an older platform that only reads the cookie still revokes it).

func TestLogoutPostsTheSessionToThePlatform(t *testing.T) {
var method, path, authz, cookie string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method, path, authz = r.Method, r.URL.Path, r.Header.Get("Authorization")
if c, err := r.Cookie("nan_session"); err == nil {
cookie = c.Value
}
_, _ = w.Write([]byte(`{"status":"logged out"}`))
}))
defer srv.Close()

c := New("session-jwt").WithBaseURL(srv.URL)
if err := c.Logout(); err != nil {
t.Fatalf("Logout: %v", err)
}
if method != http.MethodPost || path != "/auth/logout" {
t.Errorf("sent %s %s, want POST /auth/logout", method, path)
}
if authz != "Bearer session-jwt" {
t.Errorf("Authorization = %q, want the session as a bearer", authz)
}
if cookie != "session-jwt" {
t.Errorf("nan_session cookie = %q, want the session", cookie)
}
}

// A session the platform already refuses is already over: nothing to report.
func TestLogoutOfAnExpiredSessionIsNotAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()

if err := New("stale").WithBaseURL(srv.URL).Logout(); err != nil {
t.Errorf("an already-dead session reads as a failed logout: %v", err)
}
}

// A platform that is down did not revoke anything, and the caller has to be
// able to say so.
func TestLogoutReportsAPlatformThatDidNotAnswer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
if err := New("t").WithBaseURL(srv.URL).Logout(); err == nil {
t.Error("a 502 reads as a revoked session")
}
srv.Close()
if err := New("t").WithBaseURL(srv.URL).Logout(); err == nil {
t.Error("an unreachable platform reads as a revoked session")
}
}

// No session, no request: there is nothing on the platform to end.
func TestLogoutWithoutASessionSendsNothing(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ }))
defer srv.Close()

if err := New("").WithBaseURL(srv.URL).Logout(); err != nil {
t.Errorf("Logout with no session: %v", err)
}
if calls != 0 {
t.Errorf("sent %d requests with no session", calls)
}
}

// A platform that accepts the connection and never answers does not hold the
// sign-out hostage: Logout gives up and says it was not confirmed.
func TestLogoutGivesUpOnAHangingPlatform(t *testing.T) {
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
}
}))
defer srv.Close()
defer close(release)

orig := logoutTimeout
logoutTimeout = 50 * time.Millisecond
defer func() { logoutTimeout = orig }()

start := time.Now()
if err := New("t").WithBaseURL(srv.URL).Logout(); err == nil {
t.Error("a platform that never answered reads as a revoked session")
}
if took := time.Since(start); took > 5*time.Second {
t.Errorf("Logout waited %v on a hanging platform", took)
}
}

// A redirect is not a revocation, and the session is not sent on to wherever
// it points.
func TestLogoutDoesNotFollowRedirects(t *testing.T) {
followed := false
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { followed = true }))
defer target.Close()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/auth/logout", http.StatusTemporaryRedirect)
}))
defer srv.Close()

if err := New("t").WithBaseURL(srv.URL + "/").Logout(); err == nil {
t.Error("a 307 reads as a revoked session")
}
if followed {
t.Error("Logout followed the redirect")
}
}
Loading
Loading