Repository navigation
Auth: device authorization grant (RFC 8628) with a /device approval page - #717
Merged
Merged
Conversation
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
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.
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) andnuget-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.crates/server/src/auth/device.rs(new), mounted byauth_router.crates/db/src/device_auth_dao.rs(new). Migration0008_device_authorization.sqladds thedevice_authorizationtable.cli_auth::login_tokenis extracted frommintand shared by both.token_dao::insert_scoped_tokeninserts a token together with its scope in one statement, on any executor.Endpoints
POST /auth/device/code(RFC 8628 §3.1–3.2):client_idand an optionalscope, form-encoded;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) andinterval;oidc_login_startslimiter (429,slow_down,Retry-After), as npm web logins are.POST /auth/device/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_code(§3.4–3.5):authorization_pendinguntil the user decides;slow_downwhen 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_deniedafter a denial;expired_tokenpast the lifetime, and once the token was collected.SELECT … FOR UPDATE) and mints the token in the same transaction that marks the requestcollected. A failure leaves the approval to collect on the next poll.device_code, or one issued to another client, isinvalid_grant. So is a code the reaper has already deleted.error,error_description,400;503+Retry-Afteron a database failure) withCache-Control: no-store, never HTML.client_id:secret) therefore cannot turn the answer into its plain-text 401.access_token,token_type: Bearer,expires_in,scope(when one was asked for) andusername.usernameis a Forge extension: the usernamedocker logintakes 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/lookupandPOST /auth/device/decideback the/devicepage:404when no pending request has the code,400for something that cannot be a code (not counted),429+Retry-Afterwhen cut off.Codes and rate limiting
device_code: a 256-bit secret, stored only as its SHA-256.user_code: 8 letters fromBCDFGHJKLMNPQRSTVWXZ(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, aLoginThrottlewith two keys per lookup and per decision: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
cli_login.token_lifetime_days.<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_credentialis set as inmint.TokenScope:read/write/deleteset thelevel(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;adminis not offered, and any other value isinvalid_scope;access_deniedand mints nothing.forge::audit):device login requested,device login decided(approved true or false),device login: token minted, anddevice login refused: the account is not active.The
/devicepageweb/src/routes/Device.tsx, withweb/src/api/device.ts. The route/deviceis inroutes.ts, androuteTree.gen.tsis regenerated./device?code=…is sent to/login?redirect=/device?code=…and comes back with the code kept. The OIDCredirect_tokeeps the query too; the e2e test checks the callback'sLocation.verification_uri_complete. A filled-in code is looked up but never approved without the click on Approve (RFC 8628 §5.4).Retry-After), a malformed code (refused before any request), a request that expired before the click, and approved or denied./device;crates/server/tests/web_ui.rsnow covers the deep link.Settings and the admin UI
/admin/settings/auth):device_login(defaulttrue). Off, both OAuth endpoints answerunauthorized_client;device_poll_interval_seconds(default5, 1–60), theintervalgiven to clients.Settings.tsx) sets both, following the AGENTS.md rule for admin API changes.[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:/devicepage, guessing limits, settings and audit;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.docs/changes/2.2.0/649.feature.md.Tests
crates/server/tests/device_auth.rs(new, 8 tests):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 (thenexpired_token), the token signing in, listed asdocker-credential-forge login (test.local)with its expiry, and revocable;access_denied, cannot be approved afterwards);expired_token, lookup and decide404; the reaper'sdelete_expiredremoves the row, after which the code isinvalid_grant);client_idon both endpoints, missing parameters, a JSON body, a repeated parameter, bad scopes (invalid_scope), another client'sdevice_code,refresh_token(unsupported_grant_type), and a Basic header that still gets OAuth JSON;{"level":"read","repositories":[<id>]}, unknown repository dropped);429for 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;device_poll_interval_secondschangesinterval;device_login: falsegivesunauthorized_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_authorizationjoins the expected tables;auth::deviceandauth::settings.e2e,
crates/e2e/tests/device.rs(new): mock IdP with an OIDC realm.scope=read repository:device-dockerand polls (pending).redirect_to=/device?code=…, and the callback lands back there.DOCKER_CONFIG): an anonymousdocker pullfails,docker login --password-stdinwith the minted token succeeds, anddocker pullof 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 toCliLogin.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 and429, 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=trueandCARGO_INCREMENTAL=0.cargo test -p forge-server --test device_auth --test auth_settings --test web_ui --test npm: 8 + 4 + 2 + 16 passed.npmcovers the refactoredmintthroughnpm_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 thedocker pullfrom the test server.cargo test -p forge-db --test migrations: 3 passed.cargo clippy -p forge-db -p forge-server --all-targets -- -D warningsandcargo clippy -p forge-e2e --all-targets --features forge-e2e -- -D warnings: clean.cargo fmt --all -- --check,cargo xtask changelog --check,cargo xtask docs --check.pnpm run tsc, andpnpm 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 isinvalid_client(400), so the page names the client truthfully.grant_type=refresh_tokenisunsupported_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.sqlwas the next free number onmainwhen this was pushed. Another open branch may also add a0008; the later of the two to merge renumbers..sqlx: the 8 new query files match the 8 new compile-time checked queries, all incrates/db/src. They come from compilingforge-dbagainst a migrated Postgres 18 withSQLX_OFFLINE_DIRset. No existing file changed, and the offline builds offorge-serverandforge-e2e(all targets) succeed.cargo sqlx prepare --check --workspace -- --all-targets --all-featureswas 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:
CI runs them.
Generated by Claude Code