//! Authentication middleware — resolves the caller's `Principal` from //! either a session cookie / Bearer session-token OR an API key //! (`Authorization: Bearer pic_…`). Both paths converge on the same //! request extension so downstream handlers see one shape. //! //! Capability checks live in `crate::authz` and are called per-handler //! (after the relevant resource is loaded, so the capability binds to //! the actual resource's `app_id`). This middleware is gate-only: it //! ensures *some* `Principal` is attached, or returns 401. //! //! Token discriminator: the `pic_` prefix on a Bearer value selects //! the API-key path; anything else (raw 32-byte base64-url-encoded //! string) takes the session path. The session cookie can only ever //! carry a session token (cookies are never API keys). use std::sync::Arc; use std::time::Duration; use axum::body::Body; use axum::extract::{Request, State}; use axum::http::{header, StatusCode}; use axum::middleware::Next; use axum::response::{IntoResponse, Json, Response}; use chrono::Utc; use picloud_shared::{AdminUserId, Principal}; use serde_json::json; use crate::admin_session_repo::AdminSessionRepository; use crate::admin_user_repo::AdminUserRepository; use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification}; use crate::auth::{hash_token, verify_password}; pub const SESSION_COOKIE: &str = "picloud_session"; /// Prefix on the wire that selects the API-key path. The body that /// follows is `base32(32 random bytes)`; the first 8 chars of the body /// index into `api_keys.prefix` for verification. pub const API_KEY_PREFIX: &str = "pic_"; /// Length of the indexed prefix portion of an API key (the 8 chars /// immediately after `pic_`). Schema-side index is on this slice. pub const API_KEY_PREFIX_LEN: usize = 8; /// Shared state for auth: the user / session / API-key repos plus the /// configured sliding session TTL. Cheap to clone (`Arc` everywhere). #[derive(Clone)] pub struct AuthState { pub users: Arc, pub sessions: Arc, pub keys: Arc, pub ttl: Duration, } /// Legacy request-extension alias retained so the (only remaining) /// handler that pulled `AuthedAdmin` out — `GET /admin/auth/me` — /// keeps compiling during the migration. New handlers should pull /// `Extension` directly. #[deprecated(note = "use Extension directly")] #[derive(Debug, Clone)] pub struct AuthedAdmin { pub id: AdminUserId, pub username: String, } /// Middleware entry point. Wire with /// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`. /// Inserts `Principal` (and the legacy `AuthedAdmin`) as request /// extensions on success; returns 401 on any failure mode. pub async fn require_authenticated( State(state): State, mut req: Request, next: Next, ) -> Response { let Some(token) = extract_token(&req) else { return unauthorized(); }; let principal = match resolve_principal(&state, &token).await { Ok(Some(p)) => p, Ok(None) => return unauthorized(), Err(InternalError) => return internal_error(), }; let username_for_legacy = username_for(&state, principal.user_id).await; req.extensions_mut().insert(principal.clone()); #[allow(deprecated)] if let Some(username) = username_for_legacy { req.extensions_mut().insert(AuthedAdmin { id: principal.user_id, username, }); } next.run(req).await } /// Backwards-compatible alias — the single callsite that still names /// `require_admin` keeps working without an immediate rename. New /// wiring should call `require_authenticated`. #[deprecated(note = "renamed to require_authenticated")] pub async fn require_admin(state: State, req: Request, next: Next) -> Response { require_authenticated(state, req, next).await } /// Opportunistic data-plane variant: always inserts an /// `Extension>` and forwards the request. Used on /// `/execute/{id}` and the user-route fallback, where most invocations /// are anonymous public HTTP and the few authed ones (dashboard /// test-runs, API keys) should still let scripts see the caller via /// `cx.principal` once services consume it. /// /// Failure modes — all degrade to `None` rather than rejecting: /// * No bearer / cookie → `None`. /// * Malformed or unknown token → `None`. /// * DB blip while resolving → `None` (fail-open; the data plane /// should not 500 on transient infra failures for an *optional* /// identity check). /// /// Admin-side routes that REQUIRE an identity keep using /// `require_authenticated`. pub async fn attach_principal_if_present( State(state): State, mut req: Request, next: Next, ) -> Response { let principal: Option = match extract_token(&req) { Some(token) => resolve_principal(&state, &token).await.unwrap_or(None), None => None, }; req.extensions_mut().insert(principal); next.run(req).await } /// Decide whether the token is an API key (pic_ prefix) or a session /// token, then resolve the corresponding `Principal`. `Ok(None)` /// means the token was structurally valid but didn't match any active /// credential; `Err(InternalError)` means a DB blip. async fn resolve_principal( state: &AuthState, token: &str, ) -> Result, InternalError> { if let Some(rest) = token.strip_prefix(API_KEY_PREFIX) { return verify_api_key(state, rest).await; } verify_session(state, token).await } async fn verify_session( state: &AuthState, token: &str, ) -> Result, InternalError> { let token_hash = hash_token(token); let lookup = match state.sessions.lookup(&token_hash).await { Ok(Some(l)) => l, Ok(None) => return Ok(None), Err(err) => { tracing::error!(?err, "admin_sessions lookup failed"); return Err(InternalError); } }; let user = match state.users.get(lookup.user_id).await { Ok(Some(u)) if u.is_active => u, Ok(_) => return Ok(None), Err(err) => { tracing::error!(?err, "admin_users lookup failed"); return Err(InternalError); } }; // Sliding-window bump — inline so a DB blip surfaces as 500 rather // than silent stale sessions. Same shape as Phase 3a. let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default(); if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await { tracing::error!(?err, "admin_sessions touch failed"); return Err(InternalError); } Ok(Some(Principal { user_id: user.id, instance_role: user.instance_role, scopes: None, app_binding: None, })) } /// API-key verification path. `rest` is the portion of the bearer /// value *after* `pic_`. We slice off the first 8 chars as the /// indexed lookup key, then Argon2id-verify each candidate's hash /// against the full `rest`. At most one match is expected; multiple /// candidates with the same prefix is statistically negligible but /// handled correctly (verify each, take the first match). async fn verify_api_key(state: &AuthState, rest: &str) -> Result, InternalError> { if rest.len() <= API_KEY_PREFIX_LEN { return Ok(None); } let prefix = &rest[..API_KEY_PREFIX_LEN]; let candidates = match state.keys.find_active_by_prefix(prefix).await { Ok(v) => v, Err(err) => { tracing::error!(?err, "api_keys lookup failed"); return Err(InternalError); } }; let matched: Option = candidates .into_iter() .find(|c| verify_password(&c.hash, rest)); let Some(matched) = matched else { return Ok(None); }; // Resolve the owning user. is_active = false → reject even if the // key itself hasn't been expired yet (the expire_all_for_user // cascade on deactivation is the primary defense; this is the // belt-and-suspenders check at request time). let user = match state.users.get(matched.user_id).await { Ok(Some(u)) if u.is_active => u, Ok(_) => return Ok(None), Err(err) => { tracing::error!(?err, "admin_users lookup for api key failed"); return Err(InternalError); } }; if let Err(err) = state.keys.touch_last_used(matched.id).await { tracing::error!(?err, "api_keys touch_last_used failed"); // Soft-fail: a timestamp blip should not invalidate the // request. Continue with the resolved Principal. } Ok(Some(Principal { user_id: user.id, instance_role: user.instance_role, scopes: Some(matched.scopes), app_binding: matched.app_id, })) } /// Best-effort username lookup for the legacy `AuthedAdmin` extension. /// Returns `None` on DB error (the caller treats `None` as "skip the /// legacy extension"). New handlers use `Principal` and don't depend /// on this. async fn username_for(state: &AuthState, id: AdminUserId) -> Option { match state.users.get(id).await { Ok(Some(u)) => Some(u.username), Ok(None) => None, Err(err) => { tracing::warn!( ?err, "username lookup for AuthedAdmin failed; skipping legacy ext" ); None } } } /// Pull the bearer token out of an `Authorization` header (preferred) /// or the `picloud_session` cookie (fallback for browser clients). /// Same shape as Phase 3a; the cookie only ever carries session /// tokens — no `pic_` prefix expected there. fn extract_token(req: &Request) -> Option { if let Some(value) = req.headers().get(header::AUTHORIZATION) { if let Ok(s) = value.to_str() { if let Some(token) = s.strip_prefix("Bearer ") { let trimmed = token.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } } } if let Some(value) = req.headers().get(header::COOKIE) { if let Ok(s) = value.to_str() { for chunk in s.split(';') { let chunk = chunk.trim(); if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) { if !rest.is_empty() { return Some(rest.to_string()); } } } } } None } /// Sentinel returned from the resolve functions when a DB error should /// produce a 500 rather than a 401. Empty struct because the actual /// error is already logged at the failure site. struct InternalError; fn unauthorized() -> Response { ( StatusCode::UNAUTHORIZED, Json(json!({ "error": "authentication required" })), ) .into_response() } fn internal_error() -> Response { ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "internal error" })), ) .into_response() } #[cfg(test)] mod tests { use super::*; use axum::http::Request; use picloud_shared::InstanceRole; fn req_with_header(name: &str, value: &str) -> Request { Request::builder() .header(name, value) .body(Body::empty()) .unwrap() } #[test] fn extracts_bearer_token() { let r = req_with_header("authorization", "Bearer abc123"); assert_eq!(extract_token(&r).as_deref(), Some("abc123")); } #[test] fn extracts_bearer_pic_prefixed_token() { let r = req_with_header("authorization", "Bearer pic_abcdefghIJKL"); assert_eq!(extract_token(&r).as_deref(), Some("pic_abcdefghIJKL")); } #[test] fn ignores_bearer_with_no_token() { let r = req_with_header("authorization", "Bearer "); assert_eq!(extract_token(&r), None); } #[test] fn extracts_cookie_token() { let r = req_with_header("cookie", "foo=bar; picloud_session=xyz; baz=qux"); assert_eq!(extract_token(&r).as_deref(), Some("xyz")); } #[test] fn bearer_wins_over_cookie() { let r = Request::builder() .header("authorization", "Bearer header-token") .header("cookie", "picloud_session=cookie-token") .body(Body::empty()) .unwrap(); assert_eq!(extract_token(&r).as_deref(), Some("header-token")); } #[test] fn returns_none_when_neither_present() { let r = Request::builder().body(Body::empty()).unwrap(); assert_eq!(extract_token(&r), None); } // Round-trip test for the unused-variable to keep `Principal` // visibly tied to InstanceRole — caught a real bug during dev when // the field order in the struct literal had drifted. #[test] fn principal_construction_is_explicit() { let p = Principal { user_id: AdminUserId::new(), instance_role: InstanceRole::Owner, scopes: None, app_binding: None, }; assert_eq!(p.instance_role, InstanceRole::Owner); assert!(p.scopes.is_none()); assert!(p.app_binding.is_none()); } }