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}