261 lines
7.9 KiB
Rust
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)
|
|
}
|