Skip to content

Auth: device authorization grant (RFC 8628) with a /device approval page - #717

Merged
schmitch merged 2 commits into
mainfrom
schmitch/device-authorization-649
Oct 31, 2026
Merged

schmitch merged 2 commits into
mainfrom
schmitch/device-authorization-649

Conversation

@schmitch

Copy link
Copy Markdown
Member

Fixes #649

The change

Command-line clients sign in through the browser with the OAuth 2.0 device authorization grant (RFC 8628). It is the server side the helpers of #44 build on: the shared client crate (#162), forge login (#167), docker-credential-forge (#163) and nuget-plugin-forge (#164). It is the npm web login's mechanism (crates/server/src/cli_auth.rs), with two differences: the user types a code instead of opening a secret URL, and a request belongs to the Forge host, not to one repository.

  • Server: crates/server/src/auth/device.rs (new), mounted by auth_router.
  • Data: crates/db/src/device_auth_dao.rs (new). Migration 0008_device_authorization.sql adds the device_authorization table.
  • Minting: the token is built like the npm login's. cli_auth::login_token is extracted from mint and shared by both. token_dao::insert_scoped_token inserts a token together with its scope in one statement, on any executor.

Endpoints

  • POST /auth/device/code (RFC 8628 §3.1–3.2):
    • takes client_id and an optional scope, form-encoded;
    • answers device_code, user_code (BCDF-GHJK), verification_uri (<base>/device), verification_uri_complete (<base>/device?code=BCDF-GHJK), expires_in (cli_login.web_login_ttl_seconds) and interval;
    • starting a login is limited per client network by the existing oidc_login_starts limiter (429, slow_down, Retry-After), as npm web logins are.
  • POST /auth/device/token with grant_type=urn:ietf:params:oauth:grant-type:device_code (§3.4–3.5):
    • authorization_pending until the user decides;
    • slow_down when polled sooner than the interval. The stored interval then grows by 5 s for this and every later poll. An approved request is answered however soon it is polled;
    • access_denied after a denial;
    • expired_token past the lifetime, and once the token was collected.
    • The token is answered once: the poll locks the row (SELECT … FOR UPDATE) and mints the token in the same transaction that marks the request collected. A failure leaves the approval to collect on the next poll.
    • An unknown device_code, or one issued to another client, is invalid_grant. So is a code the reaper has already deleted.
    • Errors are RFC 6749 §5.2 JSON (error, error_description, 400; 503 + Retry-After on a database failure) with Cache-Control: no-store, never HTML.
    • The principal middleware passes both endpoints through, like npm's token exchange. A header the credential chain would refuse (e.g. Basic client_id:secret) therefore cannot turn the answer into its plain-text 401.
    • The success answer has access_token, token_type: Bearer, expires_in, scope (when one was asked for) and username. username is a Forge extension: the username docker login takes with the token.
  • GET /.well-known/oauth-authorization-server (RFC 8414): issuer, device_authorization_endpoint, token_endpoint, grant_types_supported, response_types_supported: [], token_endpoint_auth_methods_supported: ["none"], scopes_supported. It is served even when the device login is turned off: the endpoints then tell the client why.
  • POST /auth/device/lookup and POST /auth/device/decide back the /device page:
    • they need a session and its CSRF token;
    • the code goes in the JSON body, never in a path;
    • 404 when no pending request has the code, 400 for something that cannot be a code (not counted), 429 + Retry-After when cut off.

