feat: centralize auth REST contracts
This commit is contained in:
Generated
+1
@@ -6710,6 +6710,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ts-rs",
|
||||
"webauthn-rs-proto",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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<String>,
|
||||
pub token_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BackendAuthClientError {
|
||||
Http(reqwest::Error),
|
||||
@@ -74,16 +60,6 @@ impl From<reqwest::Error> 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 {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
print!("{}", workspace_api::auth_api_typescript());
|
||||
}
|
||||
@@ -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<RequestActor>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||
pub browser_origin: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||
pub browser_origin: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
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<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||
pub token_type: Option<DeviceAccessTokenType>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>()
|
||||
.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::<WhoamiResponse>(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::<PasskeyRegistrationOptionsResponse>(
|
||||
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::<DeviceLoginStartResponse>(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::<DeviceLoginPollResponse>(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::<DeviceLoginStartResponse>(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::<PasskeyLoginCompleteRequest>(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::<WhoamiResponse>(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::<String>()
|
||||
.replace(";}", "}")
|
||||
.replace("=|", "=")
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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<String> {
|
||||
let normalized = handle.trim().to_ascii_lowercase();
|
||||
let valid = !normalized.is_empty()
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AuthUserResponse {
|
||||
user: AuthenticatedUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct PasskeyRegistrationOptionsRequest {
|
||||
handle: String,
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
browser_origin: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
browser_origin: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
token_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct WhoamiResponse {
|
||||
actor: Option<RequestActor>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct LogoutResponse {
|
||||
status: String,
|
||||
}
|
||||
|
||||
async fn get_auth_config(State(api): State<ServerAuthApi>) -> ApiResult<Json<AuthPublicConfig>> {
|
||||
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())
|
||||
|
||||
@@ -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 };
|
||||
@@ -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<T>(response: Response): Promise<T> {
|
||||
const text = await response.text();
|
||||
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
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<string, unknown>
|
||||
: 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<WhoamiResponse> {
|
||||
return parseWhoamiResponse(await requestJson("/api/auth/whoami"));
|
||||
}
|
||||
|
||||
export async function loadWhoami(
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<WhoamiResponse> {
|
||||
return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(
|
||||
jsonOrThrow<WhoamiResponse>,
|
||||
export async function logout(): Promise<void> {
|
||||
parseLogoutResponse(
|
||||
await requestJson("/api/auth/logout", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function registerPasskey(
|
||||
handle: string,
|
||||
displayName: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<PasskeyUserResponse> {
|
||||
const options = await fetcher("/api/auth/passkeys/registration/options", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
displayName?: string,
|
||||
): Promise<AuthUser> {
|
||||
const optionsRequest: PasskeyRegistrationOptionsRequest = {
|
||||
handle,
|
||||
display_name: displayName,
|
||||
browser_origin: browserOrigin(),
|
||||
}),
|
||||
}).then(jsonOrThrow<PasskeyRegistrationOptionsResponse>);
|
||||
|
||||
display_name: displayName ?? null,
|
||||
browser_origin: window.location.origin,
|
||||
};
|
||||
const options = await requestJson("/api/auth/passkeys/registration/options", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(optionsRequest),
|
||||
});
|
||||
const optionsRecord = typeof options === "object" && options !== null
|
||||
? options as Record<string, unknown>
|
||||
: 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)) {
|
||||
if (!credential) throw new Error("Passkey registration was cancelled");
|
||||
|
||||
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),
|
||||
}),
|
||||
);
|
||||
return result.user;
|
||||
}
|
||||
|
||||
export async function loginWithPasskey(handle?: string): Promise<AuthUser> {
|
||||
const optionsRequest: PasskeyLoginOptionsRequest = {
|
||||
handle: handle ?? null,
|
||||
browser_origin: window.location.origin,
|
||||
};
|
||||
const options = await requestJson("/api/auth/passkeys/login/options", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(optionsRequest),
|
||||
});
|
||||
const optionsRecord = typeof options === "object" && options !== null
|
||||
? options as Record<string, unknown>
|
||||
: null;
|
||||
const challengeId = optionsRecord?.challenge_id;
|
||||
if (typeof challengeId !== "string" || challengeId.length === 0) {
|
||||
throw new Error(
|
||||
"Passkey registration did not return a public-key credential.",
|
||||
"Invalid auth payload: login_options.challenge_id is required.",
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
}),
|
||||
}).then(jsonOrThrow<PasskeyUserResponse>);
|
||||
}
|
||||
|
||||
export async function loginWithPasskey(
|
||||
handle: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<PasskeyUserResponse> {
|
||||
const options = await fetcher("/api/auth/passkeys/login/options", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ handle, browser_origin: browserOrigin() }),
|
||||
}).then(jsonOrThrow<PasskeyLoginOptionsResponse>);
|
||||
|
||||
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,
|
||||
const completeRequest: PasskeyLoginCompleteRequest = {
|
||||
challenge_id: challengeId,
|
||||
credential: authenticationCredentialToJson(credential),
|
||||
}),
|
||||
}).then(jsonOrThrow<PasskeyUserResponse>);
|
||||
}
|
||||
|
||||
export async function logout(fetcher: typeof fetch = fetch): Promise<void> {
|
||||
await fetcher("/api/auth/logout", {
|
||||
};
|
||||
const result = parseAuthUserResponse(
|
||||
await requestJson("/api/auth/passkeys/login/complete", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
}).then(jsonOrThrow<unknown>);
|
||||
body: JSON.stringify(completeRequest),
|
||||
}),
|
||||
);
|
||||
return result.user;
|
||||
}
|
||||
|
||||
export async function approveDeviceLogin(
|
||||
userCode: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<DeviceApprovalResponse> {
|
||||
return await fetcher("/api/auth/device-login/approve", {
|
||||
const request: DeviceLoginApproveRequest = { user_code: userCode };
|
||||
return parseDeviceApprovalResponse(
|
||||
await requestJson("/api/auth/device-login/approve", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ user_code: userCode }),
|
||||
}).then(jsonOrThrow<DeviceApprovalResponse>);
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<T>(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,6 +59,7 @@ Deno.test("prepareRegistrationOptions decodes binary public key fields", () => {
|
||||
const options = prepareRegistrationOptions({
|
||||
challenge_id: "challenge-1",
|
||||
public_key: {
|
||||
publicKey: {
|
||||
challenge: "AQID" as unknown as BufferSource,
|
||||
rp: { id: "localhost", name: "Yoi" },
|
||||
user: {
|
||||
@@ -52,6 +73,7 @@ Deno.test("prepareRegistrationOptions decodes binary public key fields", () => {
|
||||
id: "BwgJ" as unknown as BufferSource,
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assertEquals(bytes(options.challenge), [1, 2, 3]);
|
||||
@@ -67,12 +89,14 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
|
||||
const options = prepareLoginOptions({
|
||||
challenge_id: "challenge-1",
|
||||
public_key: {
|
||||
publicKey: {
|
||||
challenge: "AQID" as unknown as BufferSource,
|
||||
allowCredentials: [{
|
||||
type: "public-key",
|
||||
id: "BwgJ" as unknown as BufferSource,
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assertEquals(bytes(options.challenge), [1, 2, 3]);
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
invalid(path, "must be an object");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
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<T>(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");
|
||||
}
|
||||
return value as T;
|
||||
|
||||
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 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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>,
|
||||
): 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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user