backend: add passkey auth api

This commit is contained in:
Keisuke Hirata 2026-07-23 01:05:40 +09:00
parent ed8110ae45
commit ba0289b820
No known key found for this signature in database
5 changed files with 878 additions and 11 deletions

View 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(),
}
}

View File

@ -15,8 +15,27 @@ pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
include_str!("../../../resources/workspace-backend.default.toml");
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
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;
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)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendConfigFile {
@ -27,6 +46,8 @@ pub struct WorkspaceBackendConfigFile {
#[serde(default)]
pub limits: WorkspaceBackendLimitsConfig,
#[serde(default)]
pub auth: WorkspaceBackendAuthConfig,
#[serde(default)]
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
#[serde(default)]
pub runtimes: WorkspaceBackendRuntimesConfig,
@ -61,6 +82,30 @@ pub struct WorkspaceBackendLimitsConfig {
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)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRepositoryConfigFile {
@ -290,8 +335,11 @@ impl WorkspaceBackendConfigFile {
.iter()
.map(resolve_remote_runtime)
.collect::<Result<Vec<_>>>()?;
server.auth = AuthConfig::LocalDevToken {
token_configured: false,
server.auth = AuthConfig::Passkey {
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 {

View File

@ -4,6 +4,7 @@
//! 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.
pub mod auth;
pub mod companion;
pub mod config;
pub mod hosts;

View File

@ -5,12 +5,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
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::response::{IntoResponse, Response};
use axum::routing::{delete, get, post, put};
use axum::{Json, Router};
use chrono::{SecondsFormat, Utc};
use chrono::{Duration, SecondsFormat, Utc};
use futures::{SinkExt, StreamExt};
use memory::backend::{
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::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::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
CompanionStatusResponse, CompanionTranscriptProjection,
@ -64,8 +69,9 @@ use crate::repositories::{
use crate::resource_broker::BackendResourceBroker;
use crate::skills;
use crate::store::{
ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord,
WorkspaceRecord,
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
DeviceLoginFlowRecord, PasskeyCredentialRecord, UserRecord, WorkdirRegistryRecord,
WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
};
use crate::{Error, Result};
use worker_runtime::catalog::{
@ -91,9 +97,14 @@ fn embedded_runtime_id() -> String {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AuthConfig {
/// Local/dev-only mode. If a token is configured by a future entrypoint, it
/// is a development guard only and not a production SaaS auth model.
LocalDevToken { token_configured: bool },
/// Browser human auth uses Passkey ceremonies and HttpOnly cookie sessions;
/// CLI/TUI auth uses API tokens obtained through the device login flow.
Passkey {
rp_id: String,
origin: String,
public_base_url: String,
cookie_name: String,
},
}
#[derive(Clone)]
@ -126,8 +137,11 @@ impl ServerConfig {
frontend_url: "http://127.0.0.1:5173".to_string(),
embedded_runtime_store_root,
static_assets_dir: None,
auth: AuthConfig::LocalDevToken {
token_configured: false,
auth: AuthConfig::Passkey {
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,
repositories: Vec::new(),
@ -298,6 +312,28 @@ impl WorkspaceApi {
pub fn build_router(api: WorkspaceApi) -> Router {
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/w/{workspace_id}/workspace", get(scoped_get_workspace))
.route(
@ -2444,6 +2480,591 @@ 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>,
}
#[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>> {
let schema_version = api.store.schema_version().await?;
let stored = api.store.get_workspace(api.workspace_id()).await?;

View File

@ -41,6 +41,14 @@ frontend_url = "http://127.0.0.1:5173"
[limits]
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
# 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.