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..ae9d29f --- /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..8827fdb --- /dev/null +++ b/cognito/oauth.go @@ -0,0 +1,418 @@ +// 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 + + // 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 + + // 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.presentAuthorizeURL(cfg.buildAuthorizeURL(state, generateChallenge(verifier))); err != nil { + return nil, 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") + 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{ + 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") + } + 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 +} + +// 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 { + base := c.HTTPClient + if base == nil { + base = &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. +func (c Config) now() time.Time { + if c.Now != nil { + return c.Now() + } + return time.Now() +} + +// 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) + } + return openBrowser(rawURL) +} diff --git a/cognito/oauth_internal_test.go b/cognito/oauth_internal_test.go new file mode 100644 index 0000000..6b84acf --- /dev/null +++ b/cognito/oauth_internal_test.go @@ -0,0 +1,220 @@ +package cognito + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "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") + + // 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()) +} + +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) +} + +// 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") + }) + } +} diff --git a/cognito/oauth_test.go b/cognito/oauth_test.go new file mode 100644 index 0000000..b7c70bc --- /dev/null +++ b/cognito/oauth_test.go @@ -0,0 +1,765 @@ +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 +} + +// 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") +} + +// 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) + }) + } +} 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..cfc3481 --- /dev/null +++ b/cognito/storage/keyring.go @@ -0,0 +1,309 @@ +package storage + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + wegostrings "github.com/wego/pkg/strings" + "github.com/zalando/go-keyring" + + "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 (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 "/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 { + 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 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 + } + if err != nil { + return nil, fmt.Errorf("read the keychain: %w (is the system keychain unlocked?)", err) + } + + 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, slot, fieldRefresh) + if err != nil { + return nil, err + } + rawMeta, err := k.read(namespace, slot, 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. +// +// 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 + } + if tokens == nil { + return errNoTokens + } + + meta, err := json.Marshal(keyringMeta{ExpiresAt: tokens.ExpiresAt}) + if err != nil { + return fmt.Errorf("encode token metadata: %w", err) + } + + live, err := k.currentSlot(namespace) + if err != nil { + return 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 + } + + 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 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) + } + 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 +} + +// 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 new file mode 100644 index 0000000..4770377 --- /dev/null +++ b/cognito/storage/keyring_internal_test.go @@ -0,0 +1,616 @@ +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", + }, + { + // 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.putCommitted(map[string]string{fieldAccess: "access-value"}) + }, + wantErrContains: "id", + }, + { + name: "a missing refresh_token is an inconsistency", + givenSetup: func(f *fakeKeyring) { + f.putCommitted(map[string]string{ + fieldAccess: "access-value", + fieldID: "id-value", + }) + }, + wantErrContains: "refresh", + }, + { + name: "missing metadata is an inconsistency", + givenSetup: func(f *fakeKeyring) { + 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.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) {}, + 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_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)) + + second := sampleTokens() + second.AccessToken = "second-access" + require.NoError(t, store.Save(testNamespace, second)) + assert.Equal(t, slotB, backend.value(fieldCurrent)) + + 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) { + 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, 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.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, "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) { + 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 + // 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), failSetAfter: -1} +} + +func (f *fakeKeyring) Set(service, user, password string) error { + f.mu.Lock() + defer f.mu.Unlock() + 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 +} + +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 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() + 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" +}