Files
papercrate/backend/src/routes/auth.rs
T
2025-11-12 02:16:20 +01:00

261 lines
7.9 KiB
Rust

use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::Response,
Json,
};
use axum_extra::{
headers::{authorization::Bearer, Authorization, Cookie},
typed_header::TypedHeader,
};
use utoipa::OpenApi;
use crate::{
auth::{
passkeys::{
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
},
AuthenticatedUser, TenantScopedConn,
},
error::{AppError, AppResult},
http::responders::JsonResponse,
services::auth::{
ApiTokenExchangeRequest, AuthService, LoginRequest, LoginResponse, LoginResponseVariants,
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, SESSION_COOKIE_NAME,
},
state::AppState,
};
#[derive(OpenApi)]
#[openapi(
paths(
login,
api_token_exchange,
signup_start,
signup_finish,
refresh,
logout,
me,
select_tenant,
passkey_register_start,
passkey_register_finish,
passkey_login_start,
passkey_login_finish,
),
components(schemas(
LoginRequest,
ApiTokenExchangeRequest,
SignupStartRequest,
SignupStartResponse,
SignupFinishRequest,
LoginResponse,
LoginResponseVariants,
TenantSnippet,
TenantSelectionResponse,
TenantSelectionRequest,
TenantListResponse,
crate::auth::AuthenticatedUser,
crate::auth::passkeys::RegistrationChallengeResponse,
crate::auth::passkeys::AuthenticationChallengeResponse,
crate::auth::passkeys::PasskeySummary,
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
crate::auth::passkeys::PasskeyLoginStartPayload,
crate::auth::passkeys::PasskeyLoginFinishPayload,
crate::models::ApiCapability,
))
)]
pub struct AuthApiDoc;
#[utoipa::path(
post,
path = "/api/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login succeeded", body = LoginResponseVariants),
(status = 401, description = "Invalid credentials")
),
tag = "Auth"
)]
pub async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> AppResult<Response> {
AuthService::new(&state).login(payload)
}
#[utoipa::path(
post,
path = "/api/auth/exchange-api-token",
request_body = ApiTokenExchangeRequest,
responses((status = 200, description = "Access token issued", body = LoginResponse)),
tag = "Auth"
)]
pub async fn api_token_exchange(
State(state): State<AppState>,
Json(payload): Json<ApiTokenExchangeRequest>,
) -> AppResult<JsonResponse<LoginResponse>> {
AuthService::new(&state).exchange_api_token(payload)
}
#[utoipa::path(
post,
path = "/api/auth/signup/start",
request_body = SignupStartRequest,
responses(
(status = 200, description = "Signup challenge created", body = SignupStartResponse),
(status = 400, description = "Invalid signup request"),
(status = 409, description = "Username already exists")
),
tag = "Auth"
)]
pub async fn signup_start(
State(state): State<AppState>,
Json(payload): Json<SignupStartRequest>,
) -> AppResult<JsonResponse<SignupStartResponse>> {
AuthService::new(&state).signup_start(payload)
}
#[utoipa::path(
post,
path = "/api/auth/signup/finish",
request_body = SignupFinishRequest,
responses(
(status = 200, description = "Signup completed", body = LoginResponseVariants),
(status = 400, description = "Invalid signup completion"),
(status = 409, description = "Username already exists")
),
tag = "Auth"
)]
pub async fn signup_finish(
State(state): State<AppState>,
Json(payload): Json<SignupFinishRequest>,
) -> AppResult<Response> {
AuthService::new(&state).signup_finish(payload)
}
#[utoipa::path(
post,
path = "/api/auth/refresh",
responses(
(status = 200, description = "Refreshed access token", body = LoginResponse),
(status = 401, description = "Missing or invalid refresh token")
),
tag = "Auth"
)]
pub async fn refresh(
State(state): State<AppState>,
jar: Option<TypedHeader<Cookie>>,
) -> AppResult<Response> {
let cookies = jar.ok_or_else(AppError::unauthorized)?;
let refresh_value = cookies
.get(SESSION_COOKIE_NAME)
.ok_or_else(AppError::unauthorized)?;
AuthService::new(&state).refresh(refresh_value)
}
#[utoipa::path(
post,
path = "/api/auth/select-tenant",
request_body = TenantSelectionRequest,
responses((status = 200, description = "Tenant selected", body = LoginResponse)),
tag = "Auth"
)]
pub async fn select_tenant(
State(state): State<AppState>,
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
Json(payload): Json<TenantSelectionRequest>,
) -> AppResult<Response> {
AuthService::new(&state).select_tenant(bearer.token(), payload.tenant_id)
}
#[utoipa::path(
post,
path = "/api/auth/logout",
responses((status = 204, description = "Session revoked")),
tag = "Auth"
)]
pub async fn logout(
State(state): State<AppState>,
TenantScopedConn { mut conn, user, .. }: TenantScopedConn,
jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, StatusCode)> {
let refresh_cookie = jar.as_ref().and_then(|cookies| {
cookies
.get(SESSION_COOKIE_NAME)
.map(|value| value.to_owned())
});
AuthService::new(&state).logout(&mut conn, &user, refresh_cookie.as_deref())
}
#[utoipa::path(
get,
path = "/api/auth/me",
responses((status = 200, description = "Current session", body = crate::auth::AuthenticatedUser)),
tag = "Auth"
)]
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user)
}
#[utoipa::path(
post,
path = "/api/auth/passkeys/register/start",
responses((status = 200, body = crate::auth::passkeys::RegistrationChallengeResponse)),
tag = "Auth"
)]
pub async fn passkey_register_start(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
AuthService::new(&state).passkey_register_start(user)
}
#[utoipa::path(
post,
path = "/api/auth/passkeys/register/finish",
request_body = crate::auth::passkeys::PasskeyRegistrationFinishPayload,
responses((status = 201, body = crate::auth::passkeys::PasskeySummary)),
tag = "Auth"
)]
pub async fn passkey_register_finish(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(payload): Json<PasskeyRegistrationFinishPayload>,
) -> AppResult<JsonResponse<PasskeySummary>> {
AuthService::new(&state).passkey_register_finish(user, payload)
}
#[utoipa::path(
post,
path = "/api/auth/passkeys/login/start",
request_body = crate::auth::passkeys::PasskeyLoginStartPayload,
responses((status = 200, body = crate::auth::passkeys::AuthenticationChallengeResponse)),
tag = "Auth"
)]
pub async fn passkey_login_start(
State(state): State<AppState>,
Json(payload): Json<PasskeyLoginStartPayload>,
) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
AuthService::new(&state).passkey_login_start(&payload.username)
}
#[utoipa::path(
post,
path = "/api/auth/passkeys/login/finish",
request_body = crate::auth::passkeys::PasskeyLoginFinishPayload,
responses(
(status = 200, description = "Passkey login successful", body = LoginResponseVariants),
(status = 401, description = "Authentication failed")
),
tag = "Auth"
)]
pub async fn passkey_login_finish(
State(state): State<AppState>,
Json(payload): Json<PasskeyLoginFinishPayload>,
) -> AppResult<Response> {
AuthService::new(&state).passkey_login_finish(payload)
}