feat: OAuth login for SDK and CLI (0.3.0) - #14
Merged
Conversation
The REST API now accepts PropelAuth bearer tokens, so the SDK needs a credential that can outlive a one-hour access token. Auth moves from a static X-discolike-key header into an httpx2.Auth subclass that picks the header per credential, refreshes an OAuth token before expiry and once after a 401, and serialises concurrent refreshes so a burst of requests rotates the refresh token exactly once. Refreshes are yielded through the calling client's own transport rather than a second HTTP client, which keeps sync/async symmetric and lets MockTransport drive them in tests. Rotated tokens are written back only when the credential came from the config file; an injected auth= stays the caller's responsibility. Discolike(api_key=...), resolve_api_key, the config file location and the api_key JSON shape are unchanged; OAuth adds an "oauth" object under auth_method="oauth". Version 0.4.0.
`discolike auth login` now defaults to a browser login: discover the authorization server through the API, register a public client via DCR, run PKCE authorization-code against a loopback redirect, and save the resulting OAuth credential. The redirect URI is registered with the actual bound port (random by default, --port to pin for SSH forwarding) so it works whether or not the server relaxes loopback port matching. The API-key path stays reachable through --api-key or --method api_key, which preserves the prompt and the existing stderr JSON. auth status adds a method field and, for OAuth, expiry information.
Two processes holding the same refresh token near expiry would both refresh; the second fails at the authorization server (rotation) and the last writer could overwrite the newer token pair. save_config now writes to a temp file in the config dir and renames it into place, so a reader never sees a partial file. DiscolikeAuth, when the credential came from the config file, re-reads the file under the refresh lock and adopts a fresher credential written by another process instead of refreshing with a refresh token that is already spent.
yudelevi
marked this pull request as ready for review
August 28, 2026 17:35
Every login registered a fresh DCR client, and PropelAuth remembers consent per client_id, so users saw the consent screen on every `auth login`. The registration (client_id, exact redirect URI, issuer) is now stored under "oauth_client" in the config file, kept across credential writes, and reused when the next login discovers the same issuer and can bind the same loopback port again. PropelAuth matches the redirect URI literally, port included, so a busy port, a different issuer, or an explicit --port that differs all fall back to a fresh registration. `auth logout` still deletes the whole file.
PropelAuth remembers consent per client_id, so wiping the registration on logout put the consent screen back in front of the user on the next login. The registration is a public PKCE client with no secret, so logout now drops only the credential and leaves "oauth_client" in the config file; the file is removed only when nothing else remains.
load_config already degrades a corrupt file to an empty dict, but a file with auth_method "oauth" and a missing or malformed "oauth" object (or a malformed "oauth_client") escaped as a raw KeyError/TypeError/ ValueError from the client constructor. Follow the same rule: such a section reads as absent, so callers get the usual "run discolike auth login" AuthenticationError instead of a traceback.
A stored client registration that PropelAuth has since forgotten made every login fail the same way: the authorize step came back with invalid_client/unauthorized_client, or the code exchange did, and the CLI kept reusing the dead client_id. Those two error codes now mark the registration as dead; when the registration was a reused one it is discarded, a fresh client is registered, and the browser flow runs once more. A freshly registered client that is rejected, or a second failure, surfaces as the normal LoginError. Other callback errors such as access_denied keep the registration and fail as before. The token endpoint's error code is now carried on OAuthError.error so the CLI can distinguish invalid_client from any other rejection.
…ens from error payloads A forged /callback?error=invalid_client from anything that can reach the loopback port could evict the stored client registration before the state check ran. State is now verified first, so only the browser session the CLI started can affect login state. Token-response parsing errors attached the raw response, access token included, as exc.payload; SDK consumers that log payloads would leak a live token. Token fields are stripped before the exception is raised.
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 OAuth 2.1 (PKCE, dynamic client registration) as the default login for
the CLI and a first-class credential type for the SDK. Pairs with platform
commit daae3af33 on development, which makes REST /v1/* accept PropelAuth
bearer tokens.
Why: users should not have to mint and paste API keys to try the CLI; the
platform already ran an OAuth server for MCP, so the SDK can log in the
same way MCP clients do.
SDK
ApiKeyCredential | OAuthCredential;Discolike(auth=...). Resolution:auth>api_key>DISCOLIKE_API_KEY> config.api_key=unchanged.DiscolikeAuth(httpx2.Auth)replaces the static header: proactive refresh60s before expiry, one refresh-and-replay on 401, rotated tokens written
back to config atomically; re-reads config before refreshing so parallel
CLI processes do not burn the same refresh token.
base_urlvia/.well-known/oauth-authorization-server(dev and prod use different PropelAuth hosts).
CLI
auth logindefaults to the browser loopback flow (--no-browser,--port);--api-key/--method api_keykeep the old path and output.auth statusreports method and expiry.Verified live against dev (2026-08-28): DCR accepts the random-port loopback
redirect, login +
account usage200 with the /v1 audience, forced proactiverefresh rotates and persists the new refresh token.
Greptile Summary
Adds OAuth 2.1 browser login and persisted OAuth credentials to the SDK and CLI, while retaining API-key authentication.
Confidence Score: 4/5
The OAuth feature needs malformed saved-credential handling fixed before merging so local configuration damage cannot crash SDK and CLI authentication paths.
OAuth deserialization directly indexes an assumed config shape, allowing malformed but valid JSON to bypass existing config recovery and raise uncaught built-in exceptions during startup, status checks, or refresh.
Files Needing Attention: packages/discolike/src/discolike/_config.py, packages/discolike/src/discolike/_credentials.py
Important Files Changed
Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "fix(sdk): avoid cross-process refresh to..." | Re-trigger Greptile
Context used (3)