From dde74bd7eab917c9959a4f3b4dc8a60acc31e06e Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Thu, 28 May 2026 17:39:28 +0200 Subject: [PATCH 1/7] feat(migration+entity): cli_device_authorization --- backend/core/src/types/entity_aliases.rs | 4 + .../entity/src/cli_device_authorization.rs | 58 ++++++++ backend/entity/src/ids.rs | 1 + backend/entity/src/lib.rs | 1 + backend/migration/src/lib.rs | 2 + ...0_create_table_cli_device_authorization.rs | 131 ++++++++++++++++++ 6 files changed, 197 insertions(+) create mode 100644 backend/entity/src/cli_device_authorization.rs create mode 100644 backend/migration/src/m20260528_000000_create_table_cli_device_authorization.rs diff --git a/backend/core/src/types/entity_aliases.rs b/backend/core/src/types/entity_aliases.rs index 363aec58..489bcdfd 100644 --- a/backend/core/src/types/entity_aliases.rs +++ b/backend/core/src/types/entity_aliases.rs @@ -43,6 +43,7 @@ pub type ECacheRole = cache_role::Entity; pub type ECachedPath = cached_path::Entity; pub type ECacheUpstream = cache_upstream::Entity; pub type ECacheUser = cache_user::Entity; +pub type ECliDeviceAuthorization = cli_device_authorization::Entity; pub type ECommit = commit::Entity; pub type EDerivation = derivation::Entity; pub type EDerivationDependency = derivation_dependency::Entity; @@ -83,6 +84,7 @@ pub type MCacheRole = cache_role::Model; pub type MCachedPath = cached_path::Model; pub type MCacheUpstream = cache_upstream::Model; pub type MCacheUser = cache_user::Model; +pub type MCliDeviceAuthorization = cli_device_authorization::Model; pub type MCommit = commit::Model; pub type MDerivation = derivation::Model; pub type MDerivationDependency = derivation_dependency::Model; @@ -123,6 +125,7 @@ pub type ACacheRole = cache_role::ActiveModel; pub type ACachedPath = cached_path::ActiveModel; pub type ACacheUpstream = cache_upstream::ActiveModel; pub type ACacheUser = cache_user::ActiveModel; +pub type ACliDeviceAuthorization = cli_device_authorization::ActiveModel; pub type ACommit = commit::ActiveModel; pub type ADerivation = derivation::ActiveModel; pub type ADerivationDependency = derivation_dependency::ActiveModel; @@ -163,6 +166,7 @@ pub type CCacheRole = cache_role::Column; pub type CCachedPath = cached_path::Column; pub type CCacheUpstream = cache_upstream::Column; pub type CCacheUser = cache_user::Column; +pub type CCliDeviceAuthorization = cli_device_authorization::Column; pub type CCommit = commit::Column; pub type CDerivation = derivation::Column; pub type CDerivationDependency = derivation_dependency::Column; diff --git a/backend/entity/src/cli_device_authorization.rs b/backend/entity/src/cli_device_authorization.rs new file mode 100644 index 00000000..0c9d513d --- /dev/null +++ b/backend/entity/src/cli_device_authorization.rs @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2026 Wavelens GmbH + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +use chrono::NaiveDateTime; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::ids::{CliDeviceAuthorizationId, UserId}; + +#[derive(Clone, Default, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[sea_orm(table_name = "cli_device_authorization")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: CliDeviceAuthorizationId, + pub device_code_hash: String, + pub user_code: String, + pub user_id: Option, + pub token: Option, + pub denied_at: Option, + pub authorized_at: Option, + pub created_at: NaiveDateTime, + pub expires_at: NaiveDateTime, + pub user_agent: Option, + pub ip: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id" + )] + User, +} + +impl std::fmt::Debug for Model { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CliDeviceAuthorization") + .field("id", &self.id) + .field("device_code_hash", &"[redacted]") + .field("user_code", &self.user_code) + .field("user_id", &self.user_id) + .field("token", &self.token.as_ref().map(|_| "[redacted]")) + .field("denied_at", &self.denied_at) + .field("authorized_at", &self.authorized_at) + .field("created_at", &self.created_at) + .field("expires_at", &self.expires_at) + .field("user_agent", &self.user_agent) + .field("ip", &self.ip) + .finish() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/entity/src/ids.rs b/backend/entity/src/ids.rs index 8cb30211..a5fc6e4b 100644 --- a/backend/entity/src/ids.rs +++ b/backend/entity/src/ids.rs @@ -121,6 +121,7 @@ id_newtype!(SessionId); id_newtype!(UploadSessionId); id_newtype!(AuditLogId); id_newtype!(WorkerRegistrationId); +id_newtype!(CliDeviceAuthorizationId); #[cfg(test)] mod tests { diff --git a/backend/entity/src/lib.rs b/backend/entity/src/lib.rs index f0c45456..95372ca9 100644 --- a/backend/entity/src/lib.rs +++ b/backend/entity/src/lib.rs @@ -20,6 +20,7 @@ pub mod cache_upstream; pub mod cache_user; pub mod cached_path; pub mod cached_path_signature; +pub mod cli_device_authorization; pub mod commit; pub mod derivation; pub mod derivation_dependency; diff --git a/backend/migration/src/lib.rs b/backend/migration/src/lib.rs index 232fc3ca..10c8d547 100644 --- a/backend/migration/src/lib.rs +++ b/backend/migration/src/lib.rs @@ -120,6 +120,7 @@ mod m20260525_000004_evaluation_check_run_ids; mod m20260527_000000_evaluation_source_comment; mod m20260527_000001_add_allowed_ips_to_api; mod m20260527_000002_add_allowed_ips_to_integration; +mod m20260528_000000_create_table_cli_device_authorization; pub struct Migrator; @@ -241,6 +242,7 @@ impl MigratorTrait for Migrator { Box::new(m20260527_000000_evaluation_source_comment::Migration), Box::new(m20260527_000001_add_allowed_ips_to_api::Migration), Box::new(m20260527_000002_add_allowed_ips_to_integration::Migration), + Box::new(m20260528_000000_create_table_cli_device_authorization::Migration), ] } } diff --git a/backend/migration/src/m20260528_000000_create_table_cli_device_authorization.rs b/backend/migration/src/m20260528_000000_create_table_cli_device_authorization.rs new file mode 100644 index 00000000..516010cf --- /dev/null +++ b/backend/migration/src/m20260528_000000_create_table_cli_device_authorization.rs @@ -0,0 +1,131 @@ +/* + * SPDX-FileCopyrightText: 2026 Wavelens GmbH + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Storage for the OAuth 2.0 Device Authorization Grant used by `gradient login`. +//! +//! One row per `gradient login` invocation. The CLI polls `/auth/cli/poll` with +//! `device_code` (stored hashed) until the browser-side user clicks Authorize at +//! `/account/cli-authorize?code=`, at which point `user_id` and +//! `token` are populated and subsequent polls return the token. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(CliDeviceAuthorization::Table) + .if_not_exists() + .col( + ColumnDef::new(CliDeviceAuthorization::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(CliDeviceAuthorization::DeviceCodeHash) + .string() + .not_null() + .unique_key(), + ) + .col( + ColumnDef::new(CliDeviceAuthorization::UserCode) + .string() + .not_null() + .unique_key(), + ) + .col(ColumnDef::new(CliDeviceAuthorization::UserId).uuid().null()) + .col(ColumnDef::new(CliDeviceAuthorization::Token).text().null()) + .col( + ColumnDef::new(CliDeviceAuthorization::DeniedAt) + .date_time() + .null(), + ) + .col( + ColumnDef::new(CliDeviceAuthorization::AuthorizedAt) + .date_time() + .null(), + ) + .col( + ColumnDef::new(CliDeviceAuthorization::CreatedAt) + .date_time() + .not_null(), + ) + .col( + ColumnDef::new(CliDeviceAuthorization::ExpiresAt) + .date_time() + .not_null(), + ) + .col( + ColumnDef::new(CliDeviceAuthorization::UserAgent) + .text() + .null(), + ) + .col(ColumnDef::new(CliDeviceAuthorization::Ip).string().null()) + .foreign_key( + ForeignKey::create() + .name("fk-cli-device-auth-user") + .from( + CliDeviceAuthorization::Table, + CliDeviceAuthorization::UserId, + ) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_cli_device_auth_expires_at") + .table(CliDeviceAuthorization::Table) + .col(CliDeviceAuthorization::ExpiresAt) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(CliDeviceAuthorization::Table) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum CliDeviceAuthorization { + Table, + Id, + DeviceCodeHash, + UserCode, + UserId, + Token, + DeniedAt, + AuthorizedAt, + CreatedAt, + ExpiresAt, + UserAgent, + Ip, +} + +#[derive(DeriveIden)] +enum User { + Table, + Id, +} From d6445177da4d895b10d3a6ee8e099bc115ecc7fc Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Thu, 28 May 2026 17:39:35 +0200 Subject: [PATCH 2/7] feat(web): cli device authorization endpoints --- backend/web/src/audit.rs | 3 + backend/web/src/endpoints/auth.rs | 285 +++++++++++++++++++++++++++++- backend/web/src/error.rs | 3 + backend/web/src/lib.rs | 5 + 4 files changed, 295 insertions(+), 1 deletion(-) diff --git a/backend/web/src/audit.rs b/backend/web/src/audit.rs index 4be03b1f..f56eb2a6 100644 --- a/backend/web/src/audit.rs +++ b/backend/web/src/audit.rs @@ -35,6 +35,9 @@ pub mod events { pub const API_KEY_DELETE: &str = "api_key.delete"; pub const SESSION_REVOKE: &str = "session.revoke"; pub const AUTH_DENY: &str = "auth.deny"; + pub const CLI_DEVICE_START: &str = "cli.device.start"; + pub const CLI_DEVICE_AUTHORIZE: &str = "cli.device.authorize"; + pub const CLI_DEVICE_DENY: &str = "cli.device.deny"; pub const ORG_DELETE: &str = "organization.delete"; pub const ORG_MEMBER_ADD: &str = "organization.member.add"; pub const ORG_MEMBER_REMOVE: &str = "organization.member.remove"; diff --git a/backend/web/src/endpoints/auth.rs b/backend/web/src/endpoints/auth.rs index 914de3bb..4e930a60 100644 --- a/backend/web/src/endpoints/auth.rs +++ b/backend/web/src/endpoints/auth.rs @@ -8,25 +8,30 @@ use crate::audit::{RequestInfo, events, record as audit_record}; use crate::authorization::{ create_session_and_token, oidc_login_create, oidc_login_verify, update_last_login, }; -use crate::error::{WebError, WebResult}; +use crate::error::{ErrorCode, WebError, WebResult}; use crate::helpers::{OptionExt, ok_json}; use axum::Json; use axum::body::Body; use axum::extract::{Query, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; +use axum::Extension; +use chrono::Duration; use email_address::EmailAddress; use gradient_core::storage::generate_verification_token; use gradient_core::types::consts::*; use gradient_core::types::input::{validate_display_name, validate_password, validate_username}; use gradient_core::types::*; use password_auth::{generate_hash, verify_password}; +use rand::distr::{Alphanumeric, SampleString}; +use rand::seq::IndexedRandom; use sea_orm::ActiveValue::Set; use sea_orm::{ ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, QueryFilter, }; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Arc; @@ -693,6 +698,284 @@ pub async fn post_resend_verification( Ok(ok_json("Verification email sent successfully".to_string())) } +// ── CLI device authorization (RFC 8628 style) ──────────────────────────── + +/// Lifetime of a pending device authorization. Long enough for the user to +/// log in via the browser if they aren't already, short enough that an +/// abandoned CLI invocation can't be claimed days later. +const CLI_DEVICE_LIFETIME_MINUTES: i64 = 10; +const CLI_DEVICE_POLL_INTERVAL_SECONDS: u64 = 3; + +/// Alphabet for the human-typed `user_code` shown on both screens. Omits +/// visually ambiguous characters (0/O, 1/I/L) so a phone-screen → terminal +/// transcription doesn't mis-type. +const CLI_USER_CODE_ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789"; + +fn generate_device_code() -> String { + Alphanumeric.sample_string(&mut rand::rng(), 64) +} + +fn generate_user_code() -> String { + let mut rng = rand::rng(); + let mut s = String::with_capacity(9); + for i in 0..8 { + if i == 4 { + s.push('-'); + } + let c = *CLI_USER_CODE_ALPHABET + .choose(&mut rng) + .expect("alphabet non-empty"); + s.push(c as char); + } + s +} + +fn hash_device_code(raw: &str) -> String { + let mut h = Sha256::new(); + h.update(raw.as_bytes()); + let bytes = h.finalize(); + let mut out = String::with_capacity(64); + for b in bytes { + use std::fmt::Write as _; + write!(&mut out, "{:02x}", b).unwrap(); + } + out +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CliDeviceStartResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + pub expires_in: i64, + pub interval: u64, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CliDevicePollRequest { + pub device_code: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CliDeviceAuthorizeRequest { + pub user_code: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CliDeviceUserCodeQuery { + pub user_code: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CliDeviceInfoResponse { + pub user_code: String, + pub expires_at: chrono::NaiveDateTime, + pub user_agent: Option, + pub ip: Option, +} + +pub async fn post_cli_device_start( + state: State>, + info: RequestInfo, +) -> WebResult>> { + let device_code = generate_device_code(); + let user_code = loop { + let candidate = generate_user_code(); + let exists = ECliDeviceAuthorization::find() + .filter(CCliDeviceAuthorization::UserCode.eq(candidate.clone())) + .filter(CCliDeviceAuthorization::ExpiresAt.gt(gradient_core::types::now())) + .one(&state.web_db) + .await?; + if exists.is_none() { + break candidate; + } + }; + + let now = gradient_core::types::now(); + let expires_at = now + Duration::minutes(CLI_DEVICE_LIFETIME_MINUTES); + + let row = ACliDeviceAuthorization { + id: Set(CliDeviceAuthorizationId::now_v7()), + device_code_hash: Set(hash_device_code(&device_code)), + user_code: Set(user_code.clone()), + user_id: Set(None), + token: Set(None), + denied_at: Set(None), + authorized_at: Set(None), + created_at: Set(now), + expires_at: Set(expires_at), + user_agent: Set(info.user_agent.clone()), + ip: Set(info.ip.clone()), + }; + row.insert(&state.web_db).await?; + + audit_record( + &state.web_db, + None, + events::CLI_DEVICE_START, + &info, + Some(serde_json::json!({ "user_code": user_code })), + ) + .await; + + let base = state.config.server.serve_url.trim_end_matches('/'); + let verification_uri = format!("{}/account/cli-authorize", base); + let verification_uri_complete = format!("{}?code={}", verification_uri, user_code); + + Ok(ok_json(CliDeviceStartResponse { + device_code, + user_code, + verification_uri, + verification_uri_complete, + expires_in: CLI_DEVICE_LIFETIME_MINUTES * 60, + interval: CLI_DEVICE_POLL_INTERVAL_SECONDS, + })) +} + +pub async fn post_cli_device_poll( + state: State>, + Json(body): Json, +) -> WebResult>> { + let hash = hash_device_code(&body.device_code); + let row = ECliDeviceAuthorization::find() + .filter(CCliDeviceAuthorization::DeviceCodeHash.eq(hash)) + .one(&state.web_db) + .await? + .ok_or_else(|| WebError::not_found("Device authorization"))?; + + let now = gradient_core::types::now(); + if row.denied_at.is_some() { + return Err(WebError::bad_request_with( + ErrorCode::CLI_AUTH_DENIED, + "Authorization denied", + )); + } + if let Some(token) = row.token.clone() { + let mut active: ACliDeviceAuthorization = row.into(); + active.token = Set(None); + active.update(&state.web_db).await?; + return Ok(ok_json(token)); + } + if row.authorized_at.is_some() || row.expires_at < now { + return Err(WebError::bad_request_with( + ErrorCode::CLI_AUTH_EXPIRED, + "Authorization expired", + )); + } + Err(WebError::bad_request_with( + ErrorCode::CLI_AUTH_PENDING, + "Authorization pending", + )) +} + +async fn find_active_user_code( + state: &Arc, + user_code: &str, +) -> WebResult { + let row = ECliDeviceAuthorization::find() + .filter(CCliDeviceAuthorization::UserCode.eq(user_code.to_string())) + .one(&state.web_db) + .await? + .ok_or_else(|| WebError::not_found("Device authorization"))?; + + let now = gradient_core::types::now(); + if row.denied_at.is_some() { + return Err(WebError::bad_request_with( + ErrorCode::CLI_AUTH_DENIED, + "Authorization already denied", + )); + } + if row.authorized_at.is_some() { + return Err(WebError::bad_request_with( + ErrorCode::CLI_AUTH_DENIED, + "Authorization already completed", + )); + } + if row.expires_at < now { + return Err(WebError::bad_request_with( + ErrorCode::CLI_AUTH_EXPIRED, + "Authorization expired", + )); + } + Ok(row) +} + +pub async fn get_cli_device_info( + state: State>, + Query(query): Query, +) -> WebResult>> { + let row = find_active_user_code(&state, &query.user_code).await?; + Ok(ok_json(CliDeviceInfoResponse { + user_code: row.user_code, + expires_at: row.expires_at, + user_agent: row.user_agent, + ip: row.ip, + })) +} + +pub async fn post_cli_device_authorize( + state: State>, + info: RequestInfo, + Extension(user): Extension, + Json(body): Json, +) -> WebResult>> { + let row = find_active_user_code(&state, &body.user_code).await?; + + let (_session_id, token) = create_session_and_token( + state.clone(), + user.id, + true, + row.user_agent.clone(), + row.ip.clone(), + ) + .await + .map_err(|_| WebError::failed_to_generate_token())?; + + let now = gradient_core::types::now(); + let mut active: ACliDeviceAuthorization = row.into(); + active.user_id = Set(Some(user.id)); + active.token = Set(Some(token)); + active.authorized_at = Set(Some(now)); + active.update(&state.web_db).await?; + + audit_record( + &state.web_db, + Some(user.id), + events::CLI_DEVICE_AUTHORIZE, + &info, + Some(serde_json::json!({ "user_code": body.user_code })), + ) + .await; + + Ok(ok_json("Device authorized".to_string())) +} + +pub async fn post_cli_device_deny( + state: State>, + info: RequestInfo, + Extension(user): Extension, + Json(body): Json, +) -> WebResult>> { + let row = find_active_user_code(&state, &body.user_code).await?; + + let now = gradient_core::types::now(); + let mut active: ACliDeviceAuthorization = row.into(); + active.denied_at = Set(Some(now)); + active.update(&state.web_db).await?; + + audit_record( + &state.web_db, + Some(user.id), + events::CLI_DEVICE_DENY, + &info, + Some(serde_json::json!({ "user_code": body.user_code })), + ) + .await; + + Ok(ok_json("Device denied".to_string())) +} + #[cfg(test)] mod tests { use super::*; diff --git a/backend/web/src/error.rs b/backend/web/src/error.rs index dbd767c2..7c30234c 100644 --- a/backend/web/src/error.rs +++ b/backend/web/src/error.rs @@ -53,6 +53,9 @@ impl ErrorCode { pub const AUTHENTICATION: Self = Self("authentication"); pub const INVALID_CREDENTIALS: Self = Self("invalid_credentials"); pub const OAUTH_REQUIRED: Self = Self("oauth_required"); + pub const CLI_AUTH_PENDING: Self = Self("cli_auth_pending"); + pub const CLI_AUTH_EXPIRED: Self = Self("cli_auth_expired"); + pub const CLI_AUTH_DENIED: Self = Self("cli_auth_denied"); // 403 Forbidden pub const FORBIDDEN: Self = Self("forbidden"); diff --git a/backend/web/src/lib.rs b/backend/web/src/lib.rs index cad5de2e..4854c374 100644 --- a/backend/web/src/lib.rs +++ b/backend/web/src/lib.rs @@ -382,6 +382,9 @@ pub fn create_router(state: Arc) -> Router { "/user/settings", get(user::get_settings).patch(user::patch_settings), ) + .route("/auth/cli/info", get(auth::get_cli_device_info)) + .route("/auth/cli/authorize", post(auth::post_cli_device_authorize)) + .route("/auth/cli/deny", post(auth::post_cli_device_deny)) .nest("/admin", admin::admin_router()) .route_layer(middleware::from_fn_with_state( Arc::clone(&state), @@ -490,6 +493,8 @@ pub fn create_router(state: Arc) -> Router { ) .route("/auth/oidc/login", get(auth::get_oidc_login)) .route("/auth/oidc/callback", get(auth::get_oidc_callback)) + .route("/auth/cli/start", post(auth::post_cli_device_start)) + .route("/auth/cli/poll", post(auth::post_cli_device_poll)) .route_layer(GovernorLayer::new(rl_per_second(6, 5))); // ── Incoming forge webhooks (unauthenticated, HMAC-verified) ───────── From 424f905ce156c50e5c36bbc721e10f0aa61b74b0 Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Thu, 28 May 2026 17:39:39 +0200 Subject: [PATCH 3/7] feat(cli): browser-based gradient login --- cli/connector/src/auth.rs | 54 ++++++++++++++++ cli/connector/src/http.rs | 36 +++++++++++ cli/src/commands/base.rs | 128 +++++++++++++++++++++++++++++++------- 3 files changed, 195 insertions(+), 23 deletions(-) diff --git a/cli/connector/src/auth.rs b/cli/connector/src/auth.rs index cf4af521..92e6b5d6 100644 --- a/cli/connector/src/auth.rs +++ b/cli/connector/src/auth.rs @@ -2,6 +2,17 @@ use crate::{Client, ConnectorError, http}; use reqwest::Method; use serde::{Deserialize, Serialize}; +/// Outcome of `/auth/cli/poll`. Pending/Expired/Denied are normal states of the +/// device flow, not transport errors, so the CLI matches on them instead of +/// reading prose out of `ConnectorError::Api`. +#[derive(Debug, Clone)] +pub enum CliPollOutcome { + Pending, + Expired, + Denied, + Token(String), +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MakeUserRequest { pub username: String, @@ -36,6 +47,21 @@ pub struct OidcRedirect { pub url: String, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CliDeviceStartResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + pub expires_in: i64, + pub interval: u64, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CliDevicePollRequest { + pub device_code: String, +} + pub struct AuthApi<'a>(pub(crate) &'a Client); impl AuthApi<'_> { @@ -116,6 +142,34 @@ impl AuthApi<'_> { http::decode(req.send().await?).await } + pub async fn cli_device_start(&self) -> Result { + let req = http::request( + self.0.http(), + self.0.base_url(), + self.0.token(), + Method::POST, + "auth/cli/start", + false, + )?; + http::decode(req.send().await?).await + } + + pub async fn cli_device_poll( + &self, + body: CliDevicePollRequest, + ) -> Result { + let req = http::request( + self.0.http(), + self.0.base_url(), + self.0.token(), + Method::POST, + "auth/cli/poll", + false, + )? + .json(&body); + http::decode_cli_poll(req.send().await?).await + } + pub async fn logout(&self) -> Result { let req = http::request( self.0.http(), diff --git a/cli/connector/src/http.rs b/cli/connector/src/http.rs index db0e8fc9..87d6fcd3 100644 --- a/cli/connector/src/http.rs +++ b/cli/connector/src/http.rs @@ -1,3 +1,4 @@ +use crate::auth::CliPollOutcome; use crate::ConnectorError; use reqwest::{Method, RequestBuilder, Response}; use serde::de::DeserializeOwned; @@ -8,6 +9,13 @@ struct Envelope { message: T, } +#[derive(serde::Deserialize)] +struct ErrorEnvelope { + #[serde(default)] + code: Option, + message: String, +} + pub(crate) async fn decode(res: Response) -> Result { let status = res.status(); let bytes = res.bytes().await?; @@ -43,6 +51,34 @@ pub(crate) fn build_url(base: &str, path: &str) -> String { ) } +pub(crate) async fn decode_cli_poll(res: Response) -> Result { + let status = res.status(); + let bytes = res.bytes().await?; + + if let Ok(env) = serde_json::from_slice::>(&bytes) + && !env.error + { + return Ok(CliPollOutcome::Token(env.message)); + } + + if let Ok(env) = serde_json::from_slice::(&bytes) { + return match env.code.as_deref() { + Some("cli_auth_pending") => Ok(CliPollOutcome::Pending), + Some("cli_auth_expired") => Ok(CliPollOutcome::Expired), + Some("cli_auth_denied") => Ok(CliPollOutcome::Denied), + _ => Err(ConnectorError::Api { + status, + message: env.message, + }), + }; + } + + Err(ConnectorError::Api { + status, + message: String::from_utf8_lossy(&bytes).into_owned(), + }) +} + pub(crate) async fn decode_raw_string(res: Response) -> Result { let status = res.status(); if status == reqwest::StatusCode::UNAUTHORIZED { diff --git a/cli/src/commands/base.rs b/cli/src/commands/base.rs index dfe625f1..074b2117 100644 --- a/cli/src/commands/base.rs +++ b/cli/src/commands/base.rs @@ -10,8 +10,12 @@ use crate::input::*; use crate::output::{ExitKind, Output, to_exit_kind}; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{Shell, generate}; -use connector::auth::{MakeLoginRequest, MakeUserRequest}; +use connector::auth::{ + CliDevicePollRequest, CliPollOutcome, MakeLoginRequest, MakeUserRequest, +}; use std::io; +use std::process::Command; +use std::time::Duration; #[derive(Parser, Debug)] #[command(name = "Gradient", display_name = "Gradient", bin_name = "gradient", author = "Wavelens", version, about, long_about = None)] @@ -49,10 +53,14 @@ enum MainCommands { }, /// Login to the server Login { + /// Use basic username/password instead of the default web flow #[arg(short, long)] username: Option, #[arg(short, long)] password: Option, + /// Skip opening the browser; print the URL instead + #[arg(long)] + no_browser: bool, }, /// Logout from the server Logout, @@ -194,34 +202,40 @@ pub async fn run_cli() -> std::io::Result<()> { } } - MainCommands::Login { username, password } => { - if out.is_json() && password.is_none() { - out.err(ExitKind::Usage, "missing argument: --password"); - } - + MainCommands::Login { + username, + password, + no_browser, + } => { let server_url = set_get_value(ConfigKey::Server, None, true); if server_url.is_none() { set_get_value(ConfigKey::Server, Some(ask_for_input("Server URL")), true).unwrap(); } - let username = username.unwrap_or_else(|| ask_for_input("Username")); - let pw = password.unwrap_or_else(ask_for_password); - - let client = client_from_config(out); - match client - .auth() - .basic_login(MakeLoginRequest { - loginname: username, - password: pw, - }) - .await - { - Ok(token) => { - set_get_value(ConfigKey::AuthToken, Some(token), true).unwrap(); - out.ok(&serde_json::json!({"logged_in": true})); - out.human("Logged in."); + if username.is_some() || password.is_some() { + if out.is_json() && password.is_none() { + out.err(ExitKind::Usage, "missing argument: --password"); } - Err(e) => out.err(to_exit_kind(&e), e), + let username = username.unwrap_or_else(|| ask_for_input("Username")); + let pw = password.unwrap_or_else(ask_for_password); + let client = client_from_config(out); + match client + .auth() + .basic_login(MakeLoginRequest { + loginname: username, + password: pw, + }) + .await + { + Ok(token) => { + set_get_value(ConfigKey::AuthToken, Some(token), true).unwrap(); + out.ok(&serde_json::json!({"logged_in": true})); + out.human("Logged in."); + } + Err(e) => out.err(to_exit_kind(&e), e), + } + } else { + run_web_login(out, no_browser).await; } } @@ -278,3 +292,71 @@ pub async fn run_cli() -> std::io::Result<()> { std::process::exit(0); } + +async fn run_web_login(out: Output, no_browser: bool) { + let client = client_from_config(out); + let start = match client.auth().cli_device_start().await { + Ok(s) => s, + Err(e) => out.err(to_exit_kind(&e), e), + }; + + out.human(format!( + "Open this URL in your browser:\n {}\n\nConfirmation code: {}", + start.verification_uri_complete, start.user_code + )); + + if !no_browser && !out.is_json() { + let _ = open_url(&start.verification_uri_complete); + } + + let interval = Duration::from_secs(start.interval.max(1)); + let deadline = std::time::Instant::now() + + Duration::from_secs(start.expires_in.max(0) as u64); + + loop { + if std::time::Instant::now() >= deadline { + out.err(ExitKind::Api, "Authorization expired before approval."); + } + tokio::time::sleep(interval).await; + match client + .auth() + .cli_device_poll(CliDevicePollRequest { + device_code: start.device_code.clone(), + }) + .await + { + Ok(CliPollOutcome::Pending) => continue, + Ok(CliPollOutcome::Expired) => { + out.err(ExitKind::Api, "Authorization expired before approval."); + } + Ok(CliPollOutcome::Denied) => { + out.err(ExitKind::Unauthorized, "Authorization was denied."); + } + Ok(CliPollOutcome::Token(token)) => { + set_get_value(ConfigKey::AuthToken, Some(token), true).unwrap(); + out.ok(&serde_json::json!({"logged_in": true})); + out.human("Logged in."); + return; + } + Err(e) => out.err(to_exit_kind(&e), e), + } + } +} + +#[cfg(target_os = "macos")] +fn open_url(url: &str) -> std::io::Result<()> { + Command::new("open").arg(url).status().map(|_| ()) +} + +#[cfg(target_os = "windows")] +fn open_url(url: &str) -> std::io::Result<()> { + Command::new("cmd") + .args(["/C", "start", "", url]) + .status() + .map(|_| ()) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn open_url(url: &str) -> std::io::Result<()> { + Command::new("xdg-open").arg(url).status().map(|_| ()) +} From 478c6da85cafd99a794844fee31943fcd0439921 Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Thu, 28 May 2026 17:39:43 +0200 Subject: [PATCH 4/7] feat(web-ui): cli authorize page --- frontend/src/app/app.routes.ts | 8 ++ .../cli-authorize.component.html | 83 +++++++++++ .../cli-authorize.component.scss | 119 ++++++++++++++++ .../cli-authorize/cli-authorize.component.ts | 132 ++++++++++++++++++ .../features/auth/login/login.component.ts | 16 ++- 5 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/features/auth/cli-authorize/cli-authorize.component.html create mode 100644 frontend/src/app/features/auth/cli-authorize/cli-authorize.component.scss create mode 100644 frontend/src/app/features/auth/cli-authorize/cli-authorize.component.ts diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index c7b2fb05..2cf24f51 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -35,6 +35,14 @@ export const routes: Routes = [ (m) => m.OidcCallbackComponent ), }, + { + path: 'cli-authorize', + title: 'Authorize CLI', + loadComponent: () => + import('./features/auth/cli-authorize/cli-authorize.component').then( + (m) => m.CliAuthorizeComponent + ), + }, ], }, diff --git a/frontend/src/app/features/auth/cli-authorize/cli-authorize.component.html b/frontend/src/app/features/auth/cli-authorize/cli-authorize.component.html new file mode 100644 index 00000000..e9a55e78 --- /dev/null +++ b/frontend/src/app/features/auth/cli-authorize/cli-authorize.component.html @@ -0,0 +1,83 @@ + + +
+
+
+ +

