Device Authorization Grant¶
Sans-IO building blocks for the RFC 8628 device flow: secure device- and
user-code generators, user-code normalization, the pure evaluate_device_poll
state machine for the token endpoint, and the device authorization response
builder. The module does no DB or HTTP I/O — callers own persistence.
device
¶
Device Authorization Grant (RFC 8628) authorization-server primitives.
Sans-IO building blocks for the device flow:
- :func:
generate_device_code/ :func:generate_user_code— secure code generators.user_codeuses an unambiguous consonant alphabet so codes read cleanly aloud (per RFC 8628 §6.1). - :func:
normalize_user_code— accept the user's typed input ("wdjbmjht", "wdjb mjht", "wdjb-mjht") and produce the canonical form for lookup. - :class:
DeviceCodeRecord— Protocol describing the fields the AS must persist. Consumers implement this with their own storage (SQLAlchemy, Redis, in-memory) — the framework does not own the schema. - :func:
evaluate_device_poll— pure state machine for the token-endpointurn:ietf:params:oauth:grant-type:device_codepath. Returns a :class:DevicePollDecisiondescribing what response the caller should emit (RFC 8628 §3.5). - :func:
build_device_authorization_response— assembles the RFC 8628 §3.2 device authorization response dict.
The module deliberately does no DB I/O and no HTTP — it composes with the
Starlette response helpers in :mod:mcp_authflow.responses.
DeviceCodeStatus
¶
Bases: StrEnum
Lifecycle states for a device code as seen by the token endpoint.
DeviceCodeRecord
¶
Bases: Protocol
Storage shape the framework expects for an in-flight device authorization.
Consumers implement this with their own ORM model (SQLAlchemy, Beanie, dict-in-Redis — anything). Field semantics:
device_code: secret presented by the device at the token endpoint.user_code: short code displayed to the user.client_id: the OAuth client that initiated the authorization.scopes: opaque to the framework; pass through to the access token.status: one of the :class:DeviceCodeStatusstring values.user_id: set when the user authorizes (status == APPROVED).expires_at: timezone-aware UTC datetime.interval: minimum seconds between polls (RFC 8628 §3.5).last_poll_at: timezone-aware UTC datetime, orNonefor first poll. The caller is responsible for updating this after a successful :func:evaluate_device_pollcall that returns a non-InvalidGrantdecision (other thanSlowDown).
DevicePollDecisionKind
¶
Bases: StrEnum
Outcome categories from :func:evaluate_device_poll.
DevicePollDecision
dataclass
¶
DevicePollDecision(
kind: DevicePollDecisionKind,
record: DeviceCodeRecord | None = None,
retry_after: int | None = None,
)
Result of a token-endpoint device-code poll.
kind tells the caller which response to emit; record is the
storage row (so callers can read scopes/user_id without re-fetching);
retry_after is the polling interval to surface in slow_down
responses.
generate_device_code
¶
Generate a cryptographically random device_code.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
length
|
int
|
Number of random bytes (hex output is |
32
|
Source code in mcp_authflow/device.py
generate_user_code
¶
Generate a user-friendly user_code (e.g. "WDJB-MJHT").
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
groups
|
int
|
Number of character groups (default 2). |
2
|
group_size
|
int
|
Characters per group (default 4). |
4
|
separator
|
str
|
Separator inserted between groups. |
'-'
|
Returns:
| Type | Description |
|---|---|
str
|
Upper-case code drawn from an unambiguous consonant alphabet. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in mcp_authflow/device.py
normalize_user_code
¶
normalize_user_code(
user_code: str,
groups: int = 2,
group_size: int = 4,
separator: str = "-",
) -> str
Canonicalize a user-entered code for storage lookup.
Strips whitespace and separators, upper-cases, then re-inserts the
separator at the group boundary. Codes that don't have the expected
total length are returned upper-cased without re-grouping — let the
storage lookup fail naturally with invalid_grant.
Source code in mcp_authflow/device.py
evaluate_device_poll
¶
evaluate_device_poll(
record: DeviceCodeRecord | None,
*,
presented_device_code: str,
presented_client_id: str,
now: datetime | None = None,
) -> DevicePollDecision
Decide how the token endpoint should respond to a device-code poll.
Implements RFC 8628 §3.5:
- Constant-time check that the presented
device_codematches the stored record (returnsINVALID_GRANTon miss/mismatch — never leak whether the code exists vs. was wrong). - Client binding check (
INVALID_GRANTif the client_id differs). - Expiry check (
EXPIRED_TOKEN). - Polling-interval check against
last_poll_at(SLOW_DOWN). - Status mapping:
pending → AUTHORIZATION_PENDING,denied → ACCESS_DENIED,approved → APPROVED.
The caller is responsible for updating record.last_poll_at = now
after this returns anything other than INVALID_GRANT.
Source code in mcp_authflow/device.py
build_device_authorization_response
¶
build_device_authorization_response(
*,
device_code: str,
user_code: str,
verification_uri: str,
expires_in: int,
interval: int,
verification_uri_complete: str | None = None,
) -> dict[str, str | int]
Assemble an RFC 8628 §3.2 device authorization response.
If verification_uri_complete is omitted, it is derived by merging a
URL-encoded code=<user_code> parameter into verification_uri so the
user can scan a QR code on the device and skip typing the user_code.