Skip to content

feat(oidc): device grant, token exchange, logout helpers; contract re-sync (D6) - #33

Merged
ilpanich merged 1 commit into
mainfrom
claude/improvements-run5-benchmark-def-bazzei
Aug 8, 2026
Merged

feat(oidc): device grant, token exchange, logout helpers; contract re-sync (D6)#33
ilpanich merged 1 commit into
mainfrom
claude/improvements-run5-benchmark-def-bazzei

Conversation

@ilpanich

@ilpanich ilpanich commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Third of the eleven D6 repos, after rust#44 and typescript#46. Re-vendors CONTRACT.md (now 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 Authorization Grant (RFC 8628)

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.
  • A 5xx mid-poll is not terminal. A server restart must not lose a grant the user has already approved.

That arithmetic is a PollSchedule value object with no I/O, asserted directly — including a 30-minute grant and three cumulative slow_downs, cases no wall-clock test could reach.

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 (RFC 8693)

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

§12.7 — Logout

logout_url reads end_session_endpoint from discovery and never concatenates onto the issuer.

verify_logout_token needed a new verifier entry point: the existing verify_signature_only_unchecked requires a sub claim, and a logout token legitimately carries only sid. JwksVerifier.verify_logout_token_signature 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 and nonce rejections 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. jti is 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, exp still ahead) is refused: a captured delivery being replayed, not a live one.

§11 rule 9 reason_code — surfaced by the re-vendor

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 — both mean "no reason code".

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

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 about reason_code.

Testing

Suite Result
test_device_flow.py (incl. PollSchedule) 22
test_token_exchange.py 17
test_logout.py 21
test_decision_reason_code.py 9
Full suite 601 passed

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.

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

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
@ilpanich
ilpanich merged commit e2dbe74 into main Aug 8, 2026
16 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