Authorize CLI

+

Grant the Gradient CLI access to your account

+
+ + @if (errorMessage()) { +
+ error + {{ errorMessage() }} +
+ } + + @if (loading()) { +

Loading…

+ } @else if (status() === 'authorized') { +
+ check_circle +
+

Authorized

+

Return to your terminal. You may close this window.

+
+
+ } @else if (status() === 'denied') { +
+ cancel +
+

Denied

+

The CLI was not granted access.

+
+
+ } @else if (info()) { +
+

A Gradient CLI session is requesting to sign in as you.

+
+
Confirmation code
+
{{ info()!.user_code }}
+
Origin IP
+
{{ info()!.ip || 'unknown' }}
+
User agent
+
{{ info()!.user_agent || 'unknown' }}
+
+

+ Only authorize if you started this gradient login yourself + and the confirmation code matches what your terminal displays. +

+
+ + +
+
+ } @else { +
+
+ + +
+ +
+ } +
+
diff --git a/frontend/src/app/features/auth/cli-authorize/cli-authorize.component.scss b/frontend/src/app/features/auth/cli-authorize/cli-authorize.component.scss new file mode 100644 index 00000000..9c55b993 --- /dev/null +++ b/frontend/src/app/features/auth/cli-authorize/cli-authorize.component.scss @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2026 Wavelens GmbH + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +@use '../../../styles/variables' as *; + +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: $spacing-lg; +} + +.auth-card { + background-color: $color-tertiary; + border: 1px solid $color-border; + border-radius: $border-radius-lg; + padding: $spacing-xxl; + max-width: 520px; + width: 100%; +} + +.auth-header { + text-align: center; + margin-bottom: $spacing-xl; + + .auth-logo { + height: 48px; + margin-bottom: $spacing-lg; + } + + h1 { + font-size: $font-size-xxl; + color: $text-primary; + margin: 0 0 $spacing-sm 0; + } + + p { + margin: 0; + font-size: $font-size-md; + } +} + +.device-info { + dl { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: $spacing-md; + row-gap: $spacing-xs; + margin: $spacing-lg 0; + } + + dt { + font-weight: 600; + color: $text-secondary; + } + + dd { + margin: 0; + word-break: break-all; + } + + .code { + font-family: monospace; + font-size: $font-size-lg; + letter-spacing: 0.1em; + } + + .actions { + display: flex; + gap: $spacing-md; + margin-top: $spacing-lg; + + button { + flex: 1; + } + } +} + +.status-message { + display: flex; + gap: $spacing-md; + align-items: flex-start; + padding: $spacing-lg; + border-radius: $border-radius-sm; + + h2 { + margin: 0 0 $spacing-xs 0; + font-size: $font-size-lg; + } + + p { + margin: 0; + } + + &.success { + background-color: rgba($color-success, 0.1); + border: 1px solid $color-success; + } + + &.warning { + background-color: rgba($color-warning, 0.1); + border: 1px solid $color-warning; + } +} + +.error-message { + background-color: rgba($color-danger, 0.1); + border: 1px solid $color-danger; + border-radius: $border-radius-sm; + padding: $spacing-md; + margin-bottom: $spacing-lg; + display: flex; + gap: $spacing-sm; + align-items: center; +} diff --git a/frontend/src/app/features/auth/cli-authorize/cli-authorize.component.ts b/frontend/src/app/features/auth/cli-authorize/cli-authorize.component.ts new file mode 100644 index 00000000..ad511eaf --- /dev/null +++ b/frontend/src/app/features/auth/cli-authorize/cli-authorize.component.ts @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: 2026 Wavelens GmbH + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Component, OnInit, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ApiService } from '@core/services/api.service'; +import { AuthService } from '@core/services/auth.service'; +import { take } from 'rxjs'; + +interface CliDeviceInfo { + user_code: string; + expires_at: string; + user_agent: string | null; + ip: string | null; +} + +@Component({ + selector: 'app-cli-authorize', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './cli-authorize.component.html', + styleUrl: './cli-authorize.component.scss', +}) +export class CliAuthorizeComponent implements OnInit { + private fb = inject(FormBuilder); + private api = inject(ApiService); + private auth = inject(AuthService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + form: FormGroup; + info = signal(null); + loading = signal(true); + submitting = signal(false); + status = signal<'pending' | 'authorized' | 'denied'>('pending'); + errorMessage = signal(null); + + constructor() { + this.form = this.fb.group({ + userCode: ['', [Validators.required]], + }); + } + + ngOnInit(): void { + const code = this.route.snapshot.queryParamMap.get('code'); + if (code) { + this.form.patchValue({ userCode: code }); + } + + this.auth.initialized$.pipe(take(1)).subscribe(() => { + if (!this.auth.isAuthenticated()) { + const next = this.router.url; + this.router.navigate(['/account/login'], { + queryParams: { next }, + }); + return; + } + if (code) { + this.lookup(code); + } else { + this.loading.set(false); + } + }); + } + + private lookup(userCode: string): void { + this.loading.set(true); + this.errorMessage.set(null); + this.api + .get(`auth/cli/info?user_code=${encodeURIComponent(userCode)}`) + .subscribe({ + next: (info) => { + this.info.set(info); + this.loading.set(false); + }, + error: (e) => { + this.errorMessage.set(e.message || 'Invalid or expired code.'); + this.loading.set(false); + }, + }); + } + + onLookup(): void { + const code = this.form.value.userCode?.trim().toUpperCase(); + if (code) { + this.form.patchValue({ userCode: code }); + this.lookup(code); + } + } + + authorize(): void { + const userCode = this.info()?.user_code; + if (!userCode) return; + this.submitting.set(true); + this.api.post('auth/cli/authorize', { user_code: userCode }).subscribe({ + next: () => { + this.submitting.set(false); + this.status.set('authorized'); + }, + error: (e) => { + this.submitting.set(false); + this.errorMessage.set(e.message || 'Failed to authorize.'); + }, + }); + } + + deny(): void { + const userCode = this.info()?.user_code; + if (!userCode) return; + this.submitting.set(true); + this.api.post('auth/cli/deny', { user_code: userCode }).subscribe({ + next: () => { + this.submitting.set(false); + this.status.set('denied'); + }, + error: (e) => { + this.submitting.set(false); + this.errorMessage.set(e.message || 'Failed to deny.'); + }, + }); + } +} diff --git a/frontend/src/app/features/auth/login/login.component.ts b/frontend/src/app/features/auth/login/login.component.ts index 190f89d7..8d9b7c29 100644 --- a/frontend/src/app/features/auth/login/login.component.ts +++ b/frontend/src/app/features/auth/login/login.component.ts @@ -7,7 +7,7 @@ import { Component, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { Router, RouterModule } from '@angular/router'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { AuthService } from '@core/services/auth.service'; import { ConfigService } from '@core/services/config.service'; import { take } from 'rxjs'; @@ -24,6 +24,7 @@ export class LoginComponent { private fb = inject(FormBuilder); private authService = inject(AuthService); private router = inject(Router); + private route = inject(ActivatedRoute); private config = inject(ConfigService); loginForm: FormGroup; @@ -43,11 +44,20 @@ export class LoginComponent { this.authService.initialized$.pipe(take(1)).subscribe(() => { if (this.authService.isAuthenticated()) { - this.router.navigate(['/']); + this.navigateAfterLogin(); } }); } + private navigateAfterLogin(): void { + const next = this.route.snapshot.queryParamMap.get('next'); + if (next && next.startsWith('/')) { + this.router.navigateByUrl(next); + } else { + this.router.navigate(['/']); + } + } + onSubmit(): void { if (this.loginForm.valid) { this.loading.set(true); @@ -58,7 +68,7 @@ export class LoginComponent { this.authService.login(username, password, rememberMe).subscribe({ next: () => { this.loading.set(false); - this.router.navigate(['/']); + this.navigateAfterLogin(); }, error: (error) => { this.loading.set(false); From 29503002f75ee347d53e68baba80ca9fa1419aeb Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Thu, 28 May 2026 17:39:46 +0200 Subject: [PATCH 5/7] test: cli device authorization endpoints --- backend/web/tests/cli_device_authorization.rs | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 backend/web/tests/cli_device_authorization.rs diff --git a/backend/web/tests/cli_device_authorization.rs b/backend/web/tests/cli_device_authorization.rs new file mode 100644 index 00000000..5d4cbd7f --- /dev/null +++ b/backend/web/tests/cli_device_authorization.rs @@ -0,0 +1,319 @@ +/* + * SPDX-FileCopyrightText: 2026 Wavelens GmbH + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! End-to-end tests for the `gradient login` web flow (issue #251). +//! +//! Exercises `/auth/cli/start`, `/auth/cli/poll`, `/auth/cli/authorize`, and +//! `/auth/cli/deny` through the real router with mocked Postgres - enough to +//! pin down the state machine (pending → authorized/denied/expired) and the +//! "device_code is single-use" guarantee. + +use axum_test::TestServer; +use chrono::{Duration, Utc}; +use entity::{cli_device_authorization, session}; +use gradient_core::storage::{EmailSender, NarStore}; +use gradient_core::types::{ + CliDeviceAuthorizationId, RuntimeConfig, SecretString, ServerState, SessionId, UserId, WebDb, + WorkerDb, +}; +use jsonwebtoken::{EncodingKey, Header, encode}; +use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use test_support::cli::test_cli; +use test_support::fakes::email::InMemoryEmailSender; +use test_support::fixtures::{user, user_id}; +use test_support::log_storage::NoopLogStorage; +use uuid::Uuid; +use web::create_router; + +const JWT_SECRET: &str = "test-jwt-secret"; + +#[derive(Serialize)] +struct Claims { + exp: usize, + iat: usize, + id: UserId, + jti: SessionId, +} + +fn sign_session_jwt(user_id: UserId, session_id: SessionId) -> String { + let now = Utc::now(); + let claims = Claims { + iat: now.timestamp() as usize, + exp: (now + Duration::hours(1)).timestamp() as usize, + id: user_id, + jti: session_id, + }; + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(JWT_SECRET.as_bytes()), + ) + .expect("sign jwt") +} + +fn hash_device_code(raw: &str) -> String { + let mut h = Sha256::new(); + h.update(raw.as_bytes()); + let mut out = String::with_capacity(64); + for b in h.finalize() { + use std::fmt::Write as _; + write!(&mut out, "{:02x}", b).unwrap(); + } + out +} + +fn live_session(id: SessionId) -> session::Model { + let now = Utc::now().naive_utc(); + session::Model { + id, + user_id: user_id(), + created_at: now, + expires_at: now + Duration::hours(1), + last_used_at: now, + revoked_at: None, + user_agent: None, + ip: None, + remember_me: false, + } +} + +fn auth_queue(db: MockDatabase, session: session::Model) -> MockDatabase { + db.append_query_results([vec![session.clone()]]) + .append_exec_results([MockExecResult { + last_insert_id: 0, + rows_affected: 1, + }]) + .append_query_results([vec![session]]) + .append_query_results([vec![user()]]) +} + +fn server_with(web_db_setup: impl FnOnce(MockDatabase) -> MockDatabase) -> TestServer { + let cli = test_cli(); + let config = Arc::new(RuntimeConfig::from_cli(&cli).expect("valid test config")); + let nar_storage = NarStore::local(&config.storage.base_path).expect("create test NarStore"); + let db = web_db_setup(MockDatabase::new(DatabaseBackend::Postgres)); + let state = Arc::new(ServerState { + web_db: WebDb::new(db.into_connection()), + worker_db: WorkerDb::new(MockDatabase::new(DatabaseBackend::Postgres).into_connection()), + config, + log_storage: Arc::new(NoopLogStorage), + email: Arc::new(InMemoryEmailSender::new()) as Arc, + nar_storage, + manifest_state: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), + pending_credentials: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), + http: gradient_core::http::build_client().expect("http client"), + shutdown: gradient_core::shutdown::Shutdown::new(), + jwt_secret: SecretString::new(JWT_SECRET.to_string()), + started_at: chrono::Utc::now(), + pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()), + }); + TestServer::new(create_router(state)) +} + +fn pending_row(device_code: &str, user_code: &str) -> cli_device_authorization::Model { + let now = Utc::now().naive_utc(); + cli_device_authorization::Model { + id: CliDeviceAuthorizationId::new( + Uuid::parse_str("00000000-0000-0000-0000-0000000000c1").unwrap(), + ), + device_code_hash: hash_device_code(device_code), + user_code: user_code.to_string(), + user_id: None, + token: None, + denied_at: None, + authorized_at: None, + created_at: now, + expires_at: now + Duration::minutes(10), + user_agent: None, + ip: None, + } +} + +#[test] +fn start_returns_user_code_and_verification_uri() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let s = server_with(|db| { + db.append_query_results([Vec::::new()]) + .append_exec_results([MockExecResult { + last_insert_id: 0, + rows_affected: 1, + }]) + .append_query_results([Vec::::new()]) + .append_exec_results([MockExecResult { + last_insert_id: 0, + rows_affected: 1, + }]) + }); + + let res = s.post("/api/v1/auth/cli/start").await; + res.assert_status_ok(); + let body: Value = res.json(); + assert_eq!(body["error"], Value::Bool(false)); + let m = &body["message"]; + assert!(m["device_code"].as_str().unwrap().len() >= 32); + assert!(m["user_code"].as_str().unwrap().contains('-')); + assert!( + m["verification_uri_complete"] + .as_str() + .unwrap() + .contains("/account/cli-authorize?code=") + ); + assert!(m["interval"].as_u64().unwrap() >= 1); + assert!(m["expires_in"].as_i64().unwrap() > 0); + }); +} + +#[test] +fn poll_pending_returns_cli_auth_pending() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let row = pending_row("dev-code-xyz", "ABCD-EFGH"); + let s = server_with(|db| db.append_query_results([vec![row]])); + + let res = s + .post("/api/v1/auth/cli/poll") + .json(&serde_json::json!({ "device_code": "dev-code-xyz" })) + .await; + res.assert_status_bad_request(); + let body: Value = res.json(); + assert_eq!(body["code"], "cli_auth_pending"); + }); +} + +#[test] +fn poll_denied_returns_cli_auth_denied() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let mut row = pending_row("dev-code-xyz", "ABCD-EFGH"); + row.denied_at = Some(Utc::now().naive_utc()); + let s = server_with(|db| db.append_query_results([vec![row]])); + + let res = s + .post("/api/v1/auth/cli/poll") + .json(&serde_json::json!({ "device_code": "dev-code-xyz" })) + .await; + res.assert_status_bad_request(); + let body: Value = res.json(); + assert_eq!(body["code"], "cli_auth_denied"); + }); +} + +#[test] +fn poll_expired_returns_cli_auth_expired() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let mut row = pending_row("dev-code-xyz", "ABCD-EFGH"); + row.expires_at = Utc::now().naive_utc() - Duration::seconds(1); + let s = server_with(|db| db.append_query_results([vec![row]])); + + let res = s + .post("/api/v1/auth/cli/poll") + .json(&serde_json::json!({ "device_code": "dev-code-xyz" })) + .await; + res.assert_status_bad_request(); + let body: Value = res.json(); + assert_eq!(body["code"], "cli_auth_expired"); + }); +} + +#[test] +fn poll_authorized_returns_token_once() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let mut row = pending_row("dev-code-xyz", "ABCD-EFGH"); + row.user_id = Some(user_id()); + row.token = Some("the-session-jwt".to_string()); + row.authorized_at = Some(Utc::now().naive_utc()); + let s = server_with(|db| { + db.append_query_results([vec![row.clone()]]) + .append_exec_results([MockExecResult { + last_insert_id: 0, + rows_affected: 1, + }]) + .append_query_results([vec![cli_device_authorization::Model { + token: None, + ..row + }]]) + }); + + let res = s + .post("/api/v1/auth/cli/poll") + .json(&serde_json::json!({ "device_code": "dev-code-xyz" })) + .await; + res.assert_status_ok(); + let body: Value = res.json(); + assert_eq!(body["error"], Value::Bool(false)); + assert_eq!(body["message"], "the-session-jwt"); + }); +} + +#[test] +fn poll_unknown_device_code_returns_404() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let s = server_with(|db| db.append_query_results([Vec::::new()])); + + let res = s + .post("/api/v1/auth/cli/poll") + .json(&serde_json::json!({ "device_code": "nope" })) + .await; + res.assert_status_not_found(); + }); +} + +#[test] +fn authorize_requires_auth() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let s = server_with(|db| db); + + let res = s + .post("/api/v1/auth/cli/authorize") + .json(&serde_json::json!({ "user_code": "ABCD-EFGH" })) + .await; + res.assert_status_forbidden(); + }); +} + +#[test] +fn deny_marks_row_denied() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let session = live_session(SessionId::now_v7()); + let token = sign_session_jwt(user_id(), session.id); + let row = pending_row("dev-code-xyz", "ABCD-EFGH"); + + let s = server_with(|db| { + let db = auth_queue(db, session); + db.append_query_results([vec![row.clone()]]) + .append_exec_results([MockExecResult { + last_insert_id: 0, + rows_affected: 1, + }]) + .append_query_results([vec![cli_device_authorization::Model { + denied_at: Some(Utc::now().naive_utc()), + ..row + }]]) + .append_query_results([Vec::::new()]) + .append_exec_results([MockExecResult { + last_insert_id: 0, + rows_affected: 1, + }]) + }); + + let res = s + .post("/api/v1/auth/cli/deny") + .add_header("authorization", format!("Bearer {}", token)) + .json(&serde_json::json!({ "user_code": "ABCD-EFGH" })) + .await; + res.assert_status_ok(); + }); +} From 40a29061f8f5684b29e93a87d6b2570783d3aaec Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Thu, 28 May 2026 17:39:49 +0200 Subject: [PATCH 6/7] docs: gradient login web flow --- docs/gradient-api.yaml | 189 +++++++++++++++++++++++++++++++++++++++++ docs/src/tests.md | 20 +++++ docs/src/usage/cli.md | 10 ++- 3 files changed, 218 insertions(+), 1 deletion(-) diff --git a/docs/gradient-api.yaml b/docs/gradient-api.yaml index a9865497..34a87800 100644 --- a/docs/gradient-api.yaml +++ b/docs/gradient-api.yaml @@ -525,6 +525,128 @@ paths: error: false message: "Logout Successfully" + /auth/cli/start: + post: + tags: [auth] + summary: Start a CLI device-authorization flow + description: |- + Mints a `device_code` (returned once, opaque to the server thereafter) and a short, + human-friendly `user_code` that the CLI shows in the terminal and the user confirms + in the browser at `verification_uri_complete`. The CLI polls `/auth/cli/poll` until + the browser-side flow completes. + security: [] + operationId: cliDeviceStart + responses: + '200': + description: Device flow started + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/BaseEnvelope' + - type: object + properties: + message: + $ref: '#/components/schemas/CliDeviceStartResponse' + + /auth/cli/poll: + post: + tags: [auth] + summary: Poll a pending CLI device authorization + description: |- + Exchanges a `device_code` for a session token once the browser-side flow has been + approved. Returns `cli_auth_pending` while the user hasn't yet clicked Authorize, + `cli_auth_denied` if they explicitly denied, and `cli_auth_expired` once the device + code lifetime elapses. + security: [] + operationId: cliDevicePoll + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CliDevicePollRequest' + responses: + '200': + description: Authorized - session token returned + content: + application/json: + schema: + $ref: '#/components/schemas/StringResponse' + '400': + description: Pending, denied, or expired + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /auth/cli/info: + get: + tags: [auth] + summary: Look up a pending CLI device authorization + description: Returns metadata about a pending `user_code` so the browser-side UI can confirm what is being authorized. + operationId: cliDeviceInfo + parameters: + - name: user_code + in: query + required: true + schema: + type: string + responses: + '200': + description: Device info + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/BaseEnvelope' + - type: object + properties: + message: + $ref: '#/components/schemas/CliDeviceInfoResponse' + + /auth/cli/authorize: + post: + tags: [auth] + summary: Authorize a pending CLI device session + description: |- + Marks the device authorization identified by `user_code` as approved, minting a JWT + session token (remember_me=true, 30-day lifetime) bound to the calling user. The + token is returned to the CLI on its next `/auth/cli/poll` request. + operationId: cliDeviceAuthorize + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CliDeviceAuthorizeRequest' + responses: + '200': + description: Authorized + content: + application/json: + schema: + $ref: '#/components/schemas/StringResponse' + + /auth/cli/deny: + post: + tags: [auth] + summary: Deny a pending CLI device session + operationId: cliDeviceDeny + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CliDeviceAuthorizeRequest' + responses: + '200': + description: Denied + content: + application/json: + schema: + $ref: '#/components/schemas/StringResponse' + # ── User ───────────────────────────────────────────────────────────────────── /user: @@ -5347,6 +5469,10 @@ components: - authentication - invalid_credentials - oauth_required + # CLI device authorization (RFC 8628 style) + - cli_auth_pending + - cli_auth_expired + - cli_auth_denied # 403 Forbidden - forbidden - superuser_required @@ -5367,6 +5493,15 @@ components: # 503 Service Unavailable - service_unavailable + BaseEnvelope: + type: object + required: [error] + properties: + error: + type: boolean + enum: [false] + message: {} + StringResponse: allOf: - $ref: '#/components/schemas/BaseResponse' @@ -5375,6 +5510,60 @@ components: message: type: string + CliDeviceStartResponse: + type: object + required: [device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval] + properties: + device_code: + type: string + description: Opaque token the CLI uses to poll. Returned once; not stored on the server in plaintext. + user_code: + type: string + description: Human-readable code shown to the user (8 chars in `XXXX-XXXX` form, ambiguity-free alphabet). + verification_uri: + type: string + format: uri + verification_uri_complete: + type: string + format: uri + description: "`verification_uri` with `?code=` appended; safe to display or open directly." + expires_in: + type: integer + description: Seconds until the authorization expires. + interval: + type: integer + description: Minimum poll interval the CLI should respect, in seconds. + + CliDevicePollRequest: + type: object + required: [device_code] + properties: + device_code: + type: string + + CliDeviceAuthorizeRequest: + type: object + required: [user_code] + properties: + user_code: + type: string + + CliDeviceInfoResponse: + type: object + required: [user_code, expires_at] + properties: + user_code: + type: string + expires_at: + type: string + format: date-time + user_agent: + type: string + nullable: true + ip: + type: string + nullable: true + BoolResponse: allOf: - $ref: '#/components/schemas/BaseResponse' diff --git a/docs/src/tests.md b/docs/src/tests.md index bee7368f..9f25eca3 100644 --- a/docs/src/tests.md +++ b/docs/src/tests.md @@ -168,6 +168,26 @@ Run with: `cargo test -p web --test auth_middleware` | `malformed_bearer_returns_403_envelope` | `Authorization` header present but not `Bearer ` | 403, `message="Invalid Authorization header"` | | `undecodable_token_returns_401_envelope` | `Bearer` token that JWT can't decode | 401, `message="Unable to decode token"` | +## CLI device authorization (`gradient login` web flow) + +Integration tests in `backend/web/tests/cli_device_authorization.rs` pin the +state machine that backs `gradient login` (issue #251): a CLI calls +`POST /auth/cli/start`, polls `POST /auth/cli/poll`, and the browser-side user +hits `POST /auth/cli/authorize` or `/auth/cli/deny`. + +Run with: `cargo test -p web --test cli_device_authorization` + +| Test | Scenario | Expected | +|------|----------|----------| +| `start_returns_user_code_and_verification_uri` | `POST /auth/cli/start` | 200, response carries a `device_code`, dashed `user_code`, `verification_uri_complete` ending in `/account/cli-authorize?code=...`, and a positive `interval`/`expires_in` | +| `poll_pending_returns_cli_auth_pending` | poll on a row with no token/denial | 400, `code="cli_auth_pending"` | +| `poll_denied_returns_cli_auth_denied` | poll on a row with `denied_at` set | 400, `code="cli_auth_denied"` | +| `poll_expired_returns_cli_auth_expired` | poll on a row past `expires_at` | 400, `code="cli_auth_expired"` | +| `poll_authorized_returns_token_once` | poll on a row that has a token | 200, returns the session token | +| `poll_unknown_device_code_returns_404` | unknown `device_code` | 404 | +| `authorize_requires_auth` | `POST /auth/cli/authorize` without bearer | 403 | +| `deny_marks_row_denied` | authenticated `POST /auth/cli/deny` for a pending row | 200 | + ## Inbound forge webhook response-body (BaseResponse envelope) Integration tests in `backend/web/tests/forge_hooks.rs` verify that both diff --git a/docs/src/usage/cli.md b/docs/src/usage/cli.md index 7242642c..a92f4a84 100644 --- a/docs/src/usage/cli.md +++ b/docs/src/usage/cli.md @@ -48,7 +48,15 @@ Then log in: gradient login ``` -You will be prompted for your username and password. The token is stored in the local configuration file (`~/.config/gradient/config`). +By default this opens your browser to authorize the CLI session, which is what you want for interactive use and works the same when the Gradient server is configured for OIDC-only login. Pass `--no-browser` to print the URL instead — useful when running over SSH on a headless machine, where you can open the URL on your laptop. The browser flow asks you to confirm a short code that the CLI also prints, then issues a 30-day session token. + +For unattended scripts you can still pass credentials directly: + +```sh +gradient login --username alice --password "$PASSWORD" +``` + +Either way, the resulting token is stored in the local configuration file (`~/.config/gradient/config`). ## Commands From 1951c354dc3d68d11e5fb9b54798bf7307c25276 Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Thu, 28 May 2026 17:55:48 +0200 Subject: [PATCH 7/7] test: fix start mock ordering (insert returns via RETURNING query) --- backend/web/tests/cli_device_authorization.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/backend/web/tests/cli_device_authorization.rs b/backend/web/tests/cli_device_authorization.rs index 5d4cbd7f..09bb7a6c 100644 --- a/backend/web/tests/cli_device_authorization.rs +++ b/backend/web/tests/cli_device_authorization.rs @@ -140,13 +140,10 @@ fn pending_row(device_code: &str, user_code: &str) -> cli_device_authorization:: fn start_returns_user_code_and_verification_uri() { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { + let inserted = pending_row("dev-code", "ABCD-EFGH"); let s = server_with(|db| { db.append_query_results([Vec::::new()]) - .append_exec_results([MockExecResult { - last_insert_id: 0, - rows_affected: 1, - }]) - .append_query_results([Vec::::new()]) + .append_query_results([vec![inserted]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1,