From 58a4285504efda68bf70da82099dbc67bd09ac65 Mon Sep 17 00:00:00 2001 From: Nathan Broadbent Date: Sat, 10 Oct 2026 14:19:21 +1300 Subject: [PATCH] CLI login: send the browser to the styled result pages The loopback login ended on a bare HTML page served by the CLI. The CLI now holds the browser on its callback until it has redeemed the login code and saved the session, then redirects to the SPA's /app/cli/auth/success page (restored) or the new /app/cli/auth/error page, which maps known error codes to fixed messages. Co-Authored-By: Claude --- .../docs/security/authentication/index.mdx | 1 + .../security/authentication/oauth-flow.mdx | 5 + .../docs/user-guide/cli/authentication.mdx | 5 +- internal/cli/CLAUDE.md | 2 + internal/cli/cli_login.go | 13 +- internal/cli/login_loopback.go | 75 ++++++--- internal/cli/login_loopback_test.go | 156 +++++++++++++++--- web/e2e/cli-login-webui.spec.ts | 26 +-- web/e2e/cli-loopback.ts | 69 +++++--- web/src/app.tsx | 14 ++ web/src/pages/cli-auth-error-page.test.tsx | 42 +++++ web/src/pages/cli-auth-error-page.tsx | 91 ++++++++++ web/src/pages/cli-auth-success-page.tsx | 18 ++ 13 files changed, 425 insertions(+), 92 deletions(-) create mode 100644 web/src/pages/cli-auth-error-page.test.tsx create mode 100644 web/src/pages/cli-auth-error-page.tsx create mode 100644 web/src/pages/cli-auth-success-page.tsx diff --git a/docs/src/content/docs/security/authentication/index.mdx b/docs/src/content/docs/security/authentication/index.mdx index 82a73420..fd902c5b 100644 --- a/docs/src/content/docs/security/authentication/index.mdx +++ b/docs/src/content/docs/security/authentication/index.mdx @@ -85,6 +85,7 @@ The gateway supports two authentication channels: 5. After MFA, the browser is redirected to the CLI's loopback listener with a single-use login code 6. The CLI redeems the login code with its code verifier and receives a session token 7. Session token stored in `~/.config/rack-gateway/config.json` +8. The CLI sends the browser on to the gateway's result page (success, or the reason the login failed) See [OAuth Flow](/security/authentication/oauth-flow/) for details. diff --git a/docs/src/content/docs/security/authentication/oauth-flow.mdx b/docs/src/content/docs/security/authentication/oauth-flow.mdx index f5901990..666e63e0 100644 --- a/docs/src/content/docs/security/authentication/oauth-flow.mdx +++ b/docs/src/content/docs/security/authentication/oauth-flow.mdx @@ -116,6 +116,7 @@ sequenceDiagram CLI->>Gateway: POST /api/v1/auth/cli/complete (login_code + CLI code_verifier) Gateway-->>CLI: Session token + CLI-->>Browser: Redirect to /app/cli/auth/success (or /app/cli/auth/error?error=) ``` ### What protects the login @@ -130,6 +131,10 @@ sequenceDiagram - **Single-use login code.** The login code is 256 bits of randomness, stored only as a SHA-256 hash, valid for 2 minutes, issued once per login, and deleted when redeemed (a failed redemption also burns it). - **CLI PKCE.** Redeeming the login code also requires the CLI's code verifier, which never leaves the CLI. +- **Honest result page.** The CLI holds the browser on its callback until it has redeemed the login code and + saved the session, then redirects it (with `Referrer-Policy: no-referrer`, so the code isn't leaked as a + referrer) to the gateway's success or error page. The error page only shows fixed messages for known error + codes, so a crafted link can't put its own text on it. - **Time limit.** The whole login must finish within 10 minutes. - **First-factor enrollment.** A user with no MFA factor can enroll one during the login, but only in the browser bound to the login. diff --git a/docs/src/content/docs/user-guide/cli/authentication.mdx b/docs/src/content/docs/user-guide/cli/authentication.mdx index e4faa2de..a1d528fe 100644 --- a/docs/src/content/docs/user-guide/cli/authentication.mdx +++ b/docs/src/content/docs/user-guide/cli/authentication.mdx @@ -28,7 +28,8 @@ The `rack-gateway` CLI uses OAuth to authenticate you with the gateway. This pag 4. **Return to terminal** - After successful authentication, you can close the browser. The CLI receives a session token and stores it locally. + After successful authentication, the browser shows **Authentication Complete** and you can close it. The CLI + receives a session token and stores it locally. 5. **Verify connection** ```bash @@ -53,6 +54,7 @@ sequenceDiagram Gateway-->>CLI: 5. Browser redirected to 127.0.0.1 with a single-use login code CLI->>Gateway: 6. Redeem login code + PKCE verifier Gateway-->>CLI: 7. Session token + CLI-->>Gateway: 8. Browser sent to the login result page ``` The CLI: @@ -62,6 +64,7 @@ The CLI: local listener with a single-use login code 4. Redeems the code together with its PKCE verifier for a session token 5. Stores the session token in your config file +6. Sends the browser on to the gateway's result page: **Authentication Complete**, or what went wrong The whole login must finish within 10 minutes. The browser must be on the **same machine** as the CLI, because the login code is delivered to `127.0.0.1` (see [Logging in from a remote host](#logging-in-from-a-remote-host)). diff --git a/internal/cli/CLAUDE.md b/internal/cli/CLAUDE.md index d3cdb5ad..f2517bda 100644 --- a/internal/cli/CLAUDE.md +++ b/internal/cli/CLAUDE.md @@ -182,6 +182,8 @@ The integration tests create backups of the real Convox CLI configuration to pre loopback listener with a single-use login code (or `error=`, e.g. `canceled`) 5. Redeem the login code with the verifier at `POST /api/v1/auth/cli/complete` for a session token 6. Token stored in config file +7. The loopback listener holds the browser until then, and finally redirects it to the SPA's + `/app/cli/auth/success` or `/app/cli/auth/error?error=` page. The CLI never renders HTML itself The browser must be on the same machine as the CLI (remote hosts: `ssh -L :127.0.0.1:`). The login times out after 10 minutes. A gateway upgrade to this flow needs a matching CLI build. diff --git a/internal/cli/cli_login.go b/internal/cli/cli_login.go index 51320562..78db9791 100644 --- a/internal/cli/cli_login.go +++ b/internal/cli/cli_login.go @@ -47,22 +47,19 @@ func loginCommandWithFlags(args []string, noOpen bool, authFile string) error { fmt.Printf("Starting login for rack: %s via gateway: %s\n", rack, gatewayURL) - loginResp, err := runLoopbackLogin(gatewayURL, noOpen, authFile) + loginResp, err := runLoopbackLogin(rack, gatewayURL, noOpen, authFile) if err != nil { return err } - if err := finalizeLogin(rack, loginResp); err != nil { - return err - } - fmt.Printf("✓ Successfully logged in to %s as %s\n", rack, loginResp.Email) return nil } // runLoopbackLogin performs an RFC 8252 loopback login: the browser hands a single-use login code // back to this process on 127.0.0.1, and only this process holds the PKCE verifier to redeem it. -func runLoopbackLogin(gatewayURL string, noOpen bool, authFile string) (*LoginResponse, error) { +// The session is saved for rack before the browser is shown that the login succeeded. +func runLoopbackLogin(rack, gatewayURL string, noOpen bool, authFile string) (*LoginResponse, error) { challenge, err := newPKCE() if err != nil { return nil, err @@ -109,6 +106,10 @@ func runLoopbackLogin(gatewayURL string, noOpen bool, authFile string) (*LoginRe if err != nil { return nil, fmt.Errorf("login failed: %w", err) } + if err := finalizeLogin(rack, loginResp); err != nil { + return nil, err + } + loopback.finish("") return loginResp, nil } diff --git a/internal/cli/login_loopback.go b/internal/cli/login_loopback.go index 92a2004d..784961a9 100644 --- a/internal/cli/login_loopback.go +++ b/internal/cli/login_loopback.go @@ -8,10 +8,11 @@ import ( "encoding/base64" "errors" "fmt" - "html/template" "net" "net/http" + "net/url" "strings" + "sync" "time" ) @@ -20,6 +21,14 @@ const loginTimeout = 10 * time.Minute var errLoginTimedOut = errors.New("login timed out waiting for browser authentication") +// Error codes the CLI itself ends a login with on the gateway's result page (the gateway's own codes +// are passed through). The page maps each code to a message. +const ( + loginErrorStateMismatch = "state_mismatch" + loginErrorMissingCode = "missing_code" + loginErrorIncomplete = "cli_incomplete" +) + // pkce holds an RFC 7636 code verifier and its S256 challenge. type pkce struct { verifier string @@ -50,12 +59,20 @@ type loopbackResult struct { // loopbackServer receives the browser redirect that carries the single-use login code (RFC 8252). // It only listens on 127.0.0.1 and only accepts the redirect carrying this login's state. +// +// The browser is held on the callback until the terminal has redeemed the login code and saved the +// session, then sent to the gateway's result page, so the browser shows how the login really ended. type loopbackServer struct { redirectURI string state string gatewayURL string server *http.Server results chan loopbackResult + + finishOnce sync.Once + finished chan struct{} + // outcome is the result page error code ("" for success); set before finished is closed. + outcome string } func startLoopbackServer(state, gatewayURL string) (*loopbackServer, error) { @@ -74,6 +91,7 @@ func startLoopbackServer(state, gatewayURL string) (*loopbackServer, error) { state: state, gatewayURL: gatewayURL, results: make(chan loopbackResult, 1), + finished: make(chan struct{}), } mux := http.NewServeMux() mux.HandleFunc("/callback", s.handleCallback) @@ -86,24 +104,26 @@ func (s *loopbackServer) handleCallback(w http.ResponseWriter, r *http.Request) query := r.URL.Query() if subtle.ConstantTimeCompare([]byte(query.Get("state")), []byte(s.state)) != 1 { // Not our login: ignore it and keep waiting for the real redirect. - s.writePage(w, http.StatusBadRequest, "Login link mismatch", - "This login does not match the rack-gateway login waiting in your terminal.") + s.redirectToResult(w, r, loginErrorStateMismatch) return } if errCode := strings.TrimSpace(query.Get("error")); errCode != "" { - message := loginErrorMessage(errCode) - s.writePage(w, http.StatusOK, "Login failed", message+". Return to your terminal.") - s.deliver(loopbackResult{err: errors.New(message)}) + s.deliver(loopbackResult{err: errors.New(loginErrorMessage(errCode))}) + s.redirectToResult(w, r, errCode) return } code := strings.TrimSpace(query.Get("code")) if code == "" { - s.writePage(w, http.StatusBadRequest, "Login failed", "The login code is missing. Return to your terminal.") s.deliver(loopbackResult{err: errors.New("the gateway did not return a login code")}) + s.redirectToResult(w, r, loginErrorMissingCode) return } - s.writePage(w, http.StatusOK, "Login approved", "You can close this tab and return to your terminal.") s.deliver(loopbackResult{code: code}) + select { + case <-s.finished: + s.redirectToResult(w, r, s.outcome) + case <-r.Context().Done(): + } } func (s *loopbackServer) deliver(result loopbackResult) { @@ -125,33 +145,34 @@ func (s *loopbackServer) wait(timeout time.Duration) (string, error) { } } +// finish records how the login ended (errorCode "" for success) and releases the browser waiting on +// the callback. Only the first call counts. +func (s *loopbackServer) finish(errorCode string) { + s.finishOnce.Do(func() { + s.outcome = errorCode + close(s.finished) + }) +} + func (s *loopbackServer) close() { + // A browser still waiting here means the login ended without being finished. + s.finish(loginErrorIncomplete) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() _ = s.server.Shutdown(ctx) } -var loopbackPage = template.Must(template.New("loopback").Parse(` - -{{.Title}} - -

{{.Title}}

-

{{.Message}}

-

Open the Rack Gateway web UI

- -`)) - -func (s *loopbackServer) writePage(w http.ResponseWriter, status int, title, message string) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Content-Security-Policy", "default-src 'none'") +// redirectToResult sends the browser to the gateway's CLI login result page (errorCode "" for success). +func (s *loopbackServer) redirectToResult(w http.ResponseWriter, r *http.Request, errorCode string) { + target := buildGatewayAPIURL(s.gatewayURL, "/app/cli/auth/success") + if errorCode != "" { + query := url.Values{"error": {errorCode}} + target = buildGatewayAPIURL(s.gatewayURL, "/app/cli/auth/error") + "?" + query.Encode() + } + // The callback URL carries the login code, so it must not leak as a referrer. w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(status) - _ = loopbackPage.Execute(w, map[string]string{ - "Title": title, - "Message": message, - "WebURL": buildGatewayAPIURL(s.gatewayURL, "/app/"), - }) + http.Redirect(w, r, target, http.StatusSeeOther) } // loginErrorMessages maps the gateway's login error codes to messages for the terminal. diff --git a/internal/cli/login_loopback_test.go b/internal/cli/login_loopback_test.go index a119b02f..6a99905c 100644 --- a/internal/cli/login_loopback_test.go +++ b/internal/cli/login_loopback_test.go @@ -15,12 +15,18 @@ import ( "github.com/stretchr/testify/require" ) +// browserClient plays the browser hitting the loopback callback; it stops at the redirect so tests can +// check where the browser is sent. +var browserClient = &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, +} + func fetch(target string) (*http.Response, error) { req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, target, http.NoBody) if err != nil { return nil, err } - resp, err := http.DefaultClient.Do(req) + resp, err := browserClient.Do(req) if err != nil { return nil, err } @@ -28,6 +34,47 @@ func fetch(target string) (*http.Response, error) { return resp, nil } +type browserVisit struct { + resp *http.Response + err error +} + +// visitAsync opens target in the background, for callbacks that hold the browser until the login ends. +func visitAsync(target string) <-chan browserVisit { + done := make(chan browserVisit, 1) + go func() { + resp, err := fetch(target) + done <- browserVisit{resp: resp, err: err} + }() + return done +} + +func awaitVisit(t *testing.T, visit <-chan browserVisit) *http.Response { + t.Helper() + select { + case v := <-visit: + require.NoError(t, v.err) + return v.resp + case <-time.After(5 * time.Second): + t.Fatal("the browser was never released from the loopback callback") + return nil + } +} + +func requireResultPage(t *testing.T, resp *http.Response, want string) { + t.Helper() + require.Equal(t, http.StatusSeeOther, resp.StatusCode) + require.Equal(t, want, resp.Header.Get("Location")) + require.Equal(t, "no-referrer", resp.Header.Get("Referrer-Policy")) + require.Equal(t, "no-store", resp.Header.Get("Cache-Control")) +} + +const ( + testGatewayURL = "http://127.0.0.1:9447" + testSuccessPage = testGatewayURL + "/app/cli/auth/success" + testErrorPage = testGatewayURL + "/app/cli/auth/error?error=" +) + func getURL(t *testing.T, target string) *http.Response { t.Helper() resp, err := fetch(target) @@ -36,7 +83,7 @@ func getURL(t *testing.T, target string) *http.Response { } func TestLoopbackServerListensOnLoopbackAndChecksState(t *testing.T) { - server, err := startLoopbackServer("expected-state", "http://127.0.0.1:9447") + server, err := startLoopbackServer("expected-state", testGatewayURL) require.NoError(t, err) defer server.close() @@ -46,32 +93,64 @@ func TestLoopbackServerListensOnLoopbackAndChecksState(t *testing.T) { require.Equal(t, "127.0.0.1", redirect.Hostname()) require.Equal(t, "/callback", redirect.Path) - // A redirect for some other login is rejected and does not end the wait. + // A redirect for some other login is sent to the error page and does not end the wait. resp := getURL(t, server.redirectURI+"?code=attacker&state=wrong-state") - require.Equal(t, http.StatusBadRequest, resp.StatusCode) + requireResultPage(t, resp, testErrorPage+"state_mismatch") _, err = server.wait(50 * time.Millisecond) require.ErrorIs(t, err, errLoginTimedOut) - resp = getURL(t, server.redirectURI+"?code=the-login-code&state=expected-state") - require.Equal(t, http.StatusOK, resp.StatusCode) - require.Equal(t, "default-src 'none'", resp.Header.Get("Content-Security-Policy")) + // The real redirect delivers the code, and the browser waits until the login has finished. + visit := visitAsync(server.redirectURI + "?code=the-login-code&state=expected-state") code, err := server.wait(time.Second) require.NoError(t, err) require.Equal(t, "the-login-code", code) + select { + case <-visit: + t.Fatal("the browser was released before the login finished") + case <-time.After(50 * time.Millisecond): + } + + server.finish("") + requireResultPage(t, awaitVisit(t, visit), testSuccessPage) +} + +func TestLoopbackServerSendsUnfinishedLoginToErrorPage(t *testing.T) { + server, err := startLoopbackServer("expected-state", testGatewayURL) + require.NoError(t, err) + + visit := visitAsync(server.redirectURI + "?code=the-login-code&state=expected-state") + _, err = server.wait(time.Second) + require.NoError(t, err) + + // Closing without finishing (redeeming or saving the login failed) tells the browser so. + server.close() + requireResultPage(t, awaitVisit(t, visit), testErrorPage+loginErrorIncomplete) } func TestLoopbackServerReportsGatewayError(t *testing.T) { - server, err := startLoopbackServer("expected-state", "http://127.0.0.1:9447") + server, err := startLoopbackServer("expected-state", testGatewayURL) require.NoError(t, err) defer server.close() - getURL(t, server.redirectURI+"?error=unauthorized&state=expected-state") + resp := getURL(t, server.redirectURI+"?error=unauthorized&state=expected-state") + requireResultPage(t, resp, testErrorPage+"unauthorized") _, err = server.wait(time.Second) require.ErrorContains(t, err, "not authorized") } +func TestLoopbackServerReportsMissingCode(t *testing.T) { + server, err := startLoopbackServer("expected-state", testGatewayURL) + require.NoError(t, err) + defer server.close() + + resp := getURL(t, server.redirectURI+"?state=expected-state") + requireResultPage(t, resp, testErrorPage+loginErrorMissingCode) + _, err = server.wait(time.Second) + require.ErrorContains(t, err, "did not return a login code") +} + func TestLoopbackServerTimesOut(t *testing.T) { - server, err := startLoopbackServer("expected-state", "http://127.0.0.1:9447") + server, err := startLoopbackServer("expected-state", testGatewayURL) require.NoError(t, err) defer server.close() @@ -80,11 +159,12 @@ func TestLoopbackServerTimesOut(t *testing.T) { } func TestLoopbackServerReportsCancelledLogin(t *testing.T) { - server, err := startLoopbackServer("expected-state", "http://127.0.0.1:9447") + server, err := startLoopbackServer("expected-state", testGatewayURL) require.NoError(t, err) defer server.close() - getURL(t, server.redirectURI+"?error=canceled&state=expected-state") + resp := getURL(t, server.redirectURI+"?error=canceled&state=expected-state") + requireResultPage(t, resp, testErrorPage+"canceled") _, err = server.wait(time.Second) require.ErrorContains(t, err, "canceled in the browser") } @@ -120,10 +200,17 @@ func TestValidateAuthURL(t *testing.T) { // for the issued login code together with a verifier matching the challenge. Handler errors are // recorded and checked by the test (require can't be called from the server goroutine). type fakeLoopbackGateway struct { - mu sync.Mutex - start LoginStartRequest - errs []error - oldResponse bool + mu sync.Mutex + start LoginStartRequest + errs []error + oldResponse bool + rejectComplete bool + // browser receives where the browser was sent after the loopback callback. + browser chan browserVisit +} + +func newFakeLoopbackGateway() *fakeLoopbackGateway { + return &fakeLoopbackGateway{browser: make(chan browserVisit, 1)} } func (g *fakeLoopbackGateway) fail(w http.ResponseWriter, err error) { @@ -162,7 +249,8 @@ func (g *fakeLoopbackGateway) serveStart(w http.ResponseWriter, r *http.Request) } // Play the browser: deliver the login code to the CLI's loopback listener. go func() { - _, _ = fetch(start.RedirectURI + "?code=issued-code&state=" + url.QueryEscape(start.State)) + resp, err := fetch(start.RedirectURI + "?code=issued-code&state=" + url.QueryEscape(start.State)) + g.browser <- browserVisit{resp: resp, err: err} }() _ = json.NewEncoder(w).Encode(LoginStartResponse{AuthURL: "https://accounts.google.com/o/oauth2/auth"}) } @@ -177,7 +265,8 @@ func (g *fakeLoopbackGateway) serveComplete(w http.ResponseWriter, r *http.Reque challenge := g.start.CodeChallenge g.mu.Unlock() sum := sha256.Sum256([]byte(body["code_verifier"])) - if body["login_code"] != "issued-code" || base64.RawURLEncoding.EncodeToString(sum[:]) != challenge { + if g.rejectComplete || body["login_code"] != "issued-code" || + base64.RawURLEncoding.EncodeToString(sum[:]) != challenge { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":"invalid or expired login code"}`)) return @@ -200,14 +289,21 @@ func useTempConfig(t *testing.T) { func TestRunLoopbackLogin(t *testing.T) { useTempConfig(t) - gateway := &fakeLoopbackGateway{} + gateway := newFakeLoopbackGateway() server := httptest.NewServer(gateway) defer server.Close() + require.NoError(t, SaveGatewayConfig("staging", server.URL)) - resp, err := runLoopbackLogin(server.URL, true, "") + resp, err := runLoopbackLogin("staging", server.URL, true, "") require.NoError(t, err) require.Equal(t, "session-token", resp.Token) + // The session is saved before the browser is shown the success page. + cfg, _, err := LoadConfig() + require.NoError(t, err) + require.Equal(t, "session-token", cfg.Gateways["staging"].Token) + requireResultPage(t, awaitVisit(t, gateway.browser), server.URL+"/app/cli/auth/success") + start, errs := gateway.snapshot() require.Empty(t, errs) require.Equal(t, "S256", start.CodeChallengeMethod) @@ -216,11 +312,27 @@ func TestRunLoopbackLogin(t *testing.T) { require.Regexp(t, `^http://127\.0\.0\.1:\d+/callback$`, start.RedirectURI) } +func TestRunLoopbackLoginShowsRedeemFailureInBrowser(t *testing.T) { + useTempConfig(t) + gateway := newFakeLoopbackGateway() + gateway.rejectComplete = true + server := httptest.NewServer(gateway) + defer server.Close() + require.NoError(t, SaveGatewayConfig("staging", server.URL)) + + _, err := runLoopbackLogin("staging", server.URL, true, "") + require.ErrorContains(t, err, "login failed") + requireResultPage(t, awaitVisit(t, gateway.browser), + server.URL+"/app/cli/auth/error?error="+loginErrorIncomplete) +} + func TestRunLoopbackLoginRefusesOldGateway(t *testing.T) { useTempConfig(t) - server := httptest.NewServer(&fakeLoopbackGateway{oldResponse: true}) + gateway := newFakeLoopbackGateway() + gateway.oldResponse = true + server := httptest.NewServer(gateway) defer server.Close() - _, err := runLoopbackLogin(server.URL, true, "") + _, err := runLoopbackLogin("staging", server.URL, true, "") require.ErrorIs(t, err, errGatewayTooOld) } diff --git a/web/e2e/cli-login-webui.spec.ts b/web/e2e/cli-login-webui.spec.ts index d475c69b..5357b6bd 100644 --- a/web/e2e/cli-login-webui.spec.ts +++ b/web/e2e/cli-login-webui.spec.ts @@ -5,7 +5,7 @@ * 1. The CLI (played by the test) listens on 127.0.0.1 and starts a login with a PKCE challenge * 2. User completes OAuth and MFA enrollment in the browser * 3. The browser is handed back to the CLI's loopback listener with a single-use login code - * 4. The CLI redeems the code with its PKCE verifier + * 4. The CLI redeems the code with its PKCE verifier and sends the browser to the success page * 5. User can perform authenticated actions in the WebUI * * NOTE: Deploy approval "approve" action requires MFAAlways (inline MFA with each request). @@ -30,7 +30,16 @@ import { expect, test } from './fixtures' import { ensureMfaEnrollment, isOnMfaChallengeUrl, resetMfaFor, typeOtpCode } from './helpers' const ADMIN_EMAIL = 'admin@example.com' -const LOOPBACK_URL = /^http:\/\/127\.0\.0\.1:\d+\/callback/ +const CLI_SUCCESS_URL = /\/app\/cli\/auth\/success$/ + +/** Waits for the CLI to redeem the login and for the browser to land on the CLI login success page. */ +async function expectCliLoginApproved(page: Page, cli: CliLoopbackLogin) { + const completion = await cli.completion + expect(completion.status).toBe(200) + expect(completion.token).toBeTruthy() + await expect(page).toHaveURL(CLI_SUCCESS_URL, { timeout: 15_000 }) + await expect(page.getByRole('heading', { name: 'Authentication Complete' })).toBeVisible() +} /** Runs the browser half of a CLI login for an unenrolled admin and returns the new TOTP secret. */ async function approveCliLoginWithEnrollment(page: Page, cli: CliLoopbackLogin): Promise { @@ -46,8 +55,7 @@ async function approveCliLoginWithEnrollment(page: Page, cli: CliLoopbackLogin): // After enrolling, the browser is handed back to the CLI's loopback listener const secret = await ensureMfaEnrollment(page, { email: ADMIN_EMAIL, useUi: true }) - await expect(page).toHaveURL(LOOPBACK_URL, { timeout: 15_000 }) - await expect(page.getByText(/Login approved/i)).toBeVisible() + await expectCliLoginApproved(page, cli) return secret } @@ -97,11 +105,6 @@ test.describe('CLI login to WebUI flow', () => { await withCliLogin(request, async (cli) => { const secret = await approveCliLoginWithEnrollment(page, cli) - // The CLI redeems the login code with its PKCE verifier - const completion = await cli.complete(request) - expect(completion.status).toBe(200) - expect(completion.token).toBeTruthy() - // The browser that approved the CLI login also holds a web session await page.goto(WebRoute('rack')) await page.waitForURL(/\/app\/rack/, { timeout: 10_000 }) @@ -126,8 +129,7 @@ test.describe('CLI login to WebUI flow', () => { await approveCliLoginWithEnrollment(page, cli) // The login code is single use - expect((await cli.complete(request)).status).toBe(200) - expect((await cli.complete(request)).status).toBe(400) + expect((await cli.redeemAgain(request)).status).toBe(400) const cookies = await page.context().cookies() expect(cookies.find((c) => c.name === 'session_token')).toBeTruthy() @@ -165,7 +167,7 @@ test.describe('CLI login to WebUI flow', () => { await page.waitForURL((url) => isOnMfaChallengeUrl(url), { timeout: 10_000 }) await clearMfaAttempts() await typeOtpCode(page, page, authenticator.generate(secret as string)) - await expect(page).toHaveURL(LOOPBACK_URL, { timeout: 15_000 }) + await expectCliLoginApproved(page, cli) await page.goto(WebRoute('rack')) await page.waitForURL(/\/app\/rack/, { timeout: 10_000 }) diff --git a/web/e2e/cli-loopback.ts b/web/e2e/cli-loopback.ts index 87abb41f..ec5beaa5 100644 --- a/web/e2e/cli-loopback.ts +++ b/web/e2e/cli-loopback.ts @@ -2,15 +2,17 @@ import { createHash, randomBytes } from 'node:crypto' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' import type { APIRequestContext } from '@playwright/test' -import { APIRoute } from '@/lib/routes' +import { APIRoute, WebRoute } from '@/lib/routes' + +type CliCompletion = { status: number; token?: string } export type CliLoopbackLogin = { /** Identity provider URL the CLI would open in the browser. */ authUrl: string - /** Resolves with the single-use login code the gateway delivers to the loopback listener. */ - waitForLoginCode: () => Promise - /** Redeems the login code with the PKCE verifier, as the CLI does. */ - complete: (request: APIRequestContext) => Promise<{ status: number; token?: string }> + /** Resolves once the CLI has redeemed the login code the browser delivered to its loopback listener. */ + completion: Promise + /** Redeems the login code again with the PKCE verifier (login codes are single use). */ + redeemAgain: (request: APIRequestContext) => Promise close: () => Promise } @@ -18,29 +20,50 @@ const base64url = (buf: Buffer): string => buf.toString('base64url') /** * Plays the rack-gateway CLI in an RFC 8252 loopback login: listens on 127.0.0.1, starts the login - * with a PKCE challenge, and captures the login code the browser is redirected back with. + * with a PKCE challenge, and when the browser brings back the login code, redeems it and sends the + * browser on to the gateway's CLI login result page, as the CLI does. */ export async function startCliLoopbackLogin(request: APIRequestContext): Promise { const verifier = base64url(randomBytes(64)) const challenge = base64url(createHash('sha256').update(verifier).digest()) const state = base64url(randomBytes(32)) + let gatewayOrigin = '' + let loginCode: string | null = null + + const redeem = async (api: APIRequestContext, code: string): Promise => { + const response = await api.post(APIRoute('auth/cli/complete'), { + data: { login_code: code, code_verifier: verifier, device_name: 'e2e-cli' }, + }) + const body = (await response.json().catch(() => ({}))) as { token?: string } + return { status: response.status(), token: body.token } + } - let deliverCode: ((code: string) => void) | null = null - const codePromise = new Promise((resolve) => { - deliverCode = resolve + let resolveCompletion: ((result: CliCompletion) => void) | null = null + const completion = new Promise((resolve) => { + resolveCompletion = resolve }) - const server = createServer((req, res) => { + const server = createServer(async (req, res) => { const url = new URL(req.url ?? '/', 'http://127.0.0.1') const code = url.searchParams.get('code') - if (url.pathname === '/callback' && url.searchParams.get('state') === state && code) { - deliverCode?.(code) - res.writeHead(200, { 'Content-Type': 'text/html' }) - res.end('

Login approved

') + if (url.pathname !== '/callback' || url.searchParams.get('state') !== state || !code) { + res.writeHead(400) + res.end('unexpected login redirect') return } - res.writeHead(400) - res.end('unexpected login redirect') + loginCode = code + const result = await redeem(request, code).catch(() => ({ status: 0 })) + resolveCompletion?.(result) + const page = + result.status === 200 + ? WebRoute('cli/auth/success') + : WebRoute('cli/auth/error?error=cli_incomplete') + res.writeHead(303, { + Location: `${gatewayOrigin}${page}`, + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-store', + }) + res.end() }) await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve) @@ -60,21 +83,19 @@ export async function startCliLoopbackLogin(request: APIRequestContext): Promise server.close() throw new Error(`auth/cli/start failed: ${response.status()} ${await response.text()}`) } + gatewayOrigin = new URL(response.url()).origin const { auth_url: authUrl } = (await response.json()) as { auth_url: string } return { authUrl, - waitForLoginCode: () => codePromise, - complete: async (api) => { - const loginCode = await codePromise - const completion = await api.post(APIRoute('auth/cli/complete'), { - data: { login_code: loginCode, code_verifier: verifier, device_name: 'e2e-cli' }, - }) - const body = (await completion.json().catch(() => ({}))) as { token?: string } - return { status: completion.status(), token: body.token } + completion, + redeemAgain: async (api) => { + await completion + return redeem(api, loginCode ?? '') }, close: () => new Promise((resolve) => { + server.closeAllConnections() server.close(() => resolve()) }), } diff --git a/web/src/app.tsx b/web/src/app.tsx index c5e19a7d..85f5615d 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -26,6 +26,8 @@ import { AppSettingsPage } from './pages/app-settings-page' import { AppsListPage } from './pages/apps-list-page' import { AuditPage } from './pages/audit-page' import { CallbackPage } from './pages/callback-page' +import { CLIAuthErrorPage } from './pages/cli-auth-error-page' +import { CLIAuthSuccessPage } from './pages/cli-auth-success-page' import { DeployApprovalRequestDetailPage } from './pages/deploy-approval-request-detail-page' import { DeployApprovalRequestsPage } from './pages/deploy-approval-requests-page' import { InstancesPage } from './pages/instances-page' @@ -77,6 +79,16 @@ function buildRouteTree() { path: 'auth/mfa/challenge', component: MFAChallengePage, }) + const cliAuthSuccessRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'cli/auth/success', + component: CLIAuthSuccessPage, + }) + const cliAuthErrorRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'cli/auth/error', + component: CLIAuthErrorPage, + }) // App layout route with nested pages const layoutRoute = createRoute({ @@ -253,6 +265,8 @@ function buildRouteTree() { callbackRoute, loginErrorRoute, mfaChallengeRoute, + cliAuthSuccessRoute, + cliAuthErrorRoute, layoutRoute.addChildren(layoutChildren), ]) } diff --git a/web/src/pages/cli-auth-error-page.test.tsx b/web/src/pages/cli-auth-error-page.test.tsx new file mode 100644 index 00000000..d133f84f --- /dev/null +++ b/web/src/pages/cli-auth-error-page.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { CLIAuthErrorPage } from './cli-auth-error-page' + +function renderWithSearch(search: string) { + window.history.replaceState(null, '', `/app/cli/auth/error${search}`) + return render() +} + +describe('CLIAuthErrorPage', () => { + afterEach(() => { + window.history.replaceState(null, '', '/') + }) + + it('explains a known error code and how to retry', () => { + renderWithSearch('?error=canceled') + expect(screen.getByRole('heading', { name: 'Login canceled' })).toBeInTheDocument() + expect(screen.getByText('This CLI login was canceled.')).toBeInTheDocument() + expect(screen.getByText('rack-gateway login')).toBeInTheDocument() + }) + + it('explains errors raised by the CLI itself', () => { + renderWithSearch('?error=cli_incomplete') + expect(screen.getByRole('heading', { name: 'Login not completed' })).toBeInTheDocument() + }) + + it('shows the generic message for an unknown code without echoing it', () => { + renderWithSearch('?error=Call+555-0100+to+restore+access') + expect(screen.getByRole('heading', { name: 'CLI login failed' })).toBeInTheDocument() + expect(screen.queryByText(/555-0100/)).toBeNull() + }) + + it('does not treat object prototype keys as known codes', () => { + renderWithSearch('?error=constructor') + expect(screen.getByRole('heading', { name: 'CLI login failed' })).toBeInTheDocument() + }) + + it('shows the generic message when no code is given', () => { + renderWithSearch('') + expect(screen.getByRole('heading', { name: 'CLI login failed' })).toBeInTheDocument() + }) +}) diff --git a/web/src/pages/cli-auth-error-page.tsx b/web/src/pages/cli-auth-error-page.tsx new file mode 100644 index 00000000..b0b73d01 --- /dev/null +++ b/web/src/pages/cli-auth-error-page.tsx @@ -0,0 +1,91 @@ +import { AuthResultCard } from '@/components/auth-result-card' + +type CLILoginError = { title: string; description: string } + +// Error codes a failed CLI login ends with: the gateway's codes (delivered to the CLI's loopback +// listener) and the CLI's own. The code only selects a message and is never shown, so a crafted link +// can't put its own text on this page. +const CLI_LOGIN_ERRORS = new Map([ + ['canceled', { title: 'Login canceled', description: 'This CLI login was canceled.' }], + [ + 'access_denied', + { + title: 'Sign-in canceled', + description: 'The sign-in was canceled or denied at the identity provider.', + }, + ], + [ + 'unauthorized', + { + title: 'Account not authorized', + description: + "Your account isn't authorized for this gateway. Ask an administrator for access.", + }, + ], + [ + 'exchange_failed', + { + title: 'Sign-in failed', + description: "The identity provider sign-in couldn't be completed.", + }, + ], + [ + 'identity_provider_error', + { title: 'Sign-in failed', description: 'The identity provider reported an error.' }, + ], + [ + 'session_incomplete', + { title: 'Login incomplete', description: 'The login session was incomplete.' }, + ], + [ + 'session_failed', + { title: 'Login failed', description: "The gateway couldn't start a browser session." }, + ], + [ + 'persist_failure', + { title: 'Login failed', description: "The gateway couldn't save the login." }, + ], + ['load_failure', { title: 'Login failed', description: "The gateway couldn't load the login." }], + [ + 'state_mismatch', + { + title: 'Login link mismatch', + description: "This link doesn't belong to the login waiting in your terminal.", + }, + ], + [ + 'missing_code', + { title: 'Login failed', description: "The gateway didn't return a login code." }, + ], + [ + 'cli_incomplete', + { + title: 'Login not completed', + description: "Your terminal couldn't finish the login. Check it for details.", + }, + ], +]) + +const UNKNOWN_ERROR: CLILoginError = { + title: 'CLI login failed', + description: 'Something went wrong while completing your CLI login.', +} + +function cliLoginError(code: string | null): CLILoginError { + return (code && CLI_LOGIN_ERRORS.get(code)) || UNKNOWN_ERROR +} + +// The rack-gateway CLI sends the browser here when a login fails. +export function CLIAuthErrorPage() { + const info = cliLoginError(new URLSearchParams(window.location.search).get('error')) + + return ( + +

+ Return to your terminal and run + rack-gateway login + to try again. +

+
+ ) +} diff --git a/web/src/pages/cli-auth-success-page.tsx b/web/src/pages/cli-auth-success-page.tsx new file mode 100644 index 00000000..41defd5d --- /dev/null +++ b/web/src/pages/cli-auth-success-page.tsx @@ -0,0 +1,18 @@ +import { AuthResultCard } from '@/components/auth-result-card' +import { Button } from '@/components/ui/button' +import { WebRoute } from '@/lib/routes' + +// The rack-gateway CLI sends the browser here once it has redeemed the login and saved the session. +export function CLIAuthSuccessPage() { + return ( + + + + ) +}