Skip to content

Add OAuth 2.0 Device Authorization Grant (device flow) authentication - #154

Merged
JannisStraus merged 1 commit into
mainfrom
device-code-auth
Aug 19, 2026
Merged

Add OAuth 2.0 Device Authorization Grant (device flow) authentication#154
JannisStraus merged 1 commit into
mainfrom
device-code-auth

Conversation

@alexluft

Copy link
Copy Markdown
Contributor

What

Adds support for the OAuth 2.0 Device Authorization Grant (RFC 8628, "device flow") to FHIR-PYrate, so that scripts and notebooks can authenticate against FHIR servers and DICOMweb gateways that sit behind an OpenID Connect provider (e.g. Keycloak, commonly fronted by oauth2-proxy — the usual way to secure DICOM-RST deployments) — without ever putting a password into a script, shell history, or environment variable.

from fhir_pyrate import Ahoy

auth = Ahoy(
    auth_type="device_code",
    auth_url="https://keycloak.example.com/realms/example-realm",  # OIDC issuer
    client_id="example-cli",
    token_cache="~/.cache/fhir-pyrate/tokens.json",  # optional
)

The script prints a verification URL, the user approves the sign-in once in any browser (institutional SSO/MFA applies), and the tokens are attached and refreshed automatically from then on. The resulting Ahoy object works unchanged with both Pirate (FHIR) and DicomDownloader (DICOMweb).

How

  • New fhir_pyrate/util/device_code_auth.py with DeviceCodeAuth(requests.auth.AuthBase):
    • Endpoints via OpenID Connect discovery from the issuer URL (or passed explicitly for servers without discovery).
    • Full RFC 8628 polling semantics (authorization_pending, slow_down, expired_token, access_denied, device-code deadline).
    • Proactive refresh once 75% of the access token's lifetime has passed (same heuristic as TokenAuth), using the refresh token.
    • If a request still comes back 401 — or as a redirect to the identity provider's sign-in page, which is how oauth2-proxy answers an expired token instead of a 401 — the token is force-refreshed and the request retried exactly once, implemented like requests' own HTTPDigestAuth via a per-request response hook. Response hooks run before redirect resolution, so the redirect case is caught before the caller would receive an HTML login page. Two safety details: if requests stripped the Authorization header (cross-host redirect protection), the hook does not re-attach a fresh token (that 401 was not a rejection of our token, and re-sending one would leak it to a foreign host); and a consumed file-like/generator body is rewound before the retry (like requests does for redirects) or, if it cannot be rewound, the retry is skipped rather than sending a truncated body.
    • Everything lives in the auth object itself (no session-level hooks), so it keeps working when only session.auth is copied to another session — which is exactly what Pirate does for its requests-cache session.
    • Instances are picklable (the internal lock is recreated in __setstate__), so they survive the multiprocessing fan-out of Pirate and DicomDownloader.
    • Optional opt-in token_cache file (created with owner-only permissions 0600) so new runs reuse the previous sign-in instead of opening the browser again; caches are ignored if they belong to a different client or server.
    • scope="offline_access" for jobs that outlive the SSO session; allow_reauthentication=False for unattended jobs that should fail loudly instead of waiting for a browser approval nobody will give.
  • Ahoy gains the device_code auth type (client_id, client_secret, scope, token_cache parameters). Existing auth types are untouched; all existing tests pass unchanged.
  • No new dependencies.

Testing

  • 27 new tests in tests/test_device_code_auth.py:
    • Unit tests for construction/discovery, the polling state machine, refresh behaviour, the token cache (atomic write, owner-only file permissions), pickling, the 401/redirect retry incl. the stripped-Authorization and body-rewind edge cases, and the Ahoy wiring — in the same mock style as tests/test_auth.py.
    • An integration test that runs the complete flow over real sockets against a minimal in-process identity provider + protected resource (http.server): discovery → device flow → authenticated request → server-side token revocation → automatic refresh + retry → token-cache reuse, plus the oauth2-proxy-style redirect challenge.
  • uv run pytest: 49 passed (on Python 3.12 and 3.10).
  • uv run pre-commit run -a: all hooks green (ruff check/format, mypy, prettier, pyproject-fmt, markdownlint, codespell, misc).
  • The redirect-challenge semantics were verified against a real oauth2-proxy deployment (an unauthenticated/invalid bearer request is answered with a 302 to the IdP's /auth endpoint, not a 401 — which is what motivated the redirect interception).

🤖 Generated with Claude Code

https://claude.ai/code/session_01HjMC3NJgxxmoeCvqMKgdAy

New DeviceCodeAuth (fhir_pyrate/util/device_code_auth.py): RFC 8628 device
flow with OpenID Connect discovery, proactive token refresh, a one-shot
retry on 401 or on a redirect to the identity provider (how oauth2-proxy
answers expired tokens), an opt-in owner-only token cache file, and
pickling support for the multiprocessing of Pirate and DicomDownloader.
Wired into Ahoy as auth_type="device_code" (client_id, client_secret,
scope, token_cache); existing auth types unchanged. No new dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjMC3NJgxxmoeCvqMKgdAy
@alexluft
alexluft requested a review from JannisStraus August 18, 2026 12:13
@JannisStraus
JannisStraus merged commit 1cb6e99 into main Aug 19, 2026
3 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