Skip to main content

auth/
routes.rs

1use crate::dto::{CreateDevSessionRequest, CreateDevSessionResponse, RevokeSessionResponse};
2use crate::models::AuthUserId;
3use crate::repositories::PostgresAuthUserRepository;
4use crate::resolver::first_session_token;
5use axum::Json;
6use axum::extract::State;
7use axum::http::header::{AUTHORIZATION, COOKIE};
8use axum::http::{HeaderMap, HeaderName};
9use chrono::Duration;
10use platform_core::error::ErrorDetail;
11use platform_core::{
12    ActorResolutionRequest, AppContext, AppError, ErrorCode, is_local_development_environment,
13};
14use platform_http::responses::json;
15use platform_http::{
16    ApiErrorResponse, ApiOpenApiRouter, ErrorResponse, HttpRequestContext, JsonBody, OpenApiRouter,
17    routes,
18};
19
20const DEV_SESSION_TTL_HOURS: i64 = 12;
21
22pub fn router() -> ApiOpenApiRouter {
23    OpenApiRouter::new()
24        .routes(routes!(create_dev_session))
25        .routes(routes!(revoke_session))
26}
27
28#[utoipa::path(
29    post,
30    path = "/v1/auth/dev/sessions",
31    operation_id = "auth_create_dev_session",
32    tag = "auth",
33    request_body(
34        content = CreateDevSessionRequest,
35        content_type = "application/json",
36        description = "Create a local-development auth session for a user id"
37    ),
38    params(
39        ("x-request-id" = Option<String>, Header, description = "Optional caller-provided request identifier"),
40        ("x-correlation-id" = Option<String>, Header, description = "Optional caller-provided correlation identifier")
41    ),
42    responses(
43        (
44            status = 200,
45            description = "Development session created",
46            body = CreateDevSessionResponse,
47            content_type = "application/json",
48            headers(
49                ("x-request-id" = String, description = "Request identifier for this HTTP request"),
50                ("x-correlation-id" = String, description = "Correlation identifier shared across related work")
51            )
52        ),
53        (
54            status = 400,
55            description = "Request validation failed",
56            body = ErrorResponse,
57            content_type = "application/json"
58        ),
59        (
60            status = 403,
61            description = "Development sessions are only available in local environments",
62            body = ErrorResponse,
63            content_type = "application/json"
64        ),
65        (
66            status = 500,
67            description = "Internal server error",
68            body = ErrorResponse,
69            content_type = "application/json"
70        )
71    )
72)]
73async fn create_dev_session(
74    State(ctx): State<AppContext>,
75    HttpRequestContext(request_ctx): HttpRequestContext,
76    JsonBody(input): JsonBody<CreateDevSessionRequest>,
77) -> Result<Json<CreateDevSessionResponse>, ApiErrorResponse> {
78    if !is_local_development_environment(&ctx.config.service.environment) {
79        return Err(ApiErrorResponse::with_context(
80            AppError::new(
81                ErrorCode::Forbidden,
82                "Development auth sessions are only available in local environments",
83            ),
84            &request_ctx,
85        ));
86    }
87
88    let user_id = input.user_id.trim();
89    if user_id.is_empty() {
90        return Err(ApiErrorResponse::with_context(
91            AppError::validation(
92                "Request validation failed",
93                vec![ErrorDetail {
94                    field: Some("user_id".to_owned()),
95                    reason: "user_id must not be empty".to_owned(),
96                }],
97            ),
98            &request_ctx,
99        ));
100    }
101
102    let now = ctx.clock.now();
103    let session = PostgresAuthUserRepository::from_context(&ctx)
104        .create_dev_session(
105            AuthUserId(user_id.to_owned()),
106            ctx.ids.new_id("sess"),
107            ctx.ids.new_id("dev_session_token"),
108            now,
109            now + Duration::hours(DEV_SESSION_TTL_HOURS),
110        )
111        .await
112        .map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;
113
114    Ok(json(CreateDevSessionResponse {
115        user_id: session.user_id.0,
116        session_id: session.id,
117        token: session.token,
118        expires_at: session.expires_at,
119    }))
120}
121
122#[utoipa::path(
123    post,
124    path = "/v1/auth/sessions/revoke",
125    operation_id = "auth_revoke_session",
126    tag = "auth",
127    params(
128        ("authorization" = Option<String>, Header, description = "Bearer session token"),
129        ("cookie" = Option<String>, Header, description = "Cookie header containing `lenso_session`"),
130        ("x-request-id" = Option<String>, Header, description = "Optional caller-provided request identifier"),
131        ("x-correlation-id" = Option<String>, Header, description = "Optional caller-provided correlation identifier")
132    ),
133    responses(
134        (
135            status = 200,
136            description = "Session revoke attempted",
137            body = RevokeSessionResponse,
138            content_type = "application/json",
139            headers(
140                ("x-request-id" = String, description = "Request identifier for this HTTP request"),
141                ("x-correlation-id" = String, description = "Correlation identifier shared across related work")
142            )
143        ),
144        (
145            status = 401,
146            description = "Authentication is required",
147            body = ErrorResponse,
148            content_type = "application/json"
149        ),
150        (
151            status = 500,
152            description = "Internal server error",
153            body = ErrorResponse,
154            content_type = "application/json"
155        )
156    )
157)]
158async fn revoke_session(
159    State(ctx): State<AppContext>,
160    HttpRequestContext(request_ctx): HttpRequestContext,
161    headers: HeaderMap,
162) -> Result<Json<RevokeSessionResponse>, ApiErrorResponse> {
163    let request = ActorResolutionRequest {
164        authorization: header_value(&headers, AUTHORIZATION),
165        cookie: header_value(&headers, COOKIE),
166    };
167    let Some(token) = first_session_token(&request) else {
168        return Err(ApiErrorResponse::with_context(
169            AppError::new(ErrorCode::Unauthorized, "Authentication is required"),
170            &request_ctx,
171        ));
172    };
173
174    let revoked = PostgresAuthUserRepository::from_context(&ctx)
175        .revoke_session_token(&token, ctx.clock.now())
176        .await
177        .map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;
178
179    Ok(json(RevokeSessionResponse { revoked }))
180}
181
182fn header_value(headers: &HeaderMap, name: HeaderName) -> Option<String> {
183    headers
184        .get(name)
185        .and_then(|value| value.to_str().ok())
186        .map(ToOwned::to_owned)
187}