Skip to content

feat: OAuth login for SDK and CLI (0.3.0) - #14

Merged
quantumdark merged 14 commits into
developmentfrom
feat/oauth
Aug 29, 2026
Merged

feat: OAuth login for SDK and CLI (0.3.0)#14
quantumdark merged 14 commits into
developmentfrom
feat/oauth

Conversation

@yudelevi

@yudelevi yudelevi commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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 refresh
    60s 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.
  • AS discovered from base_url via /.well-known/oauth-authorization-server
    (dev and prod use different PropelAuth hosts).

CLI

  • auth login defaults to the browser loopback flow (--no-browser,
    --port); --api-key / --method api_key keep the old path and output.
  • auth status reports method and expiry.
    Verified live against dev (2026-08-28): DCR accepts the random-port loopback
    redirect, login + account usage 200 with the /v1 audience, forced proactive
    refresh 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.

  • Implements PKCE, authorization-server discovery, dynamic client registration, and loopback callbacks.
  • Adds proactive token refresh, one refresh-and-replay attempt after a 401, and atomic credential persistence.
  • Extends CLI login/status behavior, public credential types, tests, documentation, and package versions to 0.3.0.

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

Filename Overview
packages/discolike/src/discolike/_auth.py Introduces API-key and OAuth httpx2 authentication flows with refresh synchronization, persisted rotation, and one 401 replay.
packages/discolike/src/discolike/_config.py Adds OAuth credential resolution and atomic persistence, but malformed valid-JSON OAuth records can escape as untyped exceptions.
packages/discolike/src/discolike/_oauth.py Implements OAuth discovery, dynamic registration, PKCE, code exchange, and refresh-token parsing.
packages/discolike-cli/src/discolike_cli/auth.py Makes browser OAuth the default login and extends status reporting while preserving explicit API-key login.
packages/discolike-cli/src/discolike_cli/_loopback.py Adds a loopback HTTP callback server for receiving OAuth authorization responses.
packages/discolike/src/discolike/_transport.py Moves request credentials from static headers into the shared httpx2 authentication handler.

Fix all with Greploop Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
### Issue 1
packages/discolike/src/discolike/_config.py:70-71
**Malformed OAuth config crashes**

When a valid JSON config selects OAuth but lacks a complete, correctly typed `oauth` object, `load_credential()` raises an uncaught `KeyError`, `TypeError`, or `ValueError`, causing SDK initialization or CLI status checks to crash instead of following the typed authentication-error path.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(sdk): avoid cross-process refresh to..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used (3)

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
yudelevi marked this pull request as ready for review August 28, 2026 17:35
Comment thread packages/discolike/src/discolike/_config.py Outdated
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.
@yudelevi yudelevi changed the title feat: OAuth login for SDK and CLI (0.4.0) feat: OAuth login for SDK and CLI (0.3.0) Aug 28, 2026
…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.
@quantumdark
quantumdark merged commit c273697 into development Aug 29, 2026
7 checks passed
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.

2 participants