diff --git a/Cargo.lock b/Cargo.lock index bd0014b8..e6038be2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6710,6 +6710,7 @@ dependencies = [ "serde", "serde_json", "ts-rs", + "webauthn-rs-proto", ] [[package]] diff --git a/crates/client/src/backend_auth.rs b/crates/client/src/backend_auth.rs index 8f4c1392..fb57cda4 100644 --- a/crates/client/src/backend_auth.rs +++ b/crates/client/src/backend_auth.rs @@ -1,8 +1,11 @@ use crate::BackendOrigin; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use std::fmt; use std::time::Duration; +use workspace_api::{DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest}; +pub use workspace_api::{DeviceLoginPollResponse, DeviceLoginStartResponse}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackendAuthTarget { pub base_url: String, @@ -28,23 +31,6 @@ impl BackendAuthTarget { } } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct DeviceLoginStartResponse { - pub device_code: String, - pub user_code: String, - pub verification_uri: String, - pub verification_uri_complete: String, - pub expires_in: u64, - pub interval: u64, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct DeviceLoginPollResponse { - pub status: String, - pub access_token: Option, - pub token_type: Option, -} - #[derive(Debug)] pub enum BackendAuthClientError { Http(reqwest::Error), @@ -74,16 +60,6 @@ impl From for BackendAuthClientError { } } -#[derive(Debug, Serialize)] -struct DeviceLoginStartRequest<'a> { - client_name: Option<&'a str>, -} - -#[derive(Debug, Serialize)] -struct DeviceLoginPollRequest<'a> { - device_code: &'a str, -} - pub async fn start_device_login( target: &BackendAuthTarget, client_name: Option<&str>, @@ -91,7 +67,9 @@ pub async fn start_device_login( let client = reqwest::Client::new(); let response = client .post(target.api_url("/api/auth/device-login/start")) - .json(&DeviceLoginStartRequest { client_name }) + .json(&DeviceLoginStartRequest { + client_name: client_name.map(ToOwned::to_owned), + }) .send() .await?; parse_json_response(response).await @@ -104,7 +82,9 @@ pub async fn poll_device_login( let client = reqwest::Client::new(); let response = client .post(target.api_url("/api/auth/device-login/poll")) - .json(&DeviceLoginPollRequest { device_code }) + .json(&DeviceLoginPollRequest { + device_code: device_code.to_string(), + }) .send() .await?; parse_json_response(response).await @@ -119,25 +99,25 @@ pub async fn wait_for_device_login( let started = std::time::Instant::now(); loop { let response = poll_device_login(target, device_code).await?; - match response.status.as_str() { - "approved" => { + match response.status { + DeviceLoginPollStatus::Approved => { return response .access_token .ok_or(BackendAuthClientError::MissingAccessToken); } - "expired" => { + DeviceLoginPollStatus::Expired => { return Err(BackendAuthClientError::BackendStatus { status: 410, body: "device login expired".to_string(), }); } - "consumed" => { + DeviceLoginPollStatus::Consumed => { return Err(BackendAuthClientError::BackendStatus { status: 409, body: "device login was already consumed".to_string(), }); } - _ => {} + DeviceLoginPollStatus::Pending => {} } if started.elapsed() >= expires_in { return Err(BackendAuthClientError::BackendStatus { diff --git a/crates/workspace-api/Cargo.toml b/crates/workspace-api/Cargo.toml index 5b222e35..a4e5f31e 100644 --- a/crates/workspace-api/Cargo.toml +++ b/crates/workspace-api/Cargo.toml @@ -13,6 +13,7 @@ typescript = ["dep:ts-rs", "protocol/typescript"] protocol.workspace = true serde = { workspace = true, features = ["derive"] } ts-rs = { version = "12.0.1", optional = true } +webauthn-rs-proto = "0.5.5" [[example]] name = "generate_typescript" @@ -37,6 +38,10 @@ required-features = ["typescript"] name = "generate_memory_api_types" required-features = ["typescript"] +[[example]] +name = "generate_auth_api_types" +required-features = ["typescript"] + [[example]] name = "generate_repository_access_types" required-features = ["typescript"] diff --git a/crates/workspace-api/examples/generate_auth_api_types.rs b/crates/workspace-api/examples/generate_auth_api_types.rs new file mode 100644 index 00000000..b8506b21 --- /dev/null +++ b/crates/workspace-api/examples/generate_auth_api_types.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", workspace_api::auth_api_typescript()); +} diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 3ab42838..3952fd62 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -5,6 +5,312 @@ //! callers must explicitly construct these Workspace-authoritative resources. use serde::{Deserialize, Serialize}; +use webauthn_rs_proto::{ + CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential, + RequestChallengeResponse, +}; + +/// Public browser-authentication configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct AuthPublicConfig { + pub rp_id: String, + pub origin: String, + pub public_base_url: String, + pub cookie_name: String, +} + +/// Authentication method that established the current request actor. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum ActorAuthMethod { + BrowserSession, + ApiToken, +} + +/// Public user identity returned by authentication operations. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct AuthenticatedUser { + pub user_id: String, + pub account_id: String, + pub handle: String, + pub display_name: String, +} + +/// Authenticated actor returned by `GET /api/auth/whoami`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RequestActor { + pub user_id: String, + pub account_id: String, + pub handle: String, + pub display_name: String, + pub auth_method: ActorAuthMethod, +} + +impl RequestActor { + pub fn user(&self) -> AuthenticatedUser { + AuthenticatedUser { + user_id: self.user_id.clone(), + account_id: self.account_id.clone(), + handle: self.handle.clone(), + display_name: self.display_name.clone(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WhoamiResponse { + pub actor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct AuthBootstrapUserRequest { + pub handle: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub display_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct AuthUserResponse { + pub user: AuthenticatedUser, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyRegistrationOptionsRequest { + pub handle: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub browser_origin: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyRegistrationOptionsResponse { + pub challenge_id: String, + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + pub public_key: CreationChallengeResponse, +} + +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyRegistrationCompleteRequest { + pub challenge_id: String, + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + pub credential: RegisterPublicKeyCredential, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyLoginOptionsRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub handle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub browser_origin: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyLoginOptionsResponse { + pub challenge_id: String, + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + pub public_key: RequestChallengeResponse, +} + +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyLoginCompleteRequest { + pub challenge_id: String, + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + pub credential: PublicKeyCredential, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DeviceLoginStartRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub client_name: Option, +} + +const DEVICE_LOGIN_EXPIRES_IN_MAX_SECONDS: u64 = 24 * 60 * 60; +const DEVICE_LOGIN_POLL_INTERVAL_MAX_SECONDS: u64 = 60; + +#[derive(Clone, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct DeviceLoginStartResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub expires_in: u64, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub interval: u64, +} + +impl<'de> Deserialize<'de> for DeviceLoginStartResponse { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: String, + expires_in: u64, + interval: u64, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.expires_in == 0 || wire.expires_in > DEVICE_LOGIN_EXPIRES_IN_MAX_SECONDS { + return Err(serde::de::Error::custom(format!( + "expires_in must be between 1 and {DEVICE_LOGIN_EXPIRES_IN_MAX_SECONDS}" + ))); + } + if wire.interval == 0 || wire.interval > DEVICE_LOGIN_POLL_INTERVAL_MAX_SECONDS { + return Err(serde::de::Error::custom(format!( + "interval must be between 1 and {DEVICE_LOGIN_POLL_INTERVAL_MAX_SECONDS}" + ))); + } + Ok(Self { + device_code: wire.device_code, + user_code: wire.user_code, + verification_uri: wire.verification_uri, + verification_uri_complete: wire.verification_uri_complete, + expires_in: wire.expires_in, + interval: wire.interval, + }) + } +} + +impl std::fmt::Debug for DeviceLoginStartResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DeviceLoginStartResponse") + .field("device_code", &"[redacted]") + .field("user_code", &self.user_code) + .field("verification_uri", &self.verification_uri) + .field("verification_uri_complete", &self.verification_uri_complete) + .field("expires_in", &self.expires_in) + .field("interval", &self.interval) + .finish() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DeviceLoginApproveRequest { + pub user_code: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum DeviceLoginApprovalStatus { + Approved, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DeviceLoginApproveResponse { + pub status: DeviceLoginApprovalStatus, + pub user: AuthenticatedUser, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DeviceLoginPollRequest { + pub device_code: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub enum DeviceAccessTokenType { + Bearer, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum DeviceLoginPollStatus { + Pending, + Approved, + Expired, + Consumed, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DeviceLoginPollResponse { + pub status: DeviceLoginPollStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub access_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub token_type: Option, +} + +impl std::fmt::Debug for DeviceLoginPollResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DeviceLoginPollResponse") + .field("status", &self.status) + .field( + "access_token", + &self.access_token.as_ref().map(|_| "[redacted]"), + ) + .field("token_type", &self.token_type) + .finish() + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum LogoutStatus { + LoggedOut, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct LogoutResponse { + pub status: LogoutStatus, +} pub const REPOSITORY_KEY_MIN_LEN: usize = 1; pub const REPOSITORY_KEY_MAX_LEN: usize = 64; @@ -1698,6 +2004,47 @@ pub fn repository_access_api_typescript() -> String { ) } +#[cfg(feature = "typescript")] +pub fn auth_api_typescript() -> String { + use ts_rs::TS; + + let config = ts_rs::Config::default(); + let declarations = [ + AuthPublicConfig::decl(&config), + ActorAuthMethod::decl(&config), + AuthenticatedUser::decl(&config), + RequestActor::decl(&config), + WhoamiResponse::decl(&config), + AuthBootstrapUserRequest::decl(&config), + AuthUserResponse::decl(&config), + PasskeyRegistrationOptionsRequest::decl(&config), + PasskeyRegistrationOptionsResponse::decl(&config), + PasskeyRegistrationCompleteRequest::decl(&config), + PasskeyLoginOptionsRequest::decl(&config), + PasskeyLoginOptionsResponse::decl(&config), + PasskeyLoginCompleteRequest::decl(&config), + DeviceLoginStartRequest::decl(&config), + DeviceLoginStartResponse::decl(&config), + DeviceLoginApproveRequest::decl(&config), + DeviceLoginApprovalStatus::decl(&config), + DeviceLoginApproveResponse::decl(&config), + DeviceLoginPollRequest::decl(&config), + DeviceAccessTokenType::decl(&config), + DeviceLoginPollStatus::decl(&config), + DeviceLoginPollResponse::decl(&config), + LogoutStatus::decl(&config), + LogoutResponse::decl(&config), + ]; + format!( + "// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts\n\n{}\n", + declarations + .into_iter() + .map(|declaration| format!("export {declaration}")) + .collect::>() + .join("\n\n") + ) +} + #[cfg(feature = "typescript")] pub fn workdir_api_typescript() -> String { use ts_rs::TS; @@ -2289,6 +2636,130 @@ mod tests { } } + #[test] + fn auth_server_fixtures_round_trip_through_shared_dtos() { + let whoami = serde_json::json!({ + "actor": { + "user_id": "user-1", + "account_id": "account-1", + "handle": "hare", + "display_name": "Hare", + "auth_method": "browser_session" + } + }); + let decoded = serde_json::from_value::(whoami.clone()) + .expect("server whoami fixture should match shared DTO"); + assert_eq!(serde_json::to_value(decoded).unwrap(), whoami); + + let registration_options = serde_json::json!({ + "challenge_id": "challenge-1", + "public_key": { + "publicKey": { + "challenge": "AQID", + "rp": {"id": "localhost", "name": "Yoi"}, + "user": {"id": "BAUG", "name": "hare", "displayName": "Hare"}, + "pubKeyCredParams": [{"type": "public-key", "alg": -7}] + } + } + }); + let decoded = serde_json::from_value::( + registration_options.clone(), + ) + .expect("server registration options fixture should match shared DTO"); + assert_eq!(serde_json::to_value(decoded).unwrap(), registration_options); + + let device_start = serde_json::json!({ + "device_code": "device-secret", + "user_code": "ABCD-EFGH", + "verification_uri": "https://yoi.example/login/device", + "verification_uri_complete": "https://yoi.example/login/device?user_code=ABCD-EFGH", + "expires_in": 600, + "interval": 2 + }); + let decoded = serde_json::from_value::(device_start.clone()) + .expect("server device-login fixture should match shared DTO"); + assert_eq!(serde_json::to_value(decoded).unwrap(), device_start); + } + + #[test] + fn auth_dtos_reject_unknown_status_and_fields() { + let unknown_status = serde_json::json!({"status": "future_status"}); + assert!( + serde_json::from_value::(unknown_status).is_err(), + "unknown device-login statuses must fail closed" + ); + + let unsafe_expiry = serde_json::json!({ + "device_code": "device-secret", + "user_code": "ABCD-EFGH", + "verification_uri": "https://yoi.example/login/device", + "verification_uri_complete": "https://yoi.example/login/device?user_code=ABCD-EFGH", + "expires_in": 0, + "interval": 2 + }); + assert!(serde_json::from_value::(unsafe_expiry).is_err()); + + let malformed_credential = serde_json::json!({ + "challenge_id": "challenge-1", + "credential": { + "id": "AQID", + "rawId": "AQID", + "type": "public-key", + "response": {"clientDataJSON": 42} + } + }); + assert!( + serde_json::from_value::(malformed_credential).is_err(), + "malformed passkey credential payloads must fail closed" + ); + + let unexpected_field = serde_json::json!({ + "actor": null, + "access_token": "must-not-be-accepted" + }); + assert!(serde_json::from_value::(unexpected_field).is_err()); + } + + #[test] + fn device_login_debug_output_redacts_secret_material() { + let start = DeviceLoginStartResponse { + device_code: "device-secret".to_string(), + user_code: "ABCD-EFGH".to_string(), + verification_uri: "https://yoi.example/login/device".to_string(), + verification_uri_complete: "https://yoi.example/login/device?user_code=ABCD-EFGH" + .to_string(), + expires_in: 600, + interval: 2, + }; + let start_debug = format!("{start:?}"); + assert!(!start_debug.contains("device-secret")); + assert!(start_debug.contains("[redacted]")); + + let poll = DeviceLoginPollResponse { + status: DeviceLoginPollStatus::Approved, + access_token: Some("access-secret".to_string()), + token_type: Some(DeviceAccessTokenType::Bearer), + }; + let poll_debug = format!("{poll:?}"); + assert!(!poll_debug.contains("access-secret")); + assert!(poll_debug.contains("[redacted]")); + } + + #[cfg(feature = "typescript")] + #[test] + fn generated_auth_api_contract_is_current() { + let expected = auth_api_typescript(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/auth-api.ts"); + let actual = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + assert_eq!( + normalize_typescript(&actual), + normalize_typescript(&expected), + "regenerate auth API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts` and format the generated file", + ); + } + #[test] fn companion_transcript_fixture_round_trips() { round_trip(CompanionTranscriptProjection { @@ -2383,6 +2854,7 @@ mod tests { }) .collect::() .replace(";}", "}") + .replace("=|", "=") } #[test] diff --git a/crates/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs index c014e37a..a135f5e0 100644 --- a/crates/workspace-server/src/auth.rs +++ b/crates/workspace-server/src/auth.rs @@ -1,54 +1,11 @@ use axum::http::{HeaderMap, header}; use chrono::{DateTime, Duration, Utc}; -use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; +pub use workspace_api::{ActorAuthMethod, AuthPublicConfig, AuthenticatedUser, RequestActor}; use crate::{Error, Result, store::ControlPlaneStore}; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct AuthPublicConfig { - pub rp_id: String, - pub origin: String, - pub public_base_url: String, - pub cookie_name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RequestActor { - pub user_id: String, - pub account_id: String, - pub handle: String, - pub display_name: String, - pub auth_method: ActorAuthMethod, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ActorAuthMethod { - BrowserSession, - ApiToken, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct AuthenticatedUser { - pub user_id: String, - pub account_id: String, - pub handle: String, - pub display_name: String, -} - -impl RequestActor { - pub fn user(&self) -> AuthenticatedUser { - AuthenticatedUser { - user_id: self.user_id.clone(), - account_id: self.account_id.clone(), - handle: self.handle.clone(), - display_name: self.display_name.clone(), - } - } -} - pub fn normalize_handle(handle: &str) -> Result { let normalized = handle.trim().to_ascii_lowercase(); let valid = !normalized.is_empty() diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 31f419e8..584cb96c 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -41,9 +41,7 @@ use tower::ServiceExt; use url::Url; use uuid::Uuid; use webauthn_rs::prelude::{ - CreationChallengeResponse, Passkey, PasskeyAuthentication, PasskeyRegistration, - PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse, Webauthn, - WebauthnBuilder, + Passkey, PasskeyAuthentication, PasskeyRegistration, Webauthn, WebauthnBuilder, }; use workdir::http::{ WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError, @@ -58,19 +56,26 @@ use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjec use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use workspace_api::{ - BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, CreateRemoteRuntimeRequest, - CreateRepositorySshCredentialRequest, CreateWorkspaceRepositoryRequest, - CreateWorkspaceRepositoryResponse, CreateWorkspaceWorkerRequest, - CreateWorkspaceWorkerTicketAssignmentRequest, DeleteRepositorySshCredentialRequest, - DeleteRepositorySshHostTrustRequest, MemoryDocumentResponse, MemoryStagingListResponse, - ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest, - ObjectiveStateRequest, ProfileSettingsResponse, PutRepositorySshHostTrustRequest, + ActorAuthMethod, AuthBootstrapUserRequest, AuthPublicConfig, AuthUserResponse, + AuthenticatedUser, BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, + CreateRemoteRuntimeRequest, CreateRepositorySshCredentialRequest, + CreateWorkspaceRepositoryRequest, CreateWorkspaceRepositoryResponse, + CreateWorkspaceWorkerRequest, CreateWorkspaceWorkerTicketAssignmentRequest, + DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest, + DeviceAccessTokenType, DeviceLoginApprovalStatus, DeviceLoginApproveRequest, + DeviceLoginApproveResponse, DeviceLoginPollRequest, DeviceLoginPollResponse, + DeviceLoginPollStatus, DeviceLoginStartRequest, DeviceLoginStartResponse, LogoutResponse, + LogoutStatus, MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest, + ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, + PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse, + PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest, + PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest, RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse, - RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, + RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RequestActor, RotateRepositorySshCredentialRequest, RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, - UpdateWorkspaceMetadataRequest, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, - WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, + UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse, + WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest, WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse, WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, @@ -85,9 +90,9 @@ use workspace_api::{ }; use crate::auth::{ - ActorAuthMethod, AuthPublicConfig, AuthenticatedUser, RequestActor, SessionCookiePolicy, - auth_error, is_expired, mint_secret, new_id, new_user_code, normalize_handle, parse_cookie, - resolve_request_actor, rfc3339_after, session_set_cookie, token_hash, + SessionCookiePolicy, auth_error, is_expired, mint_secret, new_id, new_user_code, + normalize_handle, parse_cookie, resolve_request_actor, rfc3339_after, session_set_cookie, + token_hash, }; use crate::authority::{ MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput, @@ -11477,118 +11482,6 @@ async fn scoped_list_host_workers( list_host_workers(State(api), AxumPath(path.host_id)).await } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct AuthBootstrapUserRequest { - handle: String, - #[serde(default)] - display_name: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct AuthUserResponse { - user: AuthenticatedUser, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PasskeyRegistrationOptionsRequest { - handle: String, - #[serde(default)] - display_name: Option, - #[serde(default)] - browser_origin: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct PasskeyRegistrationOptionsResponse { - challenge_id: String, - public_key: CreationChallengeResponse, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PasskeyRegistrationCompleteRequest { - challenge_id: String, - credential: RegisterPublicKeyCredential, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PasskeyLoginOptionsRequest { - #[serde(default)] - handle: Option, - #[serde(default)] - browser_origin: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct PasskeyLoginOptionsResponse { - challenge_id: String, - public_key: RequestChallengeResponse, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PasskeyLoginCompleteRequest { - challenge_id: String, - credential: PublicKeyCredential, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DeviceLoginStartRequest { - #[serde(default)] - client_name: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct DeviceLoginStartResponse { - device_code: String, - user_code: String, - verification_uri: String, - verification_uri_complete: String, - expires_in: u64, - interval: u64, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DeviceLoginApproveRequest { - user_code: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct DeviceLoginApproveResponse { - status: String, - user: AuthenticatedUser, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DeviceLoginPollRequest { - device_code: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct DeviceLoginPollResponse { - status: String, - #[serde(skip_serializing_if = "Option::is_none")] - access_token: Option, - #[serde(skip_serializing_if = "Option::is_none")] - token_type: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct WhoamiResponse { - actor: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct LogoutResponse { - status: String, -} - async fn get_auth_config(State(api): State) -> ApiResult> { Ok(Json(auth_public_config(&api.config))) } @@ -11993,7 +11886,7 @@ async fn post_device_login_approve( .into()); } Ok(Json(DeviceLoginApproveResponse { - status: "approved".to_string(), + status: DeviceLoginApprovalStatus::Approved, user: actor.user(), })) } @@ -12010,14 +11903,14 @@ async fn post_device_login_poll( }; if is_expired(&flow.expires_at) { return Ok(Json(DeviceLoginPollResponse { - status: "expired".to_string(), + status: DeviceLoginPollStatus::Expired, access_token: None, token_type: None, })); } if flow.approved_at.is_none() { return Ok(Json(DeviceLoginPollResponse { - status: "pending".to_string(), + status: DeviceLoginPollStatus::Pending, access_token: None, token_type: None, })); @@ -12027,15 +11920,15 @@ async fn post_device_login_poll( .consume_device_login_token(&request.device_code, &crate::auth::now_rfc3339())? else { return Ok(Json(DeviceLoginPollResponse { - status: "consumed".to_string(), + status: DeviceLoginPollStatus::Consumed, access_token: None, token_type: None, })); }; Ok(Json(DeviceLoginPollResponse { - status: "approved".to_string(), + status: DeviceLoginPollStatus::Approved, access_token: consumed.issued_access_token, - token_type: Some("Bearer".to_string()), + token_type: Some(DeviceAccessTokenType::Bearer), })) } @@ -12073,7 +11966,7 @@ async fn post_auth_logout( Ok(( response_headers, Json(LogoutResponse { - status: "logged_out".to_string(), + status: LogoutStatus::LoggedOut, }), ) .into_response()) diff --git a/web/workspace/src/lib/generated/auth-api.ts b/web/workspace/src/lib/generated/auth-api.ts new file mode 100644 index 00000000..e04bfab5 --- /dev/null +++ b/web/workspace/src/lib/generated/auth-api.ts @@ -0,0 +1,106 @@ +// Generated from workspace-api. Do not edit by hand. +// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts + +export type AuthPublicConfig = { + rp_id: string; + origin: string; + public_base_url: string; + cookie_name: string; +}; + +export type ActorAuthMethod = "browser_session" | "api_token"; + +export type AuthenticatedUser = { + user_id: string; + account_id: string; + handle: string; + display_name: string; +}; + +export type RequestActor = { + user_id: string; + account_id: string; + handle: string; + display_name: string; + auth_method: ActorAuthMethod; +}; + +export type WhoamiResponse = { actor: RequestActor | null }; + +export type AuthBootstrapUserRequest = { + handle: string; + display_name?: string | null; +}; + +export type AuthUserResponse = { user: AuthenticatedUser }; + +export type PasskeyRegistrationOptionsRequest = { + handle: string; + display_name?: string | null; + browser_origin?: string | null; +}; + +export type PasskeyRegistrationOptionsResponse = { + challenge_id: string; + public_key: unknown; +}; + +export type PasskeyRegistrationCompleteRequest = { + challenge_id: string; + credential: unknown; +}; + +export type PasskeyLoginOptionsRequest = { + handle?: string | null; + browser_origin?: string | null; +}; + +export type PasskeyLoginOptionsResponse = { + challenge_id: string; + public_key: unknown; +}; + +export type PasskeyLoginCompleteRequest = { + challenge_id: string; + credential: unknown; +}; + +export type DeviceLoginStartRequest = { client_name?: string | null }; + +export type DeviceLoginStartResponse = { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +}; + +export type DeviceLoginApproveRequest = { user_code: string }; + +export type DeviceLoginApprovalStatus = "approved"; + +export type DeviceLoginApproveResponse = { + status: DeviceLoginApprovalStatus; + user: AuthenticatedUser; +}; + +export type DeviceLoginPollRequest = { device_code: string }; + +export type DeviceAccessTokenType = "Bearer"; + +export type DeviceLoginPollStatus = + | "pending" + | "approved" + | "expired" + | "consumed"; + +export type DeviceLoginPollResponse = { + status: DeviceLoginPollStatus; + access_token?: string | null; + token_type?: DeviceAccessTokenType | null; +}; + +export type LogoutStatus = "logged_out"; + +export type LogoutResponse = { status: LogoutStatus }; diff --git a/web/workspace/src/lib/workspace/auth/api.ts b/web/workspace/src/lib/workspace/auth/api.ts index 94bf2531..0c79d8de 100644 --- a/web/workspace/src/lib/workspace/auth/api.ts +++ b/web/workspace/src/lib/workspace/auth/api.ts @@ -1,118 +1,143 @@ +import type { + DeviceLoginApproveRequest, + PasskeyLoginCompleteRequest, + PasskeyLoginOptionsRequest, + PasskeyRegistrationCompleteRequest, + PasskeyRegistrationOptionsRequest, +} from "$lib/generated/auth-api.ts"; import { authenticationCredentialToJson, + type AuthUser, type DeviceApprovalResponse, - isPublicKeyCredential, - type PasskeyLoginOptionsResponse, - type PasskeyRegistrationOptionsResponse, - type PasskeyUserResponse, + parseAuthUserResponse, + parseDeviceApprovalResponse, + parseLogoutResponse, + parseWhoamiResponse, prepareLoginOptions, prepareRegistrationOptions, registrationCredentialToJson, type WhoamiResponse, -} from "./model"; +} from "$lib/workspace/auth/model"; -async function jsonOrThrow(response: Response): Promise { - const text = await response.text(); +async function requestJson(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + credentials: "same-origin", + ...init, + headers: { + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + }); + const body = await response.json() as unknown; if (!response.ok) { - throw new Error( - `${response.status} ${response.statusText}${text ? `: ${text}` : ""}`, - ); + const errorBody = typeof body === "object" && body !== null + ? body as Record + : null; + const message = typeof errorBody?.message === "string" + ? errorBody.message + : `Request failed (${response.status})`; + throw new Error(message); } - return text ? JSON.parse(text) as T : (null as T); + return body; } -function browserOrigin(): string | null { - return globalThis.location?.origin ?? null; +export async function loadWhoami(): Promise { + return parseWhoamiResponse(await requestJson("/api/auth/whoami")); } -export async function loadWhoami( - fetcher: typeof fetch = fetch, -): Promise { - return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then( - jsonOrThrow, +export async function logout(): Promise { + parseLogoutResponse( + await requestJson("/api/auth/logout", { + method: "POST", + body: "{}", + }), ); } export async function registerPasskey( handle: string, - displayName: string, - fetcher: typeof fetch = fetch, -): Promise { - const options = await fetcher("/api/auth/passkeys/registration/options", { + displayName?: string, +): Promise { + const optionsRequest: PasskeyRegistrationOptionsRequest = { + handle, + display_name: displayName ?? null, + browser_origin: window.location.origin, + }; + const options = await requestJson("/api/auth/passkeys/registration/options", { method: "POST", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify({ - handle, - display_name: displayName, - browser_origin: browserOrigin(), - }), - }).then(jsonOrThrow); - + body: JSON.stringify(optionsRequest), + }); + const optionsRecord = typeof options === "object" && options !== null + ? options as Record + : null; + const challengeId = optionsRecord?.challenge_id; + if (typeof challengeId !== "string" || challengeId.length === 0) { + throw new Error( + "Invalid auth payload: registration_options.challenge_id is required.", + ); + } const credential = await navigator.credentials.create({ publicKey: prepareRegistrationOptions(options), }); - if (!isPublicKeyCredential(credential)) { - throw new Error( - "Passkey registration did not return a public-key credential.", - ); - } + if (!credential) throw new Error("Passkey registration was cancelled"); - return await fetcher("/api/auth/passkeys/registration/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify({ - challenge_id: options.challenge_id, - credential: registrationCredentialToJson(credential), + const completeRequest: PasskeyRegistrationCompleteRequest = { + challenge_id: challengeId, + credential: registrationCredentialToJson(credential), + }; + const result = parseAuthUserResponse( + await requestJson("/api/auth/passkeys/registration/complete", { + method: "POST", + body: JSON.stringify(completeRequest), }), - }).then(jsonOrThrow); + ); + return result.user; } -export async function loginWithPasskey( - handle: string, - fetcher: typeof fetch = fetch, -): Promise { - const options = await fetcher("/api/auth/passkeys/login/options", { +export async function loginWithPasskey(handle?: string): Promise { + const optionsRequest: PasskeyLoginOptionsRequest = { + handle: handle ?? null, + browser_origin: window.location.origin, + }; + const options = await requestJson("/api/auth/passkeys/login/options", { method: "POST", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify({ handle, browser_origin: browserOrigin() }), - }).then(jsonOrThrow); - + body: JSON.stringify(optionsRequest), + }); + const optionsRecord = typeof options === "object" && options !== null + ? options as Record + : null; + const challengeId = optionsRecord?.challenge_id; + if (typeof challengeId !== "string" || challengeId.length === 0) { + throw new Error( + "Invalid auth payload: login_options.challenge_id is required.", + ); + } const credential = await navigator.credentials.get({ publicKey: prepareLoginOptions(options), }); - if (!isPublicKeyCredential(credential)) { - throw new Error("Passkey login did not return a public-key credential."); - } + if (!credential) throw new Error("Passkey login was cancelled"); - return await fetcher("/api/auth/passkeys/login/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify({ - challenge_id: options.challenge_id, - credential: authenticationCredentialToJson(credential), + const completeRequest: PasskeyLoginCompleteRequest = { + challenge_id: challengeId, + credential: authenticationCredentialToJson(credential), + }; + const result = parseAuthUserResponse( + await requestJson("/api/auth/passkeys/login/complete", { + method: "POST", + body: JSON.stringify(completeRequest), }), - }).then(jsonOrThrow); -} - -export async function logout(fetcher: typeof fetch = fetch): Promise { - await fetcher("/api/auth/logout", { - method: "POST", - credentials: "same-origin", - }).then(jsonOrThrow); + ); + return result.user; } export async function approveDeviceLogin( userCode: string, - fetcher: typeof fetch = fetch, ): Promise { - return await fetcher("/api/auth/device-login/approve", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify({ user_code: userCode }), - }).then(jsonOrThrow); + const request: DeviceLoginApproveRequest = { user_code: userCode }; + return parseDeviceApprovalResponse( + await requestJson("/api/auth/device-login/approve", { + method: "POST", + body: JSON.stringify(request), + }), + ); } diff --git a/web/workspace/src/lib/workspace/auth/model.test.ts b/web/workspace/src/lib/workspace/auth/model.test.ts index d014086c..5bcd4342 100644 --- a/web/workspace/src/lib/workspace/auth/model.test.ts +++ b/web/workspace/src/lib/workspace/auth/model.test.ts @@ -1,8 +1,13 @@ import { + authenticationCredentialToJson, base64UrlToBuffer, bufferToBase64Url, + parseDeviceLoginPollResponse, + parseDeviceLoginStartResponse, + parseWhoamiResponse, prepareLoginOptions, prepareRegistrationOptions, + registrationCredentialToJson, } from "./model.ts"; declare const Deno: { @@ -17,6 +22,21 @@ function assertEquals(actual: T, expected: T): void { } } +function assertThrows(fn: () => unknown, message: string): void { + try { + fn(); + } catch (error) { + if ( + !(error instanceof Error) || + !error.message.startsWith("Invalid auth payload:") + ) { + throw new Error(`${message}: unexpected error`); + } + return; + } + throw new Error(`${message}: expected an error`); +} + function bytes(buffer: BufferSource): number[] { if (buffer instanceof ArrayBuffer) { return [...new Uint8Array(buffer)]; @@ -39,18 +59,20 @@ Deno.test("prepareRegistrationOptions decodes binary public key fields", () => { const options = prepareRegistrationOptions({ challenge_id: "challenge-1", public_key: { - challenge: "AQID" as unknown as BufferSource, - rp: { id: "localhost", name: "Yoi" }, - user: { - id: "BAUG" as unknown as BufferSource, - name: "local", - displayName: "Local User", + publicKey: { + challenge: "AQID" as unknown as BufferSource, + rp: { id: "localhost", name: "Yoi" }, + user: { + id: "BAUG" as unknown as BufferSource, + name: "local", + displayName: "Local User", + }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + excludeCredentials: [{ + type: "public-key", + id: "BwgJ" as unknown as BufferSource, + }], }, - pubKeyCredParams: [{ type: "public-key", alg: -7 }], - excludeCredentials: [{ - type: "public-key", - id: "BwgJ" as unknown as BufferSource, - }], }, }); @@ -67,11 +89,13 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () const options = prepareLoginOptions({ challenge_id: "challenge-1", public_key: { - challenge: "AQID" as unknown as BufferSource, - allowCredentials: [{ - type: "public-key", - id: "BwgJ" as unknown as BufferSource, - }], + publicKey: { + challenge: "AQID" as unknown as BufferSource, + allowCredentials: [{ + type: "public-key", + id: "BwgJ" as unknown as BufferSource, + }], + }, }, }); @@ -82,3 +106,85 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () 9, ]); }); + +Deno.test("whoami rejects unknown auth methods and extra fields", () => { + assertEquals(parseWhoamiResponse({ actor: null }), { actor: null }); + assertThrows( + () => + parseWhoamiResponse({ + actor: { + user_id: "user-1", + account_id: "account-1", + handle: "hare", + display_name: "Hare", + auth_method: "future_method", + }, + }), + "unknown auth method", + ); + assertThrows( + () => parseWhoamiResponse({ actor: null, token: "secret" }), + "unexpected whoami field", + ); +}); + +Deno.test("device login rejects unsafe expiry and unknown status", () => { + const start = { + device_code: "device-1", + user_code: "ABCD-EFGH", + verification_uri: "https://yoi.example/login/device", + verification_uri_complete: + "https://yoi.example/login/device?user_code=ABCD-EFGH", + expires_in: 600, + interval: 2, + }; + assertEquals(parseDeviceLoginStartResponse(start), start); + assertThrows( + () => + parseDeviceLoginStartResponse({ + ...start, + expires_in: Number.MAX_SAFE_INTEGER + 1, + }), + "unsafe expiry", + ); + assertThrows( + () => parseDeviceLoginPollResponse({ status: "future_status" }), + "unknown device-login status", + ); + assertThrows( + () => parseDeviceLoginPollResponse({ status: "approved" }), + "approved response without token", + ); +}); + +Deno.test("passkey credential conversion fails closed on malformed payloads", () => { + const bytes = new Uint8Array([1, 2, 3]).buffer; + const registration = { + id: "AQID", + rawId: bytes, + type: "public-key", + authenticatorAttachment: "platform", + getClientExtensionResults: () => ({ credProps: { rk: true } }), + response: { + clientDataJSON: bytes, + attestationObject: bytes, + getTransports: () => ["internal"], + }, + }; + const converted = registrationCredentialToJson(registration) as Record< + string, + unknown + >; + assertEquals(converted.id, "AQID"); + assertEquals(converted.clientExtensionResults, { credProps: { rk: true } }); + + assertThrows( + () => registrationCredentialToJson({ ...registration, rawId: "AQID" }), + "registration rawId string", + ); + assertThrows( + () => + authenticationCredentialToJson({ ...registration, type: "future-key" }), + "unknown credential type", + ); +}); diff --git a/web/workspace/src/lib/workspace/auth/model.ts b/web/workspace/src/lib/workspace/auth/model.ts index 5d6711d2..88419253 100644 --- a/web/workspace/src/lib/workspace/auth/model.ts +++ b/web/workspace/src/lib/workspace/auth/model.ts @@ -1,72 +1,163 @@ -export type AuthenticatedUser = { - user_id: string; - account_id: string; - handle: string; - display_name: string; +import type { + ActorAuthMethod, + AuthenticatedUser, + AuthPublicConfig, + AuthUserResponse, + DeviceLoginApproveResponse, + DeviceLoginPollResponse, + DeviceLoginPollStatus, + DeviceLoginStartResponse, + LogoutResponse, + PasskeyLoginOptionsResponse, + PasskeyRegistrationOptionsResponse, + RequestActor, + WhoamiResponse, +} from "$lib/generated/auth-api.ts"; + +export type AuthUser = AuthenticatedUser; +export type PasskeyUserResponse = AuthUserResponse; +export type DeviceApprovalResponse = DeviceLoginApproveResponse; +export type { + AuthPublicConfig, + DeviceLoginPollResponse, + DeviceLoginStartResponse, + PasskeyLoginOptionsResponse, + PasskeyRegistrationOptionsResponse, + RequestActor, + WhoamiResponse, }; -export type RequestActor = AuthenticatedUser & { - auth_method: "browser_session" | "api_token" | string; -}; +const MAX_AUTH_STRING_LENGTH = 16 * 1024; +const MAX_AUTH_ARRAY_LENGTH = 128; +const MAX_AUTH_OBJECT_KEYS = 128; +const MAX_AUTH_VALUE_DEPTH = 8; +const MAX_DEVICE_LOGIN_SECONDS = 24 * 60 * 60; +const MAX_WEBAUTHN_TIMEOUT_MS = 10 * 60 * 1000; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/; -export type WhoamiResponse = { - actor: RequestActor | null; -}; - -export type PasskeyRegistrationOptionsResponse = { - challenge_id: string; - public_key: PublicKeyCredentialCreationOptions | { - publicKey: PublicKeyCredentialCreationOptions; - }; -}; - -export type PasskeyLoginOptionsResponse = { - challenge_id: string; - public_key: PublicKeyCredentialRequestOptions | { - publicKey: PublicKeyCredentialRequestOptions; - }; -}; - -export type PasskeyUserResponse = { - user: AuthenticatedUser; -}; - -export type DeviceApprovalResponse = { - status: string; -}; - -export type RegistrationCredentialJson = { - id: string; - rawId: string; - type: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports: string[]; - }; -}; - -export type AuthenticationCredentialJson = { - id: string; - rawId: string; - type: string; - response: { - clientDataJSON: string; - authenticatorData: string; - signature: string; - userHandle: string | null; - }; -}; - -export function base64UrlToBuffer(value: string): ArrayBuffer { - const padding = "=".repeat((4 - (value.length % 4)) % 4); - const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/"); - const binary = atob(base64); - return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer; +function invalid(path: string, reason: string): never { + throw new Error(`Invalid auth payload: ${path} ${reason}.`); } -export function bufferToBase64Url(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); +function asRecord(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + invalid(path, "must be an object"); + } + return value as Record; +} + +function requireExactKeys( + value: Record, + path: string, + required: readonly string[], + optional: readonly string[] = [], +): void { + const allowed = new Set([...required, ...optional]); + for (const key of required) { + if (!(key in value)) invalid(`${path}.${key}`, "is required"); + } + for (const key of Object.keys(value)) { + if (!allowed.has(key)) invalid(`${path}.${key}`, "is not allowed"); + } +} + +function boundedString( + value: unknown, + path: string, + { allowEmpty = false }: { allowEmpty?: boolean } = {}, +): string { + if (typeof value !== "string") invalid(path, "must be a string"); + if ( + (!allowEmpty && value.length === 0) || value.length > MAX_AUTH_STRING_LENGTH + ) { + invalid(path, "has an invalid length"); + } + return value; +} + +function optionalString(value: unknown, path: string): string | null { + if (value === null || value === undefined) return null; + return boundedString(value, path); +} + +function positiveSafeInteger( + value: unknown, + path: string, + maximum: number, +): number { + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value <= 0 || + value > maximum + ) { + invalid(path, "must be a bounded positive safe integer"); + } + return value; +} + +function boundedJson(value: unknown, path: string, depth = 0): void { + if (depth > MAX_AUTH_VALUE_DEPTH) invalid(path, "is too deeply nested"); + if (value === null || typeof value === "boolean") return; + if (typeof value === "string") { + boundedString(value, path, { allowEmpty: true }); + return; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) invalid(path, "must be finite"); + return; + } + if (Array.isArray(value)) { + if (value.length > MAX_AUTH_ARRAY_LENGTH) { + invalid(path, "has too many items"); + } + value.forEach((item, index) => + boundedJson(item, `${path}[${index}]`, depth + 1) + ); + return; + } + const record = asRecord(value, path); + const keys = Object.keys(record); + if (keys.length > MAX_AUTH_OBJECT_KEYS) invalid(path, "has too many fields"); + for (const key of keys) { + if (key.length === 0 || key.length > 256) { + invalid(path, "has an invalid field name"); + } + boundedJson(record[key], `${path}.${key}`, depth + 1); + } +} + +function base64Url(value: unknown, path: string): string { + const encoded = boundedString(value, path); + if (!BASE64URL_PATTERN.test(encoded)) invalid(path, "must be base64url"); + return encoded; +} + +function fromBase64Url(value: unknown, path: string): ArrayBuffer { + const encoded = base64Url(value, path); + try { + const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const binary = atob(padded); + const bytes = Uint8Array.from( + binary, + (character) => character.charCodeAt(0), + ); + if (bytes.byteLength === 0 || bytes.byteLength > MAX_AUTH_STRING_LENGTH) { + invalid(path, "decodes to an invalid length"); + } + return bytes.buffer; + } catch { + invalid(path, "must be valid base64url"); + } +} + +function toBase64Url(value: unknown, path: string): string { + if (!(value instanceof ArrayBuffer)) invalid(path, "must be an ArrayBuffer"); + if (value.byteLength === 0 || value.byteLength > MAX_AUTH_STRING_LENGTH) { + invalid(path, "has an invalid byte length"); + } + const bytes = new Uint8Array(value); let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( @@ -75,86 +166,569 @@ export function bufferToBase64Url(buffer: ArrayBuffer): string { ); } -function unwrapPublicKey(value: T | { publicKey: T }): T { - if (value && typeof value === "object" && "publicKey" in value) { - return (value as { publicKey: T }).publicKey; +export function base64UrlToBuffer(value: string): ArrayBuffer { + return fromBase64Url(value, "base64url"); +} + +export function bufferToBase64Url(value: ArrayBuffer): string { + return toBase64Url(value, "buffer"); +} + +function parseAuthenticatorTransport( + value: unknown, + path: string, +): AuthenticatorTransport { + const transport = boundedString(value, path); + if (!["ble", "hybrid", "internal", "nfc", "usb"].includes(transport)) { + invalid(path, "contains an unknown authenticator transport"); } - return value as T; + return transport as AuthenticatorTransport; +} + +function parseCredentialDescriptor( + value: unknown, + path: string, +): PublicKeyCredentialDescriptor { + const descriptor = asRecord(value, path); + requireExactKeys(descriptor, path, ["type", "id"], ["transports"]); + if (descriptor.type !== "public-key") { + invalid(`${path}.type`, "must be public-key"); + } + const transports = descriptor.transports === undefined ? undefined : (() => { + if (!Array.isArray(descriptor.transports)) { + invalid(`${path}.transports`, "must be an array"); + } + if (descriptor.transports.length > MAX_AUTH_ARRAY_LENGTH) { + invalid(`${path}.transports`, "has too many items"); + } + return descriptor.transports.map((transport, index) => + parseAuthenticatorTransport(transport, `${path}.transports[${index}]`) + ); + })(); + return { + type: "public-key", + id: fromBase64Url(descriptor.id, `${path}.id`), + ...(transports === undefined ? {} : { transports }), + }; +} + +function unwrapPublicKey( + value: unknown, + path: string, +): Record { + const envelope = asRecord(value, path); + if ("publicKey" in envelope) { + requireExactKeys(envelope, path, ["publicKey"]); + const publicKey = asRecord(envelope.publicKey, `${path}.publicKey`); + boundedJson(publicKey, `${path}.publicKey`); + return publicKey; + } + boundedJson(envelope, path); + return envelope; +} + +function parseCreationOptions( + value: unknown, +): PublicKeyCredentialCreationOptions { + const options = unwrapPublicKey(value, "public_key"); + for ( + const field of ["rp", "user", "challenge", "pubKeyCredParams"] as const + ) { + if (!(field in options)) { + invalid( + `public_key.publicKey.${field}`, + "is required", + ); + } + } + + const rp = asRecord(options.rp, "public_key.publicKey.rp"); + requireExactKeys(rp, "public_key.publicKey.rp", ["name"], ["id"]); + const normalizedRp: PublicKeyCredentialRpEntity = { + name: boundedString(rp.name, "public_key.publicKey.rp.name"), + ...(rp.id === undefined + ? {} + : { id: boundedString(rp.id, "public_key.publicKey.rp.id") }), + }; + + const user = asRecord(options.user, "public_key.publicKey.user"); + requireExactKeys(user, "public_key.publicKey.user", [ + "id", + "name", + "displayName", + ]); + const normalizedUser: PublicKeyCredentialUserEntity = { + id: fromBase64Url(user.id, "public_key.publicKey.user.id"), + name: boundedString(user.name, "public_key.publicKey.user.name"), + displayName: boundedString( + user.displayName, + "public_key.publicKey.user.displayName", + ), + }; + + if ( + !Array.isArray(options.pubKeyCredParams) || + options.pubKeyCredParams.length === 0 + ) { + invalid( + "public_key.publicKey.pubKeyCredParams", + "must be a non-empty array", + ); + } + if (options.pubKeyCredParams.length > MAX_AUTH_ARRAY_LENGTH) { + invalid("public_key.publicKey.pubKeyCredParams", "has too many items"); + } + const pubKeyCredParams = options.pubKeyCredParams.map((value, index) => { + const parameter = asRecord( + value, + `public_key.publicKey.pubKeyCredParams[${index}]`, + ); + requireExactKeys( + parameter, + `public_key.publicKey.pubKeyCredParams[${index}]`, + ["type", "alg"], + ); + if (parameter.type !== "public-key") { + invalid( + `public_key.publicKey.pubKeyCredParams[${index}].type`, + "must be public-key", + ); + } + if ( + typeof parameter.alg !== "number" || !Number.isSafeInteger(parameter.alg) + ) { + invalid( + `public_key.publicKey.pubKeyCredParams[${index}].alg`, + "must be a safe integer", + ); + } + return { type: "public-key" as const, alg: parameter.alg }; + }); + + let excludeCredentials: PublicKeyCredentialDescriptor[] | undefined; + if (options.excludeCredentials !== undefined) { + if (!Array.isArray(options.excludeCredentials)) { + invalid("public_key.publicKey.excludeCredentials", "must be an array"); + } + if (options.excludeCredentials.length > MAX_AUTH_ARRAY_LENGTH) { + invalid("public_key.publicKey.excludeCredentials", "has too many items"); + } + excludeCredentials = options.excludeCredentials.map((descriptor, index) => + parseCredentialDescriptor( + descriptor, + `public_key.publicKey.excludeCredentials[${index}]`, + ) + ); + } + + const timeout = options.timeout === undefined + ? undefined + : positiveSafeInteger( + options.timeout, + "public_key.publicKey.timeout", + MAX_WEBAUTHN_TIMEOUT_MS, + ); + + return { + ...(options as unknown as PublicKeyCredentialCreationOptions), + rp: normalizedRp, + user: normalizedUser, + challenge: fromBase64Url( + options.challenge, + "public_key.publicKey.challenge", + ), + pubKeyCredParams, + ...(excludeCredentials === undefined ? {} : { excludeCredentials }), + ...(timeout === undefined ? {} : { timeout }), + }; +} + +function parseRequestOptions( + value: unknown, +): PublicKeyCredentialRequestOptions { + const options = unwrapPublicKey(value, "public_key"); + if (!("challenge" in options)) { + invalid("public_key.publicKey.challenge", "is required"); + } + + let allowCredentials: PublicKeyCredentialDescriptor[] | undefined; + if (options.allowCredentials !== undefined) { + if (!Array.isArray(options.allowCredentials)) { + invalid("public_key.publicKey.allowCredentials", "must be an array"); + } + if (options.allowCredentials.length > MAX_AUTH_ARRAY_LENGTH) { + invalid("public_key.publicKey.allowCredentials", "has too many items"); + } + allowCredentials = options.allowCredentials.map((descriptor, index) => + parseCredentialDescriptor( + descriptor, + `public_key.publicKey.allowCredentials[${index}]`, + ) + ); + } + const timeout = options.timeout === undefined + ? undefined + : positiveSafeInteger( + options.timeout, + "public_key.publicKey.timeout", + MAX_WEBAUTHN_TIMEOUT_MS, + ); + if (options.rpId !== undefined) { + boundedString(options.rpId, "public_key.publicKey.rpId"); + } + + return { + ...(options as unknown as PublicKeyCredentialRequestOptions), + challenge: fromBase64Url( + options.challenge, + "public_key.publicKey.challenge", + ), + ...(allowCredentials === undefined ? {} : { allowCredentials }), + ...(timeout === undefined ? {} : { timeout }), + }; +} + +function parseAuthenticatedUser( + value: unknown, + path: string, +): AuthenticatedUser { + const user = asRecord(value, path); + requireExactKeys(user, path, [ + "user_id", + "account_id", + "handle", + "display_name", + ]); + return { + user_id: boundedString(user.user_id, `${path}.user_id`), + account_id: boundedString(user.account_id, `${path}.account_id`), + handle: boundedString(user.handle, `${path}.handle`), + display_name: boundedString(user.display_name, `${path}.display_name`, { + allowEmpty: true, + }), + }; +} + +function parseActor(value: unknown, path: string): RequestActor { + const actor = asRecord(value, path); + requireExactKeys(actor, path, [ + "user_id", + "account_id", + "handle", + "display_name", + "auth_method", + ]); + const authMethod = actor.auth_method; + if (authMethod !== "browser_session" && authMethod !== "api_token") { + invalid(`${path}.auth_method`, "contains an unknown value"); + } + return { + user_id: boundedString(actor.user_id, `${path}.user_id`), + account_id: boundedString(actor.account_id, `${path}.account_id`), + handle: boundedString(actor.handle, `${path}.handle`), + display_name: boundedString(actor.display_name, `${path}.display_name`, { + allowEmpty: true, + }), + auth_method: authMethod as ActorAuthMethod, + }; +} + +export function parseWhoamiResponse(value: unknown): WhoamiResponse { + const response = asRecord(value, "whoami"); + requireExactKeys(response, "whoami", ["actor"]); + return { + actor: response.actor === null + ? null + : parseActor(response.actor, "whoami.actor"), + }; +} + +export function parseAuthPublicConfig(value: unknown): AuthPublicConfig { + const response = asRecord(value, "auth_config"); + requireExactKeys(response, "auth_config", [ + "rp_id", + "origin", + "public_base_url", + "cookie_name", + ]); + const result = { + rp_id: boundedString(response.rp_id, "auth_config.rp_id"), + origin: boundedString(response.origin, "auth_config.origin"), + public_base_url: boundedString( + response.public_base_url, + "auth_config.public_base_url", + ), + cookie_name: boundedString(response.cookie_name, "auth_config.cookie_name"), + }; + for ( + const [field, url] of [["origin", result.origin], [ + "public_base_url", + result.public_base_url, + ]] + ) { + try { + const parsed = new URL(url); + if ( + !(["http:", "https:"].includes(parsed.protocol)) || parsed.username || + parsed.password + ) { + invalid(`auth_config.${field}`, "must be a safe HTTP(S) URL"); + } + } catch { + invalid(`auth_config.${field}`, "must be a valid URL"); + } + } + return result; +} + +export function parseAuthUserResponse(value: unknown): PasskeyUserResponse { + const response = asRecord(value, "auth_user"); + requireExactKeys(response, "auth_user", ["user"]); + return { user: parseAuthenticatedUser(response.user, "auth_user.user") }; } export function prepareRegistrationOptions( - options: PasskeyRegistrationOptionsResponse, + value: unknown, ): PublicKeyCredentialCreationOptions { - const publicKey = structuredClone(unwrapPublicKey(options.public_key)); - publicKey.challenge = base64UrlToBuffer( - publicKey.challenge as unknown as string, - ); - publicKey.user = { - ...publicKey.user, - id: base64UrlToBuffer(publicKey.user.id as unknown as string), - }; - publicKey.excludeCredentials = publicKey.excludeCredentials?.map(( - credential, - ) => ({ - ...credential, - id: base64UrlToBuffer(credential.id as unknown as string), - })); - return publicKey; + const response = asRecord(value, "registration_options"); + requireExactKeys(response, "registration_options", [ + "challenge_id", + "public_key", + ]); + boundedString(response.challenge_id, "registration_options.challenge_id"); + return parseCreationOptions(response.public_key); } export function prepareLoginOptions( - options: PasskeyLoginOptionsResponse, + value: unknown, ): PublicKeyCredentialRequestOptions { - const publicKey = structuredClone(unwrapPublicKey(options.public_key)); - publicKey.challenge = base64UrlToBuffer( - publicKey.challenge as unknown as string, - ); - publicKey.allowCredentials = publicKey.allowCredentials?.map(( + const response = asRecord(value, "login_options"); + requireExactKeys(response, "login_options", ["challenge_id", "public_key"]); + boundedString(response.challenge_id, "login_options.challenge_id"); + return parseRequestOptions(response.public_key); +} + +function credentialCore(credential: unknown): Record { + const result = asRecord(credential, "credential"); + if (result.type !== "public-key") { + invalid("credential.type", "must be public-key"); + } + boundedString(result.id, "credential.id"); + toBase64Url(result.rawId, "credential.rawId"); + if (typeof result.getClientExtensionResults !== "function") { + invalid("credential.getClientExtensionResults", "must be a function"); + } + return result; +} + +function clientExtensionResults( + credential: Record, +): AuthenticationExtensionsClientOutputs { + const value = (credential.getClientExtensionResults as () => unknown).call( credential, - ) => ({ - ...credential, - id: base64UrlToBuffer(credential.id as unknown as string), - })); - return publicKey; + ); + boundedJson(value, "credential.clientExtensionResults"); + return asRecord( + value, + "credential.clientExtensionResults", + ) as AuthenticationExtensionsClientOutputs; } -export function registrationCredentialToJson( - credential: PublicKeyCredential, -): RegistrationCredentialJson { - const response = credential.response as AuthenticatorAttestationResponse; +export function registrationCredentialToJson(credential: unknown): unknown { + const core = credentialCore(credential); + const response = asRecord(core.response, "credential.response"); + if (typeof response.getTransports !== "function") { + invalid("credential.response.getTransports", "must be a function"); + } + const transportsValue = (response.getTransports as () => unknown).call( + response, + ); + if ( + !Array.isArray(transportsValue) || + transportsValue.length > MAX_AUTH_ARRAY_LENGTH + ) { + invalid("credential.response.transports", "must be a bounded array"); + } + const transports = transportsValue.map((transport, index) => + parseAuthenticatorTransport( + transport, + `credential.response.transports[${index}]`, + ) + ); + const authenticatorAttachment = optionalString( + core.authenticatorAttachment, + "credential.authenticatorAttachment", + ); return { - id: credential.id, - rawId: bufferToBase64Url(credential.rawId), - type: credential.type, + id: boundedString(core.id, "credential.id"), + rawId: toBase64Url(core.rawId, "credential.rawId"), + type: "public-key", response: { - clientDataJSON: bufferToBase64Url(response.clientDataJSON), - attestationObject: bufferToBase64Url(response.attestationObject), - transports: response.getTransports?.() ?? [], + clientDataJSON: toBase64Url( + response.clientDataJSON, + "credential.response.clientDataJSON", + ), + attestationObject: toBase64Url( + response.attestationObject, + "credential.response.attestationObject", + ), + transports, }, + clientExtensionResults: clientExtensionResults(core), + ...(authenticatorAttachment === null ? {} : { authenticatorAttachment }), }; } -export function authenticationCredentialToJson( - credential: PublicKeyCredential, -): AuthenticationCredentialJson { - const response = credential.response as AuthenticatorAssertionResponse; +export function authenticationCredentialToJson(credential: unknown): unknown { + const core = credentialCore(credential); + const response = asRecord(core.response, "credential.response"); + const authenticatorAttachment = optionalString( + core.authenticatorAttachment, + "credential.authenticatorAttachment", + ); return { - id: credential.id, - rawId: bufferToBase64Url(credential.rawId), - type: credential.type, + id: boundedString(core.id, "credential.id"), + rawId: toBase64Url(core.rawId, "credential.rawId"), + type: "public-key", response: { - clientDataJSON: bufferToBase64Url(response.clientDataJSON), - authenticatorData: bufferToBase64Url(response.authenticatorData), - signature: bufferToBase64Url(response.signature), - userHandle: response.userHandle - ? bufferToBase64Url(response.userHandle) - : null, + clientDataJSON: toBase64Url( + response.clientDataJSON, + "credential.response.clientDataJSON", + ), + authenticatorData: toBase64Url( + response.authenticatorData, + "credential.response.authenticatorData", + ), + signature: toBase64Url( + response.signature, + "credential.response.signature", + ), + userHandle: response.userHandle === null + ? null + : toBase64Url(response.userHandle, "credential.response.userHandle"), }, + clientExtensionResults: clientExtensionResults(core), + ...(authenticatorAttachment === null ? {} : { authenticatorAttachment }), }; } -export function isPublicKeyCredential( - credential: Credential | null, -): credential is PublicKeyCredential { - return credential != null && credential.type === "public-key"; +export function parseDeviceLoginStartResponse( + value: unknown, +): DeviceLoginStartResponse { + const response = asRecord(value, "device_login_start"); + requireExactKeys(response, "device_login_start", [ + "device_code", + "user_code", + "verification_uri", + "verification_uri_complete", + "expires_in", + "interval", + ]); + const result = { + device_code: boundedString( + response.device_code, + "device_login_start.device_code", + ), + user_code: boundedString( + response.user_code, + "device_login_start.user_code", + ), + verification_uri: boundedString( + response.verification_uri, + "device_login_start.verification_uri", + ), + verification_uri_complete: boundedString( + response.verification_uri_complete, + "device_login_start.verification_uri_complete", + ), + expires_in: positiveSafeInteger( + response.expires_in, + "device_login_start.expires_in", + MAX_DEVICE_LOGIN_SECONDS, + ), + interval: positiveSafeInteger( + response.interval, + "device_login_start.interval", + 60, + ), + }; + for ( + const [field, url] of [ + ["verification_uri", result.verification_uri], + ["verification_uri_complete", result.verification_uri_complete], + ] + ) { + try { + const parsed = new URL(url); + if ( + !(["http:", "https:"].includes(parsed.protocol)) || parsed.username || + parsed.password + ) { + invalid(`device_login_start.${field}`, "must be a safe HTTP(S) URL"); + } + } catch { + invalid(`device_login_start.${field}`, "must be a valid URL"); + } + } + return result; +} + +export function parseDeviceLoginPollResponse( + value: unknown, +): DeviceLoginPollResponse { + const response = asRecord(value, "device_login_poll"); + requireExactKeys(response, "device_login_poll", ["status"], [ + "access_token", + "token_type", + ]); + const status = response.status; + if ( + !["pending", "approved", "expired", "consumed"].includes(String(status)) + ) { + invalid("device_login_poll.status", "contains an unknown value"); + } + const typedStatus = status as DeviceLoginPollStatus; + const accessToken = optionalString( + response.access_token, + "device_login_poll.access_token", + ); + const tokenType = optionalString( + response.token_type, + "device_login_poll.token_type", + ); + if (typedStatus === "approved") { + if (accessToken === null || tokenType !== "Bearer") { + invalid("device_login_poll", "has invalid approved token fields"); + } + } else if (accessToken !== null || tokenType !== null) { + invalid("device_login_poll", "has token fields for a non-approved status"); + } + return { + status: typedStatus, + ...(accessToken === null ? {} : { access_token: accessToken }), + ...(tokenType === null ? {} : { token_type: "Bearer" as const }), + }; +} + +export function parseDeviceApprovalResponse( + value: unknown, +): DeviceApprovalResponse { + const response = asRecord(value, "device_login_approval"); + requireExactKeys(response, "device_login_approval", ["status", "user"]); + if (response.status !== "approved") { + invalid("device_login_approval.status", "contains an unknown value"); + } + return { + status: "approved", + user: parseAuthenticatedUser(response.user, "device_login_approval.user"), + }; +} + +export function parseLogoutResponse(value: unknown): LogoutResponse { + const response = asRecord(value, "logout"); + requireExactKeys(response, "logout", ["status"]); + if (response.status !== "logged_out") { + invalid("logout.status", "contains an unknown value"); + } + return { status: "logged_out" }; }