systemprompt_api/routes/gateway/
auth.rs1use axum::Json;
13use axum::extract::Request;
14use axum::http::{HeaderMap, header};
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::sync::Arc;
18use systemprompt_identifiers::{JwtToken, headers};
19use systemprompt_models::Config;
20use systemprompt_models::auth::BEARER_PREFIX;
21use systemprompt_oauth::services::{
22 BridgeAuthResult, BridgeOAuthClient, exchange_bridge_session_code, hash_exchange_code,
23 issue_bridge_access, provision_bridge_oauth_client,
24};
25use systemprompt_runtime::AppContext;
26use systemprompt_traits::{AnalyticsProvider, AppContext as _};
27use systemprompt_users::{ApiKeyService, DeviceCertService, IssueApiKeyParams};
28
29use crate::error::ApiHttpError;
30use crate::services::middleware::JwtContextExtractor;
31use crate::services::middleware::client_addr::{ClientIp, client_ip_from_request};
32use crate::services::request_base_url;
33
34#[derive(Debug, Serialize)]
35pub struct AuthResponse {
36 pub token: String,
37 pub ttl: u64,
38 pub headers: HashMap<String, String>,
39}
40
41impl From<BridgeAuthResult> for AuthResponse {
42 fn from(r: BridgeAuthResult) -> Self {
43 Self {
44 token: r.token,
45 ttl: r.ttl,
46 headers: r.headers,
47 }
48 }
49}
50
51#[derive(Debug, Serialize)]
52pub struct Capabilities {
53 pub modes: Vec<&'static str>,
54}
55
56pub async fn capabilities() -> Json<Capabilities> {
57 Json(Capabilities {
58 modes: vec!["pat", "session", "mtls", "oauth-client"],
59 })
60}
61
62#[derive(Debug, Deserialize)]
63pub struct MtlsRequestBody {
64 pub device_cert_fingerprint: String,
65}
66
67#[derive(Debug, Deserialize)]
68pub struct SessionExchangeBody {
69 pub code: String,
70}
71
72#[derive(Debug, Deserialize)]
73pub struct SessionPatBody {
74 pub code: String,
75 #[serde(default)]
76 pub device_name: Option<String>,
77}
78
79#[derive(Debug, Serialize)]
80pub struct DevicePatResponse {
81 pub pat: String,
82}
83
84pub async fn pat(ctx: AppContext, request: Request) -> Result<Json<AuthResponse>, ApiHttpError> {
85 let pat_token = extract_bearer(request.headers())
86 .ok_or_else(|| ApiHttpError::unauthorized("Missing Authorization: Bearer <pat>"))?;
87
88 let service = ApiKeyService::new(Arc::clone(ctx.user_repository()));
89 let record = service
90 .verify(&pat_token)
91 .await?
92 .ok_or_else(|| ApiHttpError::unauthorized("Invalid PAT"))?;
93
94 let analytics = require_analytics(&ctx)?;
95 let caller_ip = client_ip_from_request(&request);
96 let result = issue_bridge_access(
97 &ctx.oauth_repositories().oauth,
98 analytics.as_ref(),
99 request.headers(),
100 caller_ip,
101 &record.user_id,
102 )
103 .await?;
104
105 Ok(Json(result.into()))
106}
107
108pub async fn session(
109 ctx: AppContext,
110 ClientIp(caller_ip): ClientIp,
111 headers: HeaderMap,
112 Json(body): Json<SessionExchangeBody>,
113) -> Result<Json<AuthResponse>, ApiHttpError> {
114 if body.code.trim().is_empty() {
115 return Err(ApiHttpError::bad_request("missing exchange code"));
116 }
117
118 let analytics = require_analytics(&ctx)?;
119 let result = exchange_bridge_session_code(
120 &ctx.oauth_repositories().oauth,
121 analytics.as_ref(),
122 &headers,
123 caller_ip,
124 body.code.trim(),
125 )
126 .await?
127 .ok_or_else(|| {
128 ApiHttpError::unauthorized("exchange code invalid, expired, or already consumed")
129 })?;
130
131 Ok(Json(result.into()))
132}
133
134pub async fn session_pat(
137 ctx: AppContext,
138 Json(body): Json<SessionPatBody>,
139) -> Result<Json<DevicePatResponse>, ApiHttpError> {
140 let code = body.code.trim();
141 if code.is_empty() {
142 return Err(ApiHttpError::bad_request("missing exchange code"));
143 }
144
145 let device_name = body
146 .device_name
147 .as_deref()
148 .map(str::trim)
149 .filter(|s| !s.is_empty())
150 .unwrap_or("bridge device-link");
151
152 let pat = mint_device_pat(&ctx, code, device_name).await?;
153 Ok(Json(DevicePatResponse { pat }))
154}
155
156async fn mint_device_pat(
157 ctx: &AppContext,
158 code: &str,
159 device_name: &str,
160) -> Result<String, ApiHttpError> {
161 let repo = &ctx.oauth_repositories().oauth;
162 let user_id = repo
163 .consume_bridge_exchange_code(&hash_exchange_code(code))
164 .await?
165 .ok_or_else(|| {
166 ApiHttpError::unauthorized("exchange code invalid, expired, or already consumed")
167 })?;
168
169 let service = ApiKeyService::new(Arc::clone(ctx.user_repository()));
170 let issued = service
171 .issue(IssueApiKeyParams {
172 user_id: &user_id,
173 name: device_name,
174 expires_at: None,
175 })
176 .await?;
177
178 Ok(issued.secret)
179}
180
181pub async fn provision_oauth_client(
182 jwt_extractor: Arc<JwtContextExtractor>,
183 ctx: AppContext,
184 request: Request,
185) -> Result<Json<BridgeOAuthClient>, ApiHttpError> {
186 let bearer = extract_bearer(request.headers())
187 .ok_or_else(|| ApiHttpError::unauthorized("Missing Authorization: Bearer <bridge-jwt>"))?;
188
189 let (claims, _user) = jwt_extractor
190 .decode_for_gateway(&JwtToken::new(bearer))
191 .await?;
192
193 let token_endpoint = build_token_endpoint(request.headers())?;
194
195 let result = provision_bridge_oauth_client(
196 &ctx.oauth_repositories().oauth,
197 &claims.user_id,
198 token_endpoint,
199 )
200 .await?;
201
202 Ok(Json(result))
203}
204
205#[expect(
206 clippy::result_large_err,
207 reason = "ApiError carries response context that is intentionally large; boxing here would \
208 propagate to every caller for negligible gain"
209)]
210fn build_token_endpoint(headers: &HeaderMap) -> Result<String, ApiHttpError> {
215 let cfg = Config::get().map_err(|e| ApiHttpError::internal_error(e.to_string()))?;
216 let configured = url::Url::parse(&cfg.api_external_url)
217 .map_err(|e| ApiHttpError::internal_error(e.to_string()))?;
218 let raw_host = headers.get(header::HOST).and_then(|v| v.to_str().ok());
219 let base = request_base_url::resolve(raw_host, &configured);
220 Ok(format!("{}/api/v1/core/oauth/token", base.as_str()))
221}
222
223pub async fn mtls(
224 ctx: AppContext,
225 ClientIp(caller_ip): ClientIp,
226 headers: HeaderMap,
227 Json(body): Json<MtlsRequestBody>,
228) -> Result<Json<AuthResponse>, ApiHttpError> {
229 let fingerprint = body.device_cert_fingerprint.trim();
230 if fingerprint.is_empty() {
231 return Err(ApiHttpError::bad_request("missing device_cert_fingerprint"));
232 }
233
234 let service = DeviceCertService::new(Arc::clone(ctx.user_repository()));
235 let record = service
236 .verify(fingerprint)
237 .await?
238 .ok_or_else(|| ApiHttpError::unauthorized("device certificate not enrolled or revoked"))?;
239
240 let analytics = require_analytics(&ctx)?;
241 let result = issue_bridge_access(
242 &ctx.oauth_repositories().oauth,
243 analytics.as_ref(),
244 &headers,
245 caller_ip,
246 &record.user_id,
247 )
248 .await?;
249
250 Ok(Json(result.into()))
251}
252
253fn extract_bearer(hdrs: &HeaderMap) -> Option<String> {
254 let auth = hdrs.get(headers::AUTHORIZATION)?.to_str().ok()?;
255 auth.strip_prefix(BEARER_PREFIX)
256 .map(|s| s.trim().to_owned())
257}
258
259#[expect(
260 clippy::result_large_err,
261 reason = "ApiError carries response context that is intentionally large; boxing here would \
262 propagate to every caller for negligible gain"
263)]
264fn require_analytics(ctx: &AppContext) -> Result<Arc<dyn AnalyticsProvider>, ApiHttpError> {
265 ctx.analytics_provider()
266 .ok_or_else(|| ApiHttpError::internal_error("analytics provider unavailable"))
267}