From 7c2a8f4fac18dde6942f4602a59b2c97720d7e54 Mon Sep 17 00:00:00 2001 From: github-ops Date: Thu, 14 May 2026 19:48:58 +0000 Subject: [PATCH] auth: extract community auth crate --- Cargo.lock | 20 +++ Cargo.toml | 1 + TASKS.md | 2 + apps/admin-api/Cargo.toml | 2 + apps/admin-api/src/app.rs | 53 +++++++- apps/admin-api/src/auth.rs | 113 ++-------------- apps/admin-api/src/main.rs | 4 + apps/admin-api/src/service.rs | 121 ++++++++++++------ crates/crank-community-auth/Cargo.toml | 20 +++ crates/crank-community-auth/src/hashing.rs | 61 +++++++++ crates/crank-community-auth/src/lib.rs | 10 ++ .../src/password_provider.rs | 73 +++++++++++ .../src/session_cookie.rs | 116 +++++++++++++++++ 13 files changed, 457 insertions(+), 139 deletions(-) create mode 100644 crates/crank-community-auth/Cargo.toml create mode 100644 crates/crank-community-auth/src/hashing.rs create mode 100644 crates/crank-community-auth/src/lib.rs create mode 100644 crates/crank-community-auth/src/password_provider.rs create mode 100644 crates/crank-community-auth/src/session_cookie.rs diff --git a/Cargo.lock b/Cargo.lock index 5836d70..b311cf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,9 +7,11 @@ name = "admin-api" version = "0.1.0" dependencies = [ "argon2", + "async-trait", "axum", "axum-extra", "base64", + "crank-community-auth", "crank-core", "crank-mapping", "crank-registry", @@ -395,6 +397,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "crank-community-auth" +version = "0.1.0" +dependencies = [ + "argon2", + "async-trait", + "axum-extra", + "base64", + "crank-core", + "crank-registry", + "rand 0.8.5", + "sha2", + "thiserror", + "time", + "tracing", + "uuid", +] + [[package]] name = "crank-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d45b91c..666a721 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "apps/admin-api", "apps/mcp-server", + "crates/crank-community-auth", "crates/crank-core", "crates/crank-schema", "crates/crank-mapping", diff --git a/TASKS.md b/TASKS.md index 90cb3b9..6ac8053 100644 --- a/TASKS.md +++ b/TASKS.md @@ -158,4 +158,6 @@ Progress: - Phase 0 / task `0.14`: добавлены `AdminServiceBuilder`, seam slots (`identity_provider`, `policy_engine`, `audit_sink`, `token_issuer`, `capability_profile`) и community defaults для них; `apps/admin-api/src/main.rs` переведен на builder - Phase 0 / task `0.15`: route delegation переведен на public seams — `capabilities` route идет через `capability_profile`, write handlers в `routes/access.rs` проверяют `policy_engine` и пишут generic audit events через `audit_sink`, а `machine_auth` route использует `token_issuer` seam и сохраняет текущий Community `403` contract - Phase 0 / task `0.16`: phase verification gate пройден — `just fmt`, `just check`, `just test`, таргетные Community machine-auth tests и MCP initialize smoke (`requires_initialized_notification_before_tool_methods`) зеленые; runtime regression test обновлен под новый `RuntimeError::ProtocolAdapter` contract + - Phase 1 / tasks `1.1`–`1.3`: strict-REST Community cleanup уже был закрыт ранее — non-REST adapters и `crank-proto` удалены из workspace, descriptor/premium UI surface убран из Community build, wizard и i18n приведены к REST-only baseline + - Phase 1 / task `1.4`: создан crate `crank-community-auth`; в него вынесены password hashing/session cookie primitives и `PasswordIdentityProvider`, `admin-api` переключен на этот crate, а `login()` теперь реально делегирует credential check через `IdentityProvider` seam - backward-compatible alias `CommunityMachineCredentialVerifier` сохранен, поведение Community не изменено diff --git a/apps/admin-api/Cargo.toml b/apps/admin-api/Cargo.toml index 62500d2..4b745c6 100644 --- a/apps/admin-api/Cargo.toml +++ b/apps/admin-api/Cargo.toml @@ -14,6 +14,7 @@ argon2.workspace = true axum.workspace = true axum-extra.workspace = true base64.workspace = true +crank-community-auth = { path = "../../crates/crank-community-auth" } crank-core = { path = "../../crates/crank-core" } crank-mapping = { path = "../../crates/crank-mapping" } crank-registry = { path = "../../crates/crank-registry" } @@ -33,5 +34,6 @@ tracing-subscriber.workspace = true uuid.workspace = true [dev-dependencies] +async-trait = "0.1" reqwest.workspace = true serial_test = "3" diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 28bed72..0022148 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -194,9 +194,11 @@ mod tests { use std::{ collections::BTreeMap, env, fmt, + sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; + use async_trait::async_trait; use axum::{Json, Router, routing::post}; #[cfg(any())] use crank_adapter_grpc::test_support as grpc_test_support; @@ -211,6 +213,7 @@ mod tests { OperationSecurityLevel, Protocol, ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WebsocketTarget, WorkspaceId, }; + use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome}; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::PostgresRegistry; #[cfg(any())] @@ -227,7 +230,7 @@ mod tests { use crate::{ app::build_app, auth::{AuthSettings, BootstrapAdminConfig, hash_password}, - service::{AdminService, OperationPayload}, + service::{AdminService, AdminServiceBuilder, OperationPayload}, state::AppState, }; @@ -272,6 +275,26 @@ mod tests { } } + struct RejectingIdentityProvider; + + #[async_trait] + impl IdentityProvider for RejectingIdentityProvider { + fn id(&self) -> &str { + "rejecting-test-provider" + } + + fn kind(&self) -> IdentityProviderKind { + IdentityProviderKind::Password + } + + async fn login_password( + &self, + _payload: crank_core::LoginPayload, + ) -> Result { + Err(IdentityError::BadCredentials) + } + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn creates_publishes_and_tests_rest_operation() { @@ -3344,6 +3367,34 @@ mod tests { let _ = handle.await; } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn login_uses_identity_provider_when_configured() { + let registry = test_registry().await; + let storage_root = test_storage_root("identity_provider_login"); + let service = AdminServiceBuilder::new( + registry, + storage_root, + test_auth_settings(), + test_secret_crypto(), + crank_runtime::community_default().build(), + ) + .with_identity_provider(Arc::new(RejectingIdentityProvider)) + .build(); + + let error = match service + .login(crate::service::LoginPayload { + email: TEST_AUTH_EMAIL.to_owned(), + password: TEST_AUTH_PASSWORD.to_owned(), + }) + .await + { + Ok(_) => panic!("login should delegate to identity provider"), + Err(error) => error, + }; + assert_eq!(error.to_string(), "invalid email or password"); + } + async fn assert_success_json(response: reqwest::Response) -> Value { let status = response.status(); let body = response.text().await.unwrap(); diff --git a/apps/admin-api/src/auth.rs b/apps/admin-api/src/auth.rs index 34b0c75..7548ba1 100644 --- a/apps/admin-api/src/auth.rs +++ b/apps/admin-api/src/auth.rs @@ -1,26 +1,23 @@ -#[cfg(test)] -use argon2::{Algorithm, Params, Version}; -use argon2::{ - Argon2, - password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, -}; use axum::{ extract::{OriginalUri, Request, State}, middleware::Next, response::Response, }; -use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use axum_extra::extract::cookie::{Cookie, CookieJar}; +use crank_community_auth::{ + cleared_session_cookie as build_cleared_session_cookie, + create_session_cookie as build_session_cookie, hash_password as community_hash_password, + session_cookie as build_session_cookie_header, +}; use crank_core::{User, UserSessionId, WorkspaceId}; use crank_registry::WorkspaceMembershipRecord; -use rand::RngCore; use serde::Serialize; -use sha2::{Digest, Sha256}; -use time::{Duration, OffsetDateTime}; use crate::{error::ApiError, state::AppState}; -pub const SESSION_COOKIE_NAME: &str = "crank_session"; +pub use crank_community_auth::{ + SESSION_COOKIE_NAME, SessionCookie, extract_session_token, hash_session_secret, verify_password, +}; #[derive(Clone)] pub struct BootstrapAdminConfig { @@ -46,104 +43,22 @@ pub struct AuthenticatedSession { pub current_workspace_id: Option, } -#[derive(Clone)] -pub struct SessionCookie { - pub session_id: UserSessionId, - pub value: String, - pub expires_at: OffsetDateTime, -} - pub fn hash_password(password: &str, pepper: &str) -> Result { - let salt = SaltString::generate(&mut OsRng); - let password = format!("{password}{pepper}"); - password_hasher()? - .hash_password(password.as_bytes(), &salt) - .map(|hash| hash.to_string()) + community_hash_password(password, pepper) .map_err(|error| ApiError::internal(format!("failed to hash password: {error}"))) } -pub fn verify_password(password: &str, pepper: &str, password_hash: &str) -> bool { - let Ok(parsed) = PasswordHash::new(password_hash) else { - return false; - }; - let password = format!("{password}{pepper}"); - match password_hasher() { - Ok(argon2) => argon2.verify_password(password.as_bytes(), &parsed).is_ok(), - Err(_) => false, - } -} - -fn password_hasher() -> Result, ApiError> { - #[cfg(test)] - { - let params = Params::new(8 * 1024, 1, 1, None).map_err(|error| { - ApiError::internal(format!("failed to initialize test argon2 params: {error}")) - })?; - Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) - } - - #[cfg(not(test))] - { - Ok(Argon2::default()) - } -} - pub fn create_session_cookie(settings: &AuthSettings) -> Result { - let session_id = UserSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple())); - let mut secret_bytes = [0_u8; 32]; - rand::thread_rng().fill_bytes(&mut secret_bytes); - let secret = URL_SAFE_NO_PAD.encode(secret_bytes); - let expires_at = OffsetDateTime::now_utc() - .checked_add(Duration::hours(settings.session_ttl_hours)) - .ok_or_else(|| ApiError::internal("failed to compute session expiration"))?; - - Ok(SessionCookie { - session_id, - value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()), - expires_at, - }) -} - -pub fn hash_session_secret( - session_id: &UserSessionId, - session_value: &str, - session_secret: &str, -) -> String { - let mut digest = Sha256::new(); - digest.update(session_id.as_str().as_bytes()); - digest.update(b":"); - digest.update(session_value.as_bytes()); - digest.update(b":"); - digest.update(session_secret.as_bytes()); - URL_SAFE_NO_PAD.encode(digest.finalize()) + build_session_cookie(settings.session_ttl_hours) + .map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}"))) } pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> { - Cookie::build((SESSION_COOKIE_NAME, token.to_owned())) - .http_only(true) - .same_site(SameSite::Lax) - .secure(settings.cookie_secure) - .path("/") - .max_age(Duration::hours(settings.session_ttl_hours)) - .build() + build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours) } pub fn cleared_session_cookie(settings: &AuthSettings) -> Cookie<'static> { - Cookie::build((SESSION_COOKIE_NAME, String::new())) - .http_only(true) - .same_site(SameSite::Lax) - .secure(settings.cookie_secure) - .path("/") - .max_age(Duration::seconds(0)) - .build() -} - -pub fn extract_session_token(jar: &CookieJar) -> Option<(UserSessionId, String)> { - let cookie = jar.get(SESSION_COOKIE_NAME)?; - let value = cookie.value(); - let (session_id, session_value) = value.split_once('.')?; - - Some((UserSessionId::new(session_id), session_value.to_owned())) + build_cleared_session_cookie(settings.cookie_secure) } pub async fn require_session( diff --git a/apps/admin-api/src/main.rs b/apps/admin-api/src/main.rs index a7604b8..9a5fb59 100644 --- a/apps/admin-api/src/main.rs +++ b/apps/admin-api/src/main.rs @@ -10,6 +10,7 @@ mod storage; use std::{env, net::SocketAddr, path::PathBuf}; +use crank_community_auth::PasswordIdentityProvider; use crank_registry::{PostgresPoolConfig, PostgresRegistry}; use sqlx::postgres::PgConnectOptions; use tokio::net::TcpListener; @@ -70,6 +71,8 @@ async fn main() -> Result<(), Box> { .with_limits(runtime_limits) .with_response_cache(cache_stores.response.clone()) .build(); + let identity_provider = + PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone()); let service = AdminServiceBuilder::new( registry, storage_root, @@ -77,6 +80,7 @@ async fn main() -> Result<(), Box> { secret_crypto, runtime, ) + .with_identity_provider(std::sync::Arc::new(identity_provider)) .build(); service.bootstrap_admin_user().await?; if env_flag("CRANK_DEMO_SEED") { diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 7ee1266..035de7f 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -7,12 +7,11 @@ use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode, AsyncJobHandle, AuditSink, AuthConfig, AuthKind, AuthProfile, AuthProfileId, CapabilityProfile, CommunityCapabilityProfile, ConfigExport, EditionCapabilities, ExecutionMode, ExportMode, - GeneratedDraft, GeneratedDraftStatus, IdentityProvider, InvitationId, InvitationStatus, - InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, - InvocationStatus, IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, - IssuedAgentTokenResponse, JobStatus, MachineAccessMode, MachineTokenIssuer, MembershipRole, - NoMachineTokenIssuer, NoopAuditSink, OperationId, OperationSecurityLevel, OperationStatus, - OwnerOnlyPolicyEngine, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, + GeneratedDraft, GeneratedDraftStatus, IdentityError, IdentityProvider, InvitationId, + InvitationStatus, InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, + InvocationSource, InvocationStatus, JobStatus, LoginOutcome, MachineTokenIssuer, + MembershipRole, NoMachineTokenIssuer, NoopAuditSink, OperationId, OperationSecurityLevel, + OperationStatus, OwnerOnlyPolicyEngine, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, PolicyEngine, ProductEdition, Protocol, ResponseCachePolicy, SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus, StreamSession, StreamStatus, Target, TransportBehavior, UsagePeriod, UserId, UserSessionId, Workspace, WorkspaceId, WorkspaceStatus, @@ -855,6 +854,67 @@ impl AdminService { &self, payload: LoginPayload, ) -> Result<(SessionCookie, SessionResponse), ApiError> { + let authenticated = self.authenticate_login(&payload).await?; + + let session_cookie = create_session_cookie(&self.auth_settings)?; + let secret_hash = hash_session_secret( + &session_cookie.session_id, + &session_cookie.value, + &self.auth_settings.session_secret, + ); + let memberships = self + .registry + .list_workspaces_for_user(&authenticated.user.id) + .await?; + let current_workspace_id = authenticated + .current_workspace_id + .as_ref() + .map(|workspace_id| workspace_id.as_str().to_owned()) + .or_else(|| { + memberships + .first() + .map(|membership| membership.workspace.id.as_str().to_owned()) + }); + let current_workspace_ref = current_workspace_id + .as_ref() + .map(|workspace_id| WorkspaceId::new(workspace_id.clone())); + self.registry + .create_user_session( + &session_cookie.session_id, + &authenticated.user.id, + current_workspace_ref.as_ref(), + &secret_hash, + &session_cookie.expires_at, + ) + .await?; + + Ok(( + session_cookie, + SessionResponse { + user: authenticated.user, + memberships, + current_workspace_id, + }, + )) + } + + async fn authenticate_login( + &self, + payload: &LoginPayload, + ) -> Result { + if let Some(identity_provider) = &self.identity_provider { + return match identity_provider + .login_password(crank_core::LoginPayload { + email: payload.email.clone(), + password: payload.password.clone(), + }) + .await + { + Ok(LoginOutcome::Authenticated(identity)) => Ok(identity), + Err(error) => Err(map_identity_error(error)), + }; + } + let user = self .registry .get_auth_user_by_email(&payload.email) @@ -869,39 +929,11 @@ impl AdminService { return Err(ApiError::unauthorized("invalid email or password")); } - let session_cookie = create_session_cookie(&self.auth_settings)?; - let secret_hash = hash_session_secret( - &session_cookie.session_id, - &session_cookie.value, - &self.auth_settings.session_secret, - ); - let memberships = self - .registry - .list_workspaces_for_user(&user.user.id) - .await?; - let current_workspace_id = memberships - .first() - .map(|membership| membership.workspace.id.as_str().to_owned()); - self.registry - .create_user_session( - &session_cookie.session_id, - &user.user.id, - memberships - .first() - .map(|membership| &membership.workspace.id), - &secret_hash, - &session_cookie.expires_at, - ) - .await?; - - Ok(( - session_cookie, - SessionResponse { - user: user.user, - memberships, - current_workspace_id, - }, - )) + Ok(crank_core::AuthenticatedIdentity { + user: user.user, + memberships: vec![], + current_workspace_id: None, + }) } pub async fn logout( @@ -4033,6 +4065,17 @@ fn format_timestamp(timestamp: OffsetDateTime) -> String { .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()) } +fn map_identity_error(error: IdentityError) -> ApiError { + match error { + IdentityError::BadCredentials => ApiError::unauthorized("invalid email or password"), + IdentityError::AccountDisabled => ApiError::forbidden("account is disabled"), + IdentityError::NotSupportedForProvider => ApiError::internal( + "password login is not supported by the configured identity provider", + ), + IdentityError::Internal(message) => ApiError::internal(message), + } +} + async fn resolve_runtime_auth_for_task( registry: &PostgresRegistry, secret_crypto: &SecretCrypto, diff --git a/crates/crank-community-auth/Cargo.toml b/crates/crank-community-auth/Cargo.toml new file mode 100644 index 0000000..3b32613 --- /dev/null +++ b/crates/crank-community-auth/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "crank-community-auth" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +argon2.workspace = true +async-trait = "0.1" +axum-extra.workspace = true +base64.workspace = true +crank-core = { path = "../crank-core" } +crank-registry = { path = "../crank-registry" } +rand.workspace = true +sha2.workspace = true +thiserror.workspace = true +time.workspace = true +tracing.workspace = true +uuid.workspace = true diff --git a/crates/crank-community-auth/src/hashing.rs b/crates/crank-community-auth/src/hashing.rs new file mode 100644 index 0000000..52682db --- /dev/null +++ b/crates/crank-community-auth/src/hashing.rs @@ -0,0 +1,61 @@ +#[cfg(debug_assertions)] +use argon2::{Algorithm, Params, Version}; +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, +}; + +#[derive(Debug, thiserror::Error)] +pub enum HashPasswordError { + #[error("failed to initialize password hasher: {0}")] + Hasher(String), + #[error("failed to hash password: {0}")] + Hash(String), +} + +pub fn hash_password(password: &str, pepper: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let password = format!("{password}{pepper}"); + password_hasher()? + .hash_password(password.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|error| HashPasswordError::Hash(error.to_string())) +} + +pub fn verify_password(password: &str, pepper: &str, password_hash: &str) -> bool { + let Ok(parsed) = PasswordHash::new(password_hash) else { + return false; + }; + let password = format!("{password}{pepper}"); + match password_hasher() { + Ok(argon2) => argon2.verify_password(password.as_bytes(), &parsed).is_ok(), + Err(_) => false, + } +} + +fn password_hasher() -> Result, HashPasswordError> { + #[cfg(debug_assertions)] + { + let params = Params::new(8 * 1024, 1, 1, None) + .map_err(|error| HashPasswordError::Hasher(error.to_string()))?; + Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) + } + + #[cfg(not(debug_assertions))] + { + Ok(Argon2::default()) + } +} + +#[cfg(test)] +mod tests { + use super::{hash_password, verify_password}; + + #[test] + fn hashes_and_verifies_password() { + let password_hash = hash_password("test-password", "pepper").unwrap(); + + assert!(verify_password("test-password", "pepper", &password_hash)); + assert!(!verify_password("wrong-password", "pepper", &password_hash)); + } +} diff --git a/crates/crank-community-auth/src/lib.rs b/crates/crank-community-auth/src/lib.rs new file mode 100644 index 0000000..ec3775c --- /dev/null +++ b/crates/crank-community-auth/src/lib.rs @@ -0,0 +1,10 @@ +pub mod hashing; +pub mod password_provider; +pub mod session_cookie; + +pub use hashing::{HashPasswordError, hash_password, verify_password}; +pub use password_provider::PasswordIdentityProvider; +pub use session_cookie::{ + SESSION_COOKIE_NAME, SessionCookie, SessionCookieError, cleared_session_cookie, + create_session_cookie, extract_session_token, hash_session_secret, session_cookie, +}; diff --git a/crates/crank-community-auth/src/password_provider.rs b/crates/crank-community-auth/src/password_provider.rs new file mode 100644 index 0000000..6f1ca6f --- /dev/null +++ b/crates/crank-community-auth/src/password_provider.rs @@ -0,0 +1,73 @@ +use async_trait::async_trait; +use crank_core::{ + AuthenticatedIdentity, IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome, +}; +use crank_registry::PostgresRegistry; +use tracing::debug; + +use crate::hashing::verify_password; + +#[derive(Clone)] +pub struct PasswordIdentityProvider { + registry: PostgresRegistry, + password_pepper: String, +} + +impl PasswordIdentityProvider { + pub fn new(registry: PostgresRegistry, password_pepper: impl Into) -> Self { + Self { + registry, + password_pepper: password_pepper.into(), + } + } +} + +#[async_trait] +impl IdentityProvider for PasswordIdentityProvider { + fn id(&self) -> &str { + "community-password" + } + + fn kind(&self) -> IdentityProviderKind { + IdentityProviderKind::Password + } + + async fn login_password( + &self, + payload: crank_core::LoginPayload, + ) -> Result { + let user = self + .registry + .get_auth_user_by_email(&payload.email) + .await + .map_err(|error| IdentityError::Internal(error.to_string()))? + .ok_or(IdentityError::BadCredentials)?; + + if user.user.status != crank_core::UserStatus::Active { + return Err(IdentityError::AccountDisabled); + } + + if !verify_password( + &payload.password, + &self.password_pepper, + &user.password_hash, + ) { + debug!(email = %payload.email, "password identity provider rejected credentials"); + return Err(IdentityError::BadCredentials); + } + + let current_workspace_id = self + .registry + .list_workspaces_for_user(&user.user.id) + .await + .map_err(|error| IdentityError::Internal(error.to_string()))? + .first() + .map(|membership| membership.workspace.id.clone()); + + Ok(LoginOutcome::Authenticated(AuthenticatedIdentity { + user: user.user, + memberships: vec![], + current_workspace_id, + })) + } +} diff --git a/crates/crank-community-auth/src/session_cookie.rs b/crates/crank-community-auth/src/session_cookie.rs new file mode 100644 index 0000000..b1a42c6 --- /dev/null +++ b/crates/crank-community-auth/src/session_cookie.rs @@ -0,0 +1,116 @@ +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use crank_core::UserSessionId; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use time::{Duration, OffsetDateTime}; + +pub const SESSION_COOKIE_NAME: &str = "crank_session"; + +#[derive(Clone)] +pub struct SessionCookie { + pub session_id: UserSessionId, + pub value: String, + pub expires_at: OffsetDateTime, +} + +#[derive(Debug, thiserror::Error)] +pub enum SessionCookieError { + #[error("failed to compute session expiration")] + InvalidExpiration, +} + +pub fn create_session_cookie(session_ttl_hours: i64) -> Result { + let session_id = UserSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple())); + let mut secret_bytes = [0_u8; 32]; + rand::thread_rng().fill_bytes(&mut secret_bytes); + let secret = URL_SAFE_NO_PAD.encode(secret_bytes); + let expires_at = OffsetDateTime::now_utc() + .checked_add(Duration::hours(session_ttl_hours)) + .ok_or(SessionCookieError::InvalidExpiration)?; + + Ok(SessionCookie { + session_id, + value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()), + expires_at, + }) +} + +pub fn hash_session_secret( + session_id: &UserSessionId, + session_value: &str, + session_secret: &str, +) -> String { + let mut digest = Sha256::new(); + digest.update(session_id.as_str().as_bytes()); + digest.update(b":"); + digest.update(session_value.as_bytes()); + digest.update(b":"); + digest.update(session_secret.as_bytes()); + URL_SAFE_NO_PAD.encode(digest.finalize()) +} + +pub fn session_cookie(token: &str, cookie_secure: bool, session_ttl_hours: i64) -> Cookie<'static> { + Cookie::build((SESSION_COOKIE_NAME, token.to_owned())) + .http_only(true) + .same_site(SameSite::Lax) + .secure(cookie_secure) + .path("/") + .max_age(Duration::hours(session_ttl_hours)) + .build() +} + +pub fn cleared_session_cookie(cookie_secure: bool) -> Cookie<'static> { + Cookie::build((SESSION_COOKIE_NAME, String::new())) + .http_only(true) + .same_site(SameSite::Lax) + .secure(cookie_secure) + .path("/") + .max_age(Duration::seconds(0)) + .build() +} + +pub fn extract_session_token(jar: &CookieJar) -> Option<(UserSessionId, String)> { + let cookie = jar.get(SESSION_COOKIE_NAME)?; + let value = cookie.value(); + let (session_id, session_value) = value.split_once('.')?; + + Some((UserSessionId::new(session_id), session_value.to_owned())) +} + +#[cfg(test)] +mod tests { + use axum_extra::extract::cookie::CookieJar; + + use super::{ + SESSION_COOKIE_NAME, cleared_session_cookie, create_session_cookie, extract_session_token, + session_cookie, + }; + + #[test] + fn extracts_session_token_from_cookie_jar() { + let token = "sess_01.secret-value"; + let jar = CookieJar::new().add(session_cookie(token, false, 24)); + + let (session_id, secret) = extract_session_token(&jar).unwrap(); + + assert_eq!(session_id.as_str(), "sess_01"); + assert_eq!(secret, "secret-value"); + } + + #[test] + fn clears_session_cookie() { + let cookie = cleared_session_cookie(false); + + assert_eq!(cookie.name(), SESSION_COOKIE_NAME); + assert_eq!(cookie.value(), ""); + } + + #[test] + fn creates_session_cookie_with_generated_values() { + let session = create_session_cookie(24).unwrap(); + + assert!(session.session_id.as_str().starts_with("sess_")); + assert!(session.value.contains('.')); + } +}