feat(oidc): device grant, token exchange, logout helpers; contract re-sync (D6) - #33
Merged
Merged
Conversation
Third of the eleven D6 repos. Re-vendors `CONTRACT.md` (1.7), `openapi.json` and `proto/`, and implements what the newly-vendored contract requires — on **both** clients, since §14.4/§15.4 put the same names on `AxiamClient` and `AsyncAxiamClient`. **§14 device grant** — `device_authorize`, `device_poll`, `device_login`. The first sends no `client_secret` and does not refuse a client built without one: a device that cannot show a browser cannot keep a secret either. `slow_down` raises the interval **permanently** — an SDK that backs off for one round and returns to the original interval earns another `slow_down`, forever. `access_denied` and `expired_token` stay distinct: "a human said no" and "nobody answered" are the only two things the device can act on. Polling stops at `expires_in` even while the server still answers `authorization_pending`, reported under the same `expired_token` code the server would have used. A 5xx mid-poll is not terminal. That arithmetic is a `PollSchedule` value object with no I/O, asserted directly — including a 30-minute grant and three cumulative `slow_down`s, cases no wall-clock test could reach. The integration tests assert wire behaviour instead. The **async** `device_login` awaits an async callback before polling, with its own test: a device rendering a QR code may need to await a paint, and polling before that resolves would defeat §14.3 rule 2 as surely as not calling back at all. **§15 token exchange** — mostly a list of refusals: no defaulted `actor_token` (omitting it asks for impersonation), no auto-narrowing after `invalid_scope`, no synthesised refresh token, no adoption. `ExchangedToken` has no `refresh_token` field at all, asserted with a hostile fixture that puts one on the wire. **§12.7 logout** — `logout_url` reads `end_session_endpoint` from discovery and never concatenates onto the issuer. `verify_logout_token` needed a new `JwksVerifier.verify_logout_token_signature`, because the existing `verify_signature_only_unchecked` requires a `sub` claim and a logout token legitimately carries only `sid`. It reuses the same `_get_signing_key` path — §12.7.3 requires no second key-fetching route — and keeps the §12.4 alg/kid discipline, including rejecting a token with no `kid` rather than falling back to "the only published key", which would defeat rotation. The `events`/`nonce` rejections are asserted with a real, otherwise-valid ID token: that is the actual attack, not a synthetic mutation. The result is `sid`/`sub`/`jti`, never a bool, and `jti` is surfaced rather than consumed — a redelivery must still verify. **§11 rule 9 `reason_code`, which the re-vendor surfaced.** The REST path got it for free (`AccessResult(**wire)`); the four gRPC construction sites now map `""` to `None`, since proto3 cannot distinguish an older server that never set field 3 from one that set it empty. Constants rather than an `Enum`, because the contract requires an unrecognised code to be surfaced verbatim and an `Enum` would force the SDK to drop it or raise on it. **Regenerated gRPC stubs** from the new proto — CI gates on `git diff --exit-code src/axiam_sdk/grpc/gen`, so a re-vendor that skipped this would have failed there rather than silently shipping stale stubs. 69 new tests (22 device, 17 exchange, 21 logout, 9 reason_code); 601 pass in total. Three runnable examples. README states §1–§13 and §12.7, §14, §15. Every CI gate, run locally: `pytest tests`, `mypy --strict src`, `ruff check .`, `ruff format --check .`, `interrogate` (100% docstring coverage), `python -m py_compile examples/*.py`, `python -m build` + `twine check`, and the gRPC codegen diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ubrFbqsMkBqC5gwadPsDu
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.
Third of the eleven D6 repos, after rust#44 and typescript#46. Re-vendors
CONTRACT.md(now 1.7),openapi.jsonandproto/, and implements what the newly-vendored contract requires — on both clients, since §14.4/§15.4 put the same names onAxiamClientandAsyncAxiamClient.§14 — Device Authorization Grant (RFC 8628)
device_authorize,device_poll,device_login. The first sends noclient_secretand does not refuse a client built without one: a device that cannot show a browser cannot keep a secret either.slow_downraises the interval permanently. An SDK that backs off for one round and returns to the original interval earns anotherslow_down, forever.access_deniedandexpired_tokenstay distinct — "a human said no" and "nobody answered" are the only two things the device can act on.expires_in, even while the server still answersauthorization_pending.5xxmid-poll is not terminal. A server restart must not lose a grant the user has already approved.That arithmetic is a
PollSchedulevalue object with no I/O, asserted directly — including a 30-minute grant and three cumulativeslow_downs, cases no wall-clock test could reach.The async
device_loginawaits an async callback before polling, with its own test: a device rendering a QR code may need to await a paint, and polling before that resolves would defeat §14.3 rule 2 as surely as not calling back at all.§15 — Token Exchange (RFC 8693)
Mostly a list of refusals: no defaulted
actor_token(omitting it asks for impersonation), no auto-narrowing afterinvalid_scope, no synthesised refresh token, no adoption.ExchangedTokenhas norefresh_tokenfield at all — asserted with a hostile fixture that puts one on the wire anyway.§12.7 — Logout
logout_urlreadsend_session_endpointfrom discovery and never concatenates onto the issuer.verify_logout_tokenneeded a new verifier entry point: the existingverify_signature_only_uncheckedrequires asubclaim, and a logout token legitimately carries onlysid.JwksVerifier.verify_logout_token_signaturereuses the same_get_signing_keypath — §12.7.3 requires no second key-fetching route — and keeps the §12.4 alg/kiddiscipline, including rejecting a token with nokidrather than falling back to "the only published key", which would defeat rotation.The
eventsandnoncerejections are asserted with a real, otherwise-valid ID token: correctly signed by a published key, right issuer and audience, unexpired. That is the actual attack, not a synthetic mutation.The result is
sid/sub/jti, never a bool — you have to know which session to end.jtiis surfaced rather than consumed, and a test asserts a redelivery still verifies. There's also a test that a stale-but-unexpired token (issued a day ago,expstill ahead) is refused: a captured delivery being replayed, not a live one.§11 rule 9
reason_code— surfaced by the re-vendorThe REST path got it for free (
AccessResult(**wire)); the four gRPC construction sites now map""toNone, since proto3 cannot distinguish an older server that never set field 3 from one that set it empty — both mean "no reason code".Constants rather than an
Enum, because the contract requires an unrecognised code to be surfaced verbatim, and anEnumwould force the SDK to drop it or raise on it.Regenerated gRPC stubs
CI gates on
git diff --exit-code src/axiam_sdk/grpc/gen, so a re-vendor that skipped regeneration would have failed there rather than silently shipping stubs that don't know aboutreason_code.Testing
test_device_flow.py(incl.PollSchedule)test_token_exchange.pytest_logout.pytest_decision_reason_code.pyEvery CI gate, run locally:
pytest tests,mypy --strict src,ruff check .,ruff format --check .,interrogate(100% docstring coverage),python -m py_compile examples/*.py,python -m build+twine check, and the gRPC codegen diff.Docs and examples
Three runnable examples (
device_login.py,token_exchange.py,logout.py). README gains a section per feature and states §1–§13 and §12.7, §14, §15 — the new sections named rather than folded into the range, because widening it silently would turn a statement that was true when written into a different claim without anyone editing it.Related
Contract errata this depends on: ilpanich/axiam#282 (merged).
🤖 Generated with Claude Code
https://claude.ai/code/session_011ubrFbqsMkBqC5gwadPsDu
Generated by Claude Code