Skip to content

cognito: a Cognito PKCE login flow for CLIs - #245

Open
lei-wego wants to merge 6 commits into
mainfrom
feature/cognito-cli-auth
Open

cognito: a Cognito PKCE login flow for CLIs#245
lei-wego wants to merge 6 commits into
mainfrom
feature/cognito-cli-auth

Conversation

@lei-wego

@lei-wego lei-wego commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Adds github.com/wego/pkg/cognito: the Cognito authorization-code-with-PKCE login a command-line tool uses to obtain a human operator's tokens, plus a cognito/storage subpackage that caches the token set in the OS keychain.

This is the CLI-side counterpart to http/jwt. That package verifies a token arriving at a service; this one obtains a token at a terminal. Nothing in the repo covered the second half before.

Extracted from the payments repo's pay-admin CLI (wego/payments#2300), where it was written against these constraints from the start, so the move needed no redesign — package rename and import paths only.

API

type Config struct {
    AuthorizeURL, TokenURL, ClientID, RedirectURI string
    Scopes, AllowedDomain, CallbackAddr           string
    IdentityProvider                              string             // optional: skip Cognito's IdP chooser
    OpenBrowser                                   func(string) error // nil => OS default
    NoBrowser                                     bool               // suppress the launch, report the URL instead
    PromptURL                                     func(string) error // receives the URL when NoBrowser is set
    HTTPClient                                    *http.Client       // nil => sane default
    Now                                           func() time.Time   // nil => time.Now
}

func Login(ctx context.Context, cfg Config) (*TokenSet, error)
func Refresh(ctx context.Context, cfg Config, refreshToken string) (*TokenSet, error)

type TokenSet struct{ AccessToken, IDToken, RefreshToken string; ExpiresAt time.Time }
func (t *TokenSet) IsExpired(now time.Time) bool
func (t *TokenSet) Email() (string, error)

// cognito/storage
type Store interface{ Load(ns string) (*cognito.TokenSet, error); Save(...) error; Delete(...) error }
func NewKeyring(service string) Store
func NewMemory() Store

Headless sign-in

NoBrowser suppresses the launch and hands the authorize URL to PromptURL instead, for a headless shell, a terminal on a remote host, or a caller that wants to surface the URL its own way. Everything else is unchanged: same PKCE challenge, same state, code still delivered to the loopback listener.

PromptURL is a func rather than a bool-plus-stdout so the library never writes to a stream the caller did not choose — it can print, render a QR code, or hand the URL to another process. Setting NoBrowser without it is refused at validation.

One limit is documented on the field rather than papered over: the redirect still lands on CallbackAddr, so a browser on a different machine than the CLI needs that port forwarded (ssh -L). Suppressing the launch does not move where the code is delivered.

Two properties worth preserving

  • No package-level mutable state. Every dependency — clock, HTTP client, browser opener, identity provider — arrives through Config, so one process can hold several environments live at once. http/jwt keeps its JWKS URL and header in package globals and can therefore serve exactly one issuer; payments' bo-refunds plan records that as the reason a second issuer became cross-team work. This package deliberately does not repeat the shape.
  • Stdlib-only OAuth (bar wego/pkg/strings). Hand-rolling the exchange keeps every wire parameter visible and auditable, which matters more here than the convenience an OAuth library buys.

Security-relevant behaviour

Verifier and state both come from crypto/rand; state is compared with subtle.ConstantTimeCompare and a blank value on either side is a non-match. The challenge is S256. The callback listener binds loopback only and is single-use; neither the code nor error_description is interpolated into the served HTML. Email() parses the id_token without verifying its signature — correct here because the token came from the token endpoint over TLS or the caller's own keychain, and it is documented as such — and it cannot panic on malformed input. No token, verifier, or state appears in any error string.

Kept as one module

Splitting storage into its own module would force cognito to be tagged before storage could require it, and every other module here requires tagged siblings with no replace. Import paths are identical either way, so the only cost is that an OAuth-only consumer also pulls go-keyring.

Testing

go test ./...cognito 95.4%, cognito/storage 94.7%. The uncovered lines are the ones that touch the OS: the browser exec and the real keychain adapter, both behind unexported seams so the logic around them is covered. NewMemory carries the full Store contract tests so nothing pops a keychain prompt in CI.

After merge

Tag cognito/v0.1.0 via ./auto_version, then wego/payments#2300 drops its in-repo copy and requires the tag. sdc-cli has the same duplicated flow and can adopt it in a follow-up.

mysqto and others added 4 commits September 2, 2026 09:31
Adds github.com/wego/pkg/cognito, the authorization-code-with-PKCE login a
command-line tool uses to obtain a human operator's tokens, plus a
cognito/storage subpackage that caches the token set in the OS keychain.

This is the CLI-side counterpart to http/jwt: that package verifies a token
arriving at a service, this one obtains one at a terminal. Extracted from the
payments repo's pay-admin CLI, where it was written against these constraints
from the start so the move needed no redesign.

Two properties are deliberate and worth preserving:

  - No package-level mutable state. Every dependency -- the clock, the HTTP
    client, the browser opener, the identity provider -- arrives through
    Config, so one process can hold several environments live at once.
    http/jwt keeps its JWKS URL and header in package globals and can
    therefore serve exactly one issuer; that limitation is why this package
    does not repeat the shape.
  - Stdlib-only OAuth (bar Wego's string helpers). Hand-rolling the exchange
    keeps every wire parameter visible and auditable, which matters more here
    than the convenience an OAuth library would buy.

Kept as one module rather than splitting storage out: the split would have
forced cognito to be tagged before storage could require it, and every other
module in this repo requires tagged siblings with no replace directive. The
import paths are identical either way, so the only cost is that an
OAuth-only consumer also pulls go-keyring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
revive's unhandled-error rule flags a bare fmt.Fprint. The write genuinely
cannot be acted on -- the browser tab is the only reader and the operator
sees the real outcome in the terminal -- so the discard is explicit rather
than implicit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Login could only reach the operator by launching a browser, which rules out a
headless shell, a terminal on a remote host, and any caller that wants to
surface the URL its own way.

Config.NoBrowser suppresses the launch and Config.PromptURL receives the
authorize URL instead; everything else is unchanged, so the same PKCE
challenge and state are sent and the code still arrives on the loopback
listener. PromptURL is a func rather than a bool-plus-stdout so the library
never writes to a stream the caller did not choose -- it can print, render a
QR code, or hand the URL to another process.

Setting NoBrowser without PromptURL is refused at validation: with no browser
launched and no way to report the url, the operator has nothing to open.

One limit is documented on the field rather than papered over: the redirect
still lands on CallbackAddr, so a browser on a different machine than the CLI
needs that port forwarded. Suppressing the launch does not move where the
code is delivered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by CodeRabbit and claude[bot] on the payments PR this package was
extracted from, and mirrored here so the two copies do not diverge before the
payments one is deleted.

postToken validated the three token strings but accepted any ExpiresIn. With
expires_in absent, zero or negative, ExpiresAt landed on exactly now() and
IsExpired subtracts a leeway on top, so a login that had just succeeded read
as already expired -- sending the operator back through sign-in on their next
command with no indication why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCCcwc4wtJgYKSFygyZGnR

@yanyi-wego yanyi-wego left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review withdrawn.

@yanyi-wego
yanyi-wego dismissed their stale review September 2, 2026 05:59

Withdrawn because internal review context was posted to this public repository in error.

mysqto and others added 2 commits September 3, 2026 21:39
Config.httpClient returned a client with no CheckRedirect, so Go's
default policy applied: follow up to ten redirects, re-sending the body
verbatim on a 307 or 308. The token request carries the authorization
code, the PKCE verifier and the client id on sign-in and the refresh
token on renewal, so one redirect handed a complete credential set to
whatever host the response named. It was also an injection route
inwards, since the body that came back was parsed as the session to use.

httpClient now returns a COPY with CheckRedirect set to refuse. Copying
means a caller-supplied HTTPClient keeps its transport and timeout but
cannot reinstate following, deliberately or by passing a client
configured elsewhere, and the caller's own client is not mutated.

A redirect from the token endpoint has no legitimate meaning here:
TokenURL is an operator-configured Cognito domain that answers
directly. Refusing turns it into the error it should be, reported with
the endpoint and status via the existing status check.

TestConfig_Defaults asserted the injected client was returned by
identity, which is the behaviour that allowed the override to be
bypassed. It now pins the copy semantics instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCCcwc4wtJgYKSFygyZGnR
A re-login that failed partway left a hybrid token set. Save wrote four
separate keychain entries and Load gated on the access token, so a
failure after the refresh token was written left the NEW refresh token
beside the OLD access token, id token and expiry, and Load returned
that mixture as a live session. Writing the access token last only
protected a FIRST login, where there was nothing to mix with.

The consequences are worse than a failed read: a stale expiry paired
with a fresh refresh token makes the CLI believe a session is valid, and
after an account switch one identity's id token can end up beside
another's refresh token.

Single-entry storage would fix it but does not fit. zalando/go-keyring
shells out to /usr/bin/security on macOS and rejects any command over
4096 bytes (keyring_darwin.go); after its base64 expansion that leaves
roughly 3 KB of secret per entry, and a combined set of Cognito JWTs
runs to about that, so it would work in development and fail for
operators with larger tokens. Fields therefore stay in their own
entries.

Atomicity comes from a commit pointer instead. Each token set is
written into one of two slots, and a "current" entry names the slot that
counts. Save fills the inactive slot and then moves the pointer, which
is one small write and the only write that changes what Load sees, so a
failure anywhere before it costs the new session and never the old one.
Two slots rather than a counter keep the entry count fixed and mean a
re-login never writes over the entries the live session is read from.

Delete removes the pointer first, for the same reason, and clears both
slots so a torn Save leaves no token material behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCCcwc4wtJgYKSFygyZGnR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants