Skip to content

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

Merged
lei-wego merged 14 commits into
mainfrom
feature/cognito-cli-auth
Sep 9, 2026
Merged

cognito: a Cognito PKCE login flow for CLIs#245
lei-wego merged 14 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 5 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
A token set is read across five keychain entries, so another process
could commit a replacement and clear the old slot between them. Load had
already chosen that slot, and the next field read failed with "secret
not found in keyring" even though a perfectly good session existed
throughout. Two overlapping pay-admin processes is ordinary: a command
running while a refresh fires, or two terminals on one namespace.

The commit pointer is what makes this recoverable. On a failed field
read Load now re-reads the pointer; if it has moved, the slot was
superseded rather than broken, so it starts again on the slot that is
now live. If the pointer has not moved the namespace really is
inconsistent and the original error is reported unchanged, so a missing
field is still named rather than disappearing into a generic retry.

Bounded at three attempts. Each retry needs another process to commit a
whole session in the gap, so exhausting them means a namespace being
rewritten faster than it can be read, which no retry fixes.

Immediate cleanup of the superseded slot is kept: leaving it would mean
a stale refresh token living in the keychain until the next login, and
the retry makes the deletion safe.

Reported in review on wego/payments#2300 with a reproducing test. The
regression here drives a real Save through the backend mid-read and
asserts Load returns one whole session, never a mixture of the two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCCcwc4wtJgYKSFygyZGnR
NoBrowser moved where the authorize URL is SHOWN, not where the code is
delivered: the redirect still had to reach CallbackAddr on this machine,
so a remote box needed `ssh -L`. With no browser and no port forward
there was no way to sign in at all.

Config.ReadRedirect closes that. Login shows the URL, the operator opens
it anywhere, Cognito redirects to a loopback URL nothing is listening on,
the browser shows a connection error, and its address bar holds
?code=...&state=... to copy back. No listener, no bound port.

The device authorization grant would be the conventional answer and
Cognito does not have one: the discovery document advertises no
device_authorization_endpoint, /oauth2/device_authorization is 404, and
the token endpoint answers a device_code grant with
unsupported_grant_type. This is the substitute that needs no new Cognito
configuration, because it is still authorization code with PKCE and only
the redirect is carried by hand.

State is still checked, and it is doing more work here than on the
listener path: nothing about a pasted URL proves where it came from, so
it is the only thing binding the code to this attempt. A bare code is
refused for that reason, and a query carrying `error` reports the
provider's refusal rather than a missing-code error.

ReadRedirect implies NoBrowser. Launching a browser and then also asking
for a paste would be a footgun, and where a local browser works the
listener path is less work for the operator. CallbackAddr becomes
optional on this path since nothing binds a port.

The pasted URL carries a single-use authorization code, so it should not
travel through a shared channel; that is documented on the field.

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

lei-wego commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Ready for another look. Since the last review this branch has three fixes and one addition, all with tests.

Token endpoint no longer follows redirects (ff2fbcb). Config.httpClient returned a client with no CheckRedirect, so Go's default applied and a 307/308 would re-send the request body. That body carries the authorization code and PKCE verifier on sign-in and the refresh token on renewal. It now returns a copy with redirects refused, so a caller-supplied client keeps its transport and timeout but cannot reinstate following, and the caller's own client is not mutated.

Token sets commit atomically (dda4783). Save wrote four separate keychain entries and Load gated on one of them, so a write that failed partway could leave a new refresh token beside an old access token, id token and expiry, which Load then returned as a live session. A single entry would be the obvious fix but does not fit: zalando/go-keyring shells out to security on macOS and rejects any command over 4096 bytes, which after its base64 expansion leaves roughly 3 KB of secret per entry. So fields keep their own entries and a small pointer entry names which of two slots is live. Save fills the inactive slot and then moves the pointer, which is one write and the only one that changes what Load sees.

Concurrent reads survive a commit (f338c57). The two-slot design introduced a race: Load chose a slot, a parallel process committed the replacement and cleared the old one, and the read failed on a missing entry. Load now re-reads the pointer on a failed field read and retries on the live slot if it moved, while still reporting a genuine inconsistency unchanged. Bounded at three attempts.

Paste-back sign-in (f308520). NoBrowser only moved where the authorize URL is shown; the redirect still had to reach the local callback port, so a host with no browser and no route to that port could not sign in at all. Config.ReadRedirect lets the caller return the URL the browser was redirected to instead of a listener receiving it. The device authorization grant would be the conventional answer and this provider does not implement it, so this is the substitute; it is still authorization code with PKCE, with only the redirect carried by hand. State is still checked and matters more here, since nothing else ties a pasted code to the attempt that started it.

Coverage is 95.3% on cognito and 94.4% on cognito/storage, with -race clean. The remaining uncovered statements are the three thin wrappers over the real OS keychain.

@lei-wego
lei-wego requested a review from yanyi-wego September 7, 2026 07:58
Round 4 of review on wego/payments#2300 found the two-slot layout still
allowed a hybrid credential set, by two schedules the round-3 Load retry
did not touch. Both are now regressions in this package, and both
reproduced the reported outputs before the fix.

Save/Save: two writers read the same live slot, both computed the same
other slot, and interleaved field writes into it. Both committed, so one
slot held one session's access token beside another's id and refresh
tokens ("second-access with first-id and first-refresh").

ABA: with two reusable slots the pointer could cycle A->B->A, so a
generation that HAD changed under an in-flight Load looked unchanged,
Load's moved-pointer check saw no move, and it returned fields it never
selected ("seed-access with second-id and second-refresh").

This is worse than untidy. The id token carries the operator identity
that admin writes are audited against, so a mismatched pair can
attribute a production change to the wrong person, which is why the
finding is HIGH rather than a tidiness nit.

Fields now live under a random generation name that is never reused,
with the pointer naming the live one. Concurrent writers are disjoint by
construction, so each generation is whole and the later commit simply
wins; and because a name never repeats, any change under a reader is
detectable, which removes ABA structurally rather than by timing.

Chosen over the process-shared lock the review also offered: this module
has no file-lock dependency and no platform-specific code, and a lock
would need both plus stale-holder handling on three platforms.

The cost is that a keychain cannot be enumerated, so only the
generations the pointer names can be reaped. The pointer therefore
remembers the one it replaced, and Delete clears both. A generation
orphaned by a crash mid-Save, or by two Saves overlapping, is not
reachable: it holds a superseded set no code path returns, and Cognito
refresh tokens expire, so it decays rather than accumulating. That
window is documented on Delete. Closing it entirely is what the locking
option would buy.

Save no longer fails when the pointer cannot be read. The old layout had
to know the live slot to avoid overwriting it; a fresh generation
collides with nothing, so an unreadable pointer costs only the chance to
reap and no longer blocks a sign-in.

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

lei-wego commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

@yanyi-wego review bump — this is now the only thing blocking the payments chain.

Everything downstream is done: payments#2317 is approved and merged into #2300's branch, pennyworth#1525 is approved, merged and deployed to staging. payments#2300 carries both and is green with zero open threads. None of it can reach main until the cognito package lands here and gets tagged, because #2300's require points at a pseudo-version of this branch.

State here: build green on both jobs, zero open threads, 17 files, MERGEABLE but BEHIND main. It reads REVIEW_REQUIRED because your review was withdrawn from GitHub rather than never given — I was mis-describing that as "never reviewed" for a while, which was wrong.

Every finding from that review is fixed, including round 4's storage races, which were the real ones:

  • Save/Save hybrid — two concurrent Save calls could commit a mixed credential set (one call's access token with another's ID and refresh tokens).
  • ABA on the pointer — an A→B→A generation cycle could make Load accept a stale set as current.

Both were genuine holes in my own design, not test artifacts. I reproduced each one first and matched your reported outputs exactly before changing anything. The fix is commit-pointer storage where a generation name is never reused, which removes both failure modes rather than narrowing the window, and both of your reproductions are committed as regression tests. That replaced the earlier two-reusable-slot scheme.

Also in since your review: the paste-back sign-in path for an unreachable callback port (Config.ReadRedirect), after I confirmed three ways that Cognito has no RFC 8628 device grant — no device_authorization_endpoint in discovery, /oauth2/device_authorization answers 404, and a device_code grant request returns unsupported_grant_type.

Merge order once you approve: this → tag cognito/v0.1.0 → repoint the require in payments#2300 → merge #2300. I'll bring this up to main right before merge rather than now, so it doesn't churn CI while it waits.

@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.

Requesting changes for the credential transport blocker. Two non-blocking defects are noted inline.

Comment thread cognito/oauth.go
Comment thread cognito/callback.go Outdated
Comment thread cognito/oauth.go Outdated
Three findings from review round 5, all in the same area: what this
package will accept as a place to send or receive OAuth credentials.

TokenURL and AuthorizeURL could be cleartext http. The token endpoint
receives the authorization code, the PKCE verifier and the client id on
sign-in and the refresh token on renewal, so an http:// value handed the
whole credential set to anyone on the path. Both now require https, with
no loopback exemption: these are Cognito's own endpoints, served over
https only, so http is a misconfiguration in every case. A URL embedding
userinfo is refused too, since it would be logged and re-sent verbatim.

CallbackAddr and RedirectURI could name a routable host, despite this
package being loopback-only by design. Binding 0.0.0.0 would let
anything that can reach the machine deliver a redirect to the single-use
callback, and a routable redirect would send the code across the network
to whoever answered. Both are now held to literal loopback — a name that
merely resolves to 127.0.0.1 does not count, since resolution can change
between the check and the request. Cleartext http stays allowed for the
redirect and only there: RFC 8252 has a native app receive it on
loopback, where TLS buys nothing but a certificate problem.

The callback handler published its result before checking state, so a
blind request to the predictable callback port could consume the one
delivery the flow gets and abort a real sign-in that had not landed yet.
State is now compared before anything is delivered, so an uncorrelated
request is dropped and the genuine redirect is still accepted. This is a
denial-of-sign-in guard, not the CSRF check — Login still compares state
itself, which is what the paste-back path relies on. One consequence
worth knowing: on the listener path a tampered state now surfaces as the
wait expiring rather than "state mismatch", because it is never
delivered. Two tests were rewritten to pin that contract.

ReadRedirect ran synchronously, so a cancelled Login could not return
while the reader was blocked on stdin. It now runs on a goroutine with
Login selecting on ctx.Done(). That does not unblock the read itself and
nothing can; the goroutine parks until the reader yields, then sends into
a buffered channel and exits. The signature stays func() (string, error)
so existing callers keep working.

Every rule has a rejection test, plus the invalid-then-valid regression
for the callback and a blocked-reader cancellation test. All three fixes
are mutation-checked: reverting any one of them fails its own tests. The
suite moved to httptest.NewTLSServer to match the new https rule rather
than working around it.

Verified the payments CLI still passes: it binds 127.0.0.1:8100,
redirects to http://localhost:8100/callback, and builds both endpoints as
https://<domain>/oauth2/*.
@lei-wego

lei-wego commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

@yanyi-wego round 5 is answered in b0738ab — all three fixed, each with the tests you asked for.

Finding Fix Tests
Blocking: cleartext endpoints, non-loopback bind requireHTTPS on TokenURL (via validateClient, so Refresh too) and AuthorizeURL; requireLoopbackAddr / requireLoopbackRedirect on the local pair; new exported ErrInsecureEndpoint 9 rejection cases + a 3-host acceptance test for the loopback-http redirect
Callback delivered before the state check expectedState passed into callbackHandler, compared before anything reaches the channel invalid-then-valid regression + 3 no-delivery cases
ReadRedirect not cancellation-aware Config.readRedirect(ctx) — goroutine plus select on ctx.Done() blocked-reader cancellation test

All three are mutation-checked — reverting any one fails its own tests, not just the suite. -race is clean, and the full pkg pre-push suite passed.

I've resolved the blocking thread. I left the other two open on purpose, not because work is pending but because each carries a question back to you:

  • On the callback: a tampered state now surfaces as the wait expiring rather than "state mismatch", since it is never delivered. That also means an error= redirect with no echoed state would time out instead of reporting. RFC 6749 requires state to be echoed when sent, so I accepted it — flagging in case you would rather the error branch stayed reachable.
  • On cancellation: I kept ReadRedirect's signature as func() (string, error) rather than adding ctx. Adding it would be the better API and this is the last moment before cognito/v0.1.0 makes it breaking, but it would force a matching edit in payments#2300, which is green and waiting on approval. Your call — it is free right now and not later.

Two notes on things that changed beyond the fixes. The suite moved to httptest.NewTLSServer, because every existing test pointed at a plaintext loopback server and would otherwise have exercised the new rejection path instead of the flow; exempting loopback to keep the tests unchanged felt like the wrong trade. And I verified the payments CLI still passes the new rules rather than assuming — it binds 127.0.0.1:8100, redirects to http://localhost:8100/callback, and builds both endpoints as https://<domain>/oauth2/*.

Build is green. This is still the head of the chain: payments#2317 is merged into #2300's branch and pennyworth#1525 is merged and on staging, so #245 plus #2300 are the last two approvals before the tag and the merge.

@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.

One blocking cancellation-contract issue remains inline.

Comment thread cognito/oauth.go
Round 6, and the reviewer named a hazard the previous fix left open: a
cancelled Login returned, but ReadRedirect could still be active on a
caller-owned reader, so a retry raced the abandoned attempt for shared
stdin. Whichever won, one of them saw a truncated or empty read.

The review offered ctx-awareness OR a documented contract. Taking both,
because they cover different halves and neither is sufficient alone. The
signature is now func(ctx context.Context) (string, error), so a reader
that can select on ctx is able to abandon the read instead of parking on
the descriptor. Login still selects on ctx.Done() as well, because a
reader that ignores ctx must not be able to pin Login — the ctx argument
is what lets the read end, the select is what bounds Login.

Neither can interrupt a read already blocked inside an uncooperative
reader, and nothing in this package can, so the two obligations that
genuinely fall to the caller are now written on the exported field: tear
your own reader down on cancellation if you need the read to stop, and do
not start another Login while a previous invocation may still be blocked
in there.

Breaking change to an exported field, taken deliberately now: this is the
last moment before cognito/v0.1.0, after which it would not be free. The
payments caller is updated in the same review round.

TestLogin_PasteBackReaderSeesCancellation covers the new half — a
cooperative reader observes Login's ctx being cancelled — alongside the
existing TestLogin_PasteBackIsCancellable for the uncooperative case.
Mutation-checked: passing context.Background() to the callback instead of
ctx fails it. Race clean.
Found by running the CLI rather than the tests. A keychain carrying a
pointer this build cannot parse made every command fail with

  parse the token pointer for "pay-admin/staging": invalid character 'a'
  looking for beginning of value

which tells an operator nothing they can act on. The recovery exists and
is always the same - Save rewrites the whole namespace, so signing out
and back in fixes any unreadable pointer - but it is not guessable from a
json error, so both pointer errors now say it.

Still reported rather than silently repaired: quietly discarding a
session store is not readPointer's decision, and a pointer that cannot be
read may be the visible symptom of something worth knowing about.

The specific value that surfaced this was "a", a bare slot name from the
two-reusable-slot layout that generations replaced. That shape never
shipped - v0.1.0 is the first release - so no released version can
produce it and there is deliberately NO migration code for it; only
pre-release builds on a developer's machine can have written one. It is
covered as a test case because a keychain holding one still has to fail
readably.

Mutation-checked: removing the recovery sentence fails both cases.

@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.

LGTM

@lei-wego
lei-wego merged commit a86f316 into main Sep 9, 2026
2 checks passed
@lei-wego
lei-wego deleted the feature/cognito-cli-auth branch September 9, 2026 08:03
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