auth: extract community auth crate

This commit is contained in:
github-ops
2026-05-14 19:48:58 +00:00
parent 56268d1482
commit 7c2a8f4fac
13 changed files with 457 additions and 139 deletions
+52 -1
View File
@@ -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<LoginOutcome, IdentityError> {
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();
+14 -99
View File
@@ -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<WorkspaceId>,
}
#[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<String, ApiError> {
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<Argon2<'static>, 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<SessionCookie, ApiError> {
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(
+4
View File
@@ -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<dyn std::error::Error>> {
.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<dyn std::error::Error>> {
secret_crypto,
runtime,
)
.with_identity_provider(std::sync::Arc::new(identity_provider))
.build();
service.bootstrap_admin_user().await?;
if env_flag("CRANK_DEMO_SEED") {
+82 -39
View File
@@ -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<crank_core::AuthenticatedIdentity, ApiError> {
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,