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/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). 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')