Codes and rate limiting

  • device_code: a 256-bit secret, stored only as its SHA-256.

  • user_code: 8 letters from BCDFGHJKLMNPQRSTVWXZ (RFC 8628 §6.1; no vowels, no digits, no look-alikes), stored without the hyphen. Input is case-insensitive, and the hyphen and spaces are optional. A collision with a stored code draws again (ON CONFLICT DO NOTHING).

  • Code entry is rate-limited by AppState::device_code_throttle, a LoginThrottle with two keys per lookup and per decision:

    • the client network (IPv6 by /64);
    • the browser session.

    After 10 wrong codes in 5 minutes on either key, every code entry from it gets 429. A code that is found counts as nothing, so a guesser cannot reset their failures by looking up a code of their own. The per-username soft backoff is off for this throttle, since its keys are a network and a session. Counts are per instance, like the lockout.

  • A request lives cli_login.web_login_ttl_seconds (600 s by default). The existing auth reaper (workers.rs) deletes expired rows.

The token

  • An API token of the approving user, lasting cli_login.token_lifetime_days.
  • Named <client_id> login (<host>), e.g. docker-credential-forge login (forge.example.com), and listed and revocable on the user's tokens page.
  • minted_with_forwarded_credential is set as in mint.
  • Scope mapping onto TokenScope:
    • read / write / delete set the level (the highest one named wins);
    • repository:<name> adds a repository, resolved to its id at mint time. A name no repository has reaches nothing: a scope only narrows;
    • admin is not offered, and any other value is invalid_scope;
    • without a scope, the token carries the user's rights, as the npm login's does.
  • If the approving account was deactivated before collection, the poll answers access_denied and mints nothing.
  • Audit (forge::audit): device login requested, device login decided (approved true or false), device login: token minted, and device login refused: the account is not active.

The /device page

web/src/routes/Device.tsx, with web/src/api/device.ts. The route /device is in routes.ts, and routeTree.gen.ts is regenerated.

  • It sits behind the app's login gate, so signing in uses the full realm chain (local password, OIDC/Entra, identifier-first). A visitor at /device?code=… is sent to /login?redirect=/device?code=… and comes back with the code kept. The OIDC redirect_to keeps the query too; the e2e test checks the callback's Location.
  • The user types the code, or arrives with it filled in from verification_uri_complete. A filled-in code is looked up but never approved without the click on Approve (RFC 8628 §5.4).
  • The page shows the client's name and id, the code to compare with the terminal, the access in words (scope), the client's IP, when the request started and when it expires. The user approves or denies.
  • States: an unknown or expired code ("No sign-in is waiting for this code", with the code kept in the field to correct it), too many wrong codes (with the wait from Retry-After), a malformed code (refused before any request), a request that expired before the click, and approved or denied.
  • The server's SPA fallback already serves /device; crates/server/tests/web_ui.rs now covers the deep link.

Settings and the admin UI

  • New auth settings (stored in the DB, /admin/settings/auth):
    • device_login (default true). Off, both OAuth endpoints answer unauthorized_client;
    • device_poll_interval_seconds (default 5, 1–60), the interval given to clients.
  • Admin → Settings → Device login (Settings.tsx) sets both, following the AGENTS.md rule for admin API changes.
  • The lifetimes stay in [cli_login] (token_lifetime_days, web_login_ttl_seconds), as the issue specifies. They shipped in 2.1.0, and moving them into the database would need an upgrade path of its own. The panel's text says where they are set.

Docs

  • docs/deployment.md:
    • a new Device login section: the flow, the endpoints table, a curl example, the discovery document, clients, scope, no refresh token, codes, the /device page, guessing limits, settings and audit;
    • the two settings added to the Access and sign-in settings table.
  • docs/npm.md: the [cli_login] section links to it.
  • docs/docker.md: Logging in links to it.
  • DEVELOPMENT.md: the settings and auth endpoint tables gain the new rows.
  • Changelog: docs/changes/2.2.0/649.feature.md.

