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 ( + + + + ) +}