feat(keycardai-oauth): stateless web-app authorization-code flow (spec #46) - #233
Conversation
…(spec #46) Co-Authored-By: Larry Osakwe <larry@keycard.ai>
Co-Authored-By: Larry Osakwe <larry@keycard.ai>
Co-Authored-By: Larry Osakwe <larry@keycard.ai>
Co-Authored-By: Larry Osakwe <larry@keycard.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Larry-Osakwe
left a comment
There was a problem hiding this comment.
The security-sensitive parts are right: validation ladder ordered error/state/code before any network, constant-time state compare over UTF-8 bytes with the non-ASCII case tested, exchange asserted never-awaited on every rejection path, and I diffed the moved resolve_issuer_from_challenge body side by side (verbatim, public import path preserved). One change needed before approval, one test ask, one nit inline.
| auth_strategy = NoneAuth() | ||
| config = ClientConfig(enable_metadata_discovery=True, auto_register_client=False) | ||
|
|
||
| async with AsyncClient( |
There was a problem hiding this comment.
Every begin and every complete builds a fresh AsyncClient and runs full metadata discovery, so a login route pays two discovery round trips per sign-in with no way to amortize. Statelessness between the two calls is the contract, but per-call rediscovery isn't. Add an optional metadata= param to both functions (same move as userinfo() in #232): when provided, skip discovery and read the endpoints from it. The app owns the caching, same philosophy as the session storage. The first real consumer is a login page, so this gets hit on every sign-in from day one.
There was a problem hiding this comment.
Added metadata: AuthorizationServerMetadata | None to both functions, same name/type/semantics as userinfo(metadata=...) in #232.
One design call worth flagging: I made metadata a third mutually exclusive entry mode, so the rule is now "exactly one of issuer, www_authenticate_header, or metadata". Reason: if the app has cached AS metadata it already knows the AS, and letting it also pass www_authenticate_header would keep the protected-resource-metadata fetch on the critical path — half the round trips the param exists to remove.
In begin, metadata mode builds no AsyncClient at all (it only needs authorization_endpoint). In complete, the client is still needed for the exchange but makes no discovery request: issuer=metadata.issuer, enable_metadata_discovery=False, token endpoint via the endpoints= override. Both are asserted in tests (assert_not_called / get_endpoints.assert_not_awaited).
| issuer=auth_server_url, auth=auth_strategy, config=config | ||
| ) as oauth_client: | ||
| endpoints = await oauth_client.get_endpoints() | ||
| if not endpoints.authorize or not endpoints.token: |
There was a problem hiding this comment.
Two things here: begin only uses the authorize endpoint, so requiring token_endpoint at this step is stricter than it needs to be (complete is where token_endpoint matters). And this ValueError branch is untested in both functions; one test each with metadata missing the endpoint would cover it.
There was a problem hiding this comment.
Both fixed: begin now checks only authorization_endpoint, complete only token_endpoint, each with a message naming just that endpoint. Added the missing-endpoint tests for both, in discovery mode and in metadata mode.
| "'resource_url' is required when authenticating from a " | ||
| "WWW-Authenticate challenge" | ||
| ) | ||
| logger.info("PKCE flow starting for resource %s", resource_url) |
There was a problem hiding this comment.
Nit from the refactor: challenge mode now logs "PKCE flow starting for resource None" before _resolve_auth_server_url raises when both entry modes are absent. Moving the logging after the resolver call restores the old order.
There was a problem hiding this comment.
Good catch — moved both log lines after _resolve_auth_server_url, so the resolver's ConfigError is raised before anything is logged.
Co-Authored-By: Larry Osakwe <larry@keycard.ai>
Co-Authored-By: Larry Osakwe <larry@keycard.ai>
…ranch Co-Authored-By: Larry Osakwe <larry@keycard.ai>
Summary
Implements the fourth layer of the
authorization-code-pkcecapability (spec-version 2) defined in keycard-sdk-spec#46: a stateless begin/complete pair for apps that own a registered redirect URI and receive the callback on their own route.pkce.authenticateis the wrong shape for those apps — it runs a loopback callback server (RFC 8252), which only makes sense for processes with no HTTP surface of their own. Apps with a callback route were hand-assembling the flow fromPKCEGenerator+build_authorize_url+exchange_authorization_codewith a manualstateand their own single pending-flow slot, which caps them at one concurrent sign-in.New in
keycardai.oauth.pkce(pkce/web.py):The SDK holds nothing between the two calls and keeps no per-flow registry, which is what makes concurrent sign-ins and multi-process servers work without coordination.
complete_authorizationvalidates before it discovers or requests anything:errorin the callback params →AuthorizationDeniedError(new, subclassesOAuthProtocolError, carrieserror/error_description); missing or non-matchingstate→StateMismatchError(new; compared withsecrets.compare_digestover UTF-8 bytes, since the callback value is browser-controlled andcompare_digestrejects non-ASCIIstr); missingcode→OAuthProtocolError(error="invalid_request"). No token request is made in any of those cases.Statelessness is the contract between the two calls, not a reason to rediscover metadata on every one, so both functions also accept pre-discovered metadata — same parameter as
userinfo(metadata=...)in #232, with the app owning the cache exactly as it owns the session storage:That makes three mutually exclusive ways to name the authorization server (
issuer,www_authenticate_header,metadata); anything else is aConfigError. Metadata mode is exclusive rather than additive because a caller holding cached AS metadata already knows the AS — allowing it alongsidewww_authenticate_headerwould keep the protected-resource-metadata fetch on the sign-in path, which is half of what the parameter exists to remove. In that modebegin_authorizationbuilds no client at all (it only needsauthorization_endpoint), andcomplete_authorizationbuilds one that issues no discovery request (enable_metadata_discovery=False, token endpoint passed as anendpoints=override). Each function validates only the endpoint it uses.Per the spec discussion,
begin_authorizationdoes not accept a caller-suppliedstate. Naming is left to the language idiom profile by the spec —begin_authorization/complete_authorization/AuthorizationRedirecthere.Issuer resolution moved out of
pkce/client.pyinto a privatepkce/_issuer.pyshared by both flows (publickeycardai.oauth.pkce.resolve_issuer_from_challengeis unchanged);authenticate's behavior is untouched apart from theConfigErrormessage no longer namingauthenticate().Tests cover spec cases 8–11 plus the missing-
code, non-ASCII-state, public-vs-confidential-client, challenge-driven, cached-metadata (asserting no client construction / no discovery await), missing-endpoint andConfigErrorpaths, asserting the token exchange is never awaited on every rejection path. Adds a framework-agnostic example underpackages/oauth/examples/web_authorization_code_flow/and a README section, both issuer-first with the cached-metadata variant noted.just check,just test-package oauth(356 passed) andscripts/changelog.py validatepass.docs/sdkwas not regenerated — thekeycardai.oauth.pkcepackage isn't in the generated reference today andjust sdk-ref-oauthwould pull in unrelated modules.Link to Devin session: https://app.devin.ai/sessions/a1881ef9670c452e98caec6ccd031ad1
Requested by: @Larry-Osakwe