From 422ea19aeab8af07b1f90d7ae8e027dc501839f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 09:27:06 +0000 Subject: [PATCH 1/2] Auth: device authorization grant (RFC 8628) with a /device approval page 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: 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 Claude-Session: https://claude.ai/code/session_012e6DJbbDh51swmocfFvXPe --- ...f9adb11a0f18c42f2ffe318aad88d51fbc6c8.json | 12 + ...8d3779a21d48eea365a94aa2ec7e2d5b7e673.json | 34 + ...f33abfcce922e3e32a9b7fdfaefd5453c19b4.json | 15 + ...431017fdb36fb861cb48e657723dc2376622b.json | 101 ++ ...0c24e9cf2470da744bdaf261e44be7b50a94b.json | 129 ++ ...ac745dc25768f6de2bc8c0b424993e3397dc8.json | 102 ++ ...94ffa10372d880af61af47f88f29f000436b9.json | 14 + ...7b0c1041d86119e42df10d56f3ebaa7ed9af6.json | 100 ++ DEVELOPMENT.md | 4 + crates/db/src/device_auth_dao.rs | 216 ++++ crates/db/src/lib.rs | 1 + crates/db/src/token_dao.rs | 34 +- crates/e2e/tests/device.rs | 438 +++++++ crates/server/src/auth/device.rs | 1107 +++++++++++++++++ crates/server/src/auth/mod.rs | 11 + crates/server/src/auth/settings.rs | 49 +- crates/server/src/cli_auth.rs | 49 +- crates/server/src/middleware/principal.rs | 12 +- crates/server/src/state.rs | 15 + crates/server/src/workers.rs | 14 +- crates/server/tests/auth_settings.rs | 6 + crates/server/tests/device_auth.rs | 710 +++++++++++ crates/server/tests/web_ui.rs | 20 + docs/changes/2.2.0/649.feature.md | 15 + docs/deployment.md | 92 ++ docs/docker.md | 7 + docs/npm.md | 5 + migrations/0008_device_authorization.sql | 40 + web/src/api/device.ts | 91 ++ web/src/api/settings.ts | 7 + web/src/routeTree.gen.ts | 21 + web/src/routes.ts | 5 + web/src/routes/Device.test.tsx | 266 ++++ web/src/routes/Device.tsx | 301 +++++ web/src/routes/admin/settings/Settings.tsx | 69 + .../routes/admin/settings/settings.test.tsx | 45 + 36 files changed, 4135 insertions(+), 22 deletions(-) create mode 100644 .sqlx/query-58910a3818f688ec45339db31cff9adb11a0f18c42f2ffe318aad88d51fbc6c8.json create mode 100644 .sqlx/query-6e33da34931d13a51229fd5e9798d3779a21d48eea365a94aa2ec7e2d5b7e673.json create mode 100644 .sqlx/query-755d789599e5449117bdfdac4e2f33abfcce922e3e32a9b7fdfaefd5453c19b4.json create mode 100644 .sqlx/query-97fb07bd487d057b49cb0e1ed01431017fdb36fb861cb48e657723dc2376622b.json create mode 100644 .sqlx/query-aab6656efff7326910cb7626ece0c24e9cf2470da744bdaf261e44be7b50a94b.json create mode 100644 .sqlx/query-d308dc16d28a0e5a6629c141569ac745dc25768f6de2bc8c0b424993e3397dc8.json create mode 100644 .sqlx/query-dc8431609b31a1b82a6606b221f94ffa10372d880af61af47f88f29f000436b9.json create mode 100644 .sqlx/query-fdd9c4ed8c197652411a0e148a47b0c1041d86119e42df10d56f3ebaa7ed9af6.json create mode 100644 crates/db/src/device_auth_dao.rs create mode 100644 crates/e2e/tests/device.rs create mode 100644 crates/server/src/auth/device.rs create mode 100644 crates/server/tests/device_auth.rs create mode 100644 docs/changes/2.2.0/649.feature.md create mode 100644 migrations/0008_device_authorization.sql create mode 100644 web/src/api/device.ts create mode 100644 web/src/routes/Device.test.tsx create mode 100644 web/src/routes/Device.tsx diff --git a/.sqlx/query-58910a3818f688ec45339db31cff9adb11a0f18c42f2ffe318aad88d51fbc6c8.json b/.sqlx/query-58910a3818f688ec45339db31cff9adb11a0f18c42f2ffe318aad88d51fbc6c8.json new file mode 100644 index 00000000..a1de7f67 --- /dev/null +++ b/.sqlx/query-58910a3818f688ec45339db31cff9adb11a0f18c42f2ffe318aad88d51fbc6c8.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM device_authorization WHERE expires_at <= now()", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "58910a3818f688ec45339db31cff9adb11a0f18c42f2ffe318aad88d51fbc6c8" +} diff --git a/.sqlx/query-6e33da34931d13a51229fd5e9798d3779a21d48eea365a94aa2ec7e2d5b7e673.json b/.sqlx/query-6e33da34931d13a51229fd5e9798d3779a21d48eea365a94aa2ec7e2d5b7e673.json new file mode 100644 index 00000000..59bbe7a6 --- /dev/null +++ b/.sqlx/query-6e33da34931d13a51229fd5e9798d3779a21d48eea365a94aa2ec7e2d5b7e673.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO device_authorization\n (device_code_hash, user_code, client_id, scope, client_ip, interval_seconds, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (user_code) DO NOTHING\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8", + "origin": { + "Table": { + "table": "device_authorization", + "name": "id" + } + } + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Int4", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "6e33da34931d13a51229fd5e9798d3779a21d48eea365a94aa2ec7e2d5b7e673" +} diff --git a/.sqlx/query-755d789599e5449117bdfdac4e2f33abfcce922e3e32a9b7fdfaefd5453c19b4.json b/.sqlx/query-755d789599e5449117bdfdac4e2f33abfcce922e3e32a9b7fdfaefd5453c19b4.json new file mode 100644 index 00000000..510dfd4f --- /dev/null +++ b/.sqlx/query-755d789599e5449117bdfdac4e2f33abfcce922e3e32a9b7fdfaefd5453c19b4.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE device_authorization\n SET last_polled_at = now(), interval_seconds = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "755d789599e5449117bdfdac4e2f33abfcce922e3e32a9b7fdfaefd5453c19b4" +} diff --git a/.sqlx/query-97fb07bd487d057b49cb0e1ed01431017fdb36fb861cb48e657723dc2376622b.json b/.sqlx/query-97fb07bd487d057b49cb0e1ed01431017fdb36fb861cb48e657723dc2376622b.json new file mode 100644 index 00000000..4e744e9d --- /dev/null +++ b/.sqlx/query-97fb07bd487d057b49cb0e1ed01431017fdb36fb861cb48e657723dc2376622b.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE device_authorization\n SET status = CASE WHEN $2::bigint IS NULL THEN 'denied' ELSE 'approved' END,\n user_id = $2,\n decided_at = now()\n WHERE user_code = $1 AND status = 'pending' AND expires_at > now()\n RETURNING id, user_code, client_id, scope, client_ip, created_at, expires_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8", + "origin": { + "Table": { + "table": "device_authorization", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "user_code", + "type_info": "Text", + "origin": { + "Table": { + "table": "device_authorization", + "name": "user_code" + } + } + }, + { + "ordinal": 2, + "name": "client_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "device_authorization", + "name": "client_id" + } + } + }, + { + "ordinal": 3, + "name": "scope", + "type_info": "Text", + "origin": { + "Table": { + "table": "device_authorization", + "name": "scope" + } + } + }, + { + "ordinal": 4, + "name": "client_ip", + "type_info": "Text", + "origin": { + "Table": { + "table": "device_authorization", + "name": "client_ip" + } + } + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "device_authorization", + "name": "created_at" + } + } + }, + { + "ordinal": 6, + "name": "expires_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "device_authorization", + "name": "expires_at" + } + } + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "97fb07bd487d057b49cb0e1ed01431017fdb36fb861cb48e657723dc2376622b" +} diff --git a/.sqlx/query-aab6656efff7326910cb7626ece0c24e9cf2470da744bdaf261e44be7b50a94b.json b/.sqlx/query-aab6656efff7326910cb7626ece0c24e9cf2470da744bdaf261e44be7b50a94b.json new file mode 100644 index 00000000..578bfabb --- /dev/null +++ b/.sqlx/query-aab6656efff7326910cb7626ece0c24e9cf2470da744bdaf261e44be7b50a94b.json @@ -0,0 +1,129 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO token\n (user_id, name, token_hash, expires_at, minted_with_forwarded_credential, scope)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING\n id, user_id, name, token_hash, expires_at, revoked_at,\n last_used_at, created_at, scope\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8", + "origin": { + "Table": { + "table": "token", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8", + "origin": { + "Table": { + "table": "token", + "name": "user_id" + } + } + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text", + "origin": { + "Table": { + "table": "token", + "name": "name" + } + } + }, + { + "ordinal": 3, + "name": "token_hash", + "type_info": "Text", + "origin": { + "Table": { + "table": "token", + "name": "token_hash" + } + } + }, + { + "ordinal": 4, + "name": "expires_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "token", + "name": "expires_at" + } + } + }, + { + "ordinal": 5, + "name": "revoked_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "token", + "name": "revoked_at" + } + } + }, + { + "ordinal": 6, + "name": "last_used_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "token", + "name": "last_used_at" + } + } + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "token", + "name": "created_at" + } + } + }, + { + "ordinal": 8, + "name": "scope", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "token", + "name": "scope" + } + } + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Timestamptz", + "Bool", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + false, + true + ] + }, + "hash": "aab6656efff7326910cb7626ece0c24e9cf2470da744bdaf261e44be7b50a94b" +} diff --git a/.sqlx/query-d308dc16d28a0e5a6629c141569ac745dc25768f6de2bc8c0b424993e3397dc8.json b/.sqlx/query-d308dc16d28a0e5a6629c141569ac745dc25768f6de2bc8c0b424993e3397dc8.json new file mode 100644 index 00000000..f6c995d9 --- /dev/null +++ b/.sqlx/query-d308dc16d28a0e5a6629c141569ac745dc25768f6de2bc8c0b424993e3397dc8.json @@ -0,0 +1,102 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, client_id, scope, status, user_id, interval_seconds,\n expires_at <= now() AS \"expired!\",\n COALESCE(\n last_polled_at > now() - make_interval(secs => interval_seconds),\n false\n ) AS \"too_fast!\"\n FROM device_authorization\n WHERE device_code_hash = $1\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8", + "origin": { + "Table": { + "table": "device_authorization", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "client_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "device_authorization", + "name": "client_id" + } + } + }, + { + "ordinal": 2, + "name": "scope", + "type_info": "Text", + "origin": { + "Table": { + "table": "device_authorization", + "name": "scope" + } + } + }, + { + "ordinal": 3, + "name": "status", + "type_info": "Text", + "origin": { + "Table": { + "table": "device_authorization", + "name": "status" + } + } + }, + { + "ordinal": 4, + "name": "user_id", + "type_info": "Int8", + "origin": { + "Table": { + "table": "device_authorization", + "name": "user_id" + } + } + }, + { + "ordinal": 5, + "name": "interval_seconds", + "type_info": "Int4", + "origin": { + "Table": { + "table": "device_authorization", + "name": "interval_seconds" + } + } + }, + { + "ordinal": 6, + "name": "expired!", + "type_info": "Bool", + "origin": "Expression" + }, + { + "ordinal": 7, + "name": "too_fast!", + "type_info": "Bool", + "origin": "Expression" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + false, + null, + null + ] + }, + "hash": "d308dc16d28a0e5a6629c141569ac745dc25768f6de2bc8c0b424993e3397dc8" +} diff --git a/.sqlx/query-dc8431609b31a1b82a6606b221f94ffa10372d880af61af47f88f29f000436b9.json b/.sqlx/query-dc8431609b31a1b82a6606b221f94ffa10372d880af61af47f88f29f000436b9.json new file mode 100644 index 00000000..0e2043a9 --- /dev/null +++ b/.sqlx/query-dc8431609b31a1b82a6606b221f94ffa10372d880af61af47f88f29f000436b9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE device_authorization\n SET status = 'collected', last_polled_at = now()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "dc8431609b31a1b82a6606b221f94ffa10372d880af61af47f88f29f000436b9" +} diff --git a/.sqlx/query-fdd9c4ed8c197652411a0e148a47b0c1041d86119e42df10d56f3ebaa7ed9af6.json b/.sqlx/query-fdd9c4ed8c197652411a0e148a47b0c1041d86119e42df10d56f3ebaa7ed9af6.json new file mode 100644 index 00000000..2d95ad89 --- /dev/null +++ b/.sqlx/query-fdd9c4ed8c197652411a0e148a47b0c1041d86119e42df10d56f3ebaa7ed9af6.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_code, client_id, scope, client_ip, created_at, expires_at\n FROM device_authorization\n WHERE user_code = $1 AND status = 'pending' AND expires_at > now()\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8", + "origin": { + "Table": { + "table": "device_authorization", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "user_code", + "type_info": "Text", + "origin": { + "Table": { + "table": "device_authorization", + "name": "user_code" + } + } + }, + { + "ordinal": 2, + "name": "client_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "device_authorization", + "name": "client_id" + } + } + }, + { + "ordinal": 3, + "name": "scope", + "type_info": "Text", + "origin": { + "Table": { + "table": "device_authorization", + "name": "scope" + } + } + }, + { + "ordinal": 4, + "name": "client_ip", + "type_info": "Text", + "origin": { + "Table": { + "table": "device_authorization", + "name": "client_ip" + } + } + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "device_authorization", + "name": "created_at" + } + } + }, + { + "ordinal": 6, + "name": "expires_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "device_authorization", + "name": "expires_at" + } + } + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "fdd9c4ed8c197652411a0e148a47b0c1041d86119e42df10d56f3ebaa7ed9af6" +} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 2528592a..4468298b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -325,6 +325,7 @@ to set them. See | `login_mode` | `identifier_first` (default) or `combined`. | | `session_idle_ttl_seconds` / `session_absolute_ttl_seconds` | Browser-session lifetimes (default 8 h / 7 d). | | `login_max_failures` / `login_failure_window_seconds` | Password-failure lockout per client IP + username for `/auth/login` and Basic password auth (default 10 per 300 s; `0` disables). | +| `device_login` / `device_poll_interval_seconds` | The device login (RFC 8628) of command-line clients, approved on the `/device` page (default on, polled every 5 s). See [docs/deployment.md](./docs/deployment.md#device-login). | > **Anonymous reads need both** the anonymous mode *and* the repo's > `anonymous_read=true` (authz = authenticated user **or** @@ -463,6 +464,9 @@ Settings) for a classic combined form with per-realm OIDC buttons. | `GET` | `/auth/me` | Who-am-I for the SPA: `{user_id, username, is_admin, csrf_token}`; 401 if no session. | | `POST` | `/auth/logout` | Revoke the session + clear the cookie (CSRF-protected). | | `GET`/`POST`/`DELETE` | `/auth/tokens[/{id}]` | List / mint / revoke the session user's repository tokens (unscoped; `/admin/me/tokens` also takes a `scope`). | +| `POST` | `/auth/device/code`, `/auth/device/token` | The device authorization grant (RFC 8628) of Forge's command-line helpers: start a login, poll it for the token. RFC 6749 JSON errors. | +| `GET` | `/.well-known/oauth-authorization-server` | Public: the RFC 8414 metadata naming those two endpoints. | +| `POST` | `/auth/device/lookup`, `/auth/device/decide` | The `/device` page (session + CSRF): look a user code up, approve or deny it; wrong codes are limited per network and session. | State-changing cookie endpoints (`logout`, token mint/revoke) require a **double-submit CSRF token**: read `csrf_token` from `/auth/me` (or the diff --git a/crates/db/src/device_auth_dao.rs b/crates/db/src/device_auth_dao.rs new file mode 100644 index 00000000..b0f6b90a --- /dev/null +++ b/crates/db/src/device_auth_dao.rs @@ -0,0 +1,216 @@ +//! Device authorization requests (`device_authorization`, RFC 8628): a +//! command-line client polls with a device code while its user types the +//! user code on the web UI's `/device` page and approves or denies it. + +use chrono::{DateTime, Utc}; +use sqlx::{PgConnection, PgPool}; + +/// A request as the `/device` page shows it. +#[derive(Debug, Clone)] +pub struct DeviceAuthorization { + pub id: i64, + /// The user code, normalized (eight letters, no hyphen). + pub user_code: String, + pub client_id: String, + /// The requested scope, normalized; `None`: the user's rights. + pub scope: Option, + pub client_ip: Option, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +/// A request to store ([`insert`]). +#[derive(Debug, Clone)] +pub struct NewDeviceAuthorization<'a> { + pub device_code_hash: &'a str, + pub user_code: &'a str, + pub client_id: &'a str, + pub scope: Option<&'a str>, + pub client_ip: Option<&'a str>, + pub interval_seconds: i32, + pub expires_at: DateTime, +} + +/// Store a new pending request; returns its id. Returns `None`, storing +/// nothing, when a request (live or expired, not yet reaped) holds the same +/// user code: the caller draws another. +pub async fn insert(pool: &PgPool, new: NewDeviceAuthorization<'_>) -> sqlx::Result> { + sqlx::query_scalar!( + r#" + INSERT INTO device_authorization + (device_code_hash, user_code, client_id, scope, client_ip, interval_seconds, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_code) DO NOTHING + RETURNING id + "#, + new.device_code_hash, + new.user_code, + new.client_id, + new.scope, + new.client_ip, + new.interval_seconds, + new.expires_at, + ) + .fetch_optional(pool) + .await +} + +/// The live (not expired), still pending request with `user_code`. +pub async fn find_pending( + pool: &PgPool, + user_code: &str, +) -> sqlx::Result> { + sqlx::query_as!( + DeviceAuthorization, + r#" + SELECT id, user_code, client_id, scope, client_ip, created_at, expires_at + FROM device_authorization + WHERE user_code = $1 AND status = 'pending' AND expires_at > now() + "#, + user_code, + ) + .fetch_optional(pool) + .await +} + +/// Approve (for `approved_by`) or, with `None`, deny the live pending request +/// with `user_code`. Returns the request as it was decided, `None` when there +/// is no such request (unknown, expired or decided already). +pub async fn decide( + pool: &PgPool, + user_code: &str, + approved_by: Option, +) -> sqlx::Result> { + sqlx::query_as!( + DeviceAuthorization, + r#" + UPDATE device_authorization + SET status = CASE WHEN $2::bigint IS NULL THEN 'denied' ELSE 'approved' END, + user_id = $2, + decided_at = now() + WHERE user_code = $1 AND status = 'pending' AND expires_at > now() + RETURNING id, user_code, client_id, scope, client_ip, created_at, expires_at + "#, + user_code, + approved_by, + ) + .fetch_optional(pool) + .await +} + +/// Where a request stands for its client's poll. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PollState { + /// Waiting for the user. + Pending, + /// Approved by the user; the token is still to be collected. + Approved { user_id: i64 }, + /// Denied by the user. + Denied, + /// Its token was collected, or it expired. + Expired, +} + +/// A request as a poll of its device code finds it, locked for the poll's +/// transaction ([`lock_for_poll`]). +#[derive(Debug, Clone)] +pub struct PolledRequest { + pub id: i64, + pub client_id: String, + pub scope: Option, + pub state: PollState, + /// The interval the client must keep between polls (seconds). + pub interval_seconds: i32, + /// Whether this poll came sooner than that after the previous one. + pub too_fast: bool, +} + +/// Find and lock the request whose device code hashes to `device_code_hash` +/// in the transaction on `conn` (`SELECT … FOR UPDATE`, so two polls of one +/// code are answered one after the other). The clock is the database's. +pub async fn lock_for_poll( + conn: &mut PgConnection, + device_code_hash: &str, +) -> sqlx::Result> { + let row = sqlx::query!( + r#" + SELECT id, client_id, scope, status, user_id, interval_seconds, + expires_at <= now() AS "expired!", + COALESCE( + last_polled_at > now() - make_interval(secs => interval_seconds), + false + ) AS "too_fast!" + FROM device_authorization + WHERE device_code_hash = $1 + FOR UPDATE + "#, + device_code_hash, + ) + .fetch_optional(&mut *conn) + .await?; + Ok(row.map(|row| { + let state = match (row.status.as_str(), row.user_id) { + _ if row.expired => PollState::Expired, + ("pending", _) => PollState::Pending, + ("approved", Some(user_id)) => PollState::Approved { user_id }, + ("denied", _) => PollState::Denied, + // `collected`, or an approval without its user (deleted). + _ => PollState::Expired, + }; + PolledRequest { + id: row.id, + client_id: row.client_id, + scope: row.scope, + state, + interval_seconds: row.interval_seconds, + too_fast: row.too_fast, + } + })) +} + +/// Record a poll of the pending request `id` now, with the interval the +/// client must keep from now on. +pub async fn record_poll( + conn: &mut PgConnection, + id: i64, + interval_seconds: i32, +) -> sqlx::Result<()> { + sqlx::query!( + r#" + UPDATE device_authorization + SET last_polled_at = now(), interval_seconds = $2 + WHERE id = $1 + "#, + id, + interval_seconds, + ) + .execute(&mut *conn) + .await?; + Ok(()) +} + +/// Mark the approved request `id` collected: its token was minted, and a +/// later poll gets `expired_token`. +pub async fn mark_collected(conn: &mut PgConnection, id: i64) -> sqlx::Result<()> { + sqlx::query!( + r#" + UPDATE device_authorization + SET status = 'collected', last_polled_at = now() + WHERE id = $1 + "#, + id, + ) + .execute(&mut *conn) + .await?; + Ok(()) +} + +/// Delete expired requests (the auth reaper). Returns how many. +pub async fn delete_expired(pool: &PgPool) -> sqlx::Result { + Ok( + sqlx::query!(r#"DELETE FROM device_authorization WHERE expires_at <= now()"#) + .execute(pool) + .await? + .rows_affected(), + ) +} diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index fc4e0e02..942d5b35 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -13,6 +13,7 @@ pub mod authz_dao; pub mod blob_dao; pub mod cli_login_dao; pub mod component_dao; +pub mod device_auth_dao; pub mod download_dao; pub mod email_oauth_flow_dao; pub mod email_settings_dao; diff --git a/crates/db/src/token_dao.rs b/crates/db/src/token_dao.rs index c93af565..119fc5e3 100644 --- a/crates/db/src/token_dao.rs +++ b/crates/db/src/token_dao.rs @@ -1,7 +1,7 @@ //! Data access for `token` (API access tokens). use chrono::{DateTime, Utc}; -use sqlx::PgPool; +use sqlx::{PgExecutor, PgPool}; use crate::model::Token; @@ -42,6 +42,38 @@ pub async fn insert_token(pool: &PgPool, new: NewToken) -> sqlx::Result { .await } +/// Insert a token with its `scope` jsonb (`None`: unscoped) in one +/// statement, so no unscoped row ever exists for a scoped token. Accepts any +/// [`PgExecutor`], so the insert can join a caller's transaction. +pub async fn insert_scoped_token<'e, E>( + executor: E, + new: NewToken, + scope: Option<&serde_json::Value>, +) -> sqlx::Result +where + E: PgExecutor<'e>, +{ + sqlx::query_as!( + Token, + r#" + INSERT INTO token + (user_id, name, token_hash, expires_at, minted_with_forwarded_credential, scope) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING + id, user_id, name, token_hash, expires_at, revoked_at, + last_used_at, created_at, scope + "#, + new.user_id, + new.name, + new.token_hash, + new.expires_at, + new.minted_with_forwarded_credential, + scope, + ) + .fetch_one(executor) + .await +} + /// Find a token by its unique hash. Returns the row regardless of /// expiry/revocation — the caller is responsible for validity checks. pub async fn find_token_by_hash(pool: &PgPool, token_hash: &str) -> sqlx::Result> { diff --git a/crates/e2e/tests/device.rs b/crates/e2e/tests/device.rs new file mode 100644 index 00000000..3b8ff74c --- /dev/null +++ b/crates/e2e/tests/device.rs @@ -0,0 +1,438 @@ +//! E2E: the device login (RFC 8628) through an OIDC realm, then `docker +//! login` with the token it minted and a `docker pull`. +//! +//! Drives the **real** forge server over HTTP against a real Postgres +//! testcontainer and the in-process mock OIDC IdP (`MockOidc`), as +//! `docker-credential-forge` and its user would: +//! +//! 1. the client starts a device login (`POST /auth/device/code`) and polls +//! (`authorization_pending`); +//! 2. the user opens the verification URI with the code filled in, signs in +//! at the IdP, and comes back to `/device?code=…` with the code kept; +//! 3. the user's session looks the code up and approves it; +//! 4. the client's next poll collects the token, once; +//! 5. the real Docker CLI logs in with it (a fresh `DOCKER_CONFIG`) and pulls +//! an image of a repository that allows no anonymous reads. +//! +//! The Docker steps are skipped (the test passes trivially after step 4) when +//! no Docker CLI and daemon are available. Requires Docker for Postgres. + +#![cfg(feature = "forge-e2e")] + +use std::io::Write as _; +use std::path::Path; +use std::process::{Command, Output, Stdio}; + +use forge_e2e::{MockOidc, StackBuilder, TestServer}; +use serde_json::{json, Value}; + +const CLIENT_ID: &str = "forge-e2e-client"; +const GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code"; +const DEVICE_CLIENT: &str = "docker-credential-forge"; + +/// A reqwest client that does NOT follow redirects (so we can read the 302s). +fn no_redirect_client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("client") +} + +/// The `[oidc]` block of the stack (the legacy seed of an OIDC realm, which +/// provisions accounts on their first sign-in). +fn oidc_block(issuer: &str, public_base_url: &str) -> String { + format!( + r#" +[oidc] +issuer = "{issuer}" +client_id = "{CLIENT_ID}" +client_secret = "shh-secret" +redirect_url = "{public_base_url}/auth/oidc/callback" +allow_private_issuer = true +"#, + ) +} + +fn digest(bytes: &[u8]) -> String { + use sha2::Digest; + format!("sha256:{}", hex::encode(sha2::Sha256::digest(bytes))) +} + +/// A tar archive holding the one file `name` with `content`: an image layer. +fn tar_with(name: &str, content: &[u8]) -> Vec { + let mut header = [0u8; 512]; + header[..name.len()].copy_from_slice(name.as_bytes()); + header[100..108].copy_from_slice(b"0000644\0"); + header[108..116].copy_from_slice(b"0000000\0"); + header[116..124].copy_from_slice(b"0000000\0"); + header[124..136].copy_from_slice(format!("{:011o}\0", content.len()).as_bytes()); + header[136..148].copy_from_slice(b"00000000000\0"); + header[148..156].copy_from_slice(b" "); + header[156] = b'0'; + header[257..263].copy_from_slice(b"ustar\0"); + header[263..265].copy_from_slice(b"00"); + let sum: u32 = header.iter().map(|b| u32::from(*b)).sum(); + header[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes()); + let mut tar = header.to_vec(); + tar.extend_from_slice(content); + tar.resize(tar.len().div_ceil(512) * 512, 0); + tar.extend_from_slice(&[0u8; 1024]); + tar +} + +/// The architecture of this host as images name it. +fn image_arch() -> &'static str { + match std::env::consts::ARCH { + "aarch64" => "arm64", + "x86_64" => "amd64", + other => other, + } +} + +/// Push `image:tag` (one uncompressed layer holding `hello.txt`) as the admin +/// through the registry API. +async fn push_image(srv: &TestServer, image: &str, tag: &str) { + let (user, password) = srv.admin_basic(); + let token: Value = srv + .client() + .get(format!( + "{}/v2/token?scope=repository:{image}:pull,push", + srv.base_url + )) + .basic_auth(user, Some(password)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let token = token["token"].as_str().expect("registry token"); + let layer = tar_with("hello.txt", b"signed in with a device code\n"); + let config = json!({ + "architecture": image_arch(), + "os": "linux", + "config": {}, + "rootfs": {"type": "layers", "diff_ids": [digest(&layer)]}, + }) + .to_string() + .into_bytes(); + for blob in [&config, &layer] { + let resp = srv + .client() + .post(format!( + "{}/v2/{image}/blobs/uploads/?digest={}", + srv.base_url, + digest(blob) + )) + .bearer_auth(token) + .header("content-type", "application/octet-stream") + .body(blob.clone()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "push a blob"); + } + let manifest = json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": digest(&config), + "size": config.len(), + }, + "layers": [{ + "mediaType": "application/vnd.oci.image.layer.v1.tar", + "digest": digest(&layer), + "size": layer.len(), + }], + }) + .to_string(); + let resp = srv + .client() + .put(format!("{}/v2/{image}/manifests/{tag}", srv.base_url)) + .bearer_auth(token) + .header("content-type", "application/vnd.oci.image.manifest.v1+json") + .body(manifest) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "push the manifest"); +} + +/// `POST` a form to `path`: the status and the JSON answer. +async fn post_form(srv: &TestServer, path: &str, form: &[(&str, &str)]) -> (u16, Value) { + let resp = srv + .client() + .post(format!("{}{path}", srv.base_url)) + .form(form) + .send() + .await + .unwrap(); + let status = resp.status().as_u16(); + (status, resp.json().await.expect("an OAuth JSON answer")) +} + +async fn poll(srv: &TestServer, device_code: &str) -> (u16, Value) { + post_form( + srv, + "/auth/device/token", + &[ + ("grant_type", GRANT), + ("client_id", DEVICE_CLIENT), + ("device_code", device_code), + ], + ) + .await +} + +/// Whether a Docker CLI with a reachable daemon is on PATH. +fn docker_available() -> bool { + Command::new("docker") + .args(["version", "--format", "{{.Server.Version}}"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Run `docker ` with its configuration in `config` (and `stdin`), on a +/// blocking thread so the in-process server keeps answering the daemon. +async fn docker(config: &Path, args: &[&str], stdin: Option<&str>) -> Output { + let config = config.to_path_buf(); + let args: Vec = args.iter().map(|a| a.to_string()).collect(); + let stdin = stdin.map(str::to_string); + tokio::task::spawn_blocking(move || { + let mut child = Command::new("docker") + .env("DOCKER_CONFIG", &config) + .args(&args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn docker"); + let mut input = child.stdin.take().expect("stdin"); + if let Some(stdin) = stdin { + input.write_all(stdin.as_bytes()).expect("write stdin"); + } + drop(input); + child.wait_with_output().expect("docker output") + }) + .await + .expect("join docker") +} + +fn describe(what: &str, out: &Output) -> String { + format!( + "{what}: {:?}\nstdout:\n{}\nstderr:\n{}", + out.status.code(), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn device_login_through_oidc_then_docker_login_and_pull() { + let idp = MockOidc::start(CLIENT_ID).await; + let issuer = idp.issuer().to_string(); + let srv = StackBuilder::default() + .bootstrap_admin("root@example.com", "rootpw") + .with_oidc(move |base| oidc_block(&issuer, base)) + .start() + .await; + + // A repository that allows no anonymous reads, holding an image. + let resp = srv + .create_repo(json!({"name": "device-docker", "kind": "hosted", "format": "docker"})) + .await; + assert_eq!(resp.status(), 201, "{}", resp.text().await.unwrap()); + push_image(&srv, "device-docker/hello", "1").await; + + // 1. The client starts the device login and polls. + let (status, started) = post_form( + &srv, + "/auth/device/code", + &[ + ("client_id", DEVICE_CLIENT), + ("scope", "read repository:device-docker"), + ], + ) + .await; + assert_eq!(status, 200, "{started}"); + let device_code = started["device_code"].as_str().unwrap().to_string(); + let user_code = started["user_code"].as_str().unwrap().to_string(); + let complete = started["verification_uri_complete"].as_str().unwrap(); + assert_eq!( + complete, + format!("{}/device?code={user_code}", srv.base_url) + ); + let (status, pending) = poll(&srv, &device_code).await; + assert_eq!( + (status, pending["error"].as_str()), + (400, Some("authorization_pending")) + ); + + // 2. The user opens the link: the web UI's login gate sends them to the + // IdP with the page as the target, and back there signed in. + let target = format!("/device?code={user_code}"); + let redirect_to: String = url::form_urlencoded::byte_serialize(target.as_bytes()).collect(); + let resp = no_redirect_client() + .get(format!( + "{}/auth/oidc/login?redirect_to={redirect_to}", + srv.base_url + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 302, "login redirects to the IdP"); + let authorize = resp.headers()["location"].to_str().unwrap().to_string(); + let state = url::Url::parse(&authorize) + .unwrap() + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()) + .expect("state"); + let nonce: String = sqlx::query_scalar("SELECT nonce FROM oidc_flow WHERE state = $1") + .bind(&state) + .fetch_one(&srv.pool) + .await + .unwrap(); + idp.set_next_id_token(idp.id_claims("sub-dana", "dana@example.com", &nonce, json!([]))) + .await; + let resp = no_redirect_client() + .get(format!( + "{}/auth/oidc/callback?code=mock-code&state={state}", + srv.base_url + )) + .header( + "cookie", + format!( + "forge_oidc_flow={}", + forge_core::oidc::browser_binding(&state) + ), + ) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 302); + assert_eq!( + resp.headers()["location"].to_str().unwrap(), + target, + "the sign-in keeps the code" + ); + let cookie = resp + .headers() + .get_all("set-cookie") + .iter() + .filter_map(|v| v.to_str().ok()) + .find(|v| v.starts_with("forge_session=")) + .expect("session cookie") + .split(';') + .next() + .unwrap() + .to_string(); + let me: Value = srv + .client() + .get(format!("{}/auth/me", srv.base_url)) + .header("cookie", &cookie) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(me["username"], "dana@example.com"); + let csrf = me["csrf_token"].as_str().unwrap().to_string(); + // The provisioned account may use every repository (the built-in + // writer role); the token's scope narrows that to reading one. + let writer = srv.role_id("writer").await; + let resp = srv + .assign_role(me["user_id"].as_i64().unwrap(), writer) + .await; + assert!(resp.status().is_success(), "{}", resp.status()); + + // 3. The page looks the code up and the user approves it. + let shown: Value = srv + .client() + .post(format!("{}/auth/device/lookup", srv.base_url)) + .header("cookie", &cookie) + .header("x-csrf-token", &csrf) + .json(&json!({"user_code": user_code})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(shown["client_name"], "Docker credential helper"); + assert_eq!( + shown["scope"], + json!({"level": "read", "repositories": ["device-docker"]}) + ); + let resp = srv + .client() + .post(format!("{}/auth/device/decide", srv.base_url)) + .header("cookie", &cookie) + .header("x-csrf-token", &csrf) + .json(&json!({"user_code": user_code, "approve": true})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 204); + + // 4. The client's next poll collects the token, once. + let (status, token) = poll(&srv, &device_code).await; + assert_eq!(status, 200, "{token}"); + assert_eq!(token["username"], "dana@example.com"); + let secret = token["access_token"].as_str().unwrap().to_string(); + let (status, again) = poll(&srv, &device_code).await; + assert_eq!( + (status, again["error"].as_str()), + (400, Some("expired_token")) + ); + + // 5. The Docker CLI logs in with the token and pulls. + if !docker_available() { + eprintln!("no Docker CLI and daemon; skipping docker login and pull"); + return; + } + let registry = srv.base_url.trim_start_matches("http://").to_string(); + let reference = format!("{registry}/device-docker/hello:1"); + let config = tempfile::tempdir().unwrap(); + // Without credentials the repository is closed. + let out = docker(config.path(), &["pull", &reference], None).await; + assert!( + !out.status.success(), + "{}", + describe("anonymous pull", &out) + ); + let out = docker( + config.path(), + &[ + "login", + ®istry, + "--username", + "dana@example.com", + "--password-stdin", + ], + Some(&secret), + ) + .await; + assert!(out.status.success(), "{}", describe("docker login", &out)); + let out = docker(config.path(), &["pull", &reference], None).await; + let pulled = out.status.success(); + let report = describe("docker pull", &out); + let inspected = docker( + config.path(), + &["image", "inspect", "--format", "{{.Id}}", &reference], + None, + ) + .await; + // Leave the daemon as it was. + let _ = docker(config.path(), &["image", "rm", "--force", &reference], None).await; + let _ = docker(config.path(), &["logout", ®istry], None).await; + assert!(pulled, "{report}"); + assert!( + inspected.status.success(), + "{}", + describe("docker image inspect", &inspected) + ); +} diff --git a/crates/server/src/auth/device.rs b/crates/server/src/auth/device.rs new file mode 100644 index 00000000..bb571022 --- /dev/null +++ b/crates/server/src/auth/device.rs @@ -0,0 +1,1107 @@ +//! The device authorization grant (RFC 8628, #649): how a command-line +//! client signs in through the browser. Forge's helpers use it: `forge +//! login`, `docker-credential-forge` and `nuget-plugin-forge` (the Docker CLI +//! has no browser login for a registry other than Docker Hub, so for Docker +//! it is the only way to an OIDC or Entra sign-in). +//! +//! * `POST /auth/device/code` (RFC 8628 §3.1–3.2) takes a `client_id` and an +//! optional `scope` and answers a `device_code`, the `user_code` the user +//! types on the web UI's `/device` page (also linked with the code filled +//! in, `verification_uri_complete`), `expires_in` and `interval`. +//! * `POST /auth/device/token` (§3.4–3.5), with `grant_type +//! urn:ietf:params:oauth:grant-type:device_code`, answers +//! `authorization_pending` until the user decides, `slow_down` (and five +//! more seconds of interval) when polled sooner than the interval, +//! `access_denied`, `expired_token`, or, once, the token. Errors are RFC +//! 6749 §5.2 JSON. +//! * `GET /.well-known/oauth-authorization-server` (RFC 8414) names both +//! endpoints, so a client finds everything from the server URL alone. +//! * `POST /auth/device/lookup` and `/auth/device/decide` serve the `/device` +//! page: a signed-in user's browser session looks a code up, sees which +//! client asks for what, from where and since when, and approves or denies +//! it. A code filled in from a link is never approved without that click +//! (§5.4, remote phishing). The page signs in through the whole realm +//! chain like every page of the web UI. +//! +//! Forge has no client registration: only its helpers' fixed client ids are +//! accepted ([`CLIENTS`]), so the page names the client truthfully; any other +//! is `invalid_client`. No refresh token is issued: the token minted is a +//! long-lived API token of the approving user, as the npm web login's +//! ([`crate::cli_auth`]), 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` narrows it ([`DeviceScope`]); without one it +//! carries the user's rights. +//! +//! The device code is a 256-bit secret of which only the SHA-256 is stored. +//! The user code is eight letters of an alphabet without vowels or +//! look-alikes (§6.1), typed in any case, with or without its hyphen. Code +//! entry is limited per client network and per browser session +//! ([`AppState::device_code_throttle`]: ten wrong codes per five minutes, +//! counted by each instance), so a pending code cannot be guessed within its +//! lifetime, `cli_login.web_login_ttl_seconds`. Requests, decisions and +//! mints are audit-logged (`forge::audit`). The auth settings switch the +//! device login off and set the polling interval (**Admin → Settings**). + +use std::collections::{BTreeSet, HashMap}; +use std::fmt; +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; + +use axum::body::Bytes; +use axum::extract::rejection::BytesRejection; +use axum::extract::{ConnectInfo, DefaultBodyLimit, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Json, Response}; +use axum::routing::{get, post}; +use axum::{Extension, Router}; +use chrono::{DateTime, Utc}; +use forge_core::auth::token::{generate_token_secret, hash_token}; +use forge_core::auth::{AttemptRefused, LoginAttempt, LoginThrottle}; +use forge_core::authz::{Level, TokenScope}; +use forge_db::device_auth_dao::{ + self, DeviceAuthorization, NewDeviceAuthorization, PollState, PolledRequest, +}; +use forge_db::{repository_dao, token_dao, user_dao}; +use serde::{Deserialize, Serialize}; + +use super::{login_client_ip, require_session_csrf, ActiveSession, SessionAuthCtx}; +use crate::middleware::principal::auth_unavailable; +use crate::state::AppState; + +/// The device authorization endpoint (RFC 8628 §3.1). +pub const DEVICE_CODE_PATH: &str = "/auth/device/code"; + +/// The token endpoint of the device grant (RFC 8628 §3.4). +pub const DEVICE_TOKEN_PATH: &str = "/auth/device/token"; + +/// The authorization server metadata (RFC 8414 §3). +pub const AUTHORIZATION_SERVER_METADATA_PATH: &str = "/.well-known/oauth-authorization-server"; + +/// The `/device` page's lookup of a code. +const LOOKUP_PATH: &str = "/auth/device/lookup"; + +/// The `/device` page's approval or denial. +const DECIDE_PATH: &str = "/auth/device/decide"; + +/// The web UI page the user enters the code on (`verification_uri`). +pub const VERIFICATION_PATH: &str = "/device"; + +/// The `grant_type` of the device grant. +pub const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code"; + +/// The clients that may ask, by their fixed client ids, with the name the +/// `/device` page shows: Forge's own helpers. +pub const CLIENTS: [(&str, &str); 3] = [ + ("forge-cli", "Forge CLI"), + ("docker-credential-forge", "Docker credential helper"), + ("nuget-plugin-forge", "NuGet credential provider"), +]; + +/// The user code's alphabet: consonants only, so no word and no look-alike +/// (RFC 8628 §6.1): 20^8, about 2.6 * 10^10 codes. +const USER_CODE_ALPHABET: &[u8; 20] = b"BCDFGHJKLMNPQRSTVWXZ"; + +/// Letters in a user code (shown `BCDF-GHJK`). +const USER_CODE_LEN: usize = 8; + +/// How much `slow_down` raises a request's interval (RFC 8628 §3.5). +const SLOW_DOWN_STEP_SECONDS: i32 = 5; + +/// The interval `slow_down` raises a request's to at most. +const MAX_INTERVAL_SECONDS: i32 = 3600; + +/// The largest request body of these endpoints (a form or a JSON object of +/// a few short fields). +const MAX_BODY_BYTES: usize = 8 * 1024; + +/// The longest `scope` accepted. +const MAX_SCOPE_BYTES: usize = 1024; + +/// The most repositories a `scope` names. +const MAX_SCOPE_REPOSITORIES: usize = 32; + +/// The longest repository name (the admin API's rule). +const MAX_REPOSITORY_NAME: usize = 64; + +/// How many user codes are drawn before giving up (each collides with a +/// stored one with a chance of a few in a billion). +const USER_CODE_DRAWS: usize = 5; + +/// The key a client network's wrong codes are counted under. +const CODE_ENTRY_NETWORK_KEY: &str = "\u{0}device-code"; + +/// The address a session's wrong codes are counted under, wherever they +/// come from: the session is the key. +const CODE_ENTRY_SESSION_IP: IpAddr = IpAddr::V6(Ipv6Addr::UNSPECIFIED); + +/// The routes of the device grant. +pub(super) fn device_router() -> Router { + Router::new() + .route(DEVICE_CODE_PATH, post(device_authorization)) + .route(DEVICE_TOKEN_PATH, post(device_token)) + .route(LOOKUP_PATH, post(lookup)) + .route(DECIDE_PATH, post(decide)) + .route(AUTHORIZATION_SERVER_METADATA_PATH, get(metadata)) + .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) +} + +/// The name the `/device` page shows for `client_id`, `None` for a client +/// Forge does not know. +pub fn client_name(client_id: &str) -> Option<&'static str> { + CLIENTS + .iter() + .find(|(id, _)| *id == client_id) + .map(|(_, name)| *name) +} + +// ── codes ─────────────────────────────────────────────────────────────────── + +/// A fresh user code (normalized: eight letters, no hyphen). +fn generate_user_code() -> String { + (0..USER_CODE_LEN) + .map(|_| USER_CODE_ALPHABET[rand::random_range(0..USER_CODE_ALPHABET.len())] as char) + .collect() +} + +/// `raw` as a stored user code: in any case, with or without the hyphen and +/// surrounding spaces. `None` when it cannot be one. +pub fn normalize_user_code(raw: &str) -> Option { + let code: String = raw + .chars() + .filter(|c| *c != '-' && !c.is_whitespace()) + .map(|c| c.to_ascii_uppercase()) + .collect(); + (code.len() == USER_CODE_LEN && code.bytes().all(|b| USER_CODE_ALPHABET.contains(&b))) + .then_some(code) +} + +/// A normalized user code as it is shown and typed: `BCDF-GHJK`. +pub fn display_user_code(code: &str) -> String { + match (code.get(..4), code.get(4..)) { + (Some(head), Some(tail)) if !tail.is_empty() => format!("{head}-{tail}"), + _ => code.to_string(), + } +} + +// ── scope ─────────────────────────────────────────────────────────────────── + +/// What a device login's `scope` asks for, mapped onto the token scope +/// ([`TokenScope`]): space-separated `read`, `write` or `delete` (the +/// token's level; the highest one named wins) and `repository:` (the +/// repositories it reaches, by name). Without a level the token keeps its +/// owner's levels; without a repository it reaches every one its owner may. +/// An empty scope is none: the token carries the user's rights. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceScope { + /// The level, `None`: the owner's. + pub level: Option, + /// The repositories, sorted; empty: every one the owner may reach. + pub repositories: Vec, +} + +impl DeviceScope { + /// Parse a `scope` parameter; `Ok(None)` for an empty one. The error is + /// the `error_description` of `invalid_scope` (it never echoes the + /// value). + pub fn parse(raw: &str) -> Result, &'static str> { + if raw.len() > MAX_SCOPE_BYTES { + return Err("the scope is too long"); + } + let mut level: Option = None; + let mut repositories = BTreeSet::new(); + // RFC 6749 §3.3: scope tokens are delimited by spaces. + for item in raw.split(' ').filter(|item| !item.is_empty()) { + if let Some(named) = Level::parse(item) { + level = level.max(Some(named)); + } else if let Some(name) = item.strip_prefix("repository:") { + if !is_repository_name(name) { + return Err("a repository scope names no valid repository name"); + } + repositories.insert(name.to_string()); + } else { + return Err( + "unknown scope value: expected read, write, delete or repository:", + ); + } + } + if repositories.len() > MAX_SCOPE_REPOSITORIES { + return Err("the scope names too many repositories (at most 32)"); + } + if level.is_none() && repositories.is_empty() { + return Ok(None); + } + Ok(Some(DeviceScope { + level, + repositories: repositories.into_iter().collect(), + })) + } + + /// The token scope: the repositories by id. A name no repository has + /// reaches nothing (a scope only ever narrows). + async fn token_scope(&self, state: &AppState) -> sqlx::Result { + let repositories = if self.repositories.is_empty() { + None + } else { + let mut ids = Vec::with_capacity(self.repositories.len()); + for name in &self.repositories { + match repository_dao::find_repository_by_name(&state.pool, name).await? { + Some(repo) => ids.push(repo.id), + None => tracing::info!( + repository = %name, + "device login: the scope names a repository that does not exist" + ), + } + } + Some(ids) + }; + Ok(TokenScope { + level: self.level, + admin: false, + repositories, + }) + } +} + +/// The normalized form, which [`DeviceScope::parse`] reads back: the level +/// first, then the repositories. +impl fmt::Display for DeviceScope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut items: Vec = Vec::new(); + if let Some(level) = self.level { + items.push(level.as_str().to_string()); + } + items.extend(self.repositories.iter().map(|r| format!("repository:{r}"))); + f.write_str(&items.join(" ")) + } +} + +/// Whether `name` can be a repository's name (the admin API's rule: +/// `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`). +fn is_repository_name(name: &str) -> bool { + let mut chars = name.chars(); + name.len() <= MAX_REPOSITORY_NAME + && chars.next().is_some_and(|c| c.is_ascii_alphanumeric()) + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) +} + +// ── OAuth answers ─────────────────────────────────────────────────────────── + +/// An RFC 6749 §5.2 error: JSON, never stored by a cache. +fn oauth_error(status: StatusCode, error: &'static str, description: &'static str) -> Response { + ( + status, + [ + (header::CACHE_CONTROL, "no-store"), + (header::PRAGMA, "no-cache"), + ], + Json(serde_json::json!({"error": error, "error_description": description})), + ) + .into_response() +} + +/// A `400` with `error`. +fn bad_request(error: &'static str, description: &'static str) -> Response { + oauth_error(StatusCode::BAD_REQUEST, error, description) +} + +/// Forge could not answer (its database failed): `503`, try again. +fn oauth_unavailable(e: &sqlx::Error) -> Response { + forge_db::observe_error(e); + tracing::error!(error = %e, "device login failed"); + let mut resp = oauth_error( + StatusCode::SERVICE_UNAVAILABLE, + "temporarily_unavailable", + "the server cannot answer now; try again", + ); + resp.headers_mut() + .insert(header::RETRY_AFTER, header::HeaderValue::from_static("30")); + resp +} + +/// A JSON answer that no cache keeps (RFC 6749 §5.1). +fn no_store(body: T) -> Response { + ( + [ + (header::CACHE_CONTROL, "no-store"), + (header::PRAGMA, "no-cache"), + ], + Json(body), + ) + .into_response() +} + +/// The parameters of a form-encoded OAuth request (RFC 6749 §3.2): each at +/// most once, and one sent without a value as if it were left out. +fn form_params( + headers: &HeaderMap, + body: Result, +) -> Result, Response> { + let is_form = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(';').next()) + .is_some_and(|mime| { + mime.trim() + .eq_ignore_ascii_case("application/x-www-form-urlencoded") + }); + if !is_form { + return Err(bad_request( + "invalid_request", + "send the parameters as application/x-www-form-urlencoded", + )); + } + let body = + body.map_err(|_| bad_request("invalid_request", "the request body is unreadable"))?; + let mut params = HashMap::new(); + for (key, value) in url::form_urlencoded::parse(&body) { + if value.is_empty() { + continue; + } + if params + .insert(key.into_owned(), value.into_owned()) + .is_some() + { + return Err(bad_request("invalid_request", "a parameter is repeated")); + } + } + Ok(params) +} + +/// The request's `client_id`, one of [`CLIENTS`]. +fn client_of(params: &HashMap) -> Result<&'static str, Response> { + let Some(client_id) = params.get("client_id") else { + return Err(bad_request("invalid_request", "client_id is required")); + }; + CLIENTS + .iter() + .map(|(id, _)| *id) + .find(|id| id == client_id) + .ok_or_else(|| { + bad_request( + "invalid_client", + "unknown client_id: Forge accepts forge-cli, docker-credential-forge and \ + nuget-plugin-forge", + ) + }) +} + +/// The device login is switched off (**Admin → Settings**). +fn turned_off() -> Response { + bad_request( + "unauthorized_client", + "the device login is turned off on this server", + ) +} + +// ── POST /auth/device/code ────────────────────────────────────────────────── + +/// The device authorization response (RFC 8628 §3.2). +#[derive(Debug, Serialize)] +struct DeviceAuthorizationResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: String, + expires_in: u64, + interval: u64, +} + +/// `POST /auth/device/code` — start a device login. +async fn device_authorization( + State(state): State, + connect: Option>>, + headers: HeaderMap, + body: Result, +) -> Response { + let params = match form_params(&headers, body) { + Ok(params) => params, + Err(resp) => return resp, + }; + let settings = state.auth_settings.current().await; + if !settings.settings.device_login { + return turned_off(); + } + let client_id = match client_of(¶ms) { + Ok(client_id) => client_id, + Err(resp) => return resp, + }; + let scope = match params.get("scope").map(|raw| DeviceScope::parse(raw)) { + None => None, + Some(Ok(scope)) => scope, + Some(Err(why)) => return bad_request("invalid_scope", why), + }; + // Starting a login is free for anyone and stores a row: bounded per + // client network, as OIDC and npm web logins are. + let client_ip = login_client_ip(&state, connect.map(|Extension(ci)| ci.0), &headers); + if !state.oidc_login_starts.allow(client_ip) { + let mut resp = oauth_error( + StatusCode::TOO_MANY_REQUESTS, + "slow_down", + "too many logins started from this network; try again later", + ); + resp.headers_mut() + .insert(header::RETRY_AFTER, header::HeaderValue::from_static("60")); + return resp; + } + + let interval = settings.settings.device_poll_interval_seconds; + let ttl = state.config.cli_login.web_login_ttl_seconds; + let device_code = generate_token_secret(); + let device_code_hash = hash_token(&device_code); + let scope = scope.map(|scope| scope.to_string()); + let client_ip = client_ip.to_string(); + let expires_at = Utc::now() + chrono::Duration::seconds(ttl as i64); + for _ in 0..USER_CODE_DRAWS { + let user_code = generate_user_code(); + let inserted = device_auth_dao::insert( + &state.pool, + NewDeviceAuthorization { + device_code_hash: &device_code_hash, + user_code: &user_code, + client_id, + scope: scope.as_deref(), + client_ip: Some(&client_ip), + interval_seconds: interval as i32, + expires_at, + }, + ) + .await; + let id = match inserted { + Ok(Some(id)) => id, + // Another request holds the code: draw again. + Ok(None) => continue, + Err(e) => return oauth_unavailable(&e), + }; + tracing::info!( + target: "forge::audit", + request_id = id, + client = client_id, + scope = scope.as_deref().unwrap_or(""), + ip = %client_ip, + "device login requested" + ); + let base = state.config.public_base_url(); + let user_code = display_user_code(&user_code); + return no_store(DeviceAuthorizationResponse { + device_code, + verification_uri: format!("{base}{VERIFICATION_PATH}"), + verification_uri_complete: format!("{base}{VERIFICATION_PATH}?code={user_code}"), + user_code, + expires_in: ttl, + interval, + }); + } + tracing::error!("device login: no free user code in {USER_CODE_DRAWS} draws"); + let mut resp = oauth_error( + StatusCode::SERVICE_UNAVAILABLE, + "temporarily_unavailable", + "the server cannot answer now; try again", + ); + resp.headers_mut() + .insert(header::RETRY_AFTER, header::HeaderValue::from_static("5")); + resp +} + +// ── POST /auth/device/token ───────────────────────────────────────────────── + +/// The access token response (RFC 6749 §5.1). +#[derive(Debug, Serialize)] +struct TokenResponse { + access_token: String, + token_type: &'static str, + /// Seconds until the token expires; left out for one that never does. + #[serde(skip_serializing_if = "Option::is_none")] + expires_in: Option, + /// The scope the token was minted for; left out for an unscoped one. + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, + /// Forge's own addition: whose token it is, the username a client such + /// as `docker login` sends with it. + username: String, +} + +/// `POST /auth/device/token` — a client polls its device login. +async fn device_token( + State(state): State, + headers: HeaderMap, + body: Result, +) -> Response { + let params = match form_params(&headers, body) { + Ok(params) => params, + Err(resp) => return resp, + }; + match params.get("grant_type").map(String::as_str) { + None => return bad_request("invalid_request", "grant_type is required"), + Some(DEVICE_CODE_GRANT) => {} + // No other grant, a refresh token's included: none is issued. + Some(_) => { + return bad_request( + "unsupported_grant_type", + "only the device code grant is supported", + ) + } + } + if !state.auth_settings.current().await.settings.device_login { + return turned_off(); + } + let client_id = match client_of(¶ms) { + Ok(client_id) => client_id, + Err(resp) => return resp, + }; + let Some(device_code) = params.get("device_code") else { + return bad_request("invalid_request", "device_code is required"); + }; + poll(&state, client_id, device_code).await +} + +/// Answer a poll of `device_code` by `client_id`. The request's row is +/// locked for the answer, so two polls of one code are answered one after +/// the other, and the token is minted in the transaction that marks the +/// request collected: it is answered once, and a failure leaves the +/// approval to collect on the next poll. +async fn poll(state: &AppState, client_id: &str, device_code: &str) -> Response { + let mut tx = match forge_db::begin(&state.pool).await { + Ok(tx) => tx, + Err(e) => return oauth_unavailable(&e), + }; + let request = match device_auth_dao::lock_for_poll(&mut tx, &hash_token(device_code)).await { + Ok(Some(request)) => request, + Ok(None) => return bad_request("invalid_grant", "unknown device_code"), + Err(e) => return oauth_unavailable(&e), + }; + if request.client_id != client_id { + return bad_request( + "invalid_grant", + "the device_code was issued to another client", + ); + } + match request.state { + PollState::Expired => bad_request( + "expired_token", + "the device_code expired or its token was collected; start a new login", + ), + PollState::Denied => bad_request("access_denied", "the user denied the login"), + PollState::Pending => { + let (interval, error, description) = if request.too_fast { + ( + request + .interval_seconds + .saturating_add(SLOW_DOWN_STEP_SECONDS) + .min(MAX_INTERVAL_SECONDS), + "slow_down", + "polled sooner than the interval; wait five seconds longer from now on", + ) + } else { + ( + request.interval_seconds, + "authorization_pending", + "waiting for the user to approve the login", + ) + }; + let recorded = device_auth_dao::record_poll(&mut tx, request.id, interval).await; + if let Err(e) = recorded { + return oauth_unavailable(&e); + } + if let Err(e) = tx.commit().await { + return oauth_unavailable(&e); + } + bad_request(error, description) + } + PollState::Approved { user_id } => collect(state, tx, &request, user_id).await, + } +} + +/// Mint the token of an approved request, in its poll's transaction `tx`. +async fn collect( + state: &AppState, + mut tx: sqlx::Transaction<'static, sqlx::Postgres>, + request: &PolledRequest, + user_id: i64, +) -> Response { + let user = match user_dao::find_user_by_id(&state.pool, user_id).await { + Ok(user) => user.filter(|u| u.active), + Err(e) => return oauth_unavailable(&e), + }; + let Some(user) = user else { + // Deactivated since it approved: no token, and none later. + let collected = device_auth_dao::mark_collected(&mut tx, request.id).await; + if let Err(e) = collected { + return oauth_unavailable(&e); + } + if let Err(e) = tx.commit().await { + return oauth_unavailable(&e); + } + tracing::info!( + target: "forge::audit", + user_id, + request_id = request.id, + client = %request.client_id, + "device login refused: the account is not active" + ); + return bad_request("access_denied", "the approving account is not active"); + }; + let scope = match request.scope.as_deref().map(DeviceScope::parse) { + None | Some(Ok(None)) => None, + Some(Ok(Some(scope))) => Some(scope), + Some(Err(why)) => { + // Stored normalized, so this is a bug: never mint wider. + tracing::error!( + request_id = request.id, + why, + "device login: stored scope unreadable" + ); + return oauth_error( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "the login's scope cannot be read", + ); + } + }; + let token_scope = match &scope { + None => None, + Some(scope) => match scope.token_scope(state).await { + Ok(token_scope) => Some(token_scope), + Err(e) => return oauth_unavailable(&e), + }, + }; + let scope_json = match token_scope.as_ref().map(serde_json::to_value).transpose() { + Ok(json) => json, + Err(e) => { + tracing::error!(error = %e, "device login: serializing the token scope failed"); + return oauth_error( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "the token's scope cannot be stored", + ); + } + }; + let secret = generate_token_secret(); + let name = format!( + "{} login ({})", + request.client_id, + host_of(&state.config.public_base_url()) + ); + let new = crate::cli_auth::login_token(state, &user, name, &secret); + let token = match token_dao::insert_scoped_token(&mut *tx, new, scope_json.as_ref()).await { + Ok(token) => token, + Err(e) => return oauth_unavailable(&e), + }; + if let Err(e) = device_auth_dao::mark_collected(&mut tx, request.id).await { + return oauth_unavailable(&e); + } + if let Err(e) = tx.commit().await { + return oauth_unavailable(&e); + } + tracing::info!( + target: "forge::audit", + user_id, + token_id = token.id, + request_id = request.id, + client = %request.client_id, + scope = request.scope.as_deref().unwrap_or(""), + "device login: token minted" + ); + no_store(TokenResponse { + access_token: secret, + token_type: "Bearer", + expires_in: token + .expires_at + .map(|at| (at - Utc::now()).num_seconds().max(0)), + scope: scope.map(|scope| scope.to_string()), + username: user.username, + }) +} + +/// The host (and port) of the public base URL, which a device login's +/// token is named after. +fn host_of(public_base_url: &str) -> String { + url::Url::parse(public_base_url) + .ok() + .and_then(|url| { + let host = url.host_str()?.to_string(); + Some(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host, + }) + }) + .unwrap_or_else(|| public_base_url.to_string()) +} + +// ── GET /.well-known/oauth-authorization-server ───────────────────────────── + +/// `GET /.well-known/oauth-authorization-server` — the authorization server +/// metadata (RFC 8414): where a client starts a device login and polls it. +async fn metadata(State(state): State) -> Response { + let base = state.config.public_base_url(); + Json(serde_json::json!({ + "issuer": base, + "device_authorization_endpoint": format!("{base}{DEVICE_CODE_PATH}"), + "token_endpoint": format!("{base}{DEVICE_TOKEN_PATH}"), + "grant_types_supported": [DEVICE_CODE_GRANT], + "response_types_supported": [], + "token_endpoint_auth_methods_supported": ["none"], + "scopes_supported": ["read", "write", "delete"], + })) + .into_response() +} + +// ── the /device page: POST /auth/device/lookup and /auth/device/decide ────── + +/// What the `/device` page shows of a pending request. +#[derive(Debug, Serialize)] +struct DeviceRequestView { + /// The code, as the client's terminal shows it (`BCDF-GHJK`). + user_code: String, + client_id: String, + /// The client's name ([`CLIENTS`]). + client_name: Option<&'static str>, + /// What the token may do; `null`: the user's rights. + scope: Option, + /// Where the client asked from. + client_ip: Option, + created_at: DateTime, + expires_at: DateTime, +} + +/// A request's scope on the `/device` page. +#[derive(Debug, Serialize)] +struct ScopeView { + /// The level; `null`: the user's levels. + level: Option, + /// The repositories; empty: every one the user may reach. + repositories: Vec, +} + +impl From for DeviceRequestView { + fn from(request: DeviceAuthorization) -> Self { + let scope = request + .scope + .as_deref() + .and_then(|raw| DeviceScope::parse(raw).ok().flatten()) + .map(|scope| ScopeView { + level: scope.level, + repositories: scope.repositories, + }); + DeviceRequestView { + user_code: display_user_code(&request.user_code), + client_name: client_name(&request.client_id), + client_id: request.client_id, + scope, + client_ip: request.client_ip, + created_at: request.created_at, + expires_at: request.expires_at, + } + } +} + +/// `POST /auth/device/lookup` body. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct LookupBody { + user_code: String, +} + +/// `POST /auth/device/decide` body. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DecideBody { + user_code: String, + /// `true` signs the client in as the session's user; `false` denies it. + approve: bool, +} + +/// One code entry, counted against the client network and the browser +/// session ([`AppState::device_code_throttle`]). A code that names no +/// pending request counts as a failure for both; a found one counts as +/// nothing, so finding one's own code never clears the failures. +struct CodeEntry<'a> { + network: LoginAttempt<'a>, + session: LoginAttempt<'a>, +} + +impl<'a> CodeEntry<'a> { + async fn begin( + throttle: &'a LoginThrottle, + client_ip: IpAddr, + session_id: i64, + ) -> Result { + let network = throttle + .begin(client_ip, CODE_ENTRY_NETWORK_KEY) + .await + .map_err(|refused| too_many_codes(throttle, refused))?; + let session = match throttle + .begin( + CODE_ENTRY_SESSION_IP, + &format!("\u{0}device-session:{session_id}"), + ) + .await + { + Ok(session) => session, + Err(refused) => { + network.released(); + return Err(too_many_codes(throttle, refused)); + } + }; + Ok(CodeEntry { network, session }) + } + + /// The code named no pending request. + fn failed(self) { + self.network.failed(); + self.session.failed(); + } + + /// The code was found, or Forge could not tell. + fn released(self) { + self.network.released(); + self.session.released(); + } +} + +/// `429`: too many wrong codes from this network or session. +fn too_many_codes(throttle: &LoginThrottle, refused: AttemptRefused) -> Response { + let retry_after = match refused { + AttemptRefused::Locked => throttle.current_limits().1.as_secs().max(1), + AttemptRefused::Busy => 1, + }; + ( + StatusCode::TOO_MANY_REQUESTS, + [(header::RETRY_AFTER, retry_after.to_string())], + "too many wrong codes; try again later", + ) + .into_response() +} + +/// `400`: no code can look like this. +fn malformed_code() -> Response { + ( + StatusCode::BAD_REQUEST, + "a code is eight letters, such as BCDF-GHJK", + ) + .into_response() +} + +/// `404`: no pending request has this code. +fn not_pending() -> Response { + ( + StatusCode::NOT_FOUND, + "no login is waiting for this code: it is mistyped, expired or answered already", + ) + .into_response() +} + +/// `POST /auth/device/lookup` — the pending request a code names, for the +/// signed-in user's `/device` page (CSRF-protected, like every POST of a +/// session). 404 when none is pending; 429 after too many wrong codes. +async fn lookup( + State(state): State, + connect: Option>>, + headers: HeaderMap, + Json(body): Json, +) -> Response { + let rt = SessionAuthCtx::of(&state); + let ActiveSession { session, .. } = match require_session_csrf(&rt, &headers).await { + Ok(active) => active, + Err(e) => return e.into_response(), + }; + let Some(code) = normalize_user_code(&body.user_code) else { + return malformed_code(); + }; + let client_ip = login_client_ip(&state, connect.map(|Extension(ci)| ci.0), &headers); + let entry = match CodeEntry::begin(&state.device_code_throttle, client_ip, session.id).await { + Ok(entry) => entry, + Err(resp) => return resp, + }; + match device_auth_dao::find_pending(&state.pool, &code).await { + Ok(Some(request)) => { + entry.released(); + Json(DeviceRequestView::from(request)).into_response() + } + Ok(None) => { + entry.failed(); + not_pending() + } + Err(e) => { + entry.released(); + tracing::error!(error = %e, "device login lookup failed"); + auth_unavailable() + } + } +} + +/// `POST /auth/device/decide` — approve (sign the client in as the session's +/// user) or deny the pending request a code names (CSRF-protected). The +/// client collects its token on its next poll. 204, 404 when none is +/// pending, 429 after too many wrong codes. +async fn decide( + State(state): State, + connect: Option>>, + headers: HeaderMap, + Json(body): Json, +) -> Response { + let rt = SessionAuthCtx::of(&state); + let ActiveSession { session, user } = match require_session_csrf(&rt, &headers).await { + Ok(active) => active, + Err(e) => return e.into_response(), + }; + let Some(code) = normalize_user_code(&body.user_code) else { + return malformed_code(); + }; + let client_ip = login_client_ip(&state, connect.map(|Extension(ci)| ci.0), &headers); + let entry = match CodeEntry::begin(&state.device_code_throttle, client_ip, session.id).await { + Ok(entry) => entry, + Err(resp) => return resp, + }; + let approved_by = body.approve.then_some(user.id); + match device_auth_dao::decide(&state.pool, &code, approved_by).await { + Ok(Some(request)) => { + entry.released(); + tracing::info!( + target: "forge::audit", + user_id = user.id, + request_id = request.id, + client = %request.client_id, + scope = request.scope.as_deref().unwrap_or(""), + ip = %client_ip, + approved = body.approve, + "device login decided" + ); + StatusCode::NO_CONTENT.into_response() + } + Ok(None) => { + entry.failed(); + not_pending() + } + Err(e) => { + entry.released(); + tracing::error!(error = %e, "device login decision failed"); + auth_unavailable() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_codes_use_the_alphabet_and_read_back_in_any_spelling() { + for _ in 0..100 { + let code = generate_user_code(); + assert_eq!(code.len(), USER_CODE_LEN); + assert!( + code.bytes().all(|b| USER_CODE_ALPHABET.contains(&b)), + "{code}" + ); + assert_eq!(normalize_user_code(&code).as_deref(), Some(code.as_str())); + let shown = display_user_code(&code); + assert_eq!(shown.len(), USER_CODE_LEN + 1); + assert_eq!(&shown[4..5], "-"); + assert_eq!(normalize_user_code(&shown).as_deref(), Some(code.as_str())); + let typed = format!(" {} ", shown.to_ascii_lowercase()); + assert_eq!(normalize_user_code(&typed).as_deref(), Some(code.as_str())); + } + assert_eq!(display_user_code("BCDFGHJK"), "BCDF-GHJK"); + assert_eq!( + normalize_user_code("bcdf ghjk").as_deref(), + Some("BCDFGHJK") + ); + // No vowel, no digit, no look-alike; never seven or nine letters. + for bad in [ + "", + "BCDF-GHJ", + "BCDF-GHJKL", + "BCDF-GHJA", + "BCDF-GHJ0", + "BCDF-GHJ1", + "BCDFGHJÄ", + ] { + assert_eq!(normalize_user_code(bad), None, "{bad}"); + } + } + + #[test] + fn a_scope_maps_onto_level_and_repositories() { + assert_eq!(DeviceScope::parse(""), Ok(None)); + assert_eq!(DeviceScope::parse(" "), Ok(None)); + let scope = DeviceScope::parse("read repository:npm-all write repository:b.c_d") + .unwrap() + .unwrap(); + assert_eq!(scope.level, Some(Level::Write), "the highest level named"); + assert_eq!(scope.repositories, ["b.c_d", "npm-all"]); + assert_eq!( + scope.to_string(), + "write repository:b.c_d repository:npm-all" + ); + assert_eq!( + DeviceScope::parse(&scope.to_string()), + Ok(Some(scope)), + "the normalized form reads back" + ); + let repos_only = DeviceScope::parse("repository:x repository:x") + .unwrap() + .unwrap(); + assert_eq!(repos_only.level, None); + assert_eq!(repos_only.repositories, ["x"]); + assert_eq!( + DeviceScope::parse("delete").unwrap().unwrap().to_string(), + "delete" + ); + for bad in [ + "admin", + "openid", + "repository:", + "repository:-x", + "repository:a/b", + "read\twrite", + "Read", + ] { + assert!(DeviceScope::parse(bad).is_err(), "{bad:?}"); + } + let many = (0..33) + .map(|i| format!("repository:r{i}")) + .collect::>() + .join(" "); + assert!(DeviceScope::parse(&many).is_err()); + assert!(DeviceScope::parse(&"read ".repeat(300)).is_err()); + } + + #[test] + fn only_forges_helpers_are_clients() { + let params = |id: &str| HashMap::from([("client_id".to_string(), id.to_string())]); + for (id, name) in CLIENTS { + assert_eq!(client_of(¶ms(id)).ok(), Some(id)); + assert_eq!(client_name(id), Some(name)); + } + assert_eq!(client_of(¶ms("curl")).unwrap_err().status(), 400); + assert_eq!(client_of(&HashMap::new()).unwrap_err().status(), 400); + assert_eq!(client_name("curl"), None); + } + + #[test] + fn form_parameters_are_form_encoded_and_unique() { + let form = |content_type: &str, body: &'static str| { + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, content_type.parse().unwrap()); + form_params(&headers, Ok(Bytes::from_static(body.as_bytes()))) + }; + let params = form( + "application/x-www-form-urlencoded; charset=UTF-8", + "client_id=forge-cli&scope=read+repository%3Aa&empty=", + ) + .unwrap(); + assert_eq!(params["client_id"], "forge-cli"); + assert_eq!(params["scope"], "read repository:a"); + assert!( + !params.contains_key("empty"), + "a parameter without a value is left out" + ); + assert!(form("application/json", "{}").is_err()); + assert!(form("application/x-www-form-urlencoded", "a=1&a=2").is_err()); + } + + #[test] + fn tokens_are_named_after_the_host() { + assert_eq!(host_of("https://forge.example.com"), "forge.example.com"); + assert_eq!(host_of("http://127.0.0.1:8421"), "127.0.0.1:8421"); + } +} diff --git a/crates/server/src/auth/mod.rs b/crates/server/src/auth/mod.rs index ea45da6d..de8be3c1 100644 --- a/crates/server/src/auth/mod.rs +++ b/crates/server/src/auth/mod.rs @@ -25,6 +25,10 @@ //! * `POST /auth/invitations/lookup` / `…/accept` — an invited user's link //! (#322, [`crate::invitations`]): whose account it is, and choosing its //! password. Failures are throttled per client network. +//! * `POST /auth/device/code`, `/auth/device/token`, `…/lookup`, +//! `…/decide` and `GET /.well-known/oauth-authorization-server` — the +//! device authorization grant (RFC 8628) of command-line clients and the +//! web UI's `/device` page that approves it ([`device`]). //! //! All ephemeral flow state and the sessions live in Postgres (`oidc_flow` / //! `session`), so any instance can complete a callback another started, and a @@ -63,6 +67,7 @@ use crate::config::OidcConfig; use crate::middleware::principal::auth_unavailable; use crate::state::AppState; +mod device; mod issuer; mod realms; mod redirect; @@ -71,6 +76,9 @@ mod token_exchange; mod trusted_header; mod wif; +pub use device::{ + AUTHORIZATION_SERVER_METADATA_PATH, DEVICE_CODE_GRANT, DEVICE_CODE_PATH, DEVICE_TOKEN_PATH, +}; pub use issuer::{ issuer_router, spawn_issuer_refresh, AssertionIssuer, DISCOVERY_PATH, ISSUER_KEYS_SETTING, JWKS_PATH, @@ -223,6 +231,9 @@ pub fn auth_router() -> Router { "/auth/cli-login/{secret}", get(cli_login_info).post(cli_login_decide), ) + // The device authorization grant (RFC 8628) and its discovery + // document, see [`device`]. + .merge(device::device_router()) } /// Resolve the realm snapshot or produce the fail-closed error response. diff --git a/crates/server/src/auth/settings.rs b/crates/server/src/auth/settings.rs index ec523270..0c396d53 100644 --- a/crates/server/src/auth/settings.rs +++ b/crates/server/src/auth/settings.rs @@ -1,7 +1,8 @@ //! Sign-in and anonymous-access policy, set at runtime (#294). //! //! The anonymous mode, the login screen's mode and its remembered accounts, -//! the session lifetimes and the password lockout are policy +//! the session lifetimes, the password lockout and the device login +//! (RFC 8628, [`super::device`]) are policy //! an administrator changes while Forge runs, and every instance must //! apply the same values. They are kept in `forge_setting` under //! [`AUTH_SETTINGS_KEY`] as one JSON document ([`AuthSettings`]), changed @@ -21,7 +22,8 @@ //! //! An install that never saved them uses [`AuthSettings::default`]: no //! anonymous access, the identifier-first login screen remembering accounts, -//! 8 h / 7 d sessions and 10 failures per 5 minutes. (Whether a realm accepts +//! 8 h / 7 d sessions, 10 failures per 5 minutes and the device login on, +//! polled every 5 seconds. (Whether a realm accepts //! passwords from clients over HTTP Basic is the realm's own setting, //! `accept_client_passwords`.) When the //! row cannot be read, an instance keeps the settings it has (the defaults @@ -48,6 +50,9 @@ pub const MAX_SESSION_TTL_SECONDS: u64 = 10 * 365 * 24 * 60 * 60; /// Longest accepted lockout window (a day). pub const MAX_LOGIN_FAILURE_WINDOW_SECONDS: u64 = 24 * 60 * 60; +/// Longest accepted device login polling interval (a minute). +pub const MAX_DEVICE_POLL_INTERVAL_SECONDS: u64 = 60; + /// Who is served anonymously (without credentials). A repository is read /// anonymously only when it allows anonymous reads too. /// @@ -139,6 +144,14 @@ pub struct AuthSettings { /// device and offers them for the next sign-in. The browser keeps them; /// `false` (a kiosk, a shared terminal) has it keep and show none. pub remember_accounts: bool, + /// Whether command-line clients may sign in with a code the user enters + /// on the `/device` page (the device authorization grant, RFC 8628). + /// Off, `POST /auth/device/code` and `/auth/device/token` refuse them + /// with `unauthorized_client`. + pub device_login: bool, + /// How long (seconds) a client waits between two polls of the token + /// endpoint (the `interval` it is given; `slow_down` adds five). + pub device_poll_interval_seconds: u64, } impl Default for AuthSettings { @@ -151,6 +164,8 @@ impl Default for AuthSettings { login_max_failures: 10, login_failure_window_seconds: 300, remember_accounts: true, + device_login: true, + device_poll_interval_seconds: 5, } } } @@ -179,6 +194,13 @@ impl AuthSettings { {MAX_LOGIN_FAILURE_WINDOW_SECONDS} (a day), got {window}" )); } + let interval = self.device_poll_interval_seconds; + if !(1..=MAX_DEVICE_POLL_INTERVAL_SECONDS).contains(&interval) { + return Err(format!( + "device_poll_interval_seconds must be between 1 and \ + {MAX_DEVICE_POLL_INTERVAL_SECONDS} (a minute), got {interval}" + )); + } Ok(match self.anonymous_mode { AnonymousMode::Off => AnonPolicy::Off, AnonymousMode::All => AnonPolicy::All, @@ -218,6 +240,11 @@ impl AuthSettings { "remember_accounts", self.remember_accounts != other.remember_accounts, ); + diff("device_login", self.device_login != other.device_login); + diff( + "device_poll_interval_seconds", + self.device_poll_interval_seconds != other.device_poll_interval_seconds, + ); keys } } @@ -239,6 +266,8 @@ pub struct AuthSettingsUpdate { pub login_max_failures: Option, pub login_failure_window_seconds: Option, pub remember_accounts: Option, + pub device_login: Option, + pub device_poll_interval_seconds: Option, } impl AuthSettingsUpdate { @@ -269,6 +298,12 @@ impl AuthSettingsUpdate { if let Some(remember) = self.remember_accounts { next.remember_accounts = remember; } + if let Some(on) = self.device_login { + next.device_login = on; + } + if let Some(interval) = self.device_poll_interval_seconds { + next.device_poll_interval_seconds = interval; + } next.validate()?; Ok(next) } @@ -518,6 +553,10 @@ mod tests { assert_eq!(stored.anonymous_mode, AnonymousMode::All); assert_eq!(stored.session_idle_ttl_seconds, 8 * 60 * 60); assert!(stored.remember_accounts); + // A row saved before the device login existed has it on, polled + // every 5 seconds. + assert!(stored.device_login); + assert_eq!(stored.device_poll_interval_seconds, 5); } #[test] @@ -595,6 +634,12 @@ mod tests { refused(serde_json::json!({"login_failure_window_seconds": 0})) .starts_with("login_failure_window_seconds must be between 1 and") ); + for interval in [0, 61] { + assert!( + refused(serde_json::json!({"device_poll_interval_seconds": interval})) + .starts_with("device_poll_interval_seconds must be between 1 and 60") + ); + } // An unknown field is refused, not dropped. for body in [ serde_json::json!({"anonymous": "all"}), diff --git a/crates/server/src/cli_auth.rs b/crates/server/src/cli_auth.rs index a5c95ebd..cb3a2a77 100644 --- a/crates/server/src/cli_auth.rs +++ b/crates/server/src/cli_auth.rs @@ -15,6 +15,11 @@ //! `cli_login.web_login_ttl_seconds` (10 minutes). //! * A **logout** revokes the token, when it is the caller's own. //! +//! The device login (RFC 8628: `/auth/device/*` and the web UI's `/device` +//! page) is the same mechanism for every Forge helper, for the host rather +//! than one repository; it lives in [`crate::auth`] and builds its tokens +//! with [`login_token`]. +//! //! Tokens minted by a login carry the user's rights (no scope), expire //! after `cli_login.token_lifetime_days`, and are listed and revocable on //! the user's tokens page like every token. Logins, mints and revocations @@ -27,7 +32,7 @@ use axum::response::{IntoResponse, Response}; use forge_core::auth::principal::Principal; use forge_core::auth::token::{generate_token_secret, hash_token}; use forge_db::cli_login_dao::{self, CliLoginState}; -use forge_db::model::Repository; +use forge_db::model::{AppUser, Repository}; use forge_db::token_dao::{self, NewToken}; use forge_db::user_dao; use forge_plugin::api::{AuthOutcome, AuthRequest}; @@ -83,6 +88,29 @@ pub async fn perform( } } +/// The row of a login token for `user` named `name`, whose secret is +/// `secret`: it expires after `cli_login.token_lifetime_days` (never for +/// `0`). The npm logins and the device login ([`crate::auth`]'s +/// `/auth/device/token`) mint their tokens alike. +pub(crate) fn login_token( + state: &AppState, + user: &AppUser, + name: String, + secret: &str, +) -> NewToken { + let days = state.config.cli_login.token_lifetime_days; + NewToken { + user_id: user.id, + name, + token_hash: hash_token(secret), + expires_at: (days > 0) + .then(|| chrono::Utc::now() + chrono::Duration::days(i64::from(days))), + // A credential-forwarding account's login came through its + // upstream: an admin-forced reauth revokes the token. + minted_with_forwarded_credential: user.auth_source == "upstream-basic", + } +} + /// Mint a login token for `user_id`, named `name`. async fn mint( state: &AppState, @@ -96,23 +124,10 @@ async fn mint( .map_err(db_error)? .filter(|u| u.active) .ok_or_else(|| unauthorized("the account is not active"))?; - let days = state.config.cli_login.token_lifetime_days; let secret = generate_token_secret(); - let token = token_dao::insert_token( - &state.pool, - NewToken { - user_id, - name, - token_hash: hash_token(&secret), - expires_at: (days > 0) - .then(|| chrono::Utc::now() + chrono::Duration::days(i64::from(days))), - // A credential-forwarding account's login came through its - // upstream: an admin-forced reauth revokes the token. - minted_with_forwarded_credential: user.auth_source == "upstream-basic", - }, - ) - .await - .map_err(db_error)?; + let token = token_dao::insert_token(&state.pool, login_token(state, &user, name, &secret)) + .await + .map_err(db_error)?; tracing::info!( target: "forge::audit", user_id, diff --git a/crates/server/src/middleware/principal.rs b/crates/server/src/middleware/principal.rs index 7aee09e4..26222acf 100644 --- a/crates/server/src/middleware/principal.rs +++ b/crates/server/src/middleware/principal.rs @@ -290,11 +290,21 @@ pub async fn attach_principal( // npm's exchange authenticates its ****** including JSON refusals. // Do not let the ordinary credential chain reject it with a plain-text 401. + // So do the device grant's OAuth endpoints (RFC 8628): a client is known + // by its `client_id` alone, and every answer is RFC 6749 JSON, never + // the credential chain's 401 for a header the client happens to send. if req.method() == axum::http::Method::POST && req .extensions() .get::() - .is_some_and(|path| path.as_str() == crate::auth::NPM_TOKEN_EXCHANGE_PATH) + .is_some_and(|path| { + [ + crate::auth::NPM_TOKEN_EXCHANGE_PATH, + crate::auth::DEVICE_CODE_PATH, + crate::auth::DEVICE_TOKEN_PATH, + ] + .contains(&path.as_str()) + }) { return next.run(req).await; } diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index c838cea8..4dbaf4b4 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -79,6 +79,11 @@ pub struct AppState { /// with the login lockout's budget and window, which follow /// [`Self::auth_settings`]. pub invitation_throttle: Arc, + /// Wrong codes entered on the `/device` page (the device login, RFC + /// 8628), per client network and per browser session: ten per five + /// minutes each, whatever the login lockout says, so a pending code + /// cannot be guessed within its lifetime. + pub device_code_throttle: Arc, /// Rate limit on starting OIDC logins, per client network /// (`auth.oidc_login_starts_per_minute`). pub oidc_login_starts: Arc, @@ -321,6 +326,15 @@ impl AppState { )), ); let invitation_throttle = Arc::new(forge_core::auth::LoginThrottle::default()); + // The keys are a network or a session, not a username: no soft + // backoff across networks, which would hold up everyone's codes. + let device_code_throttle = Arc::new( + forge_core::auth::LoginThrottle::default().with_user_backoff( + 0, + std::time::Duration::from_secs(1), + std::time::Duration::from_secs(1), + ), + ); let auth_settings = Arc::new( AuthSettingsCache::new( pool.clone(), @@ -375,6 +389,7 @@ impl AppState { cookie_secure, login_throttle, invitation_throttle, + device_code_throttle, oidc_login_starts, upstream, registry_keys: crate::images::token::TokenKeys::new(vec![{ diff --git a/crates/server/src/workers.rs b/crates/server/src/workers.rs index 6fb09d08..b33e83c1 100644 --- a/crates/server/src/workers.rs +++ b/crates/server/src/workers.rs @@ -24,7 +24,7 @@ //! //! 4. **Auth reaper** — periodically deletes expired/used `oidc_flow` and //! `email_oauth_flow` rows, revoked/expired `session` rows, expired `cli_login` -//! rows and `login_known_network` rows past the +//! and `device_authorization` rows and `login_known_network` rows past the //! login throttle's 30-day memory. All are pure `DELETE`s (no advisory //! locks, no leases): the auth-code-flow rows are single-use and //! short-lived, sessions carry their own validity predicate, so running the @@ -538,6 +538,18 @@ fn spawn_auth_reaper(pool: PgPool, interval: Duration) -> JoinHandle<()> { tracing::error!(error = %e, "cli login reap failed") } } + // Device logins (RFC 8628) past their lifetime: never + // approved, denied, or collected already. + match forge_db::device_auth_dao::delete_expired(&pool).await { + Ok(0) => {} + Ok(n) => { + tracing::debug!(reaped = n, "auth reaper deleted expired device logins") + } + Err(e) => { + ok = false; + tracing::error!(error = %e, "device login reap failed") + } + } // Exchanged workload keys whose CI token has expired as well, // well past any clock skew a realm accepts (until then the row // records the token's redemption). diff --git a/crates/server/tests/auth_settings.rs b/crates/server/tests/auth_settings.rs index 50ab4683..a0c45958 100644 --- a/crates/server/tests/auth_settings.rs +++ b/crates/server/tests/auth_settings.rs @@ -124,6 +124,8 @@ async fn admins_read_and_change_the_settings_and_wrong_values_are_refused() { "login_max_failures": 10, "login_failure_window_seconds": 300, "remember_accounts": true, + "device_login": true, + "device_poll_interval_seconds": 5, }) ); @@ -187,6 +189,10 @@ async fn admins_read_and_change_the_settings_and_wrong_values_are_refused() { json!({"login_failure_window_seconds": 0}), "login_failure_window_seconds must be between 1 and 86400 (a day), got 0", ), + ( + json!({"device_poll_interval_seconds": 61}), + "device_poll_interval_seconds must be between 1 and 60 (a minute), got 61", + ), ] { let resp = put_settings(&srv, body.clone()).await; assert_eq!(resp.status(), 400, "{body}"); diff --git a/crates/server/tests/device_auth.rs b/crates/server/tests/device_auth.rs new file mode 100644 index 00000000..d0c58fd4 --- /dev/null +++ b/crates/server/tests/device_auth.rs @@ -0,0 +1,710 @@ +//! Integration tests of the device authorization grant (RFC 8628, #649): +//! `POST /auth/device/code`, `POST /auth/device/token`, the discovery +//! document (`/.well-known/oauth-authorization-server`) and the API of the +//! web UI's `/device` page (`/auth/device/lookup`, `/auth/device/decide`). +//! +//! Covers a pending request, `slow_down`, expiry, denial and approval, a +//! token collected once (and what it is), the scope, unknown clients and +//! malformed requests (always RFC 6749 JSON), the rate limit on guessing +//! codes per network and per session, and the settings that turn the +//! device login off and set its interval. Requires Docker (Postgres + +//! versitygw via the shared harness), or `FORGE_TEST_DATABASE_URL` / +//! `FORGE_TEST_S3_ENDPOINT`. + +mod common; + +use common::{fresh_pool, start_with, unique, BrowserSession, ServerOpts, TestServer}; +use reqwest::StatusCode; +use serde_json::{json, Value}; + +const GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code"; + +/// A server that believes `X-Forwarded-For` from the test client, so a test +/// can come from several networks. +async fn start() -> TestServer { + start_on(common::pool().await).await +} + +async fn start_on(pool: sqlx::PgPool) -> TestServer { + start_with( + pool, + ServerOpts { + bootstrap_admin: Some((format!("admin{}@example.com", unique()), "adminpw".into())), + auth_extra: Some("trusted_proxy_cidrs = [\"127.0.0.1/32\", \"::1/128\"]".into()), + ..Default::default() + }, + ) + .await +} + +/// `POST ` with a form body: the status, the `Cache-Control` and the +/// JSON answer (every answer of these endpoints is JSON). +async fn post_form(srv: &TestServer, path: &str, form: &[(&str, &str)]) -> (StatusCode, Value) { + let resp = srv + .client() + .post(format!("{}{path}", srv.base_url)) + .form(form) + .send() + .await + .unwrap(); + answer(resp).await +} + +async fn answer(resp: reqwest::Response) -> (StatusCode, Value) { + let status = resp.status(); + let cache_control = resp + .headers() + .get("cache-control") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + assert!( + cache_control.contains("no-store"), + "{status}: cache-control {cache_control:?}" + ); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + let text = resp.text().await.unwrap(); + assert!( + content_type.starts_with("application/json"), + "{status}: {content_type:?} {text}" + ); + (status, serde_json::from_str(&text).unwrap()) +} + +/// Start a device login of `client_id` (optionally with `scope`), expecting +/// success. +async fn start_login(srv: &TestServer, client_id: &str, scope: Option<&str>) -> Value { + let mut form = vec![("client_id", client_id)]; + if let Some(scope) = scope { + form.push(("scope", scope)); + } + let (status, body) = post_form(srv, "/auth/device/code", &form).await; + assert_eq!(status, 200, "{body}"); + body +} + +/// Poll the token endpoint once. +async fn poll(srv: &TestServer, client_id: &str, device_code: &str) -> (StatusCode, Value) { + post_form( + srv, + "/auth/device/token", + &[ + ("grant_type", GRANT), + ("client_id", client_id), + ("device_code", device_code), + ], + ) + .await +} + +/// Expect an RFC 6749 §5.2 error. +fn assert_error(answer: (StatusCode, Value), error: &str) { + let (status, body) = answer; + assert_eq!(status, 400, "{body}"); + assert_eq!(body["error"], error, "{body}"); + assert!(body["error_description"].is_string(), "{body}"); +} + +/// A new user (a `role` account), signed in. +async fn signed_in(srv: &TestServer, role: &str) -> (i64, String, BrowserSession) { + let username = format!("dev{}@example.com", unique()); + let password = "device-login-password"; + let id = srv.create_user(&username, password, role).await; + let session = srv.login(&username, password).await; + (id, username, session) +} + +/// `POST /auth/device/lookup` from `ip`. +async fn lookup( + srv: &TestServer, + session: &BrowserSession, + code: &str, + ip: &str, +) -> reqwest::Response { + session + .apply( + srv.client() + .post(format!("{}/auth/device/lookup", srv.base_url)), + ) + .header("x-forwarded-for", ip) + .json(&json!({"user_code": code})) + .send() + .await + .unwrap() +} + +/// `POST /auth/device/decide` from `ip`. +async fn decide( + srv: &TestServer, + session: &BrowserSession, + code: &str, + approve: bool, + ip: &str, +) -> reqwest::Response { + session + .apply( + srv.client() + .post(format!("{}/auth/device/decide", srv.base_url)), + ) + .header("x-forwarded-for", ip) + .json(&json!({"user_code": code, "approve": approve})) + .send() + .await + .unwrap() +} + +/// The interval stored for the request with `user_code` (`BCDF-GHJK`). +async fn stored_interval(srv: &TestServer, user_code: &str) -> i32 { + sqlx::query_scalar("SELECT interval_seconds FROM device_authorization WHERE user_code = $1") + .bind(user_code.replace('-', "")) + .fetch_one(&srv.pool) + .await + .unwrap() +} + +#[tokio::test] +async fn the_discovery_document_names_the_endpoints() { + let srv = start().await; + let resp = srv + .client() + .get(format!( + "{}/.well-known/oauth-authorization-server", + srv.base_url + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let doc: Value = resp.json().await.unwrap(); + assert_eq!(doc["issuer"], "http://test.local"); + assert_eq!( + doc["device_authorization_endpoint"], + "http://test.local/auth/device/code" + ); + assert_eq!(doc["token_endpoint"], "http://test.local/auth/device/token"); + assert_eq!(doc["grant_types_supported"], json!([GRANT])); + assert_eq!( + doc["token_endpoint_auth_methods_supported"], + json!(["none"]) + ); +} + +/// Pending, `slow_down`, approval, the token collected once, and what it is. +#[tokio::test] +async fn an_approved_login_collects_its_token_once() { + let srv = start().await; + let started = start_login(&srv, "docker-credential-forge", None).await; + let device_code = started["device_code"].as_str().unwrap().to_string(); + let user_code = started["user_code"].as_str().unwrap().to_string(); + assert_eq!(user_code.len(), 9, "{user_code}"); + assert_eq!(&user_code[4..5], "-"); + assert!(user_code + .chars() + .all(|c| c == '-' || "BCDFGHJKLMNPQRSTVWXZ".contains(c))); + assert_eq!(started["verification_uri"], "http://test.local/device"); + assert_eq!( + started["verification_uri_complete"], + format!("http://test.local/device?code={user_code}") + ); + assert_eq!( + started["expires_in"], 600, + "cli_login.web_login_ttl_seconds" + ); + assert_eq!(started["interval"], 5); + // Only the device code's hash is stored. + let stored: i64 = sqlx::query_scalar( + "SELECT count(*) FROM device_authorization \ + WHERE device_code_hash = $1 AND client_id = 'docker-credential-forge'", + ) + .bind(forge_core::auth::hash_token(&device_code)) + .fetch_one(&srv.pool) + .await + .unwrap(); + assert_eq!(stored, 1); + + // Pending; polled again at once, slow down: five more seconds, each time. + assert_error( + poll(&srv, "docker-credential-forge", &device_code).await, + "authorization_pending", + ); + assert_error( + poll(&srv, "docker-credential-forge", &device_code).await, + "slow_down", + ); + assert_eq!(stored_interval(&srv, &user_code).await, 10); + assert_error( + poll(&srv, "docker-credential-forge", &device_code).await, + "slow_down", + ); + assert_eq!(stored_interval(&srv, &user_code).await, 15); + + // The /device page: a signed-in user looks the code up, in any spelling. + let (_, username, session) = signed_in(&srv, "reader").await; + let typed = user_code.replace('-', "").to_lowercase(); + let resp = lookup(&srv, &session, &typed, "198.51.100.7").await; + assert_eq!(resp.status(), 200); + let shown: Value = resp.json().await.unwrap(); + assert_eq!(shown["user_code"], user_code.as_str()); + assert_eq!(shown["client_id"], "docker-credential-forge"); + assert_eq!(shown["client_name"], "Docker credential helper"); + assert_eq!(shown["scope"], Value::Null, "the user's rights"); + assert_eq!( + shown["client_ip"], "127.0.0.1", + "where the client asked from" + ); + assert!(shown["created_at"].is_string() && shown["expires_at"].is_string()); + // It takes the session's CSRF token, and a session. + let resp = srv + .client() + .post(format!("{}/auth/device/lookup", srv.base_url)) + .header("cookie", &session.cookie) + .json(&json!({"user_code": user_code})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 403); + let resp = srv + .client() + .post(format!("{}/auth/device/lookup", srv.base_url)) + .json(&json!({"user_code": user_code})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401); + + // Approved: the next poll, however soon, gets the token; then never again. + let resp = decide(&srv, &session, &user_code, true, "198.51.100.7").await; + assert_eq!(resp.status(), 204); + let (status, token) = poll(&srv, "docker-credential-forge", &device_code).await; + assert_eq!(status, 200, "{token}"); + assert_eq!(token["token_type"], "Bearer"); + assert_eq!(token["username"], username.as_str()); + assert!(token.get("refresh_token").is_none(), "no refresh token"); + assert!(token.get("scope").is_none(), "{token}"); + let expires_in = token["expires_in"].as_i64().unwrap(); + assert!( + (90 * 86400 - 60..=90 * 86400).contains(&expires_in), + "cli_login.token_lifetime_days: {expires_in}" + ); + let secret = token["access_token"].as_str().unwrap().to_string(); + assert_error( + poll(&srv, "docker-credential-forge", &device_code).await, + "expired_token", + ); + // The request is no longer pending. + let resp = lookup(&srv, &session, &user_code, "198.51.100.7").await; + assert_eq!(resp.status(), 404); + assert_eq!( + decide(&srv, &session, &user_code, true, "198.51.100.7") + .await + .status(), + 404 + ); + + // The token is the user's: it signs in, and their tokens page lists it, + // named after the client and the host, with its lifetime. + let me: Value = srv + .client() + .get(format!("{}/admin/me", srv.base_url)) + .basic_auth(&username, Some(&secret)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(me["user"]["username"], username.as_str()); + let tokens: Value = session + .apply( + srv.client() + .get(format!("{}/admin/me/tokens", srv.base_url)), + ) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let minted: Vec<&Value> = tokens + .as_array() + .unwrap() + .iter() + .filter(|t| t["name"] == "docker-credential-forge login (test.local)") + .collect(); + assert_eq!(minted.len(), 1, "{tokens}"); + assert!(minted[0]["expires_at"].is_string()); + assert_eq!(minted[0]["scope"], Value::Null); + // ...and revocable there. + let id = minted[0]["id"].as_i64().unwrap(); + let resp = session + .apply( + srv.client() + .delete(format!("{}/admin/me/tokens/{id}", srv.base_url)), + ) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 204); + let resp = srv + .client() + .get(format!("{}/admin/me", srv.base_url)) + .basic_auth(&username, Some(&secret)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401); +} + +/// A denied login answers `access_denied`, and mints nothing. +#[tokio::test] +async fn a_denied_login_is_access_denied() { + let srv = start().await; + let started = start_login(&srv, "forge-cli", None).await; + let device_code = started["device_code"].as_str().unwrap(); + let user_code = started["user_code"].as_str().unwrap(); + let (_, _, session) = signed_in(&srv, "reader").await; + let resp = decide(&srv, &session, user_code, false, "198.51.100.8").await; + assert_eq!(resp.status(), 204); + for _ in 0..2 { + assert_error(poll(&srv, "forge-cli", device_code).await, "access_denied"); + } + // Decided: it cannot be approved after all. + assert_eq!( + decide(&srv, &session, user_code, true, "198.51.100.8") + .await + .status(), + 404 + ); + assert_error(poll(&srv, "forge-cli", device_code).await, "access_denied"); +} + +/// A request past its lifetime answers `expired_token`, cannot be approved, +/// and the auth reaper removes it. +#[tokio::test] +async fn an_expired_login_is_expired_and_reaped() { + let srv = start().await; + let started = start_login(&srv, "nuget-plugin-forge", None).await; + let device_code = started["device_code"].as_str().unwrap(); + let user_code = started["user_code"].as_str().unwrap(); + sqlx::query( + "UPDATE device_authorization SET expires_at = now() - interval '1 second' \ + WHERE user_code = $1", + ) + .bind(user_code.replace('-', "")) + .execute(&srv.pool) + .await + .unwrap(); + assert_error( + poll(&srv, "nuget-plugin-forge", device_code).await, + "expired_token", + ); + let (_, _, session) = signed_in(&srv, "reader").await; + assert_eq!( + lookup(&srv, &session, user_code, "198.51.100.9") + .await + .status(), + 404 + ); + assert_eq!( + decide(&srv, &session, user_code, true, "198.51.100.9") + .await + .status(), + 404 + ); + assert!( + forge_db::device_auth_dao::delete_expired(&srv.pool) + .await + .unwrap() + >= 1 + ); + assert_error( + poll(&srv, "nuget-plugin-forge", device_code).await, + "invalid_grant", + ); +} + +/// Only Forge's helpers are clients; every refusal is RFC 6749 JSON, also +/// with a header the credential chain would refuse. +#[tokio::test] +async fn unknown_clients_and_malformed_requests_get_oauth_errors() { + let srv = start().await; + assert_error( + post_form(&srv, "/auth/device/code", &[("client_id", "curl")]).await, + "invalid_client", + ); + assert_error( + post_form(&srv, "/auth/device/code", &[]).await, + "invalid_request", + ); + for scope in ["admin", "repository:a/b", "write openid"] { + assert_error( + post_form( + &srv, + "/auth/device/code", + &[("client_id", "forge-cli"), ("scope", scope)], + ) + .await, + "invalid_scope", + ); + } + // A JSON body is no form. + let resp = srv + .client() + .post(format!("{}/auth/device/code", srv.base_url)) + .json(&json!({"client_id": "forge-cli"})) + .send() + .await + .unwrap(); + assert_error(answer(resp).await, "invalid_request"); + // A repeated parameter. + let resp = srv + .client() + .post(format!("{}/auth/device/code", srv.base_url)) + .header("content-type", "application/x-www-form-urlencoded") + .body("client_id=forge-cli&client_id=forge-cli") + .send() + .await + .unwrap(); + assert_error(answer(resp).await, "invalid_request"); + + let started = start_login(&srv, "forge-cli", None).await; + let device_code = started["device_code"].as_str().unwrap(); + assert_error(poll(&srv, "curl", device_code).await, "invalid_client"); + assert_error( + poll(&srv, "docker-credential-forge", device_code).await, + "invalid_grant", + ); + assert_error( + poll(&srv, "forge-cli", "not-a-device-code").await, + "invalid_grant", + ); + assert_error( + post_form( + &srv, + "/auth/device/token", + &[("client_id", "forge-cli"), ("device_code", device_code)], + ) + .await, + "invalid_request", + ); + // No refresh token is issued, and no other grant is served. + assert_error( + post_form( + &srv, + "/auth/device/token", + &[ + ("grant_type", "refresh_token"), + ("client_id", "forge-cli"), + ("refresh_token", "x"), + ], + ) + .await, + "unsupported_grant_type", + ); + assert_error( + post_form( + &srv, + "/auth/device/token", + &[("grant_type", GRANT), ("client_id", "forge-cli")], + ) + .await, + "invalid_request", + ); + // Credentials the chain would refuse do not turn the answer into its + // plain-text 401. + let resp = srv + .client() + .post(format!("{}/auth/device/token", srv.base_url)) + .basic_auth("forge-cli", Some("wrong")) + .form(&[ + ("grant_type", GRANT), + ("client_id", "forge-cli"), + ("device_code", device_code), + ]) + .send() + .await + .unwrap(); + assert_error(answer(resp).await, "authorization_pending"); +} + +/// A scope narrows the token: its level and repositories by name (a name +/// no repository has reaches nothing). +#[tokio::test] +async fn a_scope_narrows_the_token() { + let srv = start().await; + let repo = format!("dev-raw-{}", unique()); + let (u, p) = srv.admin_basic(); + let resp = srv + .client() + .post(format!("{}/admin/repositories", srv.base_url)) + .basic_auth(&u, Some(&p)) + .json(&json!({"name": repo, "kind": "hosted", "format": "raw"})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + let repo_id = resp.json::().await.unwrap()["id"].as_i64().unwrap(); + + let scope = format!("repository:{repo} read repository:no-such-repo"); + let started = start_login(&srv, "forge-cli", Some(&scope)).await; + let device_code = started["device_code"].as_str().unwrap(); + let user_code = started["user_code"].as_str().unwrap(); + let (_, _, session) = signed_in(&srv, "writer").await; + let shown: Value = lookup(&srv, &session, user_code, "198.51.100.10") + .await + .json() + .await + .unwrap(); + let mut repositories = vec![repo.clone(), "no-such-repo".to_string()]; + repositories.sort(); + assert_eq!( + shown["scope"], + json!({"level": "read", "repositories": repositories}) + ); + assert_eq!( + decide(&srv, &session, user_code, true, "198.51.100.10") + .await + .status(), + 204 + ); + let (status, token) = poll(&srv, "forge-cli", device_code).await; + assert_eq!(status, 200, "{token}"); + assert_eq!( + token["scope"], + format!("read repository:{}", repositories.join(" repository:")) + ); + let tokens: Value = session + .apply( + srv.client() + .get(format!("{}/admin/me/tokens", srv.base_url)), + ) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let minted = tokens + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == "forge-cli login (test.local)") + .expect("the minted token"); + assert_eq!( + minted["scope"], + json!({"level": "read", "repositories": [repo_id]}) + ); +} + +/// Guessing codes is cut off: ten wrong codes per network and per session, +/// after which even the right code is refused, from that network with any +/// session and in that session from any network. +#[tokio::test] +async fn guessing_a_code_is_cut_off_by_the_rate_limit() { + let srv = start().await; + let started = start_login(&srv, "forge-cli", None).await; + let user_code = started["user_code"].as_str().unwrap().to_string(); + let (_, _, guesser) = signed_in(&srv, "reader").await; + let (_, _, other) = signed_in(&srv, "reader").await; + let wrong = if user_code == "BBBB-BBBB" { + "CCCC-CCCC" + } else { + "BBBB-BBBB" + }; + + // A malformed code is refused unseen, and never counted. + for _ in 0..12 { + let resp = lookup(&srv, &guesser, "1234", "203.0.113.1").await; + assert_eq!(resp.status(), 400); + } + for _ in 0..10 { + let resp = lookup(&srv, &guesser, wrong, "203.0.113.1").await; + assert_eq!(resp.status(), 404); + } + let refused = lookup(&srv, &guesser, &user_code, "203.0.113.1").await; + assert_eq!(refused.status(), 429); + assert!(refused.headers().contains_key("retry-after")); + assert_eq!( + decide(&srv, &guesser, &user_code, true, "203.0.113.1") + .await + .status(), + 429, + "deciding is code entry too" + ); + // The session from another network: still cut off. + assert_eq!( + lookup(&srv, &guesser, &user_code, "203.0.113.2") + .await + .status(), + 429 + ); + // The network with another session: cut off too. + assert_eq!( + lookup(&srv, &other, &user_code, "203.0.113.1") + .await + .status(), + 429 + ); + // Another session from another network finds the code. + assert_eq!( + lookup(&srv, &other, &user_code, "203.0.113.2") + .await + .status(), + 200 + ); + // Finding a code clears no failure: the guesser stays cut off. + assert_eq!( + lookup(&srv, &guesser, &user_code, "203.0.113.1") + .await + .status(), + 429 + ); +} + +/// The device login is an auth setting: it can be turned off, and the +/// polling interval set (Admin → Settings). +#[tokio::test] +async fn the_settings_turn_the_device_login_off_and_set_its_interval() { + let srv = start_on(fresh_pool().await).await; + let started = start_login(&srv, "forge-cli", None).await; + let device_code = started["device_code"].as_str().unwrap().to_string(); + + let saved = srv + .set_auth_settings(json!({"device_poll_interval_seconds": 7})) + .await; + assert_eq!(saved.device_poll_interval_seconds, 7); + assert!(saved.device_login); + assert_eq!(start_login(&srv, "forge-cli", None).await["interval"], 7); + + srv.set_auth_settings(json!({"device_login": false})).await; + assert_error( + post_form(&srv, "/auth/device/code", &[("client_id", "forge-cli")]).await, + "unauthorized_client", + ); + assert_error( + poll(&srv, "forge-cli", &device_code).await, + "unauthorized_client", + ); + // The discovery document stays: a client learns why from the endpoint. + let resp = srv + .client() + .get(format!( + "{}/.well-known/oauth-authorization-server", + srv.base_url + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + srv.set_auth_settings(json!({"device_login": true})).await; + assert_error( + poll(&srv, "forge-cli", &device_code).await, + "authorization_pending", + ); +} diff --git a/crates/server/tests/web_ui.rs b/crates/server/tests/web_ui.rs index 7cc87c8a..7ba773ad 100644 --- a/crates/server/tests/web_ui.rs +++ b/crates/server/tests/web_ui.rs @@ -79,6 +79,26 @@ async fn spa_serving_and_api_precedence() { body.contains("forge-spa"), "deep link should fall back to index.html: {body}" ); + // The device login's page (`verification_uri_complete`) is a UI route; + // its discovery document is the API's. + let res = client + .get(format!("{}/device?code=BCDF-GHJK", server.base_url)) + .send() + .await + .expect("GET /device"); + assert_eq!(res.status(), reqwest::StatusCode::OK); + assert!(res.text().await.expect("body").contains("forge-spa")); + let res = client + .get(format!( + "{}/.well-known/oauth-authorization-server", + server.base_url + )) + .send() + .await + .expect("GET the authorization server metadata"); + assert_eq!(res.status(), reqwest::StatusCode::OK); + let metadata: serde_json::Value = res.json().await.expect("metadata JSON"); + assert!(metadata["device_authorization_endpoint"].is_string()); // 3. A real asset file is served as itself (not the index fallback). let res = client diff --git a/docs/changes/2.2.0/649.feature.md b/docs/changes/2.2.0/649.feature.md new file mode 100644 index 00000000..b814898b --- /dev/null +++ b/docs/changes/2.2.0/649.feature.md @@ -0,0 +1,15 @@ +Command-line clients sign in through the browser with the OAuth 2.0 device +authorization grant (RFC 8628), the login Forge's coming helpers (`forge +login`, `docker-credential-forge`, `nuget-plugin-forge`) use, OIDC and Entra +sign-ins included: `POST /auth/device/code` gives the client a code, which +its user enters on the web UI's new `/device` page (or arrives with it filled +in), checks the client, scope, address and time shown there, and approves; +the client polls `POST /auth/device/token` and receives an API token of that +user, once. `GET /.well-known/oauth-authorization-server` names both +endpoints. Only the helpers' client ids are accepted, a `scope` (`read`, +`write`, `delete`, `repository:`) narrows the token, and no refresh +token is issued: the token lasts `cli_login.token_lifetime_days`, like npm's +browser login's, and is listed and revocable on the user's tokens page. +Wrong codes are limited per network and per session. **Admin → Settings → +Device login** turns it off and sets the polling interval. See +[docs/deployment.md](docs/deployment.md#device-login). diff --git a/docs/deployment.md b/docs/deployment.md index 3ff1cb59..b6d05129 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -588,6 +588,8 @@ wrong. | `session_absolute_ttl_seconds` | `604800` (7 d) | A browser session ends this long after sign-in. New lifetimes apply to sessions signed in afterwards; an open session's idle window moves to the new idle timeout on its next use. | | `login_max_failures` | `10` | Failed passwords per client network and username before the lockout; `0` turns it off. Each instance counts its own. | | `login_failure_window_seconds` | `300` | The failure window, and how long a lockout lasts (at most a day). | +| `device_login` | `true` | Command-line clients may sign in with a code entered on the `/device` page ([device login](#device-login)). Off, they are refused with `unauthorized_client`. | +| `device_poll_interval_seconds` | `5` | How long a device login's client waits between two polls (1 to 60); one that polls sooner is told to wait five seconds longer. | A configuration that still sets one of these (`auth.anonymous_mode`, `FORGE_AUTH__ALLOW_PASSWORD_BASIC`, …, or the `oidc.session_*` lifetimes) @@ -649,6 +651,96 @@ Administer through a browser session (the web UI, or `POST /auth/login` plus the returned CSRF token), or mint an admin-scoped token (`{"scope": {"admin": true}}`) for automation. +### Device login + +A command-line client signs in through the browser with the OAuth 2.0 +device authorization grant ([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)): +it shows its user a code and the address of the web UI's `/device` page, +the user signs in there as always (a password, OIDC or Entra, identifier +first) and approves, and the client receives an API token. It is what +Forge's command-line helpers sign in with: `forge login`, +`docker-credential-forge` (the Docker CLI has no browser login for a +registry other than Docker Hub) and `nuget-plugin-forge`, which are still +to come; any RFC 8628 client using one of their client ids works today. +It is the same mechanism as npm's browser login +(`npm login`, [npm registries](npm.md#logging-in)), for the Forge host +rather than one repository. + +**The endpoints.** A client finds them from the server URL alone, in the +authorization server metadata ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)): + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/.well-known/oauth-authorization-server` | `issuer` (`server.public_base_url`), `device_authorization_endpoint`, `token_endpoint`, `grant_types_supported` (`urn:ietf:params:oauth:grant-type:device_code`). | +| `POST` | `/auth/device/code` | Start a login (RFC 8628 §3.1): `client_id` and an optional `scope`, form-encoded. Answers `device_code`, `user_code`, `verification_uri` (`/device`), `verification_uri_complete` (`/device?code=BCDF-GHJK`), `expires_in` and `interval`. | +| `POST` | `/auth/device/token` | Poll it (§3.4): `grant_type=urn:ietf:params:oauth:grant-type:device_code`, `client_id` and `device_code`. Answers `authorization_pending` until the user decides, `slow_down` when polled sooner than the interval (which then grows by five seconds), `access_denied`, `expired_token`, or the token: `access_token`, `token_type` `Bearer`, `expires_in`, `scope` when one was asked for, and `username`, whose token it is. | + +```bash +curl -s https://forge.example.com/auth/device/code \ + -d client_id=forge-cli -d scope=read +curl -s https://forge.example.com/auth/device/token \ + -d grant_type=urn:ietf:params:oauth:grant-type:device_code \ + -d client_id=forge-cli -d device_code= +``` + +Every refusal of these two endpoints is an RFC 6749 §5.2 JSON object +(`{"error": "…", "error_description": "…"}`, `400`), never an HTML page, +whatever credentials the request carries; `503` with `Retry-After` when +Forge's database does not answer, `429` when a network starts too many +logins (`auth.oidc_login_starts_per_minute`, as for OIDC sign-ins). + +- **Clients.** Forge has no client registration: it accepts its helpers' + fixed client ids, `forge-cli`, `docker-credential-forge` and + `nuget-plugin-forge`, so the `/device` page names the client truthfully. + Any other is `invalid_client`. +- **Scope.** `scope` narrows the token, like a token scope set on the + **Tokens** page: `read`, `write` or `delete` is its level (the highest one + named), and `repository:`, given once per repository, the + repositories it reaches (a name no repository has reaches nothing). + `scope=write repository:npm-all` is a token that reads and publishes in + `npm-all` alone. Without a scope the token carries the user's rights, as + npm's does. Anything else is `invalid_scope`; the admin API is never in + reach. +- **No refresh token.** The token is a long-lived API token of the approving + user, as npm's browser login mints: it lasts + `cli_login.token_lifetime_days` (90 days; `0`: never), is named after the + client and the host (`docker-credential-forge login (forge.example.com)`), + and is listed and revocable on the user's **Tokens** page. A token of a + credential-forwarding account is revoked by an administrator's forced + re-authentication, like every token minted in such an account's session. +- **Codes.** The device code is a 256-bit secret of which Forge stores only + the SHA-256. The user code is eight letters without vowels or look-alikes + (`BCDF-GHJK`), typed in any case, with or without the hyphen. A request + waits for its approval for `cli_login.web_login_ttl_seconds` (10 + minutes); its token is answered to one poll, and a second poll gets + `expired_token`. The auth reaper deletes the requests past their lifetime. +- **The `/device` page.** A signed-in user types the code, or arrives with it + filled in from `verification_uri_complete`; a visitor is sent through the + sign-in and back, with the code. The page shows which client asks, for + what, from which address and since when, and the code to compare with the + terminal, and signs the client in only on **Approve**: a filled-in code is + never approved without that click, against remote phishing (RFC 8628 + §5.4). **Deny** refuses it (`access_denied`). +- **Guessing.** Each instance counts the wrong codes entered on the page + per client network (IPv6 by /64) and per browser session: after ten within + five minutes, every code from that network or session is refused with + `429` until the five minutes are over, so a pending code cannot be guessed + within its lifetime. Finding a code clears no failure. +- **Settings.** **Admin → Settings → Device login** turns the device login + off (`device_login`, see [above](#access-and-sign-in-settings)) and sets + the polling interval (`device_poll_interval_seconds`, 5 s). The lifetimes + are npm's browser login's, in the configuration: + + ```toml + [cli_login] + token_lifetime_days = 90 + web_login_ttl_seconds = 600 + ``` + +Requests, approvals, denials and minted tokens are audit-logged +(`forge::audit`: `device login requested`, `device login decided`, `device +login: token minted`). + ## Email Forge sends email for invitations and notifications through one email diff --git a/docs/docker.md b/docs/docker.md index a0d38cd1..95255597 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -64,6 +64,13 @@ docker push forge.example.com/docker-hosted/team/app:1.3 - **API tokens** (created on the Tokens page, or `POST /admin/me/tokens`) are the password to use. A token's repository scope applies. +- **Browser sign-in** (OIDC or Entra included): the Docker CLI has none for + a registry other than Docker Hub. A client gets an API token through + Forge's [device login](deployment.md#device-login) instead (the coming + `docker-credential-forge` helper does): it shows a code, you approve it + on the web UI's `/device` page, and it receives a token of yours (with + the `scope` it asked for, e.g. `read repository:docker-hosted`), which + is the password of `docker login`. - **Passwords** work for the users of a realm that accepts passwords from clients ([Admin → Authentication](deployment.md#passwords-from-clients)): a credential-forwarding realm by default (so users of an existing Nexus or diff --git a/docs/npm.md b/docs/npm.md index 16736e72..41b15eb3 100644 --- a/docs/npm.md +++ b/docs/npm.md @@ -125,6 +125,11 @@ token_lifetime_days = 90 web_login_ttl_seconds = 600 ``` +The same `[cli_login]` lifetimes apply to the [device +login](deployment.md#device-login) that Forge's command-line helpers +(`forge login`, `docker-credential-forge`, `nuget-plugin-forge`) sign in +with: the user approves it with a code on the web UI's `/device` page. + ## Publishing ```bash diff --git a/migrations/0008_device_authorization.sql b/migrations/0008_device_authorization.sql new file mode 100644 index 00000000..934cfd62 --- /dev/null +++ b/migrations/0008_device_authorization.sql @@ -0,0 +1,40 @@ +-- --------------------------------------------------------------------------- +-- device_authorization: the device authorization grant (RFC 8628, #649) +-- +-- A command-line client (`forge login`, `docker-credential-forge`, +-- `nuget-plugin-forge`) asks for a token at `POST /auth/device/code` and is +-- given two codes: the `device_code` it polls `POST /auth/device/token` +-- with, of which only the SHA-256 is stored, and the short `user_code` its +-- user types on the web UI's `/device` page. A signed-in user approves or +-- denies the request there (`user_id`, `status`); the client's next poll +-- after an approval mints an API token of that user and marks the row +-- `collected`, so a second poll gets `expired_token`. Unlike `cli_login` +-- (`npm login`) a request belongs to the Forge host, not to a repository. +-- +-- The auth reaper deletes the rows past `expires_at`. +-- --------------------------------------------------------------------------- +CREATE TABLE device_authorization ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + device_code_hash text NOT NULL UNIQUE, + -- Eight letters of the code alphabet, without the hyphen it is shown + -- with (`BCDFGHJK` for `BCDF-GHJK`). + user_code text NOT NULL UNIQUE, + -- One of the Forge helpers' fixed client ids. + client_id text NOT NULL, + -- The requested scope, normalized (`write repository:npm-all`); NULL: + -- the token carries the approving user's rights. + scope text, + client_ip text, + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'approved', 'denied', 'collected')), + user_id bigint REFERENCES app_user(id) ON DELETE CASCADE, + -- The polling interval the client must keep (seconds); `slow_down` + -- raises it by five. + interval_seconds integer NOT NULL CHECK (interval_seconds > 0), + last_polled_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + decided_at timestamptz, + expires_at timestamptz NOT NULL +); + +CREATE INDEX device_authorization_expires_idx ON device_authorization (expires_at); diff --git a/web/src/api/device.ts b/web/src/api/device.ts new file mode 100644 index 00000000..159ee7fb --- /dev/null +++ b/web/src/api/device.ts @@ -0,0 +1,91 @@ +import { queryOptions } from '@tanstack/react-query' + +import { ApiError, httpClient } from '~/lib/httpClient' + +/** + * The device login (RFC 8628): a command-line client (`forge login`, + * `docker-credential-forge`, `nuget-plugin-forge`) shows its user a code and + * the address of the `/device` page, where the signed-in user enters the code, + * checks what asks for what, and approves or denies it. The client polls the + * server and receives a token once approved. + * + * The code goes in request bodies, never in an API path: + * - `POST /auth/device/lookup` answers the pending request a code names + * (404 when none is pending: mistyped, expired or answered already); + * - `POST /auth/device/decide` approves or denies it (204, or 404). + * Both answer 400 for something that cannot be a code and 429 after too many + * wrong codes from the network or the session. + */ + +/** What the token of an approved request may do. */ +export interface DeviceScope { + /** The highest level it keeps; `null`: the user's levels. */ + level: 'read' | 'write' | 'delete' | null + /** The repositories it reaches, by name; empty: every one the user may. */ + repositories: string[] +} + +/** A pending device login, as the `/device` page shows it. */ +export interface DeviceRequest { + /** The code as the client's terminal shows it (`BCDF-GHJK`). */ + user_code: string + client_id: string + /** The client's name (`Docker credential helper`). */ + client_name: string | null + /** `null`: the token carries the user's rights. */ + scope: DeviceScope | null + /** Where the client asked from. */ + client_ip: string | null + created_at: string + expires_at: string +} + +/** The code alphabet: consonants only, no look-alikes. */ +const CODE_LETTERS = /^[BCDFGHJKLMNPQRSTVWXZ]{8}$/ + +/** + * `raw` as a code (`BCDF-GHJK`): in any case, with or without the hyphen and + * spaces. `null` when it cannot be one. + */ +export function normalizeUserCode(raw: string): string | null { + const letters = raw.replace(/[\s-]/g, '').toUpperCase() + if (!CODE_LETTERS.test(letters)) return null + return `${letters.slice(0, 4)}-${letters.slice(4)}` +} + +export const deviceKeys = { + /** A lookup of `code`; each `attempt` is one of its own. */ + request: (code: string, attempt: number) => ['device', code, attempt] as const, +} + +/** `POST /auth/device/lookup`. */ +export function lookupDeviceCode(code: string, signal?: AbortSignal): Promise { + return httpClient.post('/auth/device/lookup', { user_code: code }, { signal }) +} + +/** + * The lookup of `code`. Entering the code again is another `attempt`, so a + * code that was not found is looked up again rather than answered from the + * failed lookup. + */ +export function deviceRequestQueryOptions(code: string, attempt = 0) { + return queryOptions({ + queryKey: deviceKeys.request(code, attempt), + queryFn: ({ signal }) => lookupDeviceCode(code, signal), + // Each lookup counts against the wrong-code limit, and a decided request + // is gone: never refetch behind the user's back. + staleTime: Infinity, + gcTime: 0, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }) +} + +/** + * `POST /auth/device/decide` — sign the client in as the current user + * (`approve`), or deny it. The client collects its token on its next poll. + */ +export function decideDeviceCode(code: string, approve: boolean): Promise { + return httpClient.post('/auth/device/decide', { user_code: code, approve }) +} diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts index 14bc1ed4..5379d0b4 100644 --- a/web/src/api/settings.ts +++ b/web/src/api/settings.ts @@ -28,6 +28,13 @@ export interface AuthSettings { /** `0` turns the lockout off. */ login_max_failures: number login_failure_window_seconds: number + /** + * Command-line clients may sign in with a code entered on the `/device` + * page (the device authorization grant, RFC 8628). + */ + device_login: boolean + /** Seconds a client waits between two polls of a device login (1–60). */ + device_poll_interval_seconds: number } /** `PUT /admin/settings/auth` body: the fields to change. */ diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index c9cfcb1f..c5351848 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as AppLayoutRouteImport } from './routes/AppLayout' import { Route as InviteRouteImport } from './routes/Invite' import { Route as LoginRouteImport } from './routes/Login' import { Route as BrowseRouteImport } from './routes/Browse' +import { Route as DeviceRouteImport } from './routes/Device' import { Route as AdminLayoutRouteImport } from './routes/AdminLayout' import { Route as TokensRouteImport } from './routes/Tokens' import { Route as CliLoginRouteImport } from './routes/CliLogin' @@ -66,6 +67,11 @@ const BrowseRoute = BrowseRouteImport.update({ path: '/', getParentRoute: () => AppLayoutRoute, } as any) +const DeviceRoute = DeviceRouteImport.update({ + id: '/device', + path: '/device', + getParentRoute: () => AppLayoutRoute, +} as any) const AdminLayoutRoute = AdminLayoutRouteImport.update({ id: '/manage', path: '/manage', @@ -242,6 +248,7 @@ export interface FileRoutesByFullPath { '/': typeof BrowseRoute '/invite': typeof InviteRoute '/login': typeof LoginRoute + '/device': typeof DeviceRoute '/manage': typeof AdminLayoutRouteWithChildren '/tokens': typeof TokensRoute '/manage/': typeof adminAdminHomeRoute @@ -280,6 +287,7 @@ export interface FileRoutesByTo { '/invite': typeof InviteRoute '/login': typeof LoginRoute '/': typeof BrowseRoute + '/device': typeof DeviceRoute '/tokens': typeof TokensRoute '/manage': typeof adminAdminHomeRoute '/cli-login/$secret': typeof CliLoginRoute @@ -319,6 +327,7 @@ export interface FileRoutesById { '/invite': typeof InviteRoute '/login': typeof LoginRoute '/_app/': typeof BrowseRoute + '/_app/device': typeof DeviceRoute '/_app/manage': typeof AdminLayoutRouteWithChildren '/_app/tokens': typeof TokensRoute '/_app/manage/': typeof adminAdminHomeRoute @@ -359,6 +368,7 @@ export interface FileRouteTypes { | '/' | '/invite' | '/login' + | '/device' | '/manage' | '/tokens' | '/manage/' @@ -397,6 +407,7 @@ export interface FileRouteTypes { | '/invite' | '/login' | '/' + | '/device' | '/tokens' | '/manage' | '/cli-login/$secret' @@ -435,6 +446,7 @@ export interface FileRouteTypes { | '/invite' | '/login' | '/_app/' + | '/_app/device' | '/_app/manage' | '/_app/tokens' | '/_app/manage/' @@ -506,6 +518,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BrowseRouteImport parentRoute: typeof AppLayoutRoute } + '/_app/device': { + id: '/_app/device' + path: '/device' + fullPath: '/device' + preLoaderRoute: typeof DeviceRouteImport + parentRoute: typeof AppLayoutRoute + } '/_app/manage': { id: '/_app/manage' path: '/manage' @@ -806,6 +825,7 @@ const AdminLayoutRouteWithChildren = AdminLayoutRoute._addFileChildren( interface AppLayoutRouteChildren { BrowseRoute: typeof BrowseRoute + DeviceRoute: typeof DeviceRoute AdminLayoutRoute: typeof AdminLayoutRouteWithChildren TokensRoute: typeof TokensRoute CliLoginRoute: typeof CliLoginRoute @@ -816,6 +836,7 @@ interface AppLayoutRouteChildren { const AppLayoutRouteChildren: AppLayoutRouteChildren = { BrowseRoute: BrowseRoute, + DeviceRoute: DeviceRoute, AdminLayoutRoute: AdminLayoutRouteWithChildren, TokensRoute: TokensRoute, CliLoginRoute: CliLoginRoute, diff --git a/web/src/routes.ts b/web/src/routes.ts index 116d860b..c81d7714 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -23,6 +23,10 @@ import { index, layout, rootRoute, route } from '@tanstack/virtual-file-routes' * /tokens -> Tokens.tsx (self-service registry tokens) * /cli-login/$secret -> CliLogin.tsx (approve a command-line client's * web login: `npm login`) + * /device -> Device.tsx (enter a command-line client's + * code and approve its device + * login, RFC 8628; `?code=` from + * the client's link) * /manage -> AdminLayout.tsx (beforeLoad: require is_admin; * /manage/traffic: every instance's * requests by day, repository and @@ -46,6 +50,7 @@ export const routes = rootRoute('root.tsx', [ route('/images/$repo/$', 'ImageDetail.tsx'), route('/tokens', 'Tokens.tsx'), route('/cli-login/$secret', 'CliLogin.tsx'), + route('/device', 'Device.tsx'), route('/manage', 'AdminLayout.tsx', [ index('admin/AdminHome.tsx'), route('/repositories', 'admin/repositories/List.tsx'), diff --git a/web/src/routes/Device.test.tsx b/web/src/routes/Device.test.tsx new file mode 100644 index 00000000..2c8c2223 --- /dev/null +++ b/web/src/routes/Device.test.tsx @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClientProvider } from '@tanstack/react-query' +import { RouterProvider, createMemoryHistory, createRouter } from '@tanstack/react-router' + +import { makeQueryClient } from '~/lib/queryClient' +import { clearSession } from '~/lib/auth' +import { searchOptions } from '~/lib/search' +import type { LoginOptions } from '~/api/auth' +import type { Me } from '~/api/me' +import type { DeviceRequest } from '~/api/device' +import { normalizeUserCode } from '~/api/device' +import { routeTree } from '~/routeTree.gen' +import { describeScope } from './Device' + +/** + * The device login's page (`/device`, RFC 8628): a signed-in user enters the + * code a command-line client shows (or arrives with it filled in from the + * client's link), sees which client asks for what, and approves or denies it + * (`POST /auth/device/lookup`, `POST /auth/device/decide`). A filled-in code + * is looked up but never approved without the click; a visitor is sent + * through the sign-in and back with the code; a code no request waits for + * (mistyped, expired, answered) and too many wrong codes are said so. + */ + +const me: Me = { + user: { id: 4, username: 'dev@example.com', is_admin: false, active: true }, + is_admin: false, + capabilities: ['repo.read'], +} + +const pending: DeviceRequest = { + user_code: 'BCDF-GHJK', + client_id: 'docker-credential-forge', + client_name: 'Docker credential helper', + scope: { level: 'write', repositories: ['docker-hosted'] }, + client_ip: '192.0.2.7', + created_at: '2026-10-31T10:00:00Z', + expires_at: '2026-10-31T10:10:00Z', +} + +const combined: LoginOptions = { + mode: 'combined', + password: true, + oidc: [], + remember_accounts: false, +} + +const fetchMock = vi.fn() + +function json(body: unknown, status = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }) +} + +interface Handlers { + /** Whether a session is signed in (a visitor otherwise, until it signs in). */ + signedIn?: boolean + lookup?: (body: { user_code: string }) => Response + decide?: (body: { user_code: string; approve: boolean }) => Response +} + +/** A fake server: the identity, the login and the device endpoints. */ +function mockApi(h: Handlers) { + const state = { signedIn: h.signedIn ?? true } + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(typeof input === 'string' ? input : input.toString(), 'http://localhost') + const method = (init?.method ?? 'GET').toUpperCase() + const body = () => JSON.parse(String(init?.body)) + const path = url.pathname + if (path === '/auth/login-options') return Promise.resolve(json(combined)) + if (path === '/auth/login' && method === 'POST') { + state.signedIn = true + return Promise.resolve( + json({ user_id: 4, username: me.user.username, is_admin: false, csrf_token: 'csrf' }), + ) + } + if (!state.signedIn) { + return Promise.resolve(new Response('authentication required', { status: 401 })) + } + if (path === '/admin/me') return Promise.resolve(json(me)) + if (path === '/auth/me') { + return Promise.resolve(json({ username: me.user.username, csrf_token: 'csrf' })) + } + if (path === '/auth/device/lookup' && method === 'POST') { + return Promise.resolve(h.lookup ? h.lookup(body()) : json(pending)) + } + if (path === '/auth/device/decide' && method === 'POST') { + return Promise.resolve(h.decide ? h.decide(body()) : new Response(null, { status: 204 })) + } + if (path === '/browse/repos') return Promise.resolve(json([])) + return Promise.resolve(new Response('not found', { status: 404 })) + }) +} + +async function renderPage(initialPath: string) { + const queryClient = makeQueryClient() + const router = createRouter({ + routeTree, + ...searchOptions, + context: { queryClient }, + history: createMemoryHistory({ initialEntries: [initialPath] }), + }) + render( + + + , + ) + await waitFor(() => expect(router.state.status).toBe('idle')) + return router +} + +/** The bodies sent to `path`. */ +function sent(path: string): unknown[] { + return fetchMock.mock.calls + .filter(([input, init]) => String(input).endsWith(path) && init?.method === 'POST') + .map(([, init]) => JSON.parse(String(init?.body))) +} + +describe('Device', () => { + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockReset() + clearSession() + localStorage.clear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + clearSession() + localStorage.clear() + }) + + it('looks up a typed code and signs the client in on approval', async () => { + const user = userEvent.setup() + mockApi({}) + await renderPage('/device') + + await user.type(await screen.findByRole('textbox', { name: 'Code' }), 'bcdf ghjk') + await user.click(screen.getByRole('button', { name: 'Continue' })) + + expect(await screen.findByText('Docker credential helper asks to sign in')).toBeInTheDocument() + expect(sent('/auth/device/lookup')).toEqual([{ user_code: 'BCDF-GHJK' }]) + expect(screen.getAllByText('BCDF-GHJK').length).toBeGreaterThan(0) + expect(screen.getByText('docker-credential-forge')).toBeInTheDocument() + expect(screen.getByText('192.0.2.7')).toBeInTheDocument() + expect( + screen.getByText('Read and write, on the repository docker-hosted'), + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Approve' })) + expect(await screen.findByText('Signed in')).toBeInTheDocument() + expect(sent('/auth/device/decide')).toEqual([{ user_code: 'BCDF-GHJK', approve: true }]) + }) + + it('looks up a filled-in code but never approves it without the click, and denies', async () => { + const user = userEvent.setup() + mockApi({}) + await renderPage('/device?code=bcdf-ghjk') + + expect(await screen.findByText('Docker credential helper asks to sign in')).toBeInTheDocument() + expect(sent('/auth/device/lookup')).toEqual([{ user_code: 'BCDF-GHJK' }]) + // Shown, not decided. + await new Promise((r) => setTimeout(r, 50)) + expect(sent('/auth/device/decide')).toEqual([]) + + await user.click(screen.getByRole('button', { name: 'Deny' })) + expect(await screen.findByText('Denied')).toBeInTheDocument() + expect(sent('/auth/device/decide')).toEqual([{ user_code: 'BCDF-GHJK', approve: false }]) + }) + + it('takes a visitor through the sign-in and back to the filled-in code', async () => { + const user = userEvent.setup() + mockApi({ signedIn: false }) + const router = await renderPage('/device?code=bcdf-ghjk') + + await waitFor(() => expect(router.state.location.pathname).toBe('/login')) + expect(router.state.location.search).toMatchObject({ redirect: '/device?code=bcdf-ghjk' }) + expect(sent('/auth/device/lookup')).toEqual([]) + + await user.type(await screen.findByLabelText('Username'), me.user.username) + await user.type(screen.getByLabelText('Password'), 'secret-password') + await user.click(screen.getByRole('button', { name: 'Sign in' })) + + await waitFor(() => expect(router.state.location.pathname).toBe('/device')) + expect(router.state.location.search).toMatchObject({ code: 'bcdf-ghjk' }) + expect(await screen.findByText('Docker credential helper asks to sign in')).toBeInTheDocument() + expect(sent('/auth/device/lookup')).toEqual([{ user_code: 'BCDF-GHJK' }]) + expect(sent('/auth/device/decide')).toEqual([]) + }) + + it('says when no sign-in waits for the code (an expired one), offering no approval', async () => { + mockApi({ + lookup: () => new Response('no login is waiting for this code', { status: 404 }), + }) + await renderPage('/device?code=BCDF-GHJK') + + expect(await screen.findByText('No sign-in is waiting for this code')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull() + // The code stays in the field, to correct it. + expect(screen.getByRole('textbox', { name: 'Code' })).toHaveValue('BCDF-GHJK') + }) + + it('says when the request expired before the click', async () => { + const user = userEvent.setup() + mockApi({ + decide: () => new Response('no login is waiting for this code', { status: 404 }), + }) + await renderPage('/device?code=BCDF-GHJK') + + await user.click(await screen.findByRole('button', { name: 'Approve' })) + expect(await screen.findByText('This sign-in is no longer pending')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Deny' })).toBeDisabled() + }) + + it('refuses a malformed code before asking, and says when too many codes were wrong', async () => { + const user = userEvent.setup() + mockApi({ + lookup: () => + new Response('too many wrong codes; try again later', { + status: 429, + headers: { 'retry-after': '300' }, + }), + }) + await renderPage('/device') + + const field = await screen.findByRole('textbox', { name: 'Code' }) + await user.type(field, 'BCDF-GHJ1') + await user.click(screen.getByRole('button', { name: 'Continue' })) + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Enter the eight letters your terminal shows, such as BCDF-GHJK.', + ) + expect(sent('/auth/device/lookup')).toEqual([]) + + await user.clear(field) + await user.type(field, 'BCDF-GHJK') + await user.click(screen.getByRole('button', { name: 'Continue' })) + expect(await screen.findByText('Too many wrong codes')).toBeInTheDocument() + expect(screen.getByText('Try again in 5 minutes.')).toBeInTheDocument() + }) +}) + +describe('device codes and scopes', () => { + it('reads a code in any spelling', () => { + expect(normalizeUserCode('bcdfghjk')).toBe('BCDF-GHJK') + expect(normalizeUserCode(' BCDF-ghjk ')).toBe('BCDF-GHJK') + for (const bad of ['', 'BCDF-GHJ', 'BCDF-GHJKL', 'ABCD-EFGH', 'BCDF-GHJ0']) { + expect(normalizeUserCode(bad)).toBeNull() + } + }) + + it('says what a token may do', () => { + expect(describeScope(null)).toBe('Everything you may do') + expect(describeScope({ level: 'read', repositories: [] })).toBe('Read') + expect(describeScope({ level: null, repositories: ['a', 'b'] })).toBe( + 'Everything you may do, on the repositories a, b', + ) + expect(describeScope({ level: 'delete', repositories: ['a'] })).toBe( + 'Read, write and delete, on the repository a', + ) + }) +}) diff --git a/web/src/routes/Device.tsx b/web/src/routes/Device.tsx new file mode 100644 index 00000000..fa991468 --- /dev/null +++ b/web/src/routes/Device.tsx @@ -0,0 +1,301 @@ +import { useState, type FormEvent } from 'react' +import { useMutation, useQuery } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' + +import { + decideDeviceCode, + deviceRequestQueryOptions, + normalizeUserCode, + type DeviceRequest, + type DeviceScope, +} from '~/api/device' +import { ApiError } from '~/lib/httpClient' +import { useMe } from '~/queries/useMe' +import { + Button, + Code, + FactList, + FormActions, + Notice, + Page, + PageHeader, + Panel, + Skeleton, + TextField, +} from '~/components/ui' + +interface DeviceSearch { + /** The code, filled in from the client's link (`verification_uri_complete`). */ + code?: string +} + +export const Route = createFileRoute('/_app/device')({ + validateSearch: (search: Record): DeviceSearch => ({ + code: + typeof search.code === 'string' && search.code.length <= 32 ? search.code : undefined, + }), + component: Device, +}) + +const HEADER_DESCRIPTION = + 'Sign in a command-line client, such as forge login or a Docker or NuGet credential helper, with the code it shows.' + +/** A code entered (or filled in) and looked up; `attempt` repeats a lookup. */ +interface Lookup { + code: string + attempt: number +} + +/** + * The device login's page (RFC 8628): the code a command-line client shows + * is entered here, or filled in from the link the client prints, by a + * signed-in user (the app's login gate sends a visitor through the sign-in + * and back, with the code). The page shows which client asks for what, from + * where and since when, and the code to compare with the terminal; nothing is + * approved without the user's click on **Approve**, a filled-in code neither + * (remote phishing, RFC 8628 §5.4). + */ +function Device() { + const { code: linked } = Route.useSearch() + const [draft, setDraft] = useState(linked ?? '') + const [formError, setFormError] = useState(() => + linked !== undefined && normalizeUserCode(linked) === null ? MALFORMED : null, + ) + const [lookup, setLookup] = useState(() => { + const code = linked === undefined ? null : normalizeUserCode(linked) + return code === null ? null : { code, attempt: 0 } + }) + const request = useQuery({ + ...deviceRequestQueryOptions(lookup?.code ?? '', lookup?.attempt ?? 0), + enabled: lookup !== null, + }) + const [decided, setDecided] = useState(null) + const decide = useMutation({ + mutationFn: ({ code, approve }) => decideDeviceCode(code, approve), + onSuccess: (_, { approve }) => setDecided(approve), + }) + const header = ( + + ) + + function handleSubmit(e: FormEvent) { + e.preventDefault() + const code = normalizeUserCode(draft) + if (code === null) { + setFormError(MALFORMED) + return + } + setFormError(null) + decide.reset() + setLookup((previous) => ({ code, attempt: (previous?.attempt ?? 0) + 1 })) + } + + function enterAnother() { + decide.reset() + setLookup(null) + setDraft('') + } + + if (decided !== null) { + return ( + + {header} + + {decided + ? 'The client is signed in as you. Return to your terminal; you can close this page.' + : 'The client was not signed in. Return to your terminal; you can close this page.'} + + + ) + } + + if (lookup !== null && request.isLoading) { + return ( + + {header} + + + ) + } + + if (lookup !== null && request.data) { + return ( + + {header} + decide.mutate({ code: lookup.code, approve })} + onEnterAnother={enterAnother} + /> + + ) + } + + const lookupError = lookup !== null && request.isError ? request.error : null + return ( + + {header} + + Continue + + } + > +
+ { + setDraft(e.target.value) + setFormError(null) + }} + placeholder="BCDF-GHJK" + autoComplete="off" + autoCapitalize="characters" + spellCheck={false} + maxLength={32} + error={formError ? {formError} : undefined} + /> +
+ {lookupError && } +
+
+ ) +} + +const MALFORMED = 'Enter the eight letters your terminal shows, such as BCDF-GHJK.' + +/** Why a code could not be looked up. */ +function LookupError({ error }: { error: ApiError }) { + if (error.status === 404) { + return ( + + It is mistyped, it expired, or it was answered already. Check the code your terminal + shows, or start the sign-in again for a new one. + + ) + } + if (error.status === 429) { + return ( + + Try again {inWhile(error.retryAfterSeconds)}. + + ) + } + if (error.status === 400) { + return {MALFORMED} + } + return ( + + {error.body || error.message} + + ) +} + +/** `in 5 minutes`, from a `Retry-After`. */ +function inWhile(seconds: number | undefined): string { + if (seconds === undefined) return 'in a few minutes' + if (seconds < 60) return `in ${seconds} second${seconds === 1 ? '' : 's'}` + const minutes = Math.ceil(seconds / 60) + return `in ${minutes} minute${minutes === 1 ? '' : 's'}` +} + +const LEVELS: Record, string> = { + read: 'Read', + write: 'Read and write', + delete: 'Read, write and delete', +} + +/** What the token may do, in words. */ +export function describeScope(scope: DeviceScope | null): string { + if (scope === null) return 'Everything you may do' + const level = scope.level ? LEVELS[scope.level] : 'Everything you may do' + if (scope.repositories.length === 0) return level + const where = scope.repositories.length === 1 ? 'repository' : 'repositories' + return `${level}, on the ${where} ${scope.repositories.join(', ')}` +} + +/** The pending request, with its Approve and Deny. */ +function Approval({ + request, + pending, + error, + onDecide, + onEnterAnother, +}: { + request: DeviceRequest + pending: boolean + error: ApiError | null + onDecide: (approve: boolean) => void + onEnterAnother: () => void +}) { + const me = useMe() + const username = me.data?.user.username + const client = request.client_name ?? request.client_id + const gone = error?.status === 404 + return ( + + Approve only a sign-in you started yourself, and only if your terminal shows the code{' '} + {request.user_code}. The client gets a token that acts as{' '} + {username ? {username} : 'you'} on this server. + + } + > + + {client} ({request.client_id}) + + ), + }, + { term: 'Access', value: describeScope(request.scope) }, + ...(request.client_ip + ? [{ term: 'From address', value: request.client_ip, mono: true }] + : []), + { term: 'Requested', value: new Date(request.created_at).toLocaleString() }, + { term: 'Expires', value: new Date(request.expires_at).toLocaleString() }, + ]} + /> + {gone ? ( + + It expired or was answered already. Start the sign-in again for a new code. + + ) : ( + error && ( + + {error.status === 429 + ? `Too many wrong codes; try again ${inWhile(error.retryAfterSeconds)}.` + : error.body || error.message} + + ) + )} + + + + + + + ) +} diff --git a/web/src/routes/admin/settings/Settings.tsx b/web/src/routes/admin/settings/Settings.tsx index 0306b728..d2dbf166 100644 --- a/web/src/routes/admin/settings/Settings.tsx +++ b/web/src/routes/admin/settings/Settings.tsx @@ -40,6 +40,8 @@ const DESCRIPTION = * repositories that allow anonymous reads (the other half of the switch). * - **Sign-in**: the login screen's mode, password Basic on the feeds, the * session lifetimes and the password lockout. + * - **Device login**: whether command-line clients sign in with a code + * entered on the `/device` page (RFC 8628), and how often they poll. * * Each section is a form panel that saves its own fields (`PUT` changes only * what it names), so saving one never undoes a change another administrator @@ -63,6 +65,7 @@ function SettingsScreen() { } asideLabel="Anonymous reads"> + )} @@ -394,6 +397,72 @@ function SignInForm({ settings }: { settings: AuthSettings }) { ) } +/** The longest polling interval the server accepts. */ +const MAX_DEVICE_POLL_INTERVAL_SECONDS = 60 + +/** + * The device login (RFC 8628): `forge login` and the Docker and NuGet + * credential helpers show a code, which a signed-in user enters on the + * `/device` page and approves. + */ +function DeviceLoginForm({ settings }: { settings: AuthSettings }) { + const update = useUpdateAuthSettings() + const [enabled, setEnabled] = useState(settings.device_login) + const [pollInterval, setPollInterval] = useState(String(settings.device_poll_interval_seconds)) + const [error, setError] = useState(null) + + function handleSubmit(e: FormEvent) { + e.preventDefault() + const seconds = parseCount(pollInterval, 1) + if (seconds === null || seconds > MAX_DEVICE_POLL_INTERVAL_SECONDS) { + setError(`Enter a number of seconds from 1 to ${MAX_DEVICE_POLL_INTERVAL_SECONDS}.`) + return + } + update.mutate({ device_login: enabled, device_poll_interval_seconds: seconds }) + } + + const serverError = update.isError ? update.error.body || update.error.message : null + return ( + + forge login and the Docker and NuGet credential helpers sign in through + the browser: they show a code, which a signed-in user enters on the{' '} + /device page and approves. The client then gets an API token of that + user, listed under their tokens. A request waits for its approval, and the token + lasts, as long as npm's browser login: [cli_login] in the + configuration. + + } + footer={Save device login} + > +
+ + { + setPollInterval(v) + setError(null) + }} + hint={`How long a client waits between two checks whether its sign-in was approved; at most ${MAX_DEVICE_POLL_INTERVAL_SECONDS}. A client that checks sooner is told to wait five seconds longer.`} + /> +
+ +
+ ) +} + /** A number of seconds, with its value in words after the hint. */ function SecondsField({ label, diff --git a/web/src/routes/admin/settings/settings.test.tsx b/web/src/routes/admin/settings/settings.test.tsx index c18b17dc..af17d415 100644 --- a/web/src/routes/admin/settings/settings.test.tsx +++ b/web/src/routes/admin/settings/settings.test.tsx @@ -34,6 +34,8 @@ const stored: AuthSettings = { session_absolute_ttl_seconds: 604800, login_max_failures: 10, login_failure_window_seconds: 300, + device_login: true, + device_poll_interval_seconds: 5, } function repo(over: Partial): RepoView { @@ -237,6 +239,49 @@ describe('admin settings', () => { }) }) +describe('device login settings', () => { + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockReset() + setSession('admin-csrf') + }) + afterEach(() => { + vi.unstubAllGlobals() + clearSession() + }) + + it('turns the device login off and sets its interval, refusing a wrong one first', async () => { + mockApi({}) + const u = userEvent.setup() + await renderApp('/manage/settings') + const device = await waitFor(() => section('Device login')) + + const enabled = within(device).getByRole('checkbox', { + name: 'Let command-line clients sign in with a code', + }) + expect(enabled).toBeChecked() + const interval = within(device).getByRole('textbox', { name: 'Polling interval (seconds)' }) + expect(interval).toHaveValue('5') + + await u.clear(interval) + await u.type(interval, '61') + await u.click(within(device).getByRole('button', { name: 'Save device login' })) + expect( + await within(device).findByText('Enter a number of seconds from 1 to 60.'), + ).toBeInTheDocument() + expect(puts()).toEqual([]) + + await u.clear(interval) + await u.type(interval, '10') + await u.click(enabled) + await u.click(within(device).getByRole('button', { name: 'Save device login' })) + await waitFor(() => + expect(puts()).toEqual([{ device_login: false, device_poll_interval_seconds: 10 }]), + ) + expect(await within(device).findByRole('status')).toHaveTextContent('Saved.') + }) +}) + describe('formatDuration', () => { it('names the largest whole unit', () => { expect(formatDuration(28800)).toBe('8 hours') From 594bf8c235f3f10f8f6069035efcb80b1e802e80 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 09:30:44 +0000 Subject: [PATCH 2/2] Migrations test: expect the device_authorization table 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 Claude-Session: https://claude.ai/code/session_012e6DJbbDh51swmocfFvXPe --- crates/db/tests/migrations.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/db/tests/migrations.rs b/crates/db/tests/migrations.rs index 30a0bd96..48006339 100644 --- a/crates/db/tests/migrations.rs +++ b/crates/db/tests/migrations.rs @@ -53,6 +53,8 @@ const EXPECTED_TABLES: &[&str] = &[ "trusted_publish_audit", "trusted_publisher", "workload_api_key_trusted_publisher", + // device authorization grant (RFC 8628) + "device_authorization", ]; /// The credential-forwarding provenance columns of `app_user`: (table, column).