fix: honor browser origin for passkey ceremonies

This commit is contained in:
Keisuke Hirata 2026-07-23 17:54:51 +09:00
parent 7740191194
commit 28304f13c3
No known key found for this signature in database
2 changed files with 66 additions and 15 deletions

View File

@ -5,7 +5,7 @@ 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, SET_COOKIE}; use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION, ORIGIN, 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};
@ -2615,10 +2615,12 @@ async fn post_auth_bootstrap_user(
async fn post_passkey_registration_options( async fn post_passkey_registration_options(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
headers: HeaderMap,
Json(request): Json<PasskeyRegistrationOptionsRequest>, Json(request): Json<PasskeyRegistrationOptionsRequest>,
) -> ApiResult<Json<PasskeyRegistrationOptionsResponse>> { ) -> ApiResult<Json<PasskeyRegistrationOptionsResponse>> {
let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?; let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?;
let webauthn = webauthn(&api.config)?; let auth = auth_config_for_origin(&api.config, request_origin(&headers).as_deref())?;
let webauthn = webauthn_for_auth(&auth)?;
let exclude_credentials = passkeys_for_user(&api, &user.user_id)? let exclude_credentials = passkeys_for_user(&api, &user.user_id)?
.into_iter() .into_iter()
.map(|passkey| passkey.cred_id().clone()) .map(|passkey| passkey.cred_id().clone())
@ -2633,7 +2635,6 @@ async fn post_passkey_registration_options(
) )
.map_err(|error| auth_error("webauthn_registration_options_failed", &error.to_string()))?; .map_err(|error| auth_error("webauthn_registration_options_failed", &error.to_string()))?;
let challenge_id = new_id("webauthn-registration"); let challenge_id = new_id("webauthn-registration");
let auth = auth_public_config(&api.config);
api.store.put_auth_challenge(&AuthChallengeRecord { api.store.put_auth_challenge(&AuthChallengeRecord {
challenge_id: challenge_id.clone(), challenge_id: challenge_id.clone(),
ceremony: "passkey_registration".to_string(), ceremony: "passkey_registration".to_string(),
@ -2680,7 +2681,7 @@ async fn post_passkey_registration_complete(
) )
.into()); .into());
} }
let user_id = challenge.user_id.ok_or_else(|| { let user_id = challenge.user_id.clone().ok_or_else(|| {
auth_error( auth_error(
"invalid_passkey_challenge", "invalid_passkey_challenge",
"passkey registration challenge is not bound to a user", "passkey registration challenge is not bound to a user",
@ -2692,7 +2693,7 @@ async fn post_passkey_registration_complete(
"passkey registration user does not exist", "passkey registration user does not exist",
) )
})?; })?;
let state_json = challenge.state_json.ok_or_else(|| { let state_json = challenge.state_json.clone().ok_or_else(|| {
auth_error( auth_error(
"missing_webauthn_state", "missing_webauthn_state",
"passkey registration state was not persisted", "passkey registration state was not persisted",
@ -2700,7 +2701,7 @@ async fn post_passkey_registration_complete(
})?; })?;
let state: PasskeyRegistration = serde_json::from_str(&state_json) let state: PasskeyRegistration = serde_json::from_str(&state_json)
.map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?; .map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?;
let webauthn = webauthn(&api.config)?; let webauthn = webauthn_for_challenge(&api.config, &challenge)?;
let passkey = webauthn let passkey = webauthn
.finish_passkey_registration(&request.credential, &state) .finish_passkey_registration(&request.credential, &state)
.map_err(|error| { .map_err(|error| {
@ -2729,6 +2730,7 @@ async fn post_passkey_registration_complete(
async fn post_passkey_login_options( async fn post_passkey_login_options(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
headers: HeaderMap,
Json(request): Json<PasskeyLoginOptionsRequest>, Json(request): Json<PasskeyLoginOptionsRequest>,
) -> ApiResult<Json<PasskeyLoginOptionsResponse>> { ) -> ApiResult<Json<PasskeyLoginOptionsResponse>> {
let user = match request.handle.as_deref() { let user = match request.handle.as_deref() {
@ -2744,12 +2746,12 @@ async fn post_passkey_login_options(
) )
.into()); .into());
} }
let webauthn = webauthn(&api.config)?; let auth = auth_config_for_origin(&api.config, request_origin(&headers).as_deref())?;
let webauthn = webauthn_for_auth(&auth)?;
let (public_key, state) = webauthn let (public_key, state) = webauthn
.start_passkey_authentication(&passkeys) .start_passkey_authentication(&passkeys)
.map_err(|error| auth_error("webauthn_login_options_failed", &error.to_string()))?; .map_err(|error| auth_error("webauthn_login_options_failed", &error.to_string()))?;
let challenge_id = new_id("webauthn-login"); let challenge_id = new_id("webauthn-login");
let auth = auth_public_config(&api.config);
api.store.put_auth_challenge(&AuthChallengeRecord { api.store.put_auth_challenge(&AuthChallengeRecord {
challenge_id: challenge_id.clone(), challenge_id: challenge_id.clone(),
ceremony: "passkey_login".to_string(), ceremony: "passkey_login".to_string(),
@ -2796,7 +2798,7 @@ async fn post_passkey_login_complete(
) )
.into()); .into());
} }
let user_id = challenge.user_id.ok_or_else(|| { let user_id = challenge.user_id.clone().ok_or_else(|| {
auth_error( auth_error(
"invalid_passkey_challenge", "invalid_passkey_challenge",
"passkey login challenge is not bound to a user", "passkey login challenge is not bound to a user",
@ -2806,7 +2808,7 @@ async fn post_passkey_login_complete(
.store .store
.get_user(&user_id)? .get_user(&user_id)?
.ok_or_else(|| auth_error("unknown_auth_user", "passkey user does not exist"))?; .ok_or_else(|| auth_error("unknown_auth_user", "passkey user does not exist"))?;
let state_json = challenge.state_json.ok_or_else(|| { let state_json = challenge.state_json.clone().ok_or_else(|| {
auth_error( auth_error(
"missing_webauthn_state", "missing_webauthn_state",
"passkey login state was not persisted", "passkey login state was not persisted",
@ -2814,7 +2816,7 @@ async fn post_passkey_login_complete(
})?; })?;
let state: PasskeyAuthentication = serde_json::from_str(&state_json) let state: PasskeyAuthentication = serde_json::from_str(&state_json)
.map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?; .map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?;
let webauthn = webauthn(&api.config)?; let webauthn = webauthn_for_challenge(&api.config, &challenge)?;
let auth_result = webauthn let auth_result = webauthn
.finish_passkey_authentication(&request.credential, &state) .finish_passkey_authentication(&request.credential, &state)
.map_err(|error| auth_error("webauthn_login_verification_failed", &error.to_string()))?; .map_err(|error| auth_error("webauthn_login_verification_failed", &error.to_string()))?;
@ -3060,8 +3062,48 @@ async fn post_auth_logout(
.into_response()) .into_response())
} }
fn webauthn(config: &ServerConfig) -> ApiResult<Webauthn> { fn request_origin(headers: &HeaderMap) -> Option<String> {
let auth = auth_public_config(config); headers
.get(ORIGIN)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn auth_config_for_origin(
config: &ServerConfig,
request_origin: Option<&str>,
) -> ApiResult<AuthPublicConfig> {
let mut auth = auth_public_config(config);
let Some(request_origin) = request_origin else {
return Ok(auth);
};
let origin = Url::parse(request_origin)
.map_err(|error| auth_error("invalid_webauthn_request_origin", &error.to_string()))?;
let host = origin.host_str().ok_or_else(|| {
auth_error(
"invalid_webauthn_request_origin",
"request Origin header does not contain a host",
)
})?;
if host == auth.rp_id {
auth.origin = request_origin.to_string();
}
Ok(auth)
}
fn webauthn_for_challenge(
config: &ServerConfig,
challenge: &AuthChallengeRecord,
) -> ApiResult<Webauthn> {
let mut auth = auth_public_config(config);
auth.rp_id = challenge.rp_id.clone();
auth.origin = challenge.origin.clone();
webauthn_for_auth(&auth)
}
fn webauthn_for_auth(auth: &AuthPublicConfig) -> ApiResult<Webauthn> {
let origin = Url::parse(&auth.origin) let origin = Url::parse(&auth.origin)
.map_err(|error| auth_error("invalid_webauthn_origin", &error.to_string()))?; .map_err(|error| auth_error("invalid_webauthn_origin", &error.to_string()))?;
WebauthnBuilder::new(&auth.rp_id, &origin) WebauthnBuilder::new(&auth.rp_id, &origin)

View File

@ -10,9 +10,18 @@ export const load: LayoutLoad = async ({ fetch, params, url }) => {
const workspaceId = params.workspaceId; const workspaceId = params.workspaceId;
if (!workspaceId) { if (!workspaceId) {
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
const publicRoutes = new Set(["/account", "/login/device"]); const publicRoutes = new Set(["/account", "/login/device"]);
if (workspace.data && !publicRoutes.has(url.pathname)) { if (publicRoutes.has(url.pathname)) {
return {
workspace: null,
workspaceError: null,
repositories: null,
repositoriesError: null,
};
}
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
if (workspace.data) {
const scopedPath = workspaceRoute(workspace.data.workspace_id); const scopedPath = workspaceRoute(workspace.data.workspace_id);
throw redirect(307, `${scopedPath}${url.search}`); throw redirect(307, `${scopedPath}${url.search}`);
} }