From 782fde051a154a6b75c8bae45610f897b59d501b Mon Sep 17 00:00:00 2001 From: mysqto Date: Wed, 2 Sep 2026 09:31:25 +0800 Subject: [PATCH 1/6] cognito: add a Cognito PKCE login flow for CLIs Adds github.com/wego/pkg/cognito, the authorization-code-with-PKCE login a command-line tool uses to obtain a human operator's tokens, plus a cognito/storage subpackage that caches the token set in the OS keychain. This is the CLI-side counterpart to http/jwt: that package verifies a token arriving at a service, this one obtains one at a terminal. Extracted from the payments repo's pay-admin CLI, where it was written against these constraints from the start so the move needed no redesign. Two properties are deliberate and worth preserving: - No package-level mutable state. Every dependency -- the clock, the HTTP client, the browser opener, the identity provider -- arrives through Config, so one process can hold several environments live at once. http/jwt keeps its JWKS URL and header in package globals and can therefore serve exactly one issuer; that limitation is why this package does not repeat the shape. - Stdlib-only OAuth (bar Wego's string helpers). Hand-rolling the exchange keeps every wire parameter visible and auditable, which matters more here than the convenience an OAuth library would buy. Kept as one module rather than splitting storage out: the split would have forced cognito to be tagged before storage could require it, and every other module in this repo requires tagged siblings with no replace directive. The import paths are identical either way, so the only cost is that an OAuth-only consumer also pulls go-keyring. Co-Authored-By: Claude Opus 5 (1M context) --- cognito/browser.go | 28 + cognito/browser_internal_test.go | 46 ++ cognito/callback.go | 163 ++++++ cognito/callback_internal_test.go | 228 ++++++++ cognito/go.mod | 18 + cognito/go.sum | 24 + cognito/oauth.go | 345 ++++++++++++ cognito/oauth_internal_test.go | 138 +++++ cognito/oauth_test.go | 652 +++++++++++++++++++++++ cognito/pkce.go | 56 ++ cognito/pkce_internal_test.go | 104 ++++ cognito/storage/keyring.go | 190 +++++++ cognito/storage/keyring_internal_test.go | 398 ++++++++++++++ cognito/storage/store.go | 99 ++++ cognito/storage/store_test.go | 80 +++ cognito/tokens.go | 88 +++ cognito/tokens_test.go | 172 ++++++ 17 files changed, 2829 insertions(+) create mode 100644 cognito/browser.go create mode 100644 cognito/browser_internal_test.go create mode 100644 cognito/callback.go create mode 100644 cognito/callback_internal_test.go create mode 100644 cognito/go.mod create mode 100644 cognito/go.sum create mode 100644 cognito/oauth.go create mode 100644 cognito/oauth_internal_test.go create mode 100644 cognito/oauth_test.go create mode 100644 cognito/pkce.go create mode 100644 cognito/pkce_internal_test.go create mode 100644 cognito/storage/keyring.go create mode 100644 cognito/storage/keyring_internal_test.go create mode 100644 cognito/storage/store.go create mode 100644 cognito/storage/store_test.go create mode 100644 cognito/tokens.go create mode 100644 cognito/tokens_test.go diff --git a/cognito/browser.go b/cognito/browser.go new file mode 100644 index 0000000..3473419 --- /dev/null +++ b/cognito/browser.go @@ -0,0 +1,28 @@ +package cognito + +import ( + "os/exec" + "runtime" +) + +// browserCommand returns the command and arguments that open rawURL in the +// platform's default browser. +func browserCommand(goos, rawURL string) (string, []string) { + switch goos { + case "darwin": + return "open", []string{rawURL} + case "windows": + return "rundll32", []string{"url.dll,FileProtocolHandler", rawURL} + default: + return "xdg-open", []string{rawURL} + } +} + +// openBrowser launches the platform's default browser. This is the one +// genuinely untestable function in the package - a unit test must not spawn a +// real browser - so it is kept to a single statement over browserCommand, +// which is tested. Callers that need a seam set Config.OpenBrowser instead. +func openBrowser(rawURL string) error { + name, args := browserCommand(runtime.GOOS, rawURL) + return exec.Command(name, args...).Start() +} diff --git a/cognito/browser_internal_test.go b/cognito/browser_internal_test.go new file mode 100644 index 0000000..4957a56 --- /dev/null +++ b/cognito/browser_internal_test.go @@ -0,0 +1,46 @@ +package cognito + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBrowserCommand(t *testing.T) { + const authorizeURL = "https://cognito.test/oauth2/authorize?client_id=x" + + tests := []struct { + name string + givenGOOS string + wantName string + wantArgs []string + }{ + { + name: "darwin uses open", + givenGOOS: "darwin", + wantName: "open", + wantArgs: []string{authorizeURL}, + }, + { + name: "windows goes through the protocol handler", + givenGOOS: "windows", + wantName: "rundll32", + wantArgs: []string{"url.dll,FileProtocolHandler", authorizeURL}, + }, + { + name: "other platforms fall back to xdg-open", + givenGOOS: "linux", + wantName: "xdg-open", + wantArgs: []string{authorizeURL}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotName, gotArgs := browserCommand(tt.givenGOOS, authorizeURL) + + assert.Equal(t, tt.wantName, gotName) + assert.Equal(t, tt.wantArgs, gotArgs) + }) + } +} diff --git a/cognito/callback.go b/cognito/callback.go new file mode 100644 index 0000000..9b51b18 --- /dev/null +++ b/cognito/callback.go @@ -0,0 +1,163 @@ +package cognito + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "time" + + wegostrings "github.com/wego/pkg/strings" +) + +const ( + // defaultCallbackTimeout bounds how long we hold the loopback listener + // open waiting for the operator to finish signing in. + defaultCallbackTimeout = 5 * time.Minute + + callbackReadHeaderTimeout = 5 * time.Second + callbackShutdownTimeout = 2 * time.Second +) + +const successHTML = ` +Signed in + +

Signed in

+

You can close this window and return to your terminal.

` + +// failureHTML deliberately carries no detail from the request. The operator +// reads the actual reason in their terminal, where it cannot be reflected back +// into a page, so nothing from the redirect is ever interpolated into HTML. +const failureHTML = ` +Sign-in failed + +

Sign-in failed

+

Return to your terminal for the reason, and try again.

` + +// callbackResult is the single outcome a callback server reports. +type callbackResult struct { + code string + state string + err error +} + +// callbackServer is a single-use loopback listener for the OAuth redirect. +type callbackServer struct { + results <-chan callbackResult + srv *http.Server +} + +// callbackPath is the request path the redirect URI points at, falling back to +// root when the URI carries no path (or cannot be parsed at all). +func callbackPath(redirectURI string) string { + parsed, err := url.Parse(redirectURI) + if err != nil || wegostrings.IsBlank(parsed.Path) { + return "/" + } + return parsed.Path +} + +// startCallbackServer binds addr and serves the OAuth redirect at path. +// +// A bind failure is terminal on purpose: the Cognito app client registers +// exactly one callback URL, so listening somewhere else would produce a +// redirect the authorization server refuses. Fail loudly instead. +func startCallbackServer(addr, path string) (*callbackServer, error) { + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf( + "bind the sign-in callback listener on %s: %w (that address must be free - the Cognito app client registers exactly one callback URL, so another port is not an option; close whatever holds it and retry)", + addr, err) + } + + results := make(chan callbackResult, 1) + mux := http.NewServeMux() + mux.HandleFunc(path, callbackHandler(results)) + + srv := &http.Server{ + Handler: mux, + ReadHeaderTimeout: callbackReadHeaderTimeout, + } + go func() { + _ = srv.Serve(listener) + }() + + return &callbackServer{results: results, srv: srv}, nil +} + +// callbackHandler serves the redirect, renders a minimal page for the +// operator, and reports the outcome exactly once. +func callbackHandler(results chan<- callbackResult) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + if authErr := query.Get("error"); wegostrings.IsNotBlank(authErr) { + message := authErr + if desc := query.Get("error_description"); wegostrings.IsNotBlank(desc) { + message += ": " + desc + } + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, failureHTML) + deliver(results, callbackResult{err: fmt.Errorf("authorization server rejected the sign-in: %s", message)}) + return + } + + code := query.Get("code") + if wegostrings.IsBlank(code) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, failureHTML) + deliver(results, callbackResult{err: errors.New("the sign-in redirect carried no authorization code")}) + return + } + + fmt.Fprint(w, successHTML) + deliver(results, callbackResult{code: code, state: query.Get("state")}) + } +} + +// deliver reports the first result and silently drops later ones. The flow is +// single-use: a reloaded browser tab must neither block the handler nor +// overwrite the outcome we already acted on. +func deliver(results chan<- callbackResult, result callbackResult) { + select { + case results <- result: + default: + } +} + +// wait blocks for the callback, honouring both ctx cancellation and timeout. +func (c *callbackServer) wait(ctx context.Context, timeout time.Duration) (code, state string, err error) { + if timeout <= 0 { + timeout = defaultCallbackTimeout + } + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case <-ctx.Done(): + return "", "", fmt.Errorf("waiting for the sign-in callback: %w", ctx.Err()) + case <-timer.C: + return "", "", fmt.Errorf("timed out after %s waiting for the sign-in callback", timeout) + case result := <-c.results: + if result.err != nil { + return "", "", result.err + } + return result.code, result.state, nil + } +} + +// shutdown releases the loopback listener. +func (c *callbackServer) shutdown() { + if c.srv == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), callbackShutdownTimeout) + defer cancel() + _ = c.srv.Shutdown(ctx) +} diff --git a/cognito/callback_internal_test.go b/cognito/callback_internal_test.go new file mode 100644 index 0000000..930c3cf --- /dev/null +++ b/cognito/callback_internal_test.go @@ -0,0 +1,228 @@ +package cognito + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + wegostrings "github.com/wego/pkg/strings" +) + +func TestCallbackPath(t *testing.T) { + tests := []struct { + name string + givenURI string + want string + }{ + {name: "unparseable falls back to root", givenURI: "http://[::1", want: "/"}, + {name: "no path falls back to root", givenURI: "http://localhost:8110", want: "/"}, + {name: "blank falls back to root", givenURI: "", want: "/"}, + {name: "explicit path is used", givenURI: "http://localhost:8110/callback", want: "/callback"}, + {name: "nested path is used", givenURI: "http://127.0.0.1:8110/oauth/cb", want: "/oauth/cb"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, callbackPath(tt.givenURI)) + }) + } +} + +func TestCallbackHandler(t *testing.T) { + tests := []struct { + name string + givenQuery string + wantStatus int + wantCode string + wantState string + wantErrContains string + }{ + { + name: "authorize error is surfaced with its description", + givenQuery: "error=access_denied&error_description=user+said+no", + wantStatus: http.StatusBadRequest, + wantErrContains: "user said no", + }, + { + name: "authorize error without a description still reports", + givenQuery: "error=server_error", + wantStatus: http.StatusBadRequest, + wantErrContains: "server_error", + }, + { + name: "missing code is rejected", + givenQuery: "state=abc", + wantStatus: http.StatusBadRequest, + wantErrContains: "no authorization code", + }, + { + name: "code and state are captured", + givenQuery: "code=the-code&state=the-state", + wantStatus: http.StatusOK, + wantCode: "the-code", + wantState: "the-state", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch := make(chan callbackResult, 1) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/callback?"+tt.givenQuery, nil) + + callbackHandler(ch)(rec, req) + + assert.Equal(t, tt.wantStatus, rec.Code) + assert.Contains(t, rec.Header().Get("Content-Type"), "text/html") + + select { + case got := <-ch: + if wegostrings.IsNotEmpty(tt.wantErrContains) { + require.Error(t, got.err) + assert.Contains(t, got.err.Error(), tt.wantErrContains) + return + } + require.NoError(t, got.err) + assert.Equal(t, tt.wantCode, got.code) + assert.Equal(t, tt.wantState, got.state) + default: + t.Fatal("handler did not emit a callbackResult") + } + }) + } +} + +func TestCallbackHandler_IsSingleUse(t *testing.T) { + ch := make(chan callbackResult, 1) + handler := callbackHandler(ch) + + for range 3 { + rec := httptest.NewRecorder() + handler(rec, httptest.NewRequest(http.MethodGet, "/callback?code=c&state=s", nil)) + } + + require.Len(t, ch, 1, "a redelivered callback must neither block nor enqueue a second result") +} + +func TestStartCallbackServer_PortAlreadyBoundFailsFast(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + + addr := ln.Addr().String() + cs, err := startCallbackServer(addr, "/callback") + + require.Error(t, err, "binding an occupied port must fail rather than silently pick another") + assert.Nil(t, cs) + assert.Contains(t, err.Error(), addr, "the error must name the address the operator has to free") +} + +func TestStartCallbackServer_ServesTheCallback(t *testing.T) { + addr := mustFreeAddr(t) + cs, err := startCallbackServer(addr, "/callback") + require.NoError(t, err) + t.Cleanup(cs.shutdown) + + mustGet(t, "http://"+addr+"/callback?code=abc&state=xyz") + + code, state, err := cs.wait(context.Background(), 2*time.Second) + require.NoError(t, err) + assert.Equal(t, "abc", code) + assert.Equal(t, "xyz", state) +} + +func TestCallbackServer_Wait(t *testing.T) { + tests := []struct { + name string + givenResult *callbackResult + givenTimeout time.Duration + givenCancel bool + wantCode string + wantErrContains string + }{ + { + name: "timeout is reported", + givenTimeout: 10 * time.Millisecond, + wantErrContains: "timed out", + }, + { + name: "cancellation is reported", + givenTimeout: time.Minute, + givenCancel: true, + wantErrContains: "context canceled", + }, + { + name: "callback error is propagated", + givenResult: &callbackResult{err: stubError("authorize: access_denied")}, + givenTimeout: time.Minute, + wantErrContains: "access_denied", + }, + { + name: "code and state are returned", + givenResult: &callbackResult{code: "c", state: "s"}, + givenTimeout: time.Minute, + wantCode: "c", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch := make(chan callbackResult, 1) + if tt.givenResult != nil { + ch <- *tt.givenResult + } + cs := &callbackServer{results: ch} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if tt.givenCancel { + cancel() + } + + code, _, err := cs.wait(ctx, tt.givenTimeout) + if wegostrings.IsNotEmpty(tt.wantErrContains) { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrContains) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantCode, code) + }) + } +} + +func TestCallbackServer_ShutdownIsSafeWithoutAServer(t *testing.T) { + cs := &callbackServer{results: make(chan callbackResult, 1)} + assert.NotPanics(t, cs.shutdown) +} + +// mustFreeAddr reserves and releases a loopback port, returning its address. +func mustFreeAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + require.NoError(t, ln.Close()) + return addr +} + +// mustGet issues a throwaway GET, standing in for the operator's browser. +func mustGet(t *testing.T, rawURL string) { + t.Helper() + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) + require.NoError(t, err) + //nolint:gosec // G704: a loopback URL this test built itself. + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) +} + +// stubError is a minimal error for table cases that need a pre-baked failure. +type stubError string + +func (e stubError) Error() string { return string(e) } diff --git a/cognito/go.mod b/cognito/go.mod new file mode 100644 index 0000000..5f53e7e --- /dev/null +++ b/cognito/go.mod @@ -0,0 +1,18 @@ +module github.com/wego/pkg/cognito + +go 1.25.0 + +require ( + github.com/stretchr/testify v1.11.1 + github.com/wego/pkg/strings v0.1.2 + github.com/zalando/go-keyring v0.2.8 +) + +require ( + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.27.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cognito/go.sum b/cognito/go.sum new file mode 100644 index 0000000..3bcf314 --- /dev/null +++ b/cognito/go.sum @@ -0,0 +1,24 @@ +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wego/pkg/pointer v0.1.2 h1:KghXP86aWukvpSVPQ+Fg7YOkW8p8kyXcuOAvWVX1RUk= +github.com/wego/pkg/pointer v0.1.2/go.mod h1:TincAjFVHSyuZ05qnSP4APqs+eg+adjOfZV6VH0+CUA= +github.com/wego/pkg/strings v0.1.2 h1:sFfYDrC90JI43UCs4fnlxtuG/kXm6WJPIfiND1aqrQE= +github.com/wego/pkg/strings v0.1.2/go.mod h1:nS3SS/em72lPIJYtgaLE5XgboOLOS8lnsYRAD8U2Ptc= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cognito/oauth.go b/cognito/oauth.go new file mode 100644 index 0000000..6cda020 --- /dev/null +++ b/cognito/oauth.go @@ -0,0 +1,345 @@ +// Package cognito implements the Cognito authorization-code-with-PKCE login +// flow a command-line tool uses to obtain a human operator's tokens. +// +// It is built for CLIs rather than services: the flow opens a browser, receives +// the authorization code on a loopback listener, and hands back the token set +// for the caller to cache. A service verifying an incoming token wants +// github.com/wego/pkg/http/jwt instead. +// +// Two deliberate constraints shape this package: +// +// - It depends on nothing outside the standard library (bar Wego's string +// helpers). Hand-rolling the OAuth exchange keeps every wire parameter +// visible and auditable, which matters more here than the convenience an +// OAuth library would buy. +// - It holds no package-level mutable state. Every dependency - the clock, +// the HTTP client, the browser opener - arrives through Config, so tests +// and callers never race over shared globals. +package cognito + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + wegostrings "github.com/wego/pkg/strings" +) + +const ( + // placeholderPrefix marks a Cognito value that ops has not filled in yet. + placeholderPrefix = "REPLACE_WITH_" + + defaultHTTPTimeout = 30 * time.Second + maxTokenResponseBytes = 1 << 20 +) + +// Config carries everything the login flow needs. Nothing is read from the +// environment or from package state, so a caller holds the whole contract. +type Config struct { + // AuthorizeURL is the Cognito hosted-UI authorize endpoint. + AuthorizeURL string + + // TokenURL is the Cognito token endpoint. + TokenURL string + + // ClientID is the Cognito app client id. + ClientID string + + // RedirectURI is the callback URL registered on the app client. Its path + // determines where the local callback server listens. + RedirectURI string + + // Scopes is the space-separated scope list to request. + Scopes string + + // AllowedDomain, when set, is the email suffix an operator must sign in + // with, e.g. "@wego.com". + AllowedDomain string + + // CallbackAddr is the host:port the local callback server binds, e.g. + // "127.0.0.1:8110". It must agree with RedirectURI. + CallbackAddr string + + // IdentityProvider, when set, is forwarded as identity_provider so Cognito + // jumps straight to that IdP instead of showing its own chooser. + IdentityProvider string + + // OpenBrowser launches the authorize URL. Nil uses the OS default opener. + OpenBrowser func(string) error + + // HTTPClient calls the token endpoint. Nil uses a client with a timeout. + HTTPClient *http.Client + + // Now supplies the current time, for computing token expiry. Nil uses + // time.Now; tests inject a fixed clock. + Now func() time.Time +} + +// Login runs the browser-based authorization-code-with-PKCE flow and returns +// the operator's tokens. +func Login(ctx context.Context, cfg Config) (*TokenSet, error) { + if err := cfg.validateForLogin(); err != nil { + return nil, err + } + + verifier, err := generateVerifier() + if err != nil { + return nil, err + } + state, err := generateState() + if err != nil { + return nil, err + } + + // Bind the callback port BEFORE sending the operator to Cognito. If the + // port is unavailable the redirect could never land, and there is no + // fallback port to try, so failing here saves a pointless round trip. + server, err := startCallbackServer(cfg.CallbackAddr, callbackPath(cfg.RedirectURI)) + if err != nil { + return nil, err + } + defer server.shutdown() + + if err := cfg.openBrowserAt(cfg.buildAuthorizeURL(state, generateChallenge(verifier))); err != nil { + return nil, fmt.Errorf("open browser for sign-in: %w", err) + } + + code, callbackState, err := server.wait(ctx, defaultCallbackTimeout) + if err != nil { + return nil, err + } + + // CSRF protection, not decoration: a callback whose state is not the value + // we minted did not come from the authorize request we started, so the + // code it carries is not ours to redeem. + if !stateMatches(state, callbackState) { + return nil, errors.New("oauth state mismatch: the sign-in callback did not come from this login attempt") + } + + tokens, err := cfg.exchangeCode(ctx, code, verifier) + if err != nil { + return nil, err + } + + if err := cfg.checkAllowedDomain(tokens); err != nil { + return nil, err + } + + return tokens, nil +} + +// Refresh exchanges a refresh token for a fresh access and id token. +func Refresh(ctx context.Context, cfg Config, refreshToken string) (*TokenSet, error) { + if wegostrings.IsBlank(refreshToken) { + return nil, errors.New("no refresh token available: sign in again") + } + if err := cfg.validateClient(); err != nil { + return nil, err + } + + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("client_id", cfg.ClientID) + form.Set("refresh_token", refreshToken) + + return cfg.postToken(ctx, form, refreshToken) +} + +// exchangeCode redeems an authorization code together with its PKCE verifier. +func (c Config) exchangeCode(ctx context.Context, code, verifier string) (*TokenSet, error) { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", c.ClientID) + form.Set("code", code) + form.Set("redirect_uri", c.RedirectURI) + form.Set("code_verifier", verifier) + + return c.postToken(ctx, form, "") +} + +// tokenResponse is the wire shape of a Cognito token response. The field names +// are fixed by the OAuth spec, so gosec's secret-field warning is expected. +type tokenResponse struct { + AccessToken string `json:"access_token"` //nolint:gosec // G117: OAuth wire field; decoded in-process and never logged. + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` //nolint:gosec // G117: OAuth wire field, as above. + ExpiresIn int `json:"expires_in"` +} + +// postToken performs a token-endpoint call. fallbackRefresh is the refresh +// token to keep if the response omits one. +func (c Config) postToken(ctx context.Context, form url.Values, fallbackRefresh string) (*TokenSet, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("build token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + //nolint:gosec // G704: TokenURL is operator-controlled CLI config (the Cognito domain), not request input. + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("call the token endpoint: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxTokenResponseBytes)) + if err != nil { + return nil, fmt.Errorf("read token response: %w", err) + } + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, tokenEndpointError(resp.StatusCode, body) + } + + var parsed tokenResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("decode token response: %w", err) + } + + // Cognito omits refresh_token from a refresh response, so the original has + // to be carried forward. This is load-bearing: dropping it would silently + // sign the operator out on their next command. Do not add validation that + // rejects this fallback. + refresh := parsed.RefreshToken + if wegostrings.IsBlank(refresh) { + refresh = fallbackRefresh + } + + switch { + case wegostrings.IsBlank(parsed.AccessToken): + return nil, errors.New("token response is missing access_token") + case wegostrings.IsBlank(parsed.IDToken): + return nil, errors.New("token response is missing id_token") + case wegostrings.IsBlank(refresh): + return nil, errors.New("token response is missing refresh_token") + } + + return &TokenSet{ + AccessToken: parsed.AccessToken, + IDToken: parsed.IDToken, + RefreshToken: refresh, + ExpiresAt: c.now().Add(time.Duration(parsed.ExpiresIn) * time.Second), + }, nil +} + +// tokenEndpointError reports a non-2xx token response. +// +// Only the standard OAuth error fields are echoed. An arbitrary response body +// is deliberately withheld, so no token can ever ride out inside an error +// message that ends up in a log or a bug report. +func tokenEndpointError(status int, body []byte) error { + var parsed struct { + Error string `json:"error"` + Description string `json:"error_description"` + } + + if err := json.Unmarshal(body, &parsed); err == nil && wegostrings.IsNotBlank(parsed.Error) { + if wegostrings.IsNotBlank(parsed.Description) { + return fmt.Errorf("token endpoint returned %d: %s: %s", status, parsed.Error, parsed.Description) + } + return fmt.Errorf("token endpoint returned %d: %s", status, parsed.Error) + } + + return fmt.Errorf("token endpoint returned %d (response body withheld: it may carry credentials)", status) +} + +// buildAuthorizeURL assembles the hosted-UI URL the operator is sent to. +func (c Config) buildAuthorizeURL(state, challenge string) string { + query := url.Values{} + query.Set("response_type", "code") + query.Set("client_id", c.ClientID) + query.Set("redirect_uri", c.RedirectURI) + query.Set("scope", c.Scopes) + query.Set("state", state) + query.Set("code_challenge", challenge) + query.Set("code_challenge_method", "S256") + if wegostrings.IsNotBlank(c.IdentityProvider) { + query.Set("identity_provider", c.IdentityProvider) + } + + separator := "?" + if strings.Contains(c.AuthorizeURL, "?") { + separator = "&" + } + return c.AuthorizeURL + separator + query.Encode() +} + +// checkAllowedDomain rejects an operator signed in outside AllowedDomain. +// +// This is client-side UX, NOT a security boundary: it catches "you signed in +// with your personal Google account" before the CLI starts issuing calls that +// would fail confusingly. The server does not enforce it, so nothing may rely +// on it for authorization. +func (c Config) checkAllowedDomain(tokens *TokenSet) error { + if wegostrings.IsBlank(c.AllowedDomain) { + return nil + } + + email, err := tokens.Email() + if err != nil { + return fmt.Errorf("check the signed-in email: %w", err) + } + + if !strings.HasSuffix(strings.ToLower(email), strings.ToLower(c.AllowedDomain)) { + return fmt.Errorf("signed in as %s, but a %s account is required", email, c.AllowedDomain) + } + return nil +} + +// validateClient checks the values every token call needs. +func (c Config) validateClient() error { + if wegostrings.IsBlank(c.ClientID) || strings.HasPrefix(c.ClientID, placeholderPrefix) { + return errors.New("cognito app client is not provisioned yet: Config.ClientID is blank or still a placeholder, so the app client needs to be created and its id wired into the caller's config") + } + if wegostrings.IsBlank(c.TokenURL) { + return errors.New("cognito token url is not configured") + } + return nil +} + +// validateForLogin checks everything the interactive flow additionally needs. +func (c Config) validateForLogin() error { + if err := c.validateClient(); err != nil { + return err + } + if wegostrings.IsBlank(c.AuthorizeURL) { + return errors.New("cognito authorize url is not configured") + } + if wegostrings.IsBlank(c.RedirectURI) { + return errors.New("cognito redirect uri is not configured") + } + if wegostrings.IsBlank(c.CallbackAddr) { + return errors.New("local callback address is not configured") + } + return nil +} + +// httpClient is the client to call the token endpoint with. +func (c Config) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return &http.Client{Timeout: defaultHTTPTimeout} +} + +// now is the current time, from the injected clock when there is one. +func (c Config) now() time.Time { + if c.Now != nil { + return c.Now() + } + return time.Now() +} + +// openBrowserAt sends the operator to rawURL. +func (c Config) openBrowserAt(rawURL string) error { + if c.OpenBrowser != nil { + return c.OpenBrowser(rawURL) + } + return openBrowser(rawURL) +} diff --git a/cognito/oauth_internal_test.go b/cognito/oauth_internal_test.go new file mode 100644 index 0000000..064dc3a --- /dev/null +++ b/cognito/oauth_internal_test.go @@ -0,0 +1,138 @@ +package cognito + +import ( + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTokenEndpointError(t *testing.T) { + tests := []struct { + name string + givenStatus int + givenBody string + wantContains []string + wantNotContains []string + }{ + { + name: "non-json body is withheld entirely", + givenStatus: http.StatusInternalServerError, + givenBody: "access_token=super-secret-value", + // An unparseable body could be anything, so we report only the + // status: a token must never ride out inside an error string. + wantContains: []string{"500", "withheld"}, + wantNotContains: []string{"super-secret-value"}, + }, + { + name: "json without an error field is withheld", + givenStatus: http.StatusBadGateway, + givenBody: `{"id_token":"secret-jwt-value"}`, + wantContains: []string{"502", "withheld"}, + wantNotContains: []string{"secret-jwt-value"}, + }, + { + name: "oauth error is echoed", + givenStatus: http.StatusBadRequest, + givenBody: `{"error":"invalid_grant"}`, + wantContains: []string{"400", "invalid_grant"}, + }, + { + name: "oauth error and description are echoed", + givenStatus: http.StatusBadRequest, + givenBody: `{"error":"invalid_grant","error_description":"code expired"}`, + wantContains: []string{"400", "invalid_grant", "code expired"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tokenEndpointError(tt.givenStatus, []byte(tt.givenBody)) + + require.Error(t, err) + for _, want := range tt.wantContains { + assert.Contains(t, err.Error(), want) + } + for _, notWant := range tt.wantNotContains { + assert.NotContains(t, err.Error(), notWant) + } + }) + } +} + +func TestConfig_BuildAuthorizeURL(t *testing.T) { + tests := []struct { + name string + givenAuthorizeURL string + givenIdentityProvider string + wantQueryHasProvider bool + }{ + { + name: "an existing query string is appended to", + givenAuthorizeURL: "https://cognito.test/oauth2/authorize?foo=bar", + }, + { + name: "no identity provider leaves the parameter out", + givenAuthorizeURL: "https://cognito.test/oauth2/authorize", + }, + { + name: "identity provider is forwarded when set", + givenAuthorizeURL: "https://cognito.test/oauth2/authorize", + givenIdentityProvider: "Google", + wantQueryHasProvider: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Config{ + AuthorizeURL: tt.givenAuthorizeURL, + ClientID: "client", + RedirectURI: "http://127.0.0.1:8110/callback", + Scopes: "openid email", + IdentityProvider: tt.givenIdentityProvider, + } + + got, err := url.Parse(cfg.buildAuthorizeURL("the-state", "the-challenge")) + require.NoError(t, err) + + query := got.Query() + assert.Equal(t, "the-state", query.Get("state")) + assert.Equal(t, "the-challenge", query.Get("code_challenge")) + assert.Equal(t, "S256", query.Get("code_challenge_method")) + assert.Equal(t, tt.givenIdentityProvider, query.Get("identity_provider")) + assert.Equal(t, tt.wantQueryHasProvider, query.Has("identity_provider")) + + if tt.givenAuthorizeURL == "https://cognito.test/oauth2/authorize?foo=bar" { + assert.Equal(t, "bar", query.Get("foo"), "an existing query parameter must survive") + } + }) + } +} + +func TestConfig_Defaults(t *testing.T) { + var zero Config + + assert.Equal(t, defaultHTTPTimeout, zero.httpClient().Timeout, "a nil HTTPClient must get a timeout") + assert.WithinDuration(t, time.Now(), zero.now(), time.Minute, "a nil Now must fall back to the system clock") + + custom := &http.Client{Timeout: time.Second} + assert.Same(t, custom, Config{HTTPClient: custom}.httpClient()) + + fixed := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + assert.Equal(t, fixed, Config{Now: func() time.Time { return fixed }}.now()) +} + +func TestConfig_OpenBrowserAtUsesTheInjectedOpener(t *testing.T) { + var got string + cfg := Config{OpenBrowser: func(rawURL string) error { + got = rawURL + return nil + }} + + require.NoError(t, cfg.openBrowserAt("https://example.test/authorize")) + assert.Equal(t, "https://example.test/authorize", got) +} diff --git a/cognito/oauth_test.go b/cognito/oauth_test.go new file mode 100644 index 0000000..5117709 --- /dev/null +++ b/cognito/oauth_test.go @@ -0,0 +1,652 @@ +package cognito_test + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "net" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + wegostrings "github.com/wego/pkg/strings" + + "github.com/wego/pkg/cognito" +) + +const ( + testAuthCode = "the-authorization-code" + testAccessValue = "access-value" + testIDTokenLabel = "id_token" + testRefreshValue = "refresh-value" +) + +func TestLogin(t *testing.T) { + tests := []struct { + name string + givenConfig func(*cognito.Config) + givenBrowser func(*fakeBrowser) + givenHandler func(*testing.T) http.HandlerFunc + givenCancel bool + wantEmail string + wantErrContains string + }{ + { + name: "blank client id tells the operator it is unprovisioned", + givenConfig: func(c *cognito.Config) { c.ClientID = "" }, + wantErrContains: "not provisioned", + }, + { + name: "placeholder client id tells the operator it is unprovisioned", + givenConfig: func(c *cognito.Config) { c.ClientID = "REPLACE_WITH_COGNITO_CLIENT_ID" }, + wantErrContains: "not provisioned", + }, + { + name: "missing authorize url is rejected", + givenConfig: func(c *cognito.Config) { c.AuthorizeURL = "" }, + wantErrContains: "authorize url", + }, + { + name: "missing token url is rejected", + givenConfig: func(c *cognito.Config) { c.TokenURL = "" }, + wantErrContains: "token url", + }, + { + name: "missing redirect uri is rejected", + givenConfig: func(c *cognito.Config) { c.RedirectURI = "" }, + wantErrContains: "redirect uri", + }, + { + name: "missing callback address is rejected", + givenConfig: func(c *cognito.Config) { c.CallbackAddr = "" }, + wantErrContains: "callback address", + }, + { + name: "browser launch failure is surfaced", + givenBrowser: func(f *fakeBrowser) { f.openErr = errors.New("no browser here") }, + wantErrContains: "open browser", + }, + { + name: "tampered state is rejected", + givenBrowser: func(f *fakeBrowser) { + f.tamper = func(q url.Values) { q.Set("state", "tampered-state") } + }, + wantErrContains: "state mismatch", + }, + { + name: "missing state is rejected", + givenBrowser: func(f *fakeBrowser) { + f.tamper = func(q url.Values) { q.Del("state") } + }, + wantErrContains: "state mismatch", + }, + { + name: "authorize server error is surfaced", + givenBrowser: func(f *fakeBrowser) { + f.tamper = func(q url.Values) { + q.Del("code") + q.Set("error", "access_denied") + q.Set("error_description", "operator declined") + } + }, + wantErrContains: "access_denied", + }, + { + name: "missing code is rejected", + givenBrowser: func(f *fakeBrowser) { + f.tamper = func(q url.Values) { q.Del("code") } + }, + wantErrContains: "no authorization code", + }, + { + name: "token endpoint rejection is surfaced", + givenHandler: func(t *testing.T) http.HandlerFunc { + t.Helper() + return jsonHandler(http.StatusBadRequest, map[string]any{ + "error": "invalid_grant", + "error_description": "code already used", + }) + }, + wantErrContains: "invalid_grant", + }, + { + name: "unparseable token response is rejected", + givenHandler: func(t *testing.T) http.HandlerFunc { + t.Helper() + return rawHandler(http.StatusOK, "not json") + }, + wantErrContains: "decode token response", + }, + { + name: "token response without an access token is rejected", + givenHandler: func(t *testing.T) http.HandlerFunc { + t.Helper() + body := goodTokenBody(t, testOperatorEmail) + delete(body, "access_token") + return jsonHandler(http.StatusOK, body) + }, + wantErrContains: "missing access_token", + }, + { + name: "token response without an id token is rejected", + givenHandler: func(t *testing.T) http.HandlerFunc { + t.Helper() + body := goodTokenBody(t, testOperatorEmail) + delete(body, testIDTokenLabel) + return jsonHandler(http.StatusOK, body) + }, + wantErrContains: "missing id_token", + }, + { + name: "token response without a refresh token is rejected", + givenHandler: func(t *testing.T) http.HandlerFunc { + t.Helper() + body := goodTokenBody(t, testOperatorEmail) + delete(body, "refresh_token") + return jsonHandler(http.StatusOK, body) + }, + wantErrContains: "missing refresh_token", + }, + { + name: "id token without an email claim is rejected", + givenHandler: func(t *testing.T) http.HandlerFunc { + t.Helper() + body := goodTokenBody(t, testOperatorEmail) + body[testIDTokenLabel] = mustIDToken(t, map[string]any{"sub": "abc"}) + return jsonHandler(http.StatusOK, body) + }, + wantErrContains: "email", + }, + { + name: "email outside the allowed domain is rejected", + givenHandler: func(t *testing.T) http.HandlerFunc { + t.Helper() + return jsonHandler(http.StatusOK, goodTokenBody(t, "someone@gmail.com")) + }, + wantErrContains: "@wego.com", + }, + { + name: "cancelled context stops waiting", + givenBrowser: func(f *fakeBrowser) { f.suppress = true }, + givenCancel: true, + wantErrContains: "context canceled", + }, + { + name: "successful login returns a token set", + wantEmail: testOperatorEmail, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var handler http.HandlerFunc + if tt.givenHandler != nil { + handler = tt.givenHandler(t) + } + ts := newTokenServer(t, handler) + + browser := newFakeBrowser() + if tt.givenBrowser != nil { + tt.givenBrowser(browser) + } + + cfg := baseConfig(t, ts.URL, mustFreeAddr(t)) + cfg.OpenBrowser = browser.open + if tt.givenConfig != nil { + tt.givenConfig(&cfg) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if tt.givenCancel { + cancel() + } + + got, err := cognito.Login(ctx, cfg) + + if wegostrings.IsNotEmpty(tt.wantErrContains) { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrContains) + assert.Nil(t, got) + return + } + require.NoError(t, err) + require.NotNil(t, got) + + email, err := got.Email() + require.NoError(t, err) + assert.Equal(t, tt.wantEmail, email) + }) + } +} + +func TestLogin_BindsTheCallbackPortBeforeOpeningTheBrowser(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + addr := ln.Addr().String() + + ts := newTokenServer(t, nil) + browser := newFakeBrowser() + cfg := baseConfig(t, ts.URL, addr) + cfg.OpenBrowser = browser.open + + got, err := cognito.Login(context.Background(), cfg) + + require.Error(t, err, "an occupied callback port must fail fast, not fall back to another port") + assert.Nil(t, got) + assert.Contains(t, err.Error(), addr, "the error must name the address the operator has to free") + assert.Empty(t, browser.capturedURL(), "the browser must not be launched when the callback port is unavailable") +} + +func TestLogin_SendsPKCEAndState(t *testing.T) { + ts := newTokenServer(t, nil) + browser := newFakeBrowser() + cfg := baseConfig(t, ts.URL, mustFreeAddr(t)) + cfg.OpenBrowser = browser.open + + got, err := cognito.Login(context.Background(), cfg) + require.NoError(t, err) + + authorizeURL, err := url.Parse(browser.capturedURL()) + require.NoError(t, err) + aq := authorizeURL.Query() + + assert.Equal(t, "https://cognito.test/oauth2/authorize", authorizeURL.Scheme+"://"+authorizeURL.Host+authorizeURL.Path) + assert.Equal(t, "code", aq.Get("response_type")) + assert.Equal(t, cfg.ClientID, aq.Get("client_id")) + assert.Equal(t, cfg.RedirectURI, aq.Get("redirect_uri")) + assert.Equal(t, cfg.Scopes, aq.Get("scope")) + assert.Equal(t, "S256", aq.Get("code_challenge_method"), "plain PKCE must never be used") + assert.NotEmpty(t, aq.Get("state"), "state is CSRF protection and must always be sent") + + form := ts.lastForm(t) + assert.Equal(t, "authorization_code", form.Get("grant_type")) + assert.Equal(t, cfg.ClientID, form.Get("client_id")) + assert.Equal(t, testAuthCode, form.Get("code")) + assert.Equal(t, cfg.RedirectURI, form.Get("redirect_uri")) + + verifier := form.Get("code_verifier") + require.NotEmpty(t, verifier, "the exchange must carry the PKCE verifier") + assert.GreaterOrEqual(t, len(verifier), 43) + assert.LessOrEqual(t, len(verifier), 128) + + sum := sha256.Sum256([]byte(verifier)) + assert.Equal(t, base64.RawURLEncoding.EncodeToString(sum[:]), aq.Get("code_challenge"), + "code_challenge must be the S256 hash of the verifier actually redeemed") + + assert.Equal(t, fixedNow.Add(time.Hour), got.ExpiresAt, "ExpiresAt must be derived from the injected clock") + assert.Equal(t, testAccessValue, got.AccessToken) + assert.Equal(t, testRefreshValue, got.RefreshToken) +} + +func TestLogin_ErrorsDoNotLeakVerifierOrState(t *testing.T) { + ts := newTokenServer(t, jsonHandler(http.StatusBadRequest, map[string]any{"error": "invalid_grant"})) + browser := newFakeBrowser() + cfg := baseConfig(t, ts.URL, mustFreeAddr(t)) + cfg.OpenBrowser = browser.open + + _, err := cognito.Login(context.Background(), cfg) + require.Error(t, err) + + authorizeURL, parseErr := url.Parse(browser.capturedURL()) + require.NoError(t, parseErr) + state := authorizeURL.Query().Get("state") + require.NotEmpty(t, state) + verifier := ts.lastForm(t).Get("code_verifier") + require.NotEmpty(t, verifier) + + assert.NotContains(t, err.Error(), state, "state must never appear in an error message") + assert.NotContains(t, err.Error(), verifier, "the PKCE verifier must never appear in an error message") +} + +func TestLogin_FallsBackToTheSystemClockWhenNowIsNil(t *testing.T) { + ts := newTokenServer(t, nil) + browser := newFakeBrowser() + cfg := baseConfig(t, ts.URL, mustFreeAddr(t)) + cfg.OpenBrowser = browser.open + cfg.Now = nil + + before := time.Now() + got, err := cognito.Login(context.Background(), cfg) + require.NoError(t, err) + + assert.WithinDuration(t, before.Add(time.Hour), got.ExpiresAt, time.Minute) +} + +func TestLogin_AllowsAnyEmailWhenNoDomainIsConfigured(t *testing.T) { + ts := newTokenServer(t, jsonHandler(http.StatusOK, goodTokenBody(t, "contractor@example.test"))) + browser := newFakeBrowser() + cfg := baseConfig(t, ts.URL, mustFreeAddr(t)) + cfg.OpenBrowser = browser.open + cfg.AllowedDomain = "" + + got, err := cognito.Login(context.Background(), cfg) + require.NoError(t, err) + require.NotNil(t, got) +} + +func TestRefresh(t *testing.T) { + tests := []struct { + name string + givenConfig func(*cognito.Config) + givenRefresh string + givenHandler func(*testing.T) http.HandlerFunc + wantRefresh string + wantErrContains string + }{ + { + name: "blank refresh token is rejected", + givenRefresh: "", + wantErrContains: "refresh token", + }, + { + name: "blank client id tells the operator it is unprovisioned", + givenRefresh: testRefreshValue, + givenConfig: func(c *cognito.Config) { c.ClientID = "" }, + wantErrContains: "not provisioned", + }, + { + name: "placeholder client id tells the operator it is unprovisioned", + givenRefresh: testRefreshValue, + givenConfig: func(c *cognito.Config) { c.ClientID = "REPLACE_WITH_COGNITO_CLIENT_ID" }, + wantErrContains: "not provisioned", + }, + { + name: "missing token url is rejected", + givenRefresh: testRefreshValue, + givenConfig: func(c *cognito.Config) { c.TokenURL = "" }, + wantErrContains: "token url", + }, + { + name: "server failure is surfaced", + givenRefresh: testRefreshValue, + givenHandler: func(t *testing.T) http.HandlerFunc { + t.Helper() + return rawHandler(http.StatusInternalServerError, "upstream exploded") + }, + wantErrContains: "500", + }, + { + name: "unparseable response is rejected", + givenRefresh: testRefreshValue, + givenHandler: func(t *testing.T) http.HandlerFunc { + t.Helper() + return rawHandler(http.StatusOK, "not json") + }, + wantErrContains: "decode token response", + }, + { + name: "response without an access token is rejected", + givenRefresh: testRefreshValue, + givenHandler: func(t *testing.T) http.HandlerFunc { + t.Helper() + body := goodTokenBody(t, testOperatorEmail) + delete(body, "access_token") + return jsonHandler(http.StatusOK, body) + }, + wantErrContains: "missing access_token", + }, + { + name: "a rotated refresh token is adopted", + givenRefresh: "original-refresh", + givenHandler: func(t *testing.T) http.HandlerFunc { + t.Helper() + body := goodTokenBody(t, testOperatorEmail) + body["refresh_token"] = "rotated-refresh" + return jsonHandler(http.StatusOK, body) + }, + wantRefresh: "rotated-refresh", + }, + { + name: "an omitted refresh token falls back to the original", + givenRefresh: "original-refresh", + givenHandler: func(t *testing.T) http.HandlerFunc { + t.Helper() + body := goodTokenBody(t, testOperatorEmail) + delete(body, "refresh_token") + return jsonHandler(http.StatusOK, body) + }, + wantRefresh: "original-refresh", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var handler http.HandlerFunc + if tt.givenHandler != nil { + handler = tt.givenHandler(t) + } + ts := newTokenServer(t, handler) + + cfg := baseConfig(t, ts.URL, "127.0.0.1:0") + if tt.givenConfig != nil { + tt.givenConfig(&cfg) + } + + got, err := cognito.Refresh(context.Background(), cfg, tt.givenRefresh) + + if wegostrings.IsNotEmpty(tt.wantErrContains) { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrContains) + assert.Nil(t, got) + return + } + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, tt.wantRefresh, got.RefreshToken) + assert.Equal(t, "refresh_token", ts.lastForm(t).Get("grant_type")) + }) + } +} + +// TestRefresh_PreservesTheOriginalRefreshToken pins the load-bearing Cognito +// invariant: a refresh response omits refresh_token, and dropping the original +// would log the operator out on the very next command. +func TestRefresh_PreservesTheOriginalRefreshToken(t *testing.T) { + body := goodTokenBody(t, testOperatorEmail) + delete(body, "refresh_token") + ts := newTokenServer(t, jsonHandler(http.StatusOK, body)) + + cfg := baseConfig(t, ts.URL, "127.0.0.1:0") + + got, err := cognito.Refresh(context.Background(), cfg, "the-long-lived-refresh-token") + require.NoError(t, err) + assert.Equal(t, "the-long-lived-refresh-token", got.RefreshToken) + assert.Equal(t, "the-long-lived-refresh-token", ts.lastForm(t).Get("refresh_token"), + "the original refresh token must be what we present to Cognito") + assert.Equal(t, fixedNow.Add(time.Hour), got.ExpiresAt) +} + +// TestRefresh_DoesNotApplyTheDomainGate documents that the allowed-domain check +// is a login-time UX affordance, not something to re-run on every refresh. +func TestRefresh_DoesNotApplyTheDomainGate(t *testing.T) { + ts := newTokenServer(t, jsonHandler(http.StatusOK, goodTokenBody(t, "someone@gmail.com"))) + cfg := baseConfig(t, ts.URL, "127.0.0.1:0") + + got, err := cognito.Refresh(context.Background(), cfg, testRefreshValue) + require.NoError(t, err) + require.NotNil(t, got) +} + +func TestRefresh_HonoursContextCancellation(t *testing.T) { + ts := newTokenServer(t, nil) + cfg := baseConfig(t, ts.URL, "127.0.0.1:0") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + got, err := cognito.Refresh(ctx, cfg, testRefreshValue) + require.Error(t, err) + assert.Nil(t, got) +} + +// baseConfig is a working staging-shaped config aimed at a test token server. +func baseConfig(t *testing.T, tokenURL, callbackAddr string) cognito.Config { + t.Helper() + return cognito.Config{ + AuthorizeURL: "https://cognito.test/oauth2/authorize", + TokenURL: tokenURL, + ClientID: "test-client-id", + RedirectURI: "http://" + callbackAddr + "/callback", + Scopes: "openid email profile", + AllowedDomain: "@wego.com", + CallbackAddr: callbackAddr, + Now: func() time.Time { return fixedNow }, + } +} + +// goodTokenBody is a well-formed Cognito token response. +func goodTokenBody(t *testing.T, email string) map[string]any { + t.Helper() + return map[string]any{ + "access_token": testAccessValue, + testIDTokenLabel: mustIDToken(t, map[string]any{"email": email}), + "refresh_token": testRefreshValue, + "expires_in": 3600, + "token_type": "Bearer", + } +} + +func jsonHandler(status int, body any) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + enc, err := json.Marshal(body) + if err != nil { + http.Error(w, "marshal failed", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(enc) + } +} + +func rawHandler(status int, body string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + } +} + +// tokenServer records every form it is posted so tests can assert on the +// exchange itself, not just its result. +type tokenServer struct { + *httptest.Server + + mu sync.Mutex + forms []url.Values +} + +func newTokenServer(t *testing.T, handler http.HandlerFunc) *tokenServer { + t.Helper() + + ts := &tokenServer{} + if handler == nil { + handler = jsonHandler(http.StatusOK, goodTokenBody(t, testOperatorEmail)) + } + + ts.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + ts.mu.Lock() + ts.forms = append(ts.forms, r.PostForm) + ts.mu.Unlock() + handler(w, r) + })) + t.Cleanup(ts.Close) + + return ts +} + +func (ts *tokenServer) lastForm(t *testing.T) url.Values { + t.Helper() + ts.mu.Lock() + defer ts.mu.Unlock() + require.NotEmpty(t, ts.forms, "the token endpoint was never called") + return ts.forms[len(ts.forms)-1] +} + +// fakeBrowser stands in for the operator's browser: it reads the authorize URL +// and drives the loopback callback the way a real redirect would. +type fakeBrowser struct { + openErr error + suppress bool + tamper func(url.Values) + + mu sync.Mutex + authorizeURL string +} + +func newFakeBrowser() *fakeBrowser { + return &fakeBrowser{} +} + +func (f *fakeBrowser) open(rawURL string) error { + f.mu.Lock() + f.authorizeURL = rawURL + f.mu.Unlock() + + if f.openErr != nil { + return f.openErr + } + if f.suppress { + return nil + } + + authorizeURL, err := url.Parse(rawURL) + if err != nil { + return err + } + callback, err := url.Parse(authorizeURL.Query().Get("redirect_uri")) + if err != nil { + return err + } + + q := url.Values{} + q.Set("code", testAuthCode) + q.Set("state", authorizeURL.Query().Get("state")) + if f.tamper != nil { + f.tamper(q) + } + callback.RawQuery = q.Encode() + + go func() { + req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, callback.String(), nil) + if reqErr != nil { + return + } + //nolint:gosec // G704: a loopback callback URL derived from the authorize URL this test built. + resp, doErr := http.DefaultClient.Do(req) + if doErr != nil { + return + } + _ = resp.Body.Close() + }() + + return nil +} + +func (f *fakeBrowser) capturedURL() string { + f.mu.Lock() + defer f.mu.Unlock() + return f.authorizeURL +} + +// mustFreeAddr reserves and releases a loopback port, returning its address. +func mustFreeAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + require.NoError(t, ln.Close()) + return addr +} diff --git a/cognito/pkce.go b/cognito/pkce.go new file mode 100644 index 0000000..63572c6 --- /dev/null +++ b/cognito/pkce.go @@ -0,0 +1,56 @@ +package cognito + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "fmt" + + wegostrings "github.com/wego/pkg/strings" +) + +const ( + // verifierBytes yields a 128-character unpadded base64url verifier, the + // longest RFC 7636 permits for code_verifier. + verifierBytes = 96 + + // stateBytes yields a 32-character unpadded base64url state value. + stateBytes = 24 +) + +// generateVerifier draws a fresh PKCE code_verifier from crypto/rand. +func generateVerifier() (string, error) { + buf := make([]byte, verifierBytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate pkce verifier: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// generateChallenge derives the S256 code_challenge for a verifier. Both the +// verifier and the challenge are unpadded base64url, per RFC 7636. +func generateChallenge(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// generateState draws a fresh OAuth state value from crypto/rand. +func generateState() (string, error) { + buf := make([]byte, stateBytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate oauth state: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// stateMatches compares the state a callback presented against the one we +// minted, in constant time. A blank value on either side never matches: a +// callback that omits state is precisely the CSRF case the parameter exists to +// catch, so it must be rejected rather than waved through. +func stateMatches(want, got string) bool { + if wegostrings.IsBlank(want) || wegostrings.IsBlank(got) { + return false + } + return subtle.ConstantTimeCompare([]byte(want), []byte(got)) == 1 +} diff --git a/cognito/pkce_internal_test.go b/cognito/pkce_internal_test.go new file mode 100644 index 0000000..7cda37a --- /dev/null +++ b/cognito/pkce_internal_test.go @@ -0,0 +1,104 @@ +package cognito + +import ( + "crypto/sha256" + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateVerifier(t *testing.T) { + verifier, err := generateVerifier() + require.NoError(t, err) + + assert.GreaterOrEqual(t, len(verifier), 43, "RFC 7636 requires a code_verifier of at least 43 chars, got %q", verifier) + assert.LessOrEqual(t, len(verifier), 128, "RFC 7636 caps code_verifier at 128 chars, got %d chars", len(verifier)) + assert.NotContains(t, verifier, "=", "code_verifier must be base64url WITHOUT padding") + assert.NotContains(t, verifier, "+", "code_verifier must be base64url, not standard base64") + assert.NotContains(t, verifier, "/", "code_verifier must be base64url, not standard base64") + + _, err = base64.RawURLEncoding.DecodeString(verifier) + require.NoError(t, err, "code_verifier must decode as unpadded base64url") +} + +func TestGenerateVerifier_IsUniquePerCall(t *testing.T) { + seen := make(map[string]struct{}, 32) + for range 32 { + verifier, err := generateVerifier() + require.NoError(t, err) + _, dup := seen[verifier] + require.False(t, dup, "generateVerifier returned a duplicate; it must be drawn from crypto/rand") + seen[verifier] = struct{}{} + } +} + +func TestGenerateChallenge(t *testing.T) { + tests := []struct { + name string + givenVerifier string + want string + }{ + { + // RFC 7636 appendix B test vector - pins the S256 transformation. + name: "rfc 7636 appendix b vector", + givenVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + want: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + }, + { + name: "empty verifier still hashes", + givenVerifier: "", + want: base64.RawURLEncoding.EncodeToString(sha256Sum("")), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := generateChallenge(tt.givenVerifier) + assert.Equal(t, tt.want, got, "challenge for verifier %q", tt.givenVerifier) + assert.NotContains(t, got, "=", "code_challenge must be unpadded base64url") + }) + } +} + +func TestGenerateState(t *testing.T) { + state, err := generateState() + require.NoError(t, err) + + require.NotEmpty(t, state) + assert.NotContains(t, state, "=", "state must be unpadded base64url") + _, err = base64.RawURLEncoding.DecodeString(state) + require.NoError(t, err, "state must decode as unpadded base64url") + + other, err := generateState() + require.NoError(t, err) + assert.NotEqual(t, state, other, "state must be freshly drawn from crypto/rand on every call") +} + +func TestStateMatches(t *testing.T) { + tests := []struct { + name string + givenWant string + givenGot string + want bool + }{ + {name: "mismatch", givenWant: "abc", givenGot: "abd", want: false}, + {name: "missing callback state", givenWant: "abc", givenGot: "", want: false}, + {name: "both blank is still a mismatch", givenWant: "", givenGot: "", want: false}, + {name: "different length", givenWant: "abc", givenGot: "abcdef", want: false}, + {name: "match", givenWant: "abc", givenGot: "abc", want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, stateMatches(tt.givenWant, tt.givenGot)) + }) + } +} + +// sha256Sum computes the expectation independently of the code under test. +func sha256Sum(s string) []byte { + sum := sha256.Sum256([]byte(s)) + return sum[:] +} diff --git a/cognito/storage/keyring.go b/cognito/storage/keyring.go new file mode 100644 index 0000000..dce0c91 --- /dev/null +++ b/cognito/storage/keyring.go @@ -0,0 +1,190 @@ +package storage + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + wegostrings "github.com/wego/pkg/strings" + "github.com/zalando/go-keyring" + + "github.com/wego/pkg/cognito" +) + +// A Cognito JWT - the access token especially - can exceed the 4 KiB +// command-line cap zalando/go-keyring runs into on macOS, where it shells out +// to /usr/bin/security. So each field gets its own keychain entry, keeping +// every individual write well under that ceiling. +// +// Entries are accounted as "/". +const ( + fieldAccess = "access" + fieldID = "id" + fieldRefresh = "refresh" + fieldMeta = "meta" +) + +// keyringBackend is the slice of zalando/go-keyring this package depends on. +// Naming it lets tests substitute a fake instead of prompting a real keychain. +type keyringBackend interface { + Set(service, user, password string) error + Get(service, user string) (string, error) + Delete(service, user string) error +} + +// systemKeyring is the real OS keychain. +type systemKeyring struct{} + +func (systemKeyring) Set(service, user, password string) error { + return keyring.Set(service, user, password) +} + +func (systemKeyring) Get(service, user string) (string, error) { + return keyring.Get(service, user) +} + +func (systemKeyring) Delete(service, user string) error { + return keyring.Delete(service, user) +} + +// keyringMeta is the non-secret metadata, kept in one small entry beside the +// tokens themselves. +type keyringMeta struct { + ExpiresAt time.Time `json:"expires_at"` +} + +// keyringStore persists tokens in the OS keychain. +type keyringStore struct { + service string + backend keyringBackend +} + +// NewKeyring returns a Store backed by the operating system keychain, with +// every entry filed under service. +func NewKeyring(service string) Store { + return &keyringStore{service: service, backend: systemKeyring{}} +} + +// Load returns the tokens stored under namespace, or (nil, nil) if the +// operator is not signed in. +func (k *keyringStore) Load(namespace string) (*cognito.TokenSet, error) { + if err := k.validate(namespace); err != nil { + return nil, err + } + + // Gate on the access token: its absence means "not signed in", which is a + // normal state rather than a failure. Once it is present, anything else + // missing is a real inconsistency and must be reported. + access, err := k.backend.Get(k.service, account(namespace, fieldAccess)) + if errors.Is(err, keyring.ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read the keychain: %w (is the system keychain unlocked?)", err) + } + + idToken, err := k.read(namespace, fieldID) + if err != nil { + return nil, err + } + refresh, err := k.read(namespace, fieldRefresh) + if err != nil { + return nil, err + } + rawMeta, err := k.read(namespace, fieldMeta) + if err != nil { + return nil, err + } + + var meta keyringMeta + if err := json.Unmarshal([]byte(rawMeta), &meta); err != nil { + return nil, fmt.Errorf("parse token metadata from the keychain: %w", err) + } + + return &cognito.TokenSet{ + AccessToken: access, + IDToken: idToken, + RefreshToken: refresh, + ExpiresAt: meta.ExpiresAt, + }, nil +} + +// Save writes tokens under namespace, replacing anything already there. +func (k *keyringStore) Save(namespace string, tokens *cognito.TokenSet) error { + if err := k.validate(namespace); err != nil { + return err + } + if tokens == nil { + return errNoTokens + } + + meta, err := json.Marshal(keyringMeta{ExpiresAt: tokens.ExpiresAt}) + if err != nil { + return fmt.Errorf("encode token metadata: %w", err) + } + + // The access token is written LAST because Load gates on it: a write cut + // short partway through then reads as "not signed in", which is + // recoverable, rather than as a half-populated token set, which is not. + entries := []struct { + field string + value string + }{ + {field: fieldRefresh, value: tokens.RefreshToken}, + {field: fieldID, value: tokens.IDToken}, + {field: fieldMeta, value: string(meta)}, + {field: fieldAccess, value: tokens.AccessToken}, + } + + for _, entry := range entries { + if err := k.backend.Set(k.service, account(namespace, entry.field), entry.value); err != nil { + return fmt.Errorf("write %s to the keychain: %w (is the system keychain unlocked?)", entry.field, err) + } + } + + return nil +} + +// Delete removes every entry under namespace. Entries already gone are fine: +// the desired end state is "signed out", so signing out twice must succeed. +func (k *keyringStore) Delete(namespace string) error { + if err := k.validate(namespace); err != nil { + return err + } + + for _, field := range []string{fieldAccess, fieldID, fieldRefresh, fieldMeta} { + err := k.backend.Delete(k.service, account(namespace, field)) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("delete %s from the keychain: %w (is the system keychain unlocked?)", field, err) + } + } + + return nil +} + +// read fetches one entry, treating a missing one as an error: callers only +// reach it after the access-token gate has confirmed a login exists. +func (k *keyringStore) read(namespace, field string) (string, error) { + value, err := k.backend.Get(k.service, account(namespace, field)) + if err != nil { + return "", fmt.Errorf("read %s from the keychain: %w", field, err) + } + return value, nil +} + +// validate checks the inputs every operation needs. +func (k *keyringStore) validate(namespace string) error { + if wegostrings.IsBlank(k.service) { + return errors.New("keychain service name must not be blank") + } + if wegostrings.IsBlank(namespace) { + return errBlankNamespace + } + return nil +} + +// account is the keychain account name for one field of one namespace. +func account(namespace, field string) string { + return namespace + "/" + field +} diff --git a/cognito/storage/keyring_internal_test.go b/cognito/storage/keyring_internal_test.go new file mode 100644 index 0000000..5415498 --- /dev/null +++ b/cognito/storage/keyring_internal_test.go @@ -0,0 +1,398 @@ +package storage + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + wegostrings "github.com/wego/pkg/strings" + "github.com/zalando/go-keyring" + + "github.com/wego/pkg/cognito" +) + +const ( + testService = "pay-admin-test" + testNamespace = "pay-admin/staging" +) + +var testExpiry = time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + +// TestStoreContract holds every Store implementation to the same behaviour. +// The keyring case runs against a fake backend, so no test ever pops an OS +// keychain prompt. +func TestStoreContract(t *testing.T) { + impls := []struct { + name string + build func() Store + }{ + { + name: "memory", + build: NewMemory, + }, + { + name: "keyring", + build: func() Store { + return &keyringStore{service: testService, backend: newFakeKeyring()} + }, + }, + } + + tests := []struct { + name string + run func(t *testing.T, store Store) + }{ + { + name: "a blank namespace is rejected", + run: func(t *testing.T, store Store) { + _, err := store.Load("") + require.Error(t, err) + require.Error(t, store.Save("", sampleTokens())) + require.Error(t, store.Delete("")) + }, + }, + { + name: "saving nil tokens is rejected", + run: func(t *testing.T, store Store) { + require.Error(t, store.Save(testNamespace, nil)) + }, + }, + { + name: "loading an unknown namespace is not an error", + run: func(t *testing.T, store Store) { + got, err := store.Load(testNamespace) + require.NoError(t, err, "not logged in is a normal state, not a failure") + assert.Nil(t, got) + }, + }, + { + name: "deleting an unknown namespace is not an error", + run: func(t *testing.T, store Store) { + require.NoError(t, store.Delete(testNamespace)) + }, + }, + { + name: "delete clears a saved token set", + run: func(t *testing.T, store Store) { + require.NoError(t, store.Save(testNamespace, sampleTokens())) + require.NoError(t, store.Delete(testNamespace)) + + got, err := store.Load(testNamespace) + require.NoError(t, err) + assert.Nil(t, got) + }, + }, + { + name: "namespaces do not collide", + run: func(t *testing.T, store Store) { + staging := sampleTokens() + staging.AccessToken = "staging-access" + production := sampleTokens() + production.AccessToken = "production-access" + + require.NoError(t, store.Save("pay-admin/staging", staging)) + require.NoError(t, store.Save("pay-admin/production", production)) + + got, err := store.Load("pay-admin/staging") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "staging-access", got.AccessToken) + + require.NoError(t, store.Delete("pay-admin/staging")) + + survivor, err := store.Load("pay-admin/production") + require.NoError(t, err) + require.NotNil(t, survivor, "deleting one namespace must not touch another") + assert.Equal(t, "production-access", survivor.AccessToken) + }, + }, + { + name: "save then load round-trips every field", + run: func(t *testing.T, store Store) { + want := sampleTokens() + require.NoError(t, store.Save(testNamespace, want)) + + got, err := store.Load(testNamespace) + require.NoError(t, err) + require.NotNil(t, got) + + assert.Equal(t, want.AccessToken, got.AccessToken) + assert.Equal(t, want.IDToken, got.IDToken) + assert.Equal(t, want.RefreshToken, got.RefreshToken) + assert.Equal(t, want.ExpiresAt.UTC(), got.ExpiresAt.UTC()) + }, + }, + { + name: "save overwrites an earlier token set", + run: func(t *testing.T, store Store) { + require.NoError(t, store.Save(testNamespace, sampleTokens())) + + replacement := sampleTokens() + replacement.AccessToken = "second-access" + require.NoError(t, store.Save(testNamespace, replacement)) + + got, err := store.Load(testNamespace) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "second-access", got.AccessToken) + }, + }, + { + name: "the loaded token set is a copy", + run: func(t *testing.T, store Store) { + require.NoError(t, store.Save(testNamespace, sampleTokens())) + + first, err := store.Load(testNamespace) + require.NoError(t, err) + require.NotNil(t, first) + first.AccessToken = "mutated-by-caller" + + second, err := store.Load(testNamespace) + require.NoError(t, err) + require.NotNil(t, second) + assert.Equal(t, "access-value", second.AccessToken, "a caller must not be able to mutate stored tokens") + }, + }, + } + + for _, impl := range impls { + for _, tt := range tests { + t.Run(impl.name+"/"+tt.name, func(t *testing.T) { + tt.run(t, impl.build()) + }) + } + } +} + +func TestKeyringStore_LoadFailures(t *testing.T) { + tests := []struct { + name string + givenSetup func(*fakeKeyring) + wantNil bool + wantErrContains string + }{ + { + name: "a keychain failure on the gate entry is reported", + givenSetup: func(f *fakeKeyring) { + f.getErr = errors.New("keychain is locked") + }, + wantErrContains: "keychain", + }, + { + name: "a missing id_token is an inconsistency, not a logged-out state", + givenSetup: func(f *fakeKeyring) { + f.put(fieldAccess, "access-value") + }, + wantErrContains: "id", + }, + { + name: "a missing refresh_token is an inconsistency", + givenSetup: func(f *fakeKeyring) { + f.put(fieldAccess, "access-value") + f.put(fieldID, "id-value") + }, + wantErrContains: "refresh", + }, + { + name: "missing metadata is an inconsistency", + givenSetup: func(f *fakeKeyring) { + f.put(fieldAccess, "access-value") + f.put(fieldID, "id-value") + f.put(fieldRefresh, "refresh-value") + }, + wantErrContains: "meta", + }, + { + name: "corrupt metadata is reported", + givenSetup: func(f *fakeKeyring) { + f.put(fieldAccess, "access-value") + f.put(fieldID, "id-value") + f.put(fieldRefresh, "refresh-value") + f.put(fieldMeta, "{not json") + }, + wantErrContains: "metadata", + }, + { + name: "nothing stored reads as not logged in", + givenSetup: func(_ *fakeKeyring) {}, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + backend := newFakeKeyring() + tt.givenSetup(backend) + store := &keyringStore{service: testService, backend: backend} + + got, err := store.Load(testNamespace) + + if wegostrings.IsNotEmpty(tt.wantErrContains) { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrContains) + assert.Nil(t, got) + return + } + require.NoError(t, err) + assert.Nil(t, got) + }) + } +} + +// TestKeyringStore_SaveSplitsFieldsAcrossEntries pins the workaround for the +// 4 KiB argument cap that zalando/go-keyring hits on macOS: one entry per +// field, with the access token written last so a torn write reads as +// "not logged in" rather than as a half-populated token set. +func TestKeyringStore_SaveSplitsFieldsAcrossEntries(t *testing.T) { + backend := newFakeKeyring() + store := &keyringStore{service: testService, backend: backend} + + require.NoError(t, store.Save(testNamespace, sampleTokens())) + + assert.Equal(t, []string{ + testNamespace + "/refresh", + testNamespace + "/id", + testNamespace + "/meta", + testNamespace + "/access", + }, backend.writes, "one namespaced entry per field, access token last") + + assert.Equal(t, "access-value", backend.value(fieldAccess)) + assert.Equal(t, "refresh-value", backend.value(fieldRefresh)) +} + +func TestKeyringStore_BackendFailuresAreReported(t *testing.T) { + tests := []struct { + name string + givenSetup func(*fakeKeyring) + givenAction func(Store) error + wantErrContains string + }{ + { + name: "a save failure is reported", + givenSetup: func(f *fakeKeyring) { + f.setErr = errors.New("keychain is locked") + }, + givenAction: func(s Store) error { return s.Save(testNamespace, sampleTokens()) }, + wantErrContains: "keychain", + }, + { + name: "a delete failure is reported", + givenSetup: func(f *fakeKeyring) { + f.deleteErr = errors.New("keychain is locked") + }, + givenAction: func(s Store) error { return s.Delete(testNamespace) }, + wantErrContains: "keychain", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + backend := newFakeKeyring() + tt.givenSetup(backend) + store := &keyringStore{service: testService, backend: backend} + + err := tt.givenAction(store) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrContains) + }) + } +} + +// TestKeyringStore_DeleteToleratesMissingEntries covers a half-populated +// namespace: sign-out must succeed rather than stranding the operator. +func TestKeyringStore_DeleteToleratesMissingEntries(t *testing.T) { + backend := newFakeKeyring() + backend.put(fieldAccess, "access-value") + store := &keyringStore{service: testService, backend: backend} + + require.NoError(t, store.Delete(testNamespace)) + assert.Empty(t, backend.entries) +} + +func TestKeyringStore_BlankServiceIsRejected(t *testing.T) { + store := &keyringStore{service: "", backend: newFakeKeyring()} + + _, err := store.Load(testNamespace) + require.Error(t, err) + assert.Contains(t, err.Error(), "service") + require.Error(t, store.Save(testNamespace, sampleTokens())) + require.Error(t, store.Delete(testNamespace)) +} + +func sampleTokens() *cognito.TokenSet { + return &cognito.TokenSet{ + AccessToken: "access-value", + IDToken: "id-value", + RefreshToken: "refresh-value", + ExpiresAt: testExpiry, + } +} + +// fakeKeyring is an in-memory stand-in for the OS keychain. +type fakeKeyring struct { + mu sync.Mutex + entries map[string]string + writes []string + setErr error + getErr error + deleteErr error +} + +func newFakeKeyring() *fakeKeyring { + return &fakeKeyring{entries: make(map[string]string)} +} + +func (f *fakeKeyring) Set(service, user, password string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.setErr != nil { + return f.setErr + } + f.entries[service+"|"+user] = password + f.writes = append(f.writes, user) + return nil +} + +func (f *fakeKeyring) Get(service, user string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.getErr != nil { + return "", f.getErr + } + value, ok := f.entries[service+"|"+user] + if !ok { + return "", keyring.ErrNotFound + } + return value, nil +} + +func (f *fakeKeyring) Delete(service, user string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.deleteErr != nil { + return f.deleteErr + } + key := service + "|" + user + if _, ok := f.entries[key]; !ok { + return keyring.ErrNotFound + } + delete(f.entries, key) + return nil +} + +// put seeds an entry under testNamespace, bypassing the store under test. +func (f *fakeKeyring) put(field, value string) { + f.mu.Lock() + defer f.mu.Unlock() + f.entries[testService+"|"+testNamespace+"/"+field] = value +} + +// value reads an entry under testNamespace, bypassing the store under test. +func (f *fakeKeyring) value(field string) string { + f.mu.Lock() + defer f.mu.Unlock() + return f.entries[testService+"|"+testNamespace+"/"+field] +} diff --git a/cognito/storage/store.go b/cognito/storage/store.go new file mode 100644 index 0000000..abd6bf0 --- /dev/null +++ b/cognito/storage/store.go @@ -0,0 +1,99 @@ +// Package storage persists the Cognito token sets obtained by +// github.com/wego/pkg/cognito. +// +// Tokens live under an opaque namespace, e.g. "my-cli/staging". The caller +// chooses the string and the store never interprets it; namespacing is what +// keeps a staging login from overwriting a production one. +// +// Use NewMemory in tests and anywhere a keychain is unavailable; it satisfies +// the same interface as NewKeyring, so a caller swaps one for the other without +// touching its own code. +package storage + +import ( + "errors" + "sync" + + wegostrings "github.com/wego/pkg/strings" + + "github.com/wego/pkg/cognito" +) + +// Store persists a TokenSet under an opaque namespace. +// +// A missing entry is NOT an error: Load returns (nil, nil) when nothing is +// stored, because "not logged in" is a normal state. Callers can therefore +// tell it apart from a backend that is genuinely broken, which does return an +// error. +type Store interface { + Load(namespace string) (*cognito.TokenSet, error) + Save(namespace string, tokens *cognito.TokenSet) error + Delete(namespace string) error +} + +var ( + errBlankNamespace = errors.New("storage namespace must not be blank") + errNoTokens = errors.New("no tokens to save") +) + +// memoryStore keeps tokens in process memory only. +type memoryStore struct { + mu sync.RWMutex + tokens map[string]cognito.TokenSet +} + +// NewMemory returns a Store that holds tokens for the lifetime of the process +// and nothing longer. It serves tests and callers with no usable keychain; a +// new process always starts signed out. +func NewMemory() Store { + return &memoryStore{tokens: make(map[string]cognito.TokenSet)} +} + +// Load returns the tokens stored under namespace, or (nil, nil) if there are +// none. +func (m *memoryStore) Load(namespace string) (*cognito.TokenSet, error) { + if wegostrings.IsBlank(namespace) { + return nil, errBlankNamespace + } + + m.mu.RLock() + defer m.mu.RUnlock() + + stored, ok := m.tokens[namespace] + if !ok { + return nil, nil + } + + // stored is already a copy of the map value, so a caller mutating the + // result cannot reach back into the store. + return &stored, nil +} + +// Save writes tokens under namespace, replacing anything already there. +func (m *memoryStore) Save(namespace string, tokens *cognito.TokenSet) error { + if wegostrings.IsBlank(namespace) { + return errBlankNamespace + } + if tokens == nil { + return errNoTokens + } + + m.mu.Lock() + defer m.mu.Unlock() + m.tokens[namespace] = *tokens + + return nil +} + +// Delete removes the tokens under namespace. Deleting nothing is not an error. +func (m *memoryStore) Delete(namespace string) error { + if wegostrings.IsBlank(namespace) { + return errBlankNamespace + } + + m.mu.Lock() + defer m.mu.Unlock() + delete(m.tokens, namespace) + + return nil +} diff --git a/cognito/storage/store_test.go b/cognito/storage/store_test.go new file mode 100644 index 0000000..5b33b23 --- /dev/null +++ b/cognito/storage/store_test.go @@ -0,0 +1,80 @@ +package storage_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wego/pkg/cognito" + "github.com/wego/pkg/cognito/storage" +) + +func TestNewMemory_RoundTripsThroughThePublicAPI(t *testing.T) { + store := storage.NewMemory() + want := &cognito.TokenSet{ + AccessToken: "access-value", + IDToken: "id-value", + RefreshToken: "refresh-value", + ExpiresAt: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC), + } + + before, err := store.Load("pay-admin/staging") + require.NoError(t, err, "an empty store must report not-logged-in, not an error") + require.Nil(t, before) + + require.NoError(t, store.Save("pay-admin/staging", want)) + + got, err := store.Load("pay-admin/staging") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, *want, *got) + + require.NoError(t, store.Delete("pay-admin/staging")) + + after, err := store.Load("pay-admin/staging") + require.NoError(t, err) + assert.Nil(t, after) +} + +// TestNewKeyring_ValidatesBeforeTouchingTheKeychain exercises the exported +// constructor without ever reaching the OS keychain: every case here is +// rejected by validation first, so no test can pop a keychain prompt. +func TestNewKeyring_ValidatesBeforeTouchingTheKeychain(t *testing.T) { + tests := []struct { + name string + givenService string + givenNamespace string + }{ + { + name: "a blank service is rejected", + givenService: "", + givenNamespace: "pay-admin/staging", + }, + { + name: "a blank namespace is rejected", + givenService: "pay-admin", + givenNamespace: "", + }, + { + name: "both blank is rejected", + givenService: "", + givenNamespace: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := storage.NewKeyring(tt.givenService) + require.NotNil(t, store) + + got, err := store.Load(tt.givenNamespace) + require.Error(t, err) + assert.Nil(t, got) + + require.Error(t, store.Save(tt.givenNamespace, &cognito.TokenSet{AccessToken: "access-value"})) + require.Error(t, store.Delete(tt.givenNamespace)) + }) + } +} diff --git a/cognito/tokens.go b/cognito/tokens.go new file mode 100644 index 0000000..b9d64e9 --- /dev/null +++ b/cognito/tokens.go @@ -0,0 +1,88 @@ +package cognito + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + wegostrings "github.com/wego/pkg/strings" +) + +// expiryLeeway is shaved off the real expiry so a token cannot lapse midway +// through a request we have already started. +const expiryLeeway = 60 * time.Second + +// TokenSet is the set of Cognito tokens a signed-in operator holds. +// +// Holding tokens is the whole point of the type, so gosec's secret-field +// warning is expected here rather than a finding: these values are never +// logged, and the only place they are serialized to is the operator's own +// keychain (see github.com/wego/pkg/cognito/storage). +type TokenSet struct { + AccessToken string `json:"access_token"` //nolint:gosec // G117: this type exists to carry tokens; stored only in the local keychain, never logged. + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` //nolint:gosec // G117: as above - the refresh token is the credential this type is for. + ExpiresAt time.Time `json:"expires_at"` +} + +// IsExpired reports whether the access token is spent as of now, treating +// anything inside expiryLeeway of the deadline as already gone. A nil set, or +// one with no recorded expiry, counts as expired so callers refresh rather +// than send a token that may already be dead. +func (t *TokenSet) IsExpired(now time.Time) bool { + if t == nil || t.ExpiresAt.IsZero() { + return true + } + return !now.Before(t.ExpiresAt.Add(-expiryLeeway)) +} + +// Email returns the email claim from the id_token. +// +// It reads the JWT payload WITHOUT verifying the signature, which is correct +// here: the token came from our own keychain, and every server that accepts it +// verifies the signature itself. Treat this as decoding for display and +// client-side UX, NOT as validation - a caller must never make an +// authorization decision on the strength of these claims. +func (t *TokenSet) Email() (string, error) { + if t == nil { + return "", errors.New("no tokens: sign in first") + } + + claims, err := decodeIDTokenClaims(t.IDToken) + if err != nil { + return "", err + } + + email, ok := claims["email"].(string) + if !ok || wegostrings.IsBlank(email) { + return "", errors.New("id_token has no email claim") + } + return email, nil +} + +// decodeIDTokenClaims splits a JWT and JSON-decodes its payload. It never +// panics on a malformed or truncated token: every failure is an error. +func decodeIDTokenClaims(idToken string) (map[string]any, error) { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return nil, errors.New("id_token is not a jwt") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + // Some encoders pad their segments; accept those too. + payload, err = base64.URLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("decode id_token payload: %w", err) + } + } + + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("parse id_token payload: %w", err) + } + return claims, nil +} diff --git a/cognito/tokens_test.go b/cognito/tokens_test.go new file mode 100644 index 0000000..96ede3a --- /dev/null +++ b/cognito/tokens_test.go @@ -0,0 +1,172 @@ +package cognito_test + +import ( + "encoding/base64" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + wegostrings "github.com/wego/pkg/strings" + + "github.com/wego/pkg/cognito" +) + +const testOperatorEmail = "ops@wego.com" + +var fixedNow = time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + +func TestTokenSet_IsExpired(t *testing.T) { + tests := []struct { + name string + given *cognito.TokenSet + want bool + }{ + { + name: "nil token set counts as expired", + given: nil, + want: true, + }, + { + name: "zero expiry counts as expired", + given: &cognito.TokenSet{}, + want: true, + }, + { + name: "already elapsed", + given: &cognito.TokenSet{ExpiresAt: fixedNow.Add(-time.Second)}, + want: true, + }, + { + name: "inside the safety skew", + given: &cognito.TokenSet{ExpiresAt: fixedNow.Add(30 * time.Second)}, + want: true, + }, + { + name: "exactly at the safety skew", + given: &cognito.TokenSet{ExpiresAt: fixedNow.Add(60 * time.Second)}, + want: true, + }, + { + name: "just beyond the safety skew", + given: &cognito.TokenSet{ExpiresAt: fixedNow.Add(61 * time.Second)}, + want: false, + }, + { + name: "an hour of life left", + given: &cognito.TokenSet{ExpiresAt: fixedNow.Add(time.Hour)}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.given.IsExpired(fixedNow), "evaluated at %v", fixedNow) + }) + } +} + +func TestTokenSet_Email(t *testing.T) { + tests := []struct { + name string + givenIDToken string + want string + wantErrContains string + }{ + { + name: "blank id_token is rejected", + givenIDToken: "", + wantErrContains: "not a jwt", + }, + { + name: "two-segment token is not a jwt", + givenIDToken: "header.payload", + wantErrContains: "not a jwt", + }, + { + name: "truncated payload does not panic", + givenIDToken: "header.!!!not-base64!!!.signature", + wantErrContains: "decode", + }, + { + name: "payload is not json", + givenIDToken: "header." + rawB64("this is not json") + ".signature", + wantErrContains: "parse", + }, + { + name: "payload is a json array not an object", + givenIDToken: "header." + rawB64(`["nope"]`) + ".signature", + wantErrContains: "parse", + }, + { + name: "no email claim", + givenIDToken: "header." + rawB64(`{"sub":"abc"}`) + ".signature", + wantErrContains: "no email claim", + }, + { + name: "email claim is not a string", + givenIDToken: "header." + rawB64(`{"email":42}`) + ".signature", + wantErrContains: "no email claim", + }, + { + name: "email claim is blank", + givenIDToken: "header." + rawB64(`{"email":""}`) + ".signature", + wantErrContains: "no email claim", + }, + { + name: "padded base64 payload still decodes", + givenIDToken: "header." + base64.URLEncoding.EncodeToString([]byte(`{"email":"`+testOperatorEmail+`"}`)) + ".signature", + want: testOperatorEmail, + }, + { + name: "email is returned", + givenIDToken: "header." + rawB64(`{"email":"`+testOperatorEmail+`"}`) + ".signature", + want: testOperatorEmail, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := &cognito.TokenSet{IDToken: tt.givenIDToken} + + var ( + got string + err error + ) + require.NotPanics(t, func() { got, err = ts.Email() }, "Email must never panic on a malformed id_token") + + if wegostrings.IsNotEmpty(tt.wantErrContains) { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrContains) + assert.Empty(t, got) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestTokenSet_EmailOnNilReceiver(t *testing.T) { + var ts *cognito.TokenSet + + var err error + require.NotPanics(t, func() { _, err = ts.Email() }) + require.Error(t, err) +} + +// rawB64 is unpadded base64url, the encoding JWT segments use. +func rawB64(s string) string { + return base64.RawURLEncoding.EncodeToString([]byte(s)) +} + +// mustIDToken builds an unsigned JWT carrying claims. The signature is +// deliberately junk: Email() must not verify it. +func mustIDToken(t *testing.T, claims map[string]any) string { + t.Helper() + payload, err := json.Marshal(claims) + require.NoError(t, err) + return rawB64(`{"alg":"RS256","typ":"JWT"}`) + "." + + base64.RawURLEncoding.EncodeToString(payload) + ".not-a-real-signature" +} From 699ef695d2ee498ca9eb6a006fab02d6ee1a2868 Mon Sep 17 00:00:00 2001 From: mysqto Date: Wed, 2 Sep 2026 09:43:36 +0800 Subject: [PATCH 2/6] cognito: handle the callback writer's error explicitly revive's unhandled-error rule flags a bare fmt.Fprint. The write genuinely cannot be acted on -- the browser tab is the only reader and the operator sees the real outcome in the terminal -- so the discard is explicit rather than implicit. Co-Authored-By: Claude Opus 5 (1M context) --- cognito/callback.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cognito/callback.go b/cognito/callback.go index 9b51b18..ae9d29f 100644 --- a/cognito/callback.go +++ b/cognito/callback.go @@ -102,7 +102,7 @@ func callbackHandler(results chan<- callbackResult) http.HandlerFunc { message += ": " + desc } w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, failureHTML) + _, _ = fmt.Fprint(w, failureHTML) deliver(results, callbackResult{err: fmt.Errorf("authorization server rejected the sign-in: %s", message)}) return } @@ -110,12 +110,12 @@ func callbackHandler(results chan<- callbackResult) http.HandlerFunc { code := query.Get("code") if wegostrings.IsBlank(code) { w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, failureHTML) + _, _ = fmt.Fprint(w, failureHTML) deliver(results, callbackResult{err: errors.New("the sign-in redirect carried no authorization code")}) return } - fmt.Fprint(w, successHTML) + _, _ = fmt.Fprint(w, successHTML) deliver(results, callbackResult{code: code, state: query.Get("state")}) } } From 1150429faab986c76ee797fd9bf3bd123d4c32a6 Mon Sep 17 00:00:00 2001 From: mysqto Date: Wed, 2 Sep 2026 09:57:53 +0800 Subject: [PATCH 3/6] cognito: add a no-browser sign-in option Login could only reach the operator by launching a browser, which rules out a headless shell, a terminal on a remote host, and any caller that wants to surface the URL its own way. Config.NoBrowser suppresses the launch and Config.PromptURL receives the authorize URL instead; everything else is unchanged, so the same PKCE challenge and state are sent and the code still arrives on the loopback listener. PromptURL is a func rather than a bool-plus-stdout so the library never writes to a stream the caller did not choose -- it can print, render a QR code, or hand the URL to another process. Setting NoBrowser without PromptURL is refused at validation: with no browser launched and no way to report the url, the operator has nothing to open. One limit is documented on the field rather than papered over: the redirect still lands on CallbackAddr, so a browser on a different machine than the CLI needs that port forwarded. Suppressing the launch does not move where the code is delivered. Co-Authored-By: Claude Opus 5 (1M context) --- cognito/oauth.go | 42 +++++++++++++++++++++++++++-- cognito/oauth_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/cognito/oauth.go b/cognito/oauth.go index 6cda020..645110c 100644 --- a/cognito/oauth.go +++ b/cognito/oauth.go @@ -73,6 +73,23 @@ type Config struct { // OpenBrowser launches the authorize URL. Nil uses the OS default opener. OpenBrowser func(string) error + // NoBrowser suppresses the browser launch. Login reports the authorize URL + // through PromptURL instead and then waits on the loopback listener exactly + // as it otherwise would, for a headless shell, a terminal on a remote host, + // or an operator who would rather open the URL themselves. + // + // The redirect still lands on CallbackAddr, so when the browser runs on a + // different machine than the CLI that port has to be reachable from it — + // usually `ssh -L :localhost:`. Suppressing the launch does not + // move where the code is delivered. + NoBrowser bool + + // PromptURL receives the authorize URL in place of a browser launch, so the + // caller decides how to surface it: print it, render a QR code, hand it to + // another process. Required when NoBrowser is set — a sign-in whose URL the + // operator never sees cannot complete — and ignored otherwise. + PromptURL func(url string) error + // HTTPClient calls the token endpoint. Nil uses a client with a timeout. HTTPClient *http.Client @@ -106,8 +123,8 @@ func Login(ctx context.Context, cfg Config) (*TokenSet, error) { } defer server.shutdown() - if err := cfg.openBrowserAt(cfg.buildAuthorizeURL(state, generateChallenge(verifier))); err != nil { - return nil, fmt.Errorf("open browser for sign-in: %w", err) + if err := cfg.presentAuthorizeURL(cfg.buildAuthorizeURL(state, generateChallenge(verifier))); err != nil { + return nil, err } code, callbackState, err := server.wait(ctx, defaultCallbackTimeout) @@ -317,6 +334,9 @@ func (c Config) validateForLogin() error { if wegostrings.IsBlank(c.CallbackAddr) { return errors.New("local callback address is not configured") } + if c.NoBrowser && c.PromptURL == nil { + return errors.New("no-browser sign-in needs Config.PromptURL: with no browser launched and no way to report the url, the operator has nothing to open") + } return nil } @@ -337,6 +357,24 @@ func (c Config) now() time.Time { } // openBrowserAt sends the operator to rawURL. +// presentAuthorizeURL gets the operator to the authorize URL, by launching a +// browser or, under NoBrowser, by handing the URL to PromptURL. +func (c Config) presentAuthorizeURL(rawURL string) error { + if c.NoBrowser { + if err := c.PromptURL(rawURL); err != nil { + return fmt.Errorf("present the sign-in url: %w", err) + } + + return nil + } + + if err := c.openBrowserAt(rawURL); err != nil { + return fmt.Errorf("open browser for sign-in: %w", err) + } + + return nil +} + func (c Config) openBrowserAt(rawURL string) error { if c.OpenBrowser != nil { return c.OpenBrowser(rawURL) diff --git a/cognito/oauth_test.go b/cognito/oauth_test.go index 5117709..b5bf985 100644 --- a/cognito/oauth_test.go +++ b/cognito/oauth_test.go @@ -650,3 +650,65 @@ func mustFreeAddr(t *testing.T) string { require.NoError(t, ln.Close()) return addr } + +// TestLogin_NoBrowser covers the headless path: the caller suppresses the +// launch and surfaces the URL itself, and the sign-in still completes on the +// loopback listener. Suppressing the launch must not change anything else — +// the same PKCE and state parameters have to be sent, because a URL an +// operator pastes by hand is the same URL a browser would have been given. +func TestLogin_NoBrowser(t *testing.T) { + ts := newTokenServer(t, nil) + + // The fake browser doubles as the prompt: it records the URL and drives the + // callback, which is exactly what an operator pasting the URL would cause. + prompt := newFakeBrowser() + + cfg := baseConfig(t, ts.URL, mustFreeAddr(t)) + cfg.NoBrowser = true + cfg.PromptURL = prompt.open + cfg.OpenBrowser = func(string) error { + t.Error("NoBrowser must not launch a browser") + + return nil + } + + got, err := cognito.Login(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, testAccessValue, got.AccessToken) + + authorizeURL, err := url.Parse(prompt.capturedURL()) + require.NoError(t, err, "PromptURL must receive a usable authorize url") + + aq := authorizeURL.Query() + assert.Equal(t, cfg.ClientID, aq.Get("client_id")) + assert.Equal(t, cfg.RedirectURI, aq.Get("redirect_uri")) + assert.Equal(t, "S256", aq.Get("code_challenge_method"), "plain PKCE must never be used") + assert.NotEmpty(t, aq.Get("code_challenge")) + assert.NotEmpty(t, aq.Get("state"), "state is CSRF protection and must always be sent") +} + +func TestLogin_NoBrowserRequiresPromptURL(t *testing.T) { + cfg := baseConfig(t, "https://unused.test/oauth2/token", mustFreeAddr(t)) + cfg.NoBrowser = true + cfg.PromptURL = nil + + _, err := cognito.Login(context.Background(), cfg) + + require.Error(t, err, "a login with no browser and no way to report the url cannot complete") + assert.Contains(t, err.Error(), "PromptURL", + "the error must name the field that is missing") +} + +func TestLogin_NoBrowserPromptFailurePropagates(t *testing.T) { + wantErr := errors.New("no tty to print to") + + cfg := baseConfig(t, "https://unused.test/oauth2/token", mustFreeAddr(t)) + cfg.NoBrowser = true + cfg.PromptURL = func(string) error { return wantErr } + + _, err := cognito.Login(context.Background(), cfg) + + require.Error(t, err) + assert.ErrorIs(t, err, wantErr, "a prompt failure must reach the caller unwrapped in meaning") + assert.NotContains(t, err.Error(), "open browser", "the browser path was not taken") +} From 1586a5d337423490d9d624f27ddeb8256b50184b Mon Sep 17 00:00:00 2001 From: mysqto Date: Wed, 2 Sep 2026 11:01:45 +0800 Subject: [PATCH 4/6] cognito: reject a token response with no usable expires_in Found by CodeRabbit and claude[bot] on the payments PR this package was extracted from, and mirrored here so the two copies do not diverge before the payments one is deleted. postToken validated the three token strings but accepted any ExpiresIn. With expires_in absent, zero or negative, ExpiresAt landed on exactly now() and IsExpired subtracts a leeway on top, so a login that had just succeeded read as already expired -- sending the operator back through sign-in on their next command with no indication why. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JCCcwc4wtJgYKSFygyZGnR --- cognito/oauth.go | 7 ++++++ cognito/oauth_test.go | 51 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/cognito/oauth.go b/cognito/oauth.go index 645110c..c661cf8 100644 --- a/cognito/oauth.go +++ b/cognito/oauth.go @@ -235,6 +235,13 @@ func (c Config) postToken(ctx context.Context, form url.Values, fallbackRefresh return nil, errors.New("token response is missing id_token") case wegostrings.IsBlank(refresh): return nil, errors.New("token response is missing refresh_token") + case parsed.ExpiresIn <= 0: + // Silently accepting this is worse than failing. ExpiresAt would land on + // exactly now(), and IsExpired subtracts a leeway on top, so a login that + // just succeeded would read as already expired and the next command would + // refresh or send the operator back through sign-in. Refusing here names + // the real problem instead. + return nil, errors.New("token response has no usable expires_in") } return &TokenSet{ diff --git a/cognito/oauth_test.go b/cognito/oauth_test.go index b5bf985..b7c70bc 100644 --- a/cognito/oauth_test.go +++ b/cognito/oauth_test.go @@ -712,3 +712,54 @@ func TestLogin_NoBrowserPromptFailurePropagates(t *testing.T) { assert.ErrorIs(t, err, wantErr, "a prompt failure must reach the caller unwrapped in meaning") assert.NotContains(t, err.Error(), "open browser", "the browser path was not taken") } + +// TestLogin_RejectsAnUnusableExpiresIn pins that a token response with no +// usable expires_in fails loudly. +// +// Accepting it silently is worse than failing: ExpiresAt would land on exactly +// now(), IsExpired subtracts a leeway on top, and a login that had just +// succeeded would read as already expired — sending the operator back through +// sign-in on their very next command with no indication why. +func TestLogin_RejectsAnUnusableExpiresIn(t *testing.T) { + tests := []struct { + name string + givenExpiresIn any + wantErrContains string + }{ + { + name: "expires_in omitted entirely", + givenExpiresIn: nil, + wantErrContains: "expires_in", + }, + { + name: "expires_in zero", + givenExpiresIn: 0, + wantErrContains: "expires_in", + }, + { + name: "expires_in negative", + givenExpiresIn: -1, + wantErrContains: "expires_in", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := goodTokenBody(t, testOperatorEmail) + delete(body, "expires_in") + if tt.givenExpiresIn != nil { + body["expires_in"] = tt.givenExpiresIn + } + + ts := newTokenServer(t, jsonHandler(http.StatusOK, body)) + browser := newFakeBrowser() + cfg := baseConfig(t, ts.URL, mustFreeAddr(t)) + cfg.OpenBrowser = browser.open + + _, err := cognito.Login(context.Background(), cfg) + + require.Error(t, err, "an unusable expires_in must not produce a session") + assert.Contains(t, err.Error(), tt.wantErrContains) + }) + } +} From ff2fbcb06e3560cd1186d6634ace91ded2e45eb0 Mon Sep 17 00:00:00 2001 From: mysqto Date: Thu, 3 Sep 2026 21:39:02 +0800 Subject: [PATCH 5/6] cognito: never follow a redirect from the token endpoint Config.httpClient returned a client with no CheckRedirect, so Go's default policy applied: follow up to ten redirects, re-sending the body verbatim on a 307 or 308. The token request carries the authorization code, the PKCE verifier and the client id on sign-in and the refresh token on renewal, so one redirect handed a complete credential set to whatever host the response named. It was also an injection route inwards, since the body that came back was parsed as the session to use. httpClient now returns a COPY with CheckRedirect set to refuse. Copying means a caller-supplied HTTPClient keeps its transport and timeout but cannot reinstate following, deliberately or by passing a client configured elsewhere, and the caller's own client is not mutated. A redirect from the token endpoint has no legitimate meaning here: TokenURL is an operator-configured Cognito domain that answers directly. Refusing turns it into the error it should be, reported with the endpoint and status via the existing status check. TestConfig_Defaults asserted the injected client was returned by identity, which is the behaviour that allowed the override to be bypassed. It now pins the copy semantics instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JCCcwc4wtJgYKSFygyZGnR --- cognito/oauth.go | 36 ++++++++++++-- cognito/oauth_internal_test.go | 86 +++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/cognito/oauth.go b/cognito/oauth.go index c661cf8..8827fdb 100644 --- a/cognito/oauth.go +++ b/cognito/oauth.go @@ -347,12 +347,40 @@ func (c Config) validateForLogin() error { return nil } -// httpClient is the client to call the token endpoint with. +// httpClient is the client to call the token endpoint with. It never follows +// redirects, whoever supplied it. +// +// The returned client is a COPY, so a caller-supplied HTTPClient is neither +// mutated nor able to reinstate redirect-following. That override is +// deliberate: the token request carries the authorization code, the PKCE +// verifier and the client id on sign-in and the refresh token on renewal, and +// Go's default policy follows up to ten redirects, re-sending the body +// verbatim on a 307 or 308. One redirect would therefore hand a complete +// credential set to whatever host the response named. It is also an injection +// route inwards, since the body that came back would be parsed as the session +// to use. +// +// A redirect from the token endpoint has no legitimate meaning here: TokenURL +// is a Cognito domain the operator configured, and Cognito answers it +// directly. Refusing turns the redirect into the error it should be. func (c Config) httpClient() *http.Client { - if c.HTTPClient != nil { - return c.HTTPClient + base := c.HTTPClient + if base == nil { + base = &http.Client{Timeout: defaultHTTPTimeout} } - return &http.Client{Timeout: defaultHTTPTimeout} + + refusing := *base + refusing.CheckRedirect = refuseRedirect + + return &refusing +} + +// refuseRedirect stops the client at the redirect response instead of +// following it. Returning ErrUseLastResponse rather than an error of our own +// hands postToken the 3xx itself, which its status check then reports with the +// endpoint and status an operator needs to debug the misconfiguration. +func refuseRedirect(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse } // now is the current time, from the injected clock when there is one. diff --git a/cognito/oauth_internal_test.go b/cognito/oauth_internal_test.go index 064dc3a..6b84acf 100644 --- a/cognito/oauth_internal_test.go +++ b/cognito/oauth_internal_test.go @@ -1,8 +1,12 @@ package cognito import ( + "context" + "encoding/json" "net/http" + "net/http/httptest" "net/url" + "sync/atomic" "testing" "time" @@ -119,8 +123,19 @@ func TestConfig_Defaults(t *testing.T) { assert.Equal(t, defaultHTTPTimeout, zero.httpClient().Timeout, "a nil HTTPClient must get a timeout") assert.WithinDuration(t, time.Now(), zero.now(), time.Minute, "a nil Now must fall back to the system clock") - custom := &http.Client{Timeout: time.Second} - assert.Same(t, custom, Config{HTTPClient: custom}.httpClient()) + // An injected client is honoured for its transport and timeout but is + // COPIED, never handed back: httpClient has to own CheckRedirect, and it + // must not mutate a client the caller may use elsewhere. See + // TestPostToken_RefusesRedirects for why the override exists. + transport := &http.Transport{} + custom := &http.Client{Timeout: time.Second, Transport: transport} + got := Config{HTTPClient: custom}.httpClient() + + assert.NotSame(t, custom, got, "the caller's client must not be handed back") + assert.Equal(t, time.Second, got.Timeout, "the caller's timeout must survive") + assert.Same(t, transport, got.Transport, "the caller's transport must survive") + assert.NotNil(t, got.CheckRedirect, "the returned client must refuse redirects") + assert.Nil(t, custom.CheckRedirect, "the caller's client must not be mutated") fixed := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) assert.Equal(t, fixed, Config{Now: func() time.Time { return fixed }}.now()) @@ -136,3 +151,70 @@ func TestConfig_OpenBrowserAtUsesTheInjectedOpener(t *testing.T) { require.NoError(t, cfg.openBrowserAt("https://example.test/authorize")) assert.Equal(t, "https://example.test/authorize", got) } + +// TestPostToken_RefusesRedirects proves a token request is never re-sent to a +// host other than the one configured. +// +// The form carries the authorization code, the PKCE verifier and the client id +// on sign-in, and the refresh token on renewal. Go's default client follows up +// to ten redirects and re-sends the body verbatim on a 307 or 308, so +// following one would hand a complete credential set to whatever host the +// response named. A redirect is also an injection route in the other +// direction: the body that comes back would be parsed as a token set, letting +// an unintended host choose the session the CLI then uses. +func TestPostToken_RefusesRedirects(t *testing.T) { + tests := []struct { + name string + givenStatus int + givenClient *http.Client + }{ + { + name: "the default client refuses a 307, which would re-send the body", + givenStatus: http.StatusTemporaryRedirect, + }, + { + name: "the default client refuses a 302", + givenStatus: http.StatusFound, + }, + { + // A caller supplying its own client must not be able to reinstate + // following, deliberately or by copying a client from elsewhere. + name: "an injected permissive client cannot opt back into following", + givenStatus: http.StatusTemporaryRedirect, + givenClient: &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return nil }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var elsewhereHits atomic.Int32 + + // Answers with a usable token set, so following the redirect would + // look like a successful sign-in rather than an error. + elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + elsewhereHits.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "attacker-access", + "id_token": "attacker-id", + "expires_in": 3600, + }) + })) + t.Cleanup(elsewhere.Close) + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, elsewhere.URL, tt.givenStatus) + })) + t.Cleanup(origin.Close) + + cfg := Config{TokenURL: origin.URL, HTTPClient: tt.givenClient} + form := url.Values{"code": {"secret-code"}, "code_verifier": {"secret-verifier"}} + + tokens, err := cfg.postToken(context.Background(), form, "") + + require.Error(t, err, "a redirected token endpoint must not produce a session") + assert.Nil(t, tokens) + assert.Zero(t, elsewhereHits.Load(), "credentials must never reach the redirect target") + }) + } +} From dda4783bb31361474a54e814865cc75a4ac6580b Mon Sep 17 00:00:00 2001 From: mysqto Date: Thu, 3 Sep 2026 21:39:07 +0800 Subject: [PATCH 6/6] cognito/storage: commit a token set with a single pointer write A re-login that failed partway left a hybrid token set. Save wrote four separate keychain entries and Load gated on the access token, so a failure after the refresh token was written left the NEW refresh token beside the OLD access token, id token and expiry, and Load returned that mixture as a live session. Writing the access token last only protected a FIRST login, where there was nothing to mix with. The consequences are worse than a failed read: a stale expiry paired with a fresh refresh token makes the CLI believe a session is valid, and after an account switch one identity's id token can end up beside another's refresh token. Single-entry storage would fix it but does not fit. zalando/go-keyring shells out to /usr/bin/security on macOS and rejects any command over 4096 bytes (keyring_darwin.go); after its base64 expansion that leaves roughly 3 KB of secret per entry, and a combined set of Cognito JWTs runs to about that, so it would work in development and fail for operators with larger tokens. Fields therefore stay in their own entries. Atomicity comes from a commit pointer instead. Each token set is written into one of two slots, and a "current" entry names the slot that counts. Save fills the inactive slot and then moves the pointer, which is one small write and the only write that changes what Load sees, so a failure anywhere before it costs the new session and never the old one. Two slots rather than a counter keep the entry count fixed and mean a re-login never writes over the entries the live session is read from. Delete removes the pointer first, for the same reason, and clears both slots so a torn Save leaves no token material behind. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JCCcwc4wtJgYKSFygyZGnR --- cognito/storage/keyring.go | 189 ++++++++++++--- cognito/storage/keyring_internal_test.go | 286 ++++++++++++++++++++--- 2 files changed, 406 insertions(+), 69 deletions(-) diff --git a/cognito/storage/keyring.go b/cognito/storage/keyring.go index dce0c91..cfc3481 100644 --- a/cognito/storage/keyring.go +++ b/cognito/storage/keyring.go @@ -12,19 +12,47 @@ import ( "github.com/wego/pkg/cognito" ) +// Layout. +// // A Cognito JWT - the access token especially - can exceed the 4 KiB // command-line cap zalando/go-keyring runs into on macOS, where it shells out -// to /usr/bin/security. So each field gets its own keychain entry, keeping -// every individual write well under that ceiling. +// to /usr/bin/security (see keyring_darwin.go, which refuses any command over +// 4096 bytes). After the library's base64 expansion that leaves roughly 3 KB +// of secret per entry, and a whole token set does not reliably fit. So each +// field keeps its own entry. +// +// That rules out getting atomicity by writing one entry, and a keychain has no +// transaction. Instead each token set is written into one of two SLOTS, and a +// separate pointer entry names the slot that counts. Save fills the inactive +// slot and then moves the pointer, which is one small write and the only write +// that changes what Load sees. A failure anywhere before it leaves the pointer +// and the live slot untouched, so a torn write costs the new session, never +// the old one. +// +// Two slots rather than a counter keeps the entry count fixed and means a +// re-login never writes over the entries the current session is read from. // -// Entries are accounted as "/". +// Entries are accounted as "/current" for the pointer and +// "//" for the token fields. const ( fieldAccess = "access" fieldID = "id" fieldRefresh = "refresh" fieldMeta = "meta" + // fieldCurrent is the pointer entry naming the live slot. Committing a + // token set is exactly one write of this entry. + fieldCurrent = "current" +) + +// The two slots a token set alternates between. +const ( + slotA = "a" + slotB = "b" ) +// tokenFields are the per-field entries that make up one stored token set. +var tokenFields = []string{fieldAccess, fieldID, fieldRefresh, fieldMeta} + // keyringBackend is the slice of zalando/go-keyring this package depends on. // Naming it lets tests substitute a fake instead of prompting a real keychain. type keyringBackend interface { @@ -73,10 +101,10 @@ func (k *keyringStore) Load(namespace string) (*cognito.TokenSet, error) { return nil, err } - // Gate on the access token: its absence means "not signed in", which is a - // normal state rather than a failure. Once it is present, anything else - // missing is a real inconsistency and must be reported. - access, err := k.backend.Get(k.service, account(namespace, fieldAccess)) + // Gate on the pointer: its absence means "not signed in", which is a normal + // state rather than a failure. Once it is present, anything the slot it + // names is missing is a real inconsistency and must be reported. + slot, err := k.backend.Get(k.service, pointerAccount(namespace)) if errors.Is(err, keyring.ErrNotFound) { return nil, nil } @@ -84,15 +112,26 @@ func (k *keyringStore) Load(namespace string) (*cognito.TokenSet, error) { return nil, fmt.Errorf("read the keychain: %w (is the system keychain unlocked?)", err) } - idToken, err := k.read(namespace, fieldID) + if slot != slotA && slot != slotB { + // Nothing here writes any other value, so this entry was tampered with + // or written by a version that stored something else. Guessing which + // slot was meant would be worse than refusing. + return nil, fmt.Errorf("keychain names an unknown token slot %q for %q", slot, namespace) + } + + access, err := k.read(namespace, slot, fieldAccess) + if err != nil { + return nil, err + } + idToken, err := k.read(namespace, slot, fieldID) if err != nil { return nil, err } - refresh, err := k.read(namespace, fieldRefresh) + refresh, err := k.read(namespace, slot, fieldRefresh) if err != nil { return nil, err } - rawMeta, err := k.read(namespace, fieldMeta) + rawMeta, err := k.read(namespace, slot, fieldMeta) if err != nil { return nil, err } @@ -111,6 +150,11 @@ func (k *keyringStore) Load(namespace string) (*cognito.TokenSet, error) { } // Save writes tokens under namespace, replacing anything already there. +// +// It is atomic from Load's point of view: the fields go into the slot that is +// not live, and only the final pointer write makes them the session. If any +// write fails, the previous session is still whole and still what Load +// returns. func (k *keyringStore) Save(namespace string, tokens *cognito.TokenSet) error { if err := k.validate(namespace); err != nil { return err @@ -124,49 +168,119 @@ func (k *keyringStore) Save(namespace string, tokens *cognito.TokenSet) error { return fmt.Errorf("encode token metadata: %w", err) } - // The access token is written LAST because Load gates on it: a write cut - // short partway through then reads as "not signed in", which is - // recoverable, rather than as a half-populated token set, which is not. - entries := []struct { - field string - value string - }{ - {field: fieldRefresh, value: tokens.RefreshToken}, - {field: fieldID, value: tokens.IDToken}, - {field: fieldMeta, value: string(meta)}, - {field: fieldAccess, value: tokens.AccessToken}, + live, err := k.currentSlot(namespace) + if err != nil { + return err } - for _, entry := range entries { - if err := k.backend.Set(k.service, account(namespace, entry.field), entry.value); err != nil { - return fmt.Errorf("write %s to the keychain: %w (is the system keychain unlocked?)", entry.field, err) + next := otherSlot(live) + + values := map[string]string{ + fieldAccess: tokens.AccessToken, + fieldID: tokens.IDToken, + fieldRefresh: tokens.RefreshToken, + fieldMeta: string(meta), + } + + // Ordering is irrelevant now: nothing written here is reachable until the + // pointer moves. tokenFields is used rather than ranging the map so the + // write sequence is deterministic, which keeps failures reproducible. + for _, field := range tokenFields { + if err := k.backend.Set(k.service, fieldAccount(namespace, next, field), values[field]); err != nil { + return fmt.Errorf("write %s to the keychain: %w (is the system keychain unlocked?)", field, err) } } + // The commit. + if err := k.backend.Set(k.service, pointerAccount(namespace), next); err != nil { + return fmt.Errorf("commit the session to the keychain: %w (is the system keychain unlocked?)", err) + } + + // The old slot is now unreachable. Clearing it keeps a superseded token set + // out of the keychain, but the session is already committed, so a failure + // here is not the caller's problem and must not fail the sign-in. + k.clearSlot(namespace, live) + return nil } +// currentSlot reports the live slot, or "" when nothing is stored. An +// unrecognised value is treated as "nothing live": Save's job is to establish a +// good session, and it can do that without deciding what the bad value meant. +// Load is where a corrupt pointer is reported. +func (k *keyringStore) currentSlot(namespace string) (string, error) { + slot, err := k.backend.Get(k.service, pointerAccount(namespace)) + if errors.Is(err, keyring.ErrNotFound) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("read the current token slot: %w (is the system keychain unlocked?)", err) + } + + if slot != slotA && slot != slotB { + return "", nil + } + + return slot, nil +} + +// otherSlot returns the slot to write next. An empty live slot means nothing is +// stored, so either is free and slotA keeps a first login predictable. +func otherSlot(live string) string { + if live == slotA { + return slotB + } + + return slotA +} + +// clearSlot removes one slot's field entries, best effort. Callers use it for a +// slot nothing points at any more. +func (k *keyringStore) clearSlot(namespace, slot string) { + if slot == "" { + return + } + + for _, field := range tokenFields { + _ = k.backend.Delete(k.service, fieldAccount(namespace, slot, field)) + } +} + // Delete removes every entry under namespace. Entries already gone are fine: // the desired end state is "signed out", so signing out twice must succeed. +// +// The pointer goes FIRST, for the same reason Save moves it last: once it is +// gone the operator is signed out, even if a later delete fails and leaves +// orphaned field entries behind. func (k *keyringStore) Delete(namespace string) error { if err := k.validate(namespace); err != nil { return err } - for _, field := range []string{fieldAccess, fieldID, fieldRefresh, fieldMeta} { - err := k.backend.Delete(k.service, account(namespace, field)) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - return fmt.Errorf("delete %s from the keychain: %w (is the system keychain unlocked?)", field, err) + err := k.backend.Delete(k.service, pointerAccount(namespace)) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("delete the current token slot from the keychain: %w (is the system keychain unlocked?)", err) + } + + // Both slots, not just the live one: a torn Save can leave fields in the + // inactive slot, and signing out should not leave a token set behind. + for _, slot := range []string{slotA, slotB} { + for _, field := range tokenFields { + err := k.backend.Delete(k.service, fieldAccount(namespace, slot, field)) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("delete %s from the keychain: %w (is the system keychain unlocked?)", field, err) + } } } return nil } -// read fetches one entry, treating a missing one as an error: callers only -// reach it after the access-token gate has confirmed a login exists. -func (k *keyringStore) read(namespace, field string) (string, error) { - value, err := k.backend.Get(k.service, account(namespace, field)) +// read fetches one field of one slot, treating a missing entry as an error: +// callers only reach it after the pointer has confirmed a committed session, +// so anything absent is an inconsistency rather than a logged-out state. +func (k *keyringStore) read(namespace, slot, field string) (string, error) { + value, err := k.backend.Get(k.service, fieldAccount(namespace, slot, field)) if err != nil { return "", fmt.Errorf("read %s from the keychain: %w", field, err) } @@ -184,7 +298,12 @@ func (k *keyringStore) validate(namespace string) error { return nil } -// account is the keychain account name for one field of one namespace. -func account(namespace, field string) string { - return namespace + "/" + field +// pointerAccount is the keychain account name of a namespace's commit pointer. +func pointerAccount(namespace string) string { + return namespace + "/" + fieldCurrent +} + +// fieldAccount is the keychain account name for one field of one slot. +func fieldAccount(namespace, slot, field string) string { + return namespace + "/" + slot + "/" + field } diff --git a/cognito/storage/keyring_internal_test.go b/cognito/storage/keyring_internal_test.go index 5415498..4770377 100644 --- a/cognito/storage/keyring_internal_test.go +++ b/cognito/storage/keyring_internal_test.go @@ -182,39 +182,57 @@ func TestKeyringStore_LoadFailures(t *testing.T) { wantErrContains: "keychain", }, { + // The pointer says a session was committed, so anything the slot is + // missing is an inconsistency rather than a logged-out state. name: "a missing id_token is an inconsistency, not a logged-out state", givenSetup: func(f *fakeKeyring) { - f.put(fieldAccess, "access-value") + f.putCommitted(map[string]string{fieldAccess: "access-value"}) }, wantErrContains: "id", }, { name: "a missing refresh_token is an inconsistency", givenSetup: func(f *fakeKeyring) { - f.put(fieldAccess, "access-value") - f.put(fieldID, "id-value") + f.putCommitted(map[string]string{ + fieldAccess: "access-value", + fieldID: "id-value", + }) }, wantErrContains: "refresh", }, { name: "missing metadata is an inconsistency", givenSetup: func(f *fakeKeyring) { - f.put(fieldAccess, "access-value") - f.put(fieldID, "id-value") - f.put(fieldRefresh, "refresh-value") + f.putCommitted(map[string]string{ + fieldAccess: "access-value", + fieldID: "id-value", + fieldRefresh: "refresh-value", + }) }, wantErrContains: "meta", }, { name: "corrupt metadata is reported", givenSetup: func(f *fakeKeyring) { - f.put(fieldAccess, "access-value") - f.put(fieldID, "id-value") - f.put(fieldRefresh, "refresh-value") - f.put(fieldMeta, "{not json") + f.putCommitted(map[string]string{ + fieldAccess: "access-value", + fieldID: "id-value", + fieldRefresh: "refresh-value", + fieldMeta: "{not json", + }) }, wantErrContains: "metadata", }, + { + // Fields present but never committed: the sign-in was torn before + // the pointer moved, so the operator is simply not signed in. + name: "an uncommitted slot reads as not logged in", + givenSetup: func(f *fakeKeyring) { + f.putField(slotA, fieldAccess, "access-value") + f.putField(slotA, fieldID, "id-value") + }, + wantNil: true, + }, { name: "nothing stored reads as not logged in", givenSetup: func(_ *fakeKeyring) {}, @@ -242,25 +260,121 @@ func TestKeyringStore_LoadFailures(t *testing.T) { } } -// TestKeyringStore_SaveSplitsFieldsAcrossEntries pins the workaround for the -// 4 KiB argument cap that zalando/go-keyring hits on macOS: one entry per -// field, with the access token written last so a torn write reads as -// "not logged in" rather than as a half-populated token set. -func TestKeyringStore_SaveSplitsFieldsAcrossEntries(t *testing.T) { +// TestKeyringStore_SaveCommitsWithOnePointerWrite pins both halves of the +// storage contract. +// +// Fields stay in SEPARATE entries because zalando/go-keyring shells out to +// /usr/bin/security on macOS and refuses any command over 4096 bytes +// (keyring_darwin.go). After its base64 expansion that leaves roughly 3 KB of +// secret per entry, which a single combined token set would exceed. +// +// Atomicity therefore cannot come from writing one entry. It comes from +// writing the fields into the inactive slot and then moving the pointer, which +// is ONE small Set and the only write that changes what Load sees. +func TestKeyringStore_SaveCommitsWithOnePointerWrite(t *testing.T) { + backend := newFakeKeyring() + store := &keyringStore{service: testService, backend: backend} + + require.NoError(t, store.Save(testNamespace, sampleTokens())) + + require.NotEmpty(t, backend.writes) + assert.Equal(t, testNamespace+"/"+fieldCurrent, backend.writes[len(backend.writes)-1], + "the pointer must be the last write, so nothing before it is observable") + assert.Equal(t, 1, countWrites(backend.writes, testNamespace+"/"+fieldCurrent), + "the commit must be a single pointer write") + + for _, field := range []string{fieldAccess, fieldID, fieldRefresh, fieldMeta} { + assert.Contains(t, backend.writes, testNamespace+"/"+slotA+"/"+field, + "each field keeps its own entry, to stay under the 4 KiB cap") + } +} + +// TestKeyringStore_SaveAlternatesSlots covers why there are two slots: a +// re-login must not overwrite the entries the current session is still being +// read from, or a torn write would corrupt the live session rather than an +// unused copy. +func TestKeyringStore_SaveAlternatesSlots(t *testing.T) { backend := newFakeKeyring() store := &keyringStore{service: testService, backend: backend} require.NoError(t, store.Save(testNamespace, sampleTokens())) + assert.Equal(t, slotA, backend.value(fieldCurrent)) - assert.Equal(t, []string{ - testNamespace + "/refresh", - testNamespace + "/id", - testNamespace + "/meta", - testNamespace + "/access", - }, backend.writes, "one namespaced entry per field, access token last") + second := sampleTokens() + second.AccessToken = "second-access" + require.NoError(t, store.Save(testNamespace, second)) + assert.Equal(t, slotB, backend.value(fieldCurrent)) - assert.Equal(t, "access-value", backend.value(fieldAccess)) - assert.Equal(t, "refresh-value", backend.value(fieldRefresh)) + third := sampleTokens() + third.AccessToken = "third-access" + require.NoError(t, store.Save(testNamespace, third)) + assert.Equal(t, slotA, backend.value(fieldCurrent), "slots alternate rather than growing") + + got, err := store.Load(testNamespace) + require.NoError(t, err) + assert.Equal(t, "third-access", got.AccessToken) +} + +// TestKeyringStore_TornResaveLeavesThePreviousSession is the regression test +// for the reported defect. +// +// With one entry per field and no commit pointer, a re-login that failed +// partway left the NEW refresh token beside the OLD access token, id token and +// expiry. Load gated on the access token, which was present, so it handed that +// mixture back as a live session: a stale expiry paired with a fresh refresh +// token, or after an account switch one identity's id token paired with +// another's refresh token. +func TestKeyringStore_TornResaveLeavesThePreviousSession(t *testing.T) { + backend := newFakeKeyring() + store := &keyringStore{service: testService, backend: backend} + + require.NoError(t, store.Save(testNamespace, sampleTokens())) + + // Let two writes of the re-login land, then fail. + backend.okWrites = 0 + backend.failSetAfter = 2 + + newer := &cognito.TokenSet{ + AccessToken: "new-access", + IDToken: "new-id", + RefreshToken: "new-refresh", + ExpiresAt: testExpiry.Add(time.Hour), + } + require.Error(t, store.Save(testNamespace, newer)) + + backend.failSetAfter = -1 + + got, err := store.Load(testNamespace) + require.NoError(t, err, "the previous session must still be readable") + require.NotNil(t, got) + assert.Equal(t, sampleTokens(), got, + "a torn re-login must leave the previous session exactly, never a mixture of the two") +} + +// TestKeyringStore_LoadRejectsAnUnknownSlot covers a pointer naming a slot that +// is not one of the two. That cannot arise from this code, so it means the +// entry was tampered with or written by another version, and guessing which +// slot was meant would be worse than refusing. +func TestKeyringStore_LoadRejectsAnUnknownSlot(t *testing.T) { + backend := newFakeKeyring() + backend.put(fieldCurrent, "somewhere-else") + store := &keyringStore{service: testService, backend: backend} + + _, err := store.Load(testNamespace) + require.Error(t, err) + assert.Contains(t, err.Error(), "slot") +} + +// countWrites reports how many times account appears in a write log. +func countWrites(writes []string, account string) int { + n := 0 + for _, w := range writes { + if w == account { + n++ + } + } + + return n } func TestKeyringStore_BackendFailuresAreReported(t *testing.T) { @@ -302,14 +416,92 @@ func TestKeyringStore_BackendFailuresAreReported(t *testing.T) { } // TestKeyringStore_DeleteToleratesMissingEntries covers a half-populated -// namespace: sign-out must succeed rather than stranding the operator. +// namespace: sign-out must succeed rather than stranding the operator, and it +// must clear the slot a torn Save left behind as well as the live one. func TestKeyringStore_DeleteToleratesMissingEntries(t *testing.T) { backend := newFakeKeyring() - backend.put(fieldAccess, "access-value") + backend.putField(slotA, fieldAccess, "access-value") + backend.putField(slotB, fieldRefresh, "orphaned-refresh") store := &keyringStore{service: testService, backend: backend} require.NoError(t, store.Delete(testNamespace)) - assert.Empty(t, backend.entries) + assert.Empty(t, backend.entries, "no token material may survive a sign-out, in either slot") +} + +// TestKeyringStore_FailedCommitKeepsThePreviousSession covers the last write. +// Every field of the new session is already in the keychain; only the pointer +// move failed. Load must still return the old session, because the new one was +// never committed. +func TestKeyringStore_FailedCommitKeepsThePreviousSession(t *testing.T) { + backend := newFakeKeyring() + store := &keyringStore{service: testService, backend: backend} + + require.NoError(t, store.Save(testNamespace, sampleTokens())) + + // Let all four field writes land, then fail the pointer write. + backend.okWrites = 0 + backend.failSetAfter = len(tokenFields) + + newer := sampleTokens() + newer.AccessToken = "new-access" + + err := store.Save(testNamespace, newer) + require.Error(t, err) + assert.Contains(t, err.Error(), "commit", "the failure must name the commit, not a field") + + backend.failSetAfter = -1 + + got, err := store.Load(testNamespace) + require.NoError(t, err) + assert.Equal(t, sampleTokens(), got, "an uncommitted session must not become the live one") +} + +// TestKeyringStore_SaveReportsAnUnreadablePointer covers a keychain that +// cannot be read at all. Save must not proceed on a guess about which slot is +// live: writing to the wrong one would overwrite the session it is meant to +// protect. +func TestKeyringStore_SaveReportsAnUnreadablePointer(t *testing.T) { + backend := newFakeKeyring() + backend.getErr = errors.New("keychain is locked") + store := &keyringStore{service: testService, backend: backend} + + err := store.Save(testNamespace, sampleTokens()) + require.Error(t, err) + assert.Contains(t, err.Error(), "slot") + assert.Empty(t, backend.writes, "nothing may be written when the live slot is unknown") +} + +// TestKeyringStore_SaveOverAnUnknownSlotStillSignsIn covers a pointer holding +// a value this code never writes. Load refuses it, but Save's job is to +// establish a good session and it can do that without deciding what the bad +// value meant. +func TestKeyringStore_SaveOverAnUnknownSlotStillSignsIn(t *testing.T) { + backend := newFakeKeyring() + backend.put(fieldCurrent, "somewhere-else") + store := &keyringStore{service: testService, backend: backend} + + require.NoError(t, store.Save(testNamespace, sampleTokens())) + assert.Equal(t, slotA, backend.value(fieldCurrent)) + + got, err := store.Load(testNamespace) + require.NoError(t, err) + assert.Equal(t, sampleTokens(), got) +} + +// TestKeyringStore_DeleteReportsAPointerFailure covers sign-out when the +// pointer cannot be removed. That is the entry that decides whether the +// operator is signed in, so the failure has to be reported rather than +// swallowed after clearing the fields. +func TestKeyringStore_DeleteReportsAPointerFailure(t *testing.T) { + backend := newFakeKeyring() + store := &keyringStore{service: testService, backend: backend} + require.NoError(t, store.Save(testNamespace, sampleTokens())) + + backend.deleteErr = errors.New("keychain is locked") + + err := store.Delete(testNamespace) + require.Error(t, err) + assert.Contains(t, err.Error(), "slot") } func TestKeyringStore_BlankServiceIsRejected(t *testing.T) { @@ -333,16 +525,21 @@ func sampleTokens() *cognito.TokenSet { // fakeKeyring is an in-memory stand-in for the OS keychain. type fakeKeyring struct { - mu sync.Mutex - entries map[string]string - writes []string - setErr error - getErr error - deleteErr error + mu sync.Mutex + entries map[string]string + writes []string + setErr error + // failSetAfter fails every Set from the (failSetAfter+1)th of this counter + // onward, so a test can tear a write sequence in the middle. Negative + // disables it. Reset okWrites to re-arm. + failSetAfter int + okWrites int + getErr error + deleteErr error } func newFakeKeyring() *fakeKeyring { - return &fakeKeyring{entries: make(map[string]string)} + return &fakeKeyring{entries: make(map[string]string), failSetAfter: -1} } func (f *fakeKeyring) Set(service, user, password string) error { @@ -351,6 +548,10 @@ func (f *fakeKeyring) Set(service, user, password string) error { if f.setErr != nil { return f.setErr } + if f.failSetAfter >= 0 && f.okWrites >= f.failSetAfter { + return errors.New("keychain is locked") + } + f.okWrites++ f.entries[service+"|"+user] = password f.writes = append(f.writes, user) return nil @@ -383,13 +584,30 @@ func (f *fakeKeyring) Delete(service, user string) error { return nil } -// put seeds an entry under testNamespace, bypassing the store under test. +// put seeds a namespace-level entry (the pointer) under testNamespace, +// bypassing the store under test. func (f *fakeKeyring) put(field, value string) { f.mu.Lock() defer f.mu.Unlock() f.entries[testService+"|"+testNamespace+"/"+field] = value } +// putField seeds one field of one slot, bypassing the store under test. +func (f *fakeKeyring) putField(slot, field, value string) { + f.mu.Lock() + defer f.mu.Unlock() + f.entries[testService+"|"+testNamespace+"/"+slot+"/"+field] = value +} + +// putCommitted seeds a committed session in slotA, field by field, so a test +// can then remove or corrupt exactly one part of it. +func (f *fakeKeyring) putCommitted(fields map[string]string) { + for field, value := range fields { + f.putField(slotA, field, value) + } + f.put(fieldCurrent, slotA) +} + // value reads an entry under testNamespace, bypassing the store under test. func (f *fakeKeyring) value(field string) string { f.mu.Lock()