Tests

  • crates/server/tests/device_auth.rs (new, 8 tests):

    • the discovery document;
    • pending, then two slow_downs (interval stored as 10, then 15), lookup in lower case without the hyphen, CSRF (403) and session (401) required, approval, the token collected once (then expired_token), the token signing in, listed as docker-credential-forge login (test.local) with its expiry, and revocable;
    • denial (access_denied, cannot be approved afterwards);
    • expiry (expired_token, lookup and decide 404; the reaper's delete_expired removes the row, after which the code is invalid_grant);
    • unknown client_id on both endpoints, missing parameters, a JSON body, a repeated parameter, bad scopes (invalid_scope), another client's device_code, refresh_token (unsupported_grant_type), and a Basic header that still gets OAuth JSON;
    • scope mapping ({"level":"read","repositories":[<id>]}, unknown repository dropped);
    • the rate limit cutting off guessing: 10 wrong codes, then even the right code is 429 for lookup and decide, for the session from another network and for another session from the same network. Another session from another network finds the code, and finding it clears nothing for the guesser;
    • the settings (device_poll_interval_seconds changes interval; device_login: false gives unauthorized_client; back on, pending again).
  • Updated:

    • auth_settings.rs: the defaults document and an out-of-range interval;
    • web_ui.rs: the /device?code= deep link serves the SPA, and the metadata is JSON;
    • crates/db/tests/migrations.rs: device_authorization joins the expected tables;
    • unit tests in auth::device and auth::settings.
  • e2e, crates/e2e/tests/device.rs (new): mock IdP with an OIDC realm.

    1. The client starts a login with scope=read repository:device-docker and polls (pending).
    2. The user goes through the OIDC sign-in with redirect_to=/device?code=…, and the callback lands back there.
    3. The user looks the code up and approves.
    4. The poll collects the token once.
    5. The real Docker CLI (fresh DOCKER_CONFIG): an anonymous docker pull fails, docker login --password-stdin with the minted token succeeds, and docker pull of an image of a repository with no anonymous reads succeeds.

    The image is pushed as the admin through the registry API, with an uncompressed OCI layer. The Docker steps are skipped (and the test passes) where no Docker CLI and daemon are available, like the dotnet smoke test.

  • Web:

    • web/src/routes/Device.test.tsx (next to CliLogin.test.tsx): code entry, a filled-in code (looked up, not approved until the click; deny), the sign-in round trip (visitor → /login?redirect=/device?code=… → password sign-in → back with the code), an expired or unknown code, a request expiring before the click, malformed code and 429, plus code normalization and scope wording;
    • settings.test.tsx: the Device login panel (wrong interval refused before a request; saves {device_login:false, device_poll_interval_seconds:10}).

Checks run (all passed)

These ran on a shared build machine, with per-package profile overrides that keep this branch's artifacts apart from other checkouts in the shared target dir, SQLX_OFFLINE=true and CARGO_INCREMENTAL=0.

  • cargo test -p forge-server --test device_auth --test auth_settings --test web_ui --test npm: 8 + 4 + 2 + 16 passed. npm covers the refactored mint through npm_login_legacy_and_web_and_logout.
  • cargo test -p forge-server --lib -- auth:: middleware:: cli_auth telemetry::: 84 passed.
  • cargo test -p forge-e2e --features forge-e2e --test device: passed, with Docker 29.9.2. The daemon's event log shows the docker pull from the test server.
  • cargo test -p forge-db --test migrations: 3 passed.
  • cargo clippy -p forge-db -p forge-server --all-targets -- -D warnings and cargo clippy -p forge-e2e --all-targets --features forge-e2e -- -D warnings: clean.
  • cargo fmt --all -- --check, cargo xtask changelog --check, cargo xtask docs --check.
  • Web: pnpm run tsc, and pnpm run test (41 files, 543 passed).

Notes

  • Decisions (the issue's "To decide"):

    • client_id: only the helpers' fixed ids (forge-cli, docker-credential-forge, nuget-plugin-forge) are accepted. Anything else is invalid_client (400), so the page names the client truthfully.
    • Refresh tokens: none is issued. grant_type=refresh_token is unsupported_grant_type. The minted token is long-lived and revocable, like the npm one. Refresh tokens can follow if docker-credential-forge: credential helper for Docker, Podman, containerd and Helm OCI #163 picks the identity-token option.
  • The code-entry limit is fixed (10 per 5 minutes per network and per session) rather than following the login lockout settings. With the lockout turned off (login_max_failures = 0), codes would otherwise become guessable.

  • Migration number: 0008_device_authorization.sql was the next free number on main when this was pushed. Another open branch may also add a 0008; the later of the two to merge renumbers.

  • .sqlx: the 8 new query files match the 8 new compile-time checked queries, all in crates/db/src. They come from compiling forge-db against a migrated Postgres 18 with SQLX_OFFLINE_DIR set. No existing file changed, and the offline builds of forge-server and forge-e2e (all targets) succeed. cargo sqlx prepare --check --workspace -- --all-targets --all-features was not run here: it builds every test target of the workspace, which the shared machine's disk could not hold. CI's drift guard runs it.

  • Not run here:

    • the rest of the server integration suite;
    • the other e2e scenarios;
    • the Playwright suite.

    CI runs them.


Generated by Claude Code

Command-line clients without a browser login of their own (the coming
`forge login`, `docker-credential-forge` and `nuget-plugin-forge`; the
Docker CLI has none for a registry other than Docker Hub) sign in through
the browser with the OAuth 2.0 device authorization grant, the way npm's
web login already does, but for the Forge host instead of one repository.

POST /auth/device/code takes a client_id and an optional scope and
answers a device_code, a user_code, the /device page as verification_uri
(and with the code filled in as verification_uri_complete), expires_in and
interval. POST /auth/device/token answers authorization_pending until the
user decides, slow_down when polled sooner than the interval (which grows
by five seconds), access_denied, expired_token, or the token, once: the
poll locks the request's row and mints the token in the transaction that
marks it collected. Every refusal is RFC 6749 JSON, and the principal
middleware leaves both endpoints alone so a header the credential chain
would refuse cannot turn the answer into its plain-text 401. GET
/.well-known/oauth-authorization-server (RFC 8414) names the endpoints.

Only the helpers' fixed client ids are accepted (invalid_client
otherwise), so the page names the client truthfully, and no refresh token
is issued: the token is an API token of the approving user built like the
npm login's (cli_auth::login_token), lasting
cli_login.token_lifetime_days, named after the client and the host, and
listed and revocable on the user's tokens page. A scope of read, write or
delete and repository:<name> maps onto the token scope, inserted with the
token in one statement; without one the token carries the user's rights.

The device code is stored as its SHA-256 only; the user code is eight
letters without vowels or look-alikes, read in any case with or without
its hyphen. A request lives cli_login.web_login_ttl_seconds and the auth
reaper deletes it afterwards (migration 0008, device_authorization).

The web UI's /device page (behind the app's login gate, so a visitor goes
through the realm chain and comes back with the code) looks a code up
through POST /auth/device/lookup, shows the client, scope, address, start
time and code, and approves or denies it through /auth/device/decide only
on a click, a filled-in code included. Wrong codes are counted per client
network and per browser session (ten per five minutes each, whatever the
lockout says); finding a code clears nothing. Requests, decisions and
mints are audit-logged.

The auth settings gain device_login (on) and device_poll_interval_seconds
(5), set under Admin -> Settings -> Device login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e6DJbbDh51swmocfFvXPe
The migrations test asserts every table the migrations create exists;
0008_device_authorization.sql adds device_authorization, so it joins the
list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e6DJbbDh51swmocfFvXPe
@schmitch
schmitch added this pull request to the merge queue Oct 31, 2026
Merged via the queue into main with commit dd2f28a Oct 31, 2026
19 checks passed
@schmitch
schmitch deleted the schmitch/device-authorization-649 branch October 31, 2026 12:26
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.

Device authorization grant (RFC 8628): device and token endpoints and a /device approval page

2 participants