backend: add passkey auth api
This commit is contained in:
parent
ed8110ae45
commit
ba0289b820
189
crates/workspace-server/src/auth.rs
Normal file
189
crates/workspace-server/src/auth.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
use axum::http::{HeaderMap, header};
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
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()
|
||||||
|
&& normalized.len() <= 64
|
||||||
|
&& normalized
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
|
||||||
|
if !valid {
|
||||||
|
return Err(auth_error(
|
||||||
|
"invalid_handle",
|
||||||
|
"handle must be 1-64 chars of ascii lowercase letters, digits, '-' or '_'",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_rfc3339() -> String {
|
||||||
|
Utc::now().to_rfc3339()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rfc3339_after(duration: Duration) -> String {
|
||||||
|
(Utc::now() + duration).to_rfc3339()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_expired(expires_at: &str) -> bool {
|
||||||
|
match DateTime::parse_from_rfc3339(expires_at) {
|
||||||
|
Ok(expires_at) => expires_at.with_timezone(&Utc) <= Utc::now(),
|
||||||
|
Err(_) => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_id(prefix: &str) -> String {
|
||||||
|
format!("{prefix}-{}", Uuid::now_v7())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_challenge() -> String {
|
||||||
|
format!("chal-{}-{}", Uuid::now_v7(), Uuid::now_v7())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mint_secret(prefix: &str) -> String {
|
||||||
|
format!("{prefix}_{}_{}", Uuid::now_v7(), Uuid::now_v7())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn token_hash(token: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
let mut encoded = String::with_capacity(digest.len() * 2 + "sha256:".len());
|
||||||
|
encoded.push_str("sha256:");
|
||||||
|
for byte in digest {
|
||||||
|
encoded.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_user_code() -> String {
|
||||||
|
let hex = Uuid::now_v7().simple().to_string().to_ascii_uppercase();
|
||||||
|
format!("{}-{}", &hex[0..4], &hex[4..8])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_bearer(headers: &HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.strip_prefix("Bearer "))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option<String> {
|
||||||
|
let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?;
|
||||||
|
cookie_header.split(';').find_map(|pair| {
|
||||||
|
let (name, value) = pair.trim().split_once('=')?;
|
||||||
|
(name == cookie_name && !value.is_empty()).then(|| value.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_request_actor<S: ControlPlaneStore + ?Sized>(
|
||||||
|
store: &S,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
cookie_name: &str,
|
||||||
|
) -> Result<Option<RequestActor>> {
|
||||||
|
if let Some(token) = parse_bearer(headers) {
|
||||||
|
let token_hash = token_hash(&token);
|
||||||
|
if let Some(api_token) = store.resolve_api_token(&token_hash)? {
|
||||||
|
if api_token
|
||||||
|
.expires_at
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|expires_at| is_expired(expires_at))
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
store.mark_api_token_used(&token_hash, &now_rfc3339())?;
|
||||||
|
return actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(session_token) = parse_cookie(headers, cookie_name) {
|
||||||
|
if let Some(session) = store.resolve_browser_session(&token_hash(&session_token))? {
|
||||||
|
if is_expired(&session.expires_at) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
return actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor_for_user<S: ControlPlaneStore + ?Sized>(
|
||||||
|
store: &S,
|
||||||
|
user_id: &str,
|
||||||
|
auth_method: ActorAuthMethod,
|
||||||
|
) -> Result<Option<RequestActor>> {
|
||||||
|
let Some(user) = store.get_user(user_id)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(Some(RequestActor {
|
||||||
|
user_id: user.user_id,
|
||||||
|
account_id: user.account_id,
|
||||||
|
handle: user.handle,
|
||||||
|
display_name: user.display_name,
|
||||||
|
auth_method,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_set_cookie(cookie_name: &str, token: &str, max_age_seconds: i64) -> String {
|
||||||
|
format!("{cookie_name}={token}; Max-Age={max_age_seconds}; Path=/; HttpOnly; SameSite=Lax")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn auth_error(code: &str, message: &str) -> Error {
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: "workspace-auth".to_string(),
|
||||||
|
code: code.to_string(),
|
||||||
|
message: message.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,8 +15,27 @@ pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
|
||||||
include_str!("../../../resources/workspace-backend.default.toml");
|
include_str!("../../../resources/workspace-backend.default.toml");
|
||||||
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
||||||
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173";
|
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173";
|
||||||
|
const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://127.0.0.1:8787";
|
||||||
|
const DEFAULT_AUTH_RP_ID: &str = "127.0.0.1";
|
||||||
|
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
|
||||||
const DEFAULT_MAX_RECORDS: usize = 200;
|
const DEFAULT_MAX_RECORDS: usize = 200;
|
||||||
|
|
||||||
|
fn default_auth_rp_id() -> String {
|
||||||
|
DEFAULT_AUTH_RP_ID.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_auth_origin() -> String {
|
||||||
|
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_auth_public_base_url() -> String {
|
||||||
|
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_auth_cookie_name() -> String {
|
||||||
|
DEFAULT_AUTH_COOKIE_NAME.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceBackendConfigFile {
|
pub struct WorkspaceBackendConfigFile {
|
||||||
|
|
@ -27,6 +46,8 @@ pub struct WorkspaceBackendConfigFile {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub limits: WorkspaceBackendLimitsConfig,
|
pub limits: WorkspaceBackendLimitsConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub auth: WorkspaceBackendAuthConfig,
|
||||||
|
#[serde(default)]
|
||||||
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
|
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub runtimes: WorkspaceBackendRuntimesConfig,
|
pub runtimes: WorkspaceBackendRuntimesConfig,
|
||||||
|
|
@ -61,6 +82,30 @@ pub struct WorkspaceBackendLimitsConfig {
|
||||||
pub max_records: Option<usize>,
|
pub max_records: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkspaceBackendAuthConfig {
|
||||||
|
#[serde(default = "default_auth_rp_id")]
|
||||||
|
pub rp_id: String,
|
||||||
|
#[serde(default = "default_auth_origin")]
|
||||||
|
pub origin: String,
|
||||||
|
#[serde(default = "default_auth_public_base_url")]
|
||||||
|
pub public_base_url: String,
|
||||||
|
#[serde(default = "default_auth_cookie_name")]
|
||||||
|
pub cookie_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WorkspaceBackendAuthConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
rp_id: default_auth_rp_id(),
|
||||||
|
origin: default_auth_origin(),
|
||||||
|
public_base_url: default_auth_public_base_url(),
|
||||||
|
cookie_name: default_auth_cookie_name(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceRepositoryConfigFile {
|
pub struct WorkspaceRepositoryConfigFile {
|
||||||
|
|
@ -290,8 +335,11 @@ impl WorkspaceBackendConfigFile {
|
||||||
.iter()
|
.iter()
|
||||||
.map(resolve_remote_runtime)
|
.map(resolve_remote_runtime)
|
||||||
.collect::<Result<Vec<_>>>()?;
|
.collect::<Result<Vec<_>>>()?;
|
||||||
server.auth = AuthConfig::LocalDevToken {
|
server.auth = AuthConfig::Passkey {
|
||||||
token_configured: false,
|
rp_id: self.auth.rp_id.trim().to_string(),
|
||||||
|
origin: self.auth.origin.trim().to_string(),
|
||||||
|
public_base_url: self.auth.public_base_url.trim().to_string(),
|
||||||
|
cookie_name: self.auth.cookie_name.trim().to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(ResolvedWorkspaceBackendConfig {
|
Ok(ResolvedWorkspaceBackendConfig {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
//! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files
|
//! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files
|
||||||
//! remain the canonical project records and are read through bounded bridge APIs.
|
//! remain the canonical project records and are read through bounded bridge APIs.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
pub mod companion;
|
pub mod companion;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod hosts;
|
pub mod hosts;
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{Path as AxumPath, Query, State};
|
use axum::extract::{Path as AxumPath, Query, State};
|
||||||
use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION};
|
use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION, SET_COOKIE};
|
||||||
use axum::http::{HeaderMap, StatusCode, Uri};
|
use axum::http::{HeaderMap, StatusCode, Uri};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{delete, get, post, put};
|
use axum::routing::{delete, get, post, put};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use chrono::{SecondsFormat, Utc};
|
use chrono::{Duration, SecondsFormat, Utc};
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use memory::backend::{
|
use memory::backend::{
|
||||||
MemoryBackendHttpResponse, MemoryBackendOperation, execute_memory_backend_operation,
|
MemoryBackendHttpResponse, MemoryBackendOperation, execute_memory_backend_operation,
|
||||||
|
|
@ -29,6 +29,11 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
||||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||||
|
|
||||||
|
use crate::auth::{
|
||||||
|
AuthPublicConfig, AuthenticatedUser, RequestActor, auth_error, is_expired, mint_secret,
|
||||||
|
new_challenge, new_id, new_user_code, normalize_handle, resolve_request_actor, rfc3339_after,
|
||||||
|
session_set_cookie, token_hash,
|
||||||
|
};
|
||||||
use crate::companion::{
|
use crate::companion::{
|
||||||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||||
CompanionStatusResponse, CompanionTranscriptProjection,
|
CompanionStatusResponse, CompanionTranscriptProjection,
|
||||||
|
|
@ -64,8 +69,9 @@ use crate::repositories::{
|
||||||
use crate::resource_broker::BackendResourceBroker;
|
use crate::resource_broker::BackendResourceBroker;
|
||||||
use crate::skills;
|
use crate::skills;
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord,
|
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
|
||||||
WorkspaceRecord,
|
DeviceLoginFlowRecord, PasskeyCredentialRecord, UserRecord, WorkdirRegistryRecord,
|
||||||
|
WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
||||||
};
|
};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
|
|
@ -91,9 +97,14 @@ fn embedded_runtime_id() -> String {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub enum AuthConfig {
|
pub enum AuthConfig {
|
||||||
/// Local/dev-only mode. If a token is configured by a future entrypoint, it
|
/// Browser human auth uses Passkey ceremonies and HttpOnly cookie sessions;
|
||||||
/// is a development guard only and not a production SaaS auth model.
|
/// CLI/TUI auth uses API tokens obtained through the device login flow.
|
||||||
LocalDevToken { token_configured: bool },
|
Passkey {
|
||||||
|
rp_id: String,
|
||||||
|
origin: String,
|
||||||
|
public_base_url: String,
|
||||||
|
cookie_name: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -126,8 +137,11 @@ impl ServerConfig {
|
||||||
frontend_url: "http://127.0.0.1:5173".to_string(),
|
frontend_url: "http://127.0.0.1:5173".to_string(),
|
||||||
embedded_runtime_store_root,
|
embedded_runtime_store_root,
|
||||||
static_assets_dir: None,
|
static_assets_dir: None,
|
||||||
auth: AuthConfig::LocalDevToken {
|
auth: AuthConfig::Passkey {
|
||||||
token_configured: false,
|
rp_id: "127.0.0.1".to_string(),
|
||||||
|
origin: "http://127.0.0.1:8787".to_string(),
|
||||||
|
public_base_url: "http://127.0.0.1:8787".to_string(),
|
||||||
|
cookie_name: "yoi_workspace_session".to_string(),
|
||||||
},
|
},
|
||||||
max_records: 200,
|
max_records: 200,
|
||||||
repositories: Vec::new(),
|
repositories: Vec::new(),
|
||||||
|
|
@ -298,6 +312,28 @@ impl WorkspaceApi {
|
||||||
|
|
||||||
pub fn build_router(api: WorkspaceApi) -> Router {
|
pub fn build_router(api: WorkspaceApi) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/api/auth/config", get(get_auth_config))
|
||||||
|
.route("/api/auth/bootstrap-user", post(post_auth_bootstrap_user))
|
||||||
|
.route(
|
||||||
|
"/api/auth/passkeys/registration/options",
|
||||||
|
post(post_passkey_registration_options),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/auth/passkeys/registration/complete",
|
||||||
|
post(post_passkey_registration_complete),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/auth/passkeys/login/options",
|
||||||
|
post(post_passkey_login_options),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/auth/passkeys/login/complete",
|
||||||
|
post(post_passkey_login_complete),
|
||||||
|
)
|
||||||
|
.route("/api/auth/device-login/start", post(post_device_login_start))
|
||||||
|
.route("/api/auth/device-login/approve", post(post_device_login_approve))
|
||||||
|
.route("/api/auth/device-login/poll", post(post_device_login_poll))
|
||||||
|
.route("/api/auth/whoami", get(get_auth_whoami))
|
||||||
.route("/api/workspace", get(get_workspace))
|
.route("/api/workspace", get(get_workspace))
|
||||||
.route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace))
|
.route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace))
|
||||||
.route(
|
.route(
|
||||||
|
|
@ -2444,6 +2480,591 @@ async fn scoped_list_host_workers(
|
||||||
list_host_workers(State(api), AxumPath(path.host_id)).await
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct PasskeyRegistrationOptionsResponse {
|
||||||
|
challenge: String,
|
||||||
|
rp: PublicKeyCredentialRpEntity,
|
||||||
|
user: PublicKeyCredentialUserEntity,
|
||||||
|
timeout_ms: u64,
|
||||||
|
attestation: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct PublicKeyCredentialRpEntity {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct PublicKeyCredentialUserEntity {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct PasskeyRegistrationCompleteRequest {
|
||||||
|
challenge: String,
|
||||||
|
credential_id: String,
|
||||||
|
public_key_cose: String,
|
||||||
|
#[serde(default)]
|
||||||
|
transports: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
sign_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct PasskeyLoginOptionsRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
handle: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct PasskeyLoginOptionsResponse {
|
||||||
|
challenge: String,
|
||||||
|
rp_id: String,
|
||||||
|
timeout_ms: u64,
|
||||||
|
allow_credentials: Vec<PasskeyAllowedCredential>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct PasskeyAllowedCredential {
|
||||||
|
credential_id: String,
|
||||||
|
transports: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct PasskeyLoginCompleteRequest {
|
||||||
|
challenge: String,
|
||||||
|
credential_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct PasskeyLoginCompleteResponse {
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_auth_config(State(api): State<WorkspaceApi>) -> ApiResult<Json<AuthPublicConfig>> {
|
||||||
|
Ok(Json(auth_public_config(&api.config)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_auth_bootstrap_user(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
Json(request): Json<AuthBootstrapUserRequest>,
|
||||||
|
) -> ApiResult<Json<AuthUserResponse>> {
|
||||||
|
let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?;
|
||||||
|
Ok(Json(AuthUserResponse { user }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_passkey_registration_options(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
Json(request): Json<PasskeyRegistrationOptionsRequest>,
|
||||||
|
) -> ApiResult<Json<PasskeyRegistrationOptionsResponse>> {
|
||||||
|
let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?;
|
||||||
|
let auth = auth_public_config(&api.config);
|
||||||
|
let challenge = new_challenge();
|
||||||
|
api.store.put_auth_challenge(&AuthChallengeRecord {
|
||||||
|
challenge_id: new_id("auth-challenge"),
|
||||||
|
ceremony: "passkey_registration".to_string(),
|
||||||
|
challenge: challenge.clone(),
|
||||||
|
user_id: Some(user.user_id.clone()),
|
||||||
|
rp_id: auth.rp_id.clone(),
|
||||||
|
origin: auth.origin.clone(),
|
||||||
|
expires_at: rfc3339_after(Duration::minutes(5)),
|
||||||
|
created_at: crate::auth::now_rfc3339(),
|
||||||
|
consumed_at: None,
|
||||||
|
})?;
|
||||||
|
Ok(Json(PasskeyRegistrationOptionsResponse {
|
||||||
|
challenge,
|
||||||
|
rp: PublicKeyCredentialRpEntity {
|
||||||
|
id: auth.rp_id,
|
||||||
|
name: "Yoi Workspace".to_string(),
|
||||||
|
},
|
||||||
|
user: PublicKeyCredentialUserEntity {
|
||||||
|
id: user.user_id,
|
||||||
|
name: user.handle,
|
||||||
|
display_name: user.display_name,
|
||||||
|
},
|
||||||
|
timeout_ms: 300_000,
|
||||||
|
attestation: "none".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_passkey_registration_complete(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
Json(request): Json<PasskeyRegistrationCompleteRequest>,
|
||||||
|
) -> ApiResult<Json<AuthUserResponse>> {
|
||||||
|
let challenge = api
|
||||||
|
.store
|
||||||
|
.consume_auth_challenge(
|
||||||
|
&request.challenge,
|
||||||
|
"passkey_registration",
|
||||||
|
&crate::auth::now_rfc3339(),
|
||||||
|
)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
auth_error(
|
||||||
|
"invalid_passkey_challenge",
|
||||||
|
"passkey registration challenge is invalid or already consumed",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if is_expired(&challenge.expires_at) {
|
||||||
|
return Err(auth_error(
|
||||||
|
"expired_passkey_challenge",
|
||||||
|
"passkey registration challenge expired",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let user_id = challenge.user_id.ok_or_else(|| {
|
||||||
|
auth_error(
|
||||||
|
"invalid_passkey_challenge",
|
||||||
|
"passkey registration challenge is not bound to a user",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let user = api.store.get_user(&user_id)?.ok_or_else(|| {
|
||||||
|
auth_error(
|
||||||
|
"unknown_auth_user",
|
||||||
|
"passkey registration user does not exist",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let transports_json = if request.transports.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
serde_json::to_string(&request.transports)
|
||||||
|
.map_err(|error| auth_error("invalid_passkey_transports", &error.to_string()))?,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
api.store
|
||||||
|
.upsert_passkey_credential(&PasskeyCredentialRecord {
|
||||||
|
credential_id: request.credential_id,
|
||||||
|
user_id: user.user_id.clone(),
|
||||||
|
public_key_cose: request.public_key_cose,
|
||||||
|
transports_json,
|
||||||
|
sign_count: request.sign_count,
|
||||||
|
created_at: crate::auth::now_rfc3339(),
|
||||||
|
last_used_at: None,
|
||||||
|
})?;
|
||||||
|
Ok(Json(AuthUserResponse {
|
||||||
|
user: user_response(user),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_passkey_login_options(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
Json(request): Json<PasskeyLoginOptionsRequest>,
|
||||||
|
) -> ApiResult<Json<PasskeyLoginOptionsResponse>> {
|
||||||
|
let user = match request.handle.as_deref() {
|
||||||
|
Some(handle) => api.store.get_user_by_handle(&normalize_handle(handle)?)?,
|
||||||
|
None => api.store.any_user()?,
|
||||||
|
}
|
||||||
|
.ok_or_else(|| auth_error("unknown_auth_user", "no matching user account exists"))?;
|
||||||
|
let credentials = api.store.list_passkey_credentials_for_user(&user.user_id)?;
|
||||||
|
if credentials.is_empty() {
|
||||||
|
return Err(auth_error(
|
||||||
|
"passkey_not_registered",
|
||||||
|
"user has no registered passkey credentials",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let auth = auth_public_config(&api.config);
|
||||||
|
let challenge = new_challenge();
|
||||||
|
api.store.put_auth_challenge(&AuthChallengeRecord {
|
||||||
|
challenge_id: new_id("auth-challenge"),
|
||||||
|
ceremony: "passkey_login".to_string(),
|
||||||
|
challenge: challenge.clone(),
|
||||||
|
user_id: Some(user.user_id),
|
||||||
|
rp_id: auth.rp_id.clone(),
|
||||||
|
origin: auth.origin,
|
||||||
|
expires_at: rfc3339_after(Duration::minutes(5)),
|
||||||
|
created_at: crate::auth::now_rfc3339(),
|
||||||
|
consumed_at: None,
|
||||||
|
})?;
|
||||||
|
Ok(Json(PasskeyLoginOptionsResponse {
|
||||||
|
challenge,
|
||||||
|
rp_id: auth.rp_id,
|
||||||
|
timeout_ms: 300_000,
|
||||||
|
allow_credentials: credentials
|
||||||
|
.into_iter()
|
||||||
|
.map(|credential| PasskeyAllowedCredential {
|
||||||
|
credential_id: credential.credential_id,
|
||||||
|
transports: credential
|
||||||
|
.transports_json
|
||||||
|
.and_then(|raw| serde_json::from_str(&raw).ok())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_passkey_login_complete(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
Json(request): Json<PasskeyLoginCompleteRequest>,
|
||||||
|
) -> ApiResult<Response> {
|
||||||
|
let challenge = api
|
||||||
|
.store
|
||||||
|
.consume_auth_challenge(
|
||||||
|
&request.challenge,
|
||||||
|
"passkey_login",
|
||||||
|
&crate::auth::now_rfc3339(),
|
||||||
|
)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
auth_error(
|
||||||
|
"invalid_passkey_challenge",
|
||||||
|
"passkey login challenge is invalid or already consumed",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if is_expired(&challenge.expires_at) {
|
||||||
|
return Err(auth_error(
|
||||||
|
"expired_passkey_challenge",
|
||||||
|
"passkey login challenge expired",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let credential = api
|
||||||
|
.store
|
||||||
|
.get_passkey_credential(&request.credential_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
auth_error(
|
||||||
|
"unknown_passkey_credential",
|
||||||
|
"passkey credential is not registered",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if Some(credential.user_id.clone()) != challenge.user_id {
|
||||||
|
return Err(auth_error(
|
||||||
|
"passkey_user_mismatch",
|
||||||
|
"passkey credential does not belong to the challenged user",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let user = api
|
||||||
|
.store
|
||||||
|
.get_user(&credential.user_id)?
|
||||||
|
.ok_or_else(|| auth_error("unknown_auth_user", "passkey user does not exist"))?;
|
||||||
|
let session_token = mint_secret("yoi_sess");
|
||||||
|
api.store.create_browser_session(&BrowserSessionRecord {
|
||||||
|
session_id: new_id("session"),
|
||||||
|
token_hash: token_hash(&session_token),
|
||||||
|
user_id: user.user_id.clone(),
|
||||||
|
created_at: crate::auth::now_rfc3339(),
|
||||||
|
expires_at: rfc3339_after(Duration::days(14)),
|
||||||
|
revoked_at: None,
|
||||||
|
})?;
|
||||||
|
let cookie_name = auth_public_config(&api.config).cookie_name;
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
SET_COOKIE,
|
||||||
|
session_set_cookie(&cookie_name, &session_token, 14 * 24 * 60 * 60)
|
||||||
|
.parse()
|
||||||
|
.map_err(|error| {
|
||||||
|
auth_error(
|
||||||
|
"invalid_session_cookie",
|
||||||
|
&format!("failed to build session cookie: {error}"),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
Ok((
|
||||||
|
headers,
|
||||||
|
Json(PasskeyLoginCompleteResponse {
|
||||||
|
user: user_response(user),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_device_login_start(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
Json(request): Json<DeviceLoginStartRequest>,
|
||||||
|
) -> ApiResult<Json<DeviceLoginStartResponse>> {
|
||||||
|
let auth = auth_public_config(&api.config);
|
||||||
|
let device_code = mint_secret("yoi_device");
|
||||||
|
let user_code = new_user_code();
|
||||||
|
let verification_uri = format!(
|
||||||
|
"{}/login/device",
|
||||||
|
auth.public_base_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let verification_uri_complete = format!("{verification_uri}?user_code={user_code}");
|
||||||
|
api.store.create_device_login_flow(&DeviceLoginFlowRecord {
|
||||||
|
device_code: device_code.clone(),
|
||||||
|
user_code: user_code.clone(),
|
||||||
|
verification_uri: verification_uri.clone(),
|
||||||
|
client_name: request.client_name,
|
||||||
|
user_id: None,
|
||||||
|
api_token_id: None,
|
||||||
|
issued_access_token: None,
|
||||||
|
created_at: crate::auth::now_rfc3339(),
|
||||||
|
expires_at: rfc3339_after(Duration::minutes(10)),
|
||||||
|
approved_at: None,
|
||||||
|
consumed_at: None,
|
||||||
|
})?;
|
||||||
|
Ok(Json(DeviceLoginStartResponse {
|
||||||
|
device_code,
|
||||||
|
user_code,
|
||||||
|
verification_uri,
|
||||||
|
verification_uri_complete,
|
||||||
|
expires_in: 600,
|
||||||
|
interval: 5,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_device_login_approve(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<DeviceLoginApproveRequest>,
|
||||||
|
) -> ApiResult<Json<DeviceLoginApproveResponse>> {
|
||||||
|
let actor = require_actor(&api, &headers).await?;
|
||||||
|
let flow = api
|
||||||
|
.store
|
||||||
|
.get_device_login_flow_by_user_code(&request.user_code.trim().to_ascii_uppercase())?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
auth_error(
|
||||||
|
"unknown_device_login_code",
|
||||||
|
"device login code does not exist",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if is_expired(&flow.expires_at) {
|
||||||
|
return Err(auth_error("expired_device_login", "device login code expired").into());
|
||||||
|
}
|
||||||
|
if flow.approved_at.is_some() || flow.consumed_at.is_some() {
|
||||||
|
return Err(auth_error(
|
||||||
|
"device_login_already_used",
|
||||||
|
"device login code is already used",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let access_token = mint_secret("yoi_api");
|
||||||
|
let token_id = new_id("api-token");
|
||||||
|
api.store.create_api_token(&ApiTokenRecord {
|
||||||
|
token_id: token_id.clone(),
|
||||||
|
token_hash: token_hash(&access_token),
|
||||||
|
user_id: actor.user_id.clone(),
|
||||||
|
label: flow
|
||||||
|
.client_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "yoi device login".to_string()),
|
||||||
|
created_at: crate::auth::now_rfc3339(),
|
||||||
|
expires_at: None,
|
||||||
|
revoked_at: None,
|
||||||
|
last_used_at: None,
|
||||||
|
})?;
|
||||||
|
let approved = api.store.approve_device_login_flow(
|
||||||
|
&flow.device_code,
|
||||||
|
&actor.user_id,
|
||||||
|
&token_id,
|
||||||
|
&access_token,
|
||||||
|
&crate::auth::now_rfc3339(),
|
||||||
|
)?;
|
||||||
|
if !approved {
|
||||||
|
return Err(auth_error(
|
||||||
|
"device_login_already_used",
|
||||||
|
"device login code is already used",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
Ok(Json(DeviceLoginApproveResponse {
|
||||||
|
status: "approved".to_string(),
|
||||||
|
user: actor.user(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_device_login_poll(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
Json(request): Json<DeviceLoginPollRequest>,
|
||||||
|
) -> ApiResult<Json<DeviceLoginPollResponse>> {
|
||||||
|
let Some(flow) = api
|
||||||
|
.store
|
||||||
|
.get_device_login_flow_by_device_code(&request.device_code)?
|
||||||
|
else {
|
||||||
|
return Err(auth_error("unknown_device_login", "device login flow does not exist").into());
|
||||||
|
};
|
||||||
|
if is_expired(&flow.expires_at) {
|
||||||
|
return Ok(Json(DeviceLoginPollResponse {
|
||||||
|
status: "expired".to_string(),
|
||||||
|
access_token: None,
|
||||||
|
token_type: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if flow.approved_at.is_none() {
|
||||||
|
return Ok(Json(DeviceLoginPollResponse {
|
||||||
|
status: "pending".to_string(),
|
||||||
|
access_token: None,
|
||||||
|
token_type: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let Some(consumed) = api
|
||||||
|
.store
|
||||||
|
.consume_device_login_token(&request.device_code, &crate::auth::now_rfc3339())?
|
||||||
|
else {
|
||||||
|
return Ok(Json(DeviceLoginPollResponse {
|
||||||
|
status: "consumed".to_string(),
|
||||||
|
access_token: None,
|
||||||
|
token_type: None,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
Ok(Json(DeviceLoginPollResponse {
|
||||||
|
status: "approved".to_string(),
|
||||||
|
access_token: consumed.issued_access_token,
|
||||||
|
token_type: Some("Bearer".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_auth_whoami(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> ApiResult<Json<WhoamiResponse>> {
|
||||||
|
Ok(Json(WhoamiResponse {
|
||||||
|
actor: resolve_actor(&api, &headers).await?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_public_config(config: &ServerConfig) -> AuthPublicConfig {
|
||||||
|
match &config.auth {
|
||||||
|
AuthConfig::Passkey {
|
||||||
|
rp_id,
|
||||||
|
origin,
|
||||||
|
public_base_url,
|
||||||
|
cookie_name,
|
||||||
|
} => AuthPublicConfig {
|
||||||
|
rp_id: rp_id.clone(),
|
||||||
|
origin: origin.clone(),
|
||||||
|
public_base_url: public_base_url.clone(),
|
||||||
|
cookie_name: cookie_name.clone(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_user_account(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
handle: &str,
|
||||||
|
display_name: Option<&str>,
|
||||||
|
) -> ApiResult<AuthenticatedUser> {
|
||||||
|
let handle = normalize_handle(handle)?;
|
||||||
|
if let Some(user) = api.store.get_user_by_handle(&handle)? {
|
||||||
|
return Ok(user_response(user));
|
||||||
|
}
|
||||||
|
let now = crate::auth::now_rfc3339();
|
||||||
|
let display_name = display_name
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(handle.as_str())
|
||||||
|
.to_string();
|
||||||
|
let account = AccountRecord {
|
||||||
|
account_id: new_id("acct-user"),
|
||||||
|
kind: "user".to_string(),
|
||||||
|
handle: handle.clone(),
|
||||||
|
display_name: display_name.clone(),
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now.clone(),
|
||||||
|
};
|
||||||
|
api.store.upsert_account(&account)?;
|
||||||
|
let user = UserRecord {
|
||||||
|
user_id: new_id("user"),
|
||||||
|
account_id: account.account_id,
|
||||||
|
handle,
|
||||||
|
display_name,
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
api.store.upsert_user(&user)?;
|
||||||
|
Ok(user_response(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_response(user: UserRecord) -> AuthenticatedUser {
|
||||||
|
AuthenticatedUser {
|
||||||
|
user_id: user.user_id,
|
||||||
|
account_id: user.account_id,
|
||||||
|
handle: user.handle,
|
||||||
|
display_name: user.display_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_actor(api: &WorkspaceApi, headers: &HeaderMap) -> ApiResult<Option<RequestActor>> {
|
||||||
|
let cookie_name = auth_public_config(&api.config).cookie_name;
|
||||||
|
Ok(resolve_request_actor(api.store.as_ref(), headers, &cookie_name).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn require_actor(api: &WorkspaceApi, headers: &HeaderMap) -> ApiResult<RequestActor> {
|
||||||
|
resolve_actor(api, headers).await?.ok_or_else(|| {
|
||||||
|
auth_error(
|
||||||
|
"auth_required",
|
||||||
|
"request requires a browser session or Bearer API token",
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
|
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
|
||||||
let schema_version = api.store.schema_version().await?;
|
let schema_version = api.store.schema_version().await?;
|
||||||
let stored = api.store.get_workspace(api.workspace_id()).await?;
|
let stored = api.store.get_workspace(api.workspace_id()).await?;
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,14 @@ frontend_url = "http://127.0.0.1:5173"
|
||||||
[limits]
|
[limits]
|
||||||
max_records = 200
|
max_records = 200
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
# WebAuthn / Passkey relying-party settings. For local development keep rp_id
|
||||||
|
# aligned with the browser host in public_base_url/origin.
|
||||||
|
rp_id = "127.0.0.1"
|
||||||
|
origin = "http://127.0.0.1:8787"
|
||||||
|
public_base_url = "http://127.0.0.1:8787"
|
||||||
|
cookie_name = "yoi_workspace_session"
|
||||||
|
|
||||||
# Repository registry. Browser/API repository projection reads only configured
|
# Repository registry. Browser/API repository projection reads only configured
|
||||||
# entries and never falls back to the backend process cwd. Relative URI values
|
# entries and never falls back to the backend process cwd. Relative URI values
|
||||||
# are resolved from this workspace config root. Git is the v0 supported provider.
|
# are resolved from this workspace config root. Git is the v0 supported provider.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user