cognito: a Cognito PKCE login flow for CLIs - #245
Open
lei-wego wants to merge 6 commits into
Open
Conversation
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
dismissed
their stale review
September 2, 2026 05:59
Withdrawn because internal review context was posted to this public repository in error.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 acognito/storagesubpackage 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-adminCLI (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
Headless sign-in
NoBrowsersuppresses the launch and hands the authorize URL toPromptURLinstead, 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.PromptURLis 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. SettingNoBrowserwithout 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
Config, so one process can hold several environments live at once.http/jwtkeeps 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.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
stateboth come fromcrypto/rand;stateis compared withsubtle.ConstantTimeCompareand a blank value on either side is a non-match. The challenge isS256. The callback listener binds loopback only and is single-use; neither the code norerror_descriptionis 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
storageinto its own module would forcecognitoto be tagged beforestoragecould require it, and every other module here requires tagged siblings with noreplace. Import paths are identical either way, so the only cost is that an OAuth-only consumer also pullsgo-keyring.Testing
go test ./...—cognito95.4%,cognito/storage94.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.NewMemorycarries the fullStorecontract tests so nothing pops a keychain prompt in CI.After merge
Tag
cognito/v0.1.0via./auto_version, then wego/payments#2300 drops its in-repo copy and requires the tag.sdc-clihas the same duplicated flow and can adopt it in a follow-up.