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(
135 ctx: AppContext,
136 Json(body): Json<SessionPatBody>,
137) -> Result<Json<DevicePatResponse>, ApiHttpError> {
138 let code = body.code.trim();
139 if code.is_empty() {
140 return Err(ApiHttpError::bad_request("missing exchange code"));
141 }
142
143 let device_name = body
144 .device_name
145 .as_deref()
146 .map(str::trim)
147 .filter(|s| !s.is_empty())
148 .unwrap_or("bridge device-link");
149
150 let pat = mint_device_pat(&ctx, code, device_name).await?;
151 Ok(Json(DevicePatResponse { pat }))
152}
153
154async fn mint_device_pat(
155 ctx: &AppContext,
156 code: &str,
157 device_name: &str,
158) -> Result<String, ApiHttpError> {
159 let repo = &ctx.oauth_repositories().oauth;
160 let user_id = repo
161 .consume_bridge_exchange_code(&hash_exchange_code(code))
162 .await?
163 .ok_or_else(|| {
164 ApiHttpError::unauthorized("exchange code invalid, expired, or already consumed")
165 })?;
166
167 let service = ApiKeyService::new(Arc::clone(ctx.user_repository()));
168 let issued = service
169 .issue(IssueApiKeyParams {
170 user_id: &user_id,
171 name: device_name,
172 expires_at: None,
173 })
174 .await?;
175
176 Ok(issued.secret)
177}
178
179pub async fn provision_oauth_client(
180 jwt_extractor: Arc<JwtContextExtractor>,
181 ctx: AppContext,
182 request: Request,
183) -> Result<Json<BridgeOAuthClient>, ApiHttpError> {
184 let bearer = extract_bearer(request.headers())
185 .ok_or_else(|| ApiHttpError::unauthorized("Missing Authorization: Bearer <bridge-jwt>"))?;
186
187 let (claims, _user) = jwt_extractor
188 .decode_for_gateway(&JwtToken::new(bearer))
189 .await?;
190
191 let token_endpoint = build_token_endpoint(request.headers())?;
192
193 let result = provision_bridge_oauth_client(
194 &ctx.oauth_repositories().oauth,
195 &claims.user_id,
196 token_endpoint,
197 )
198 .await?;
199
200 Ok(Json(result))
201}
202
203#[expect(
204 clippy::result_large_err,
205 reason = "ApiError carries response context that is intentionally large; boxing here would \
206 propagate to every caller for negligible gain"
207)]
208fn build_token_endpoint(headers: &HeaderMap) -> Result<String, ApiHttpError> {
213 let cfg = Config::get().map_err(|e| ApiHttpError::internal_error(e.to_string()))?;
214 let configured = url::Url::parse(&cfg.api_external_url)
215 .map_err(|e| ApiHttpError::internal_error(e.to_string()))?;
216 let raw_host = headers.get(header::HOST).and_then(|v| v.to_str().ok());
217 let base = request_base_url::resolve(raw_host, &configured);
218 Ok(format!("{}/api/v1/core/oauth/token", base.as_str()))
219}
220
221pub async fn mtls(
222 ctx: AppContext,
223 ClientIp(caller_ip): ClientIp,
224 headers: HeaderMap,
225 Json(body): Json<MtlsRequestBody>,
226) -> Result<Json<AuthResponse>, ApiHttpError> {
227 let fingerprint = body.device_cert_fingerprint.trim();
228 if fingerprint.is_empty() {
229 return Err(ApiHttpError::bad_request("missing device_cert_fingerprint"));
230 }
231
232 let service = DeviceCertService::new(Arc::clone(ctx.user_repository()));
233 let record = service
234 .verify(fingerprint)
235 .await?
236 .ok_or_else(|| ApiHttpError::unauthorized("device certificate not enrolled or revoked"))?;
237
238 let analytics = require_analytics(&ctx)?;
239 let result = issue_bridge_access(
240 &ctx.oauth_repositories().oauth,
241 analytics.as_ref(),
242 &headers,
243 caller_ip,
244 &record.user_id,
245 )
246 .await?;
247
248 Ok(Json(result.into()))
249}
250
251fn extract_bearer(hdrs: &HeaderMap) -> Option<String> {
252 let auth = hdrs.get(headers::AUTHORIZATION)?.to_str().ok()?;
253 auth.strip_prefix(BEARER_PREFIX)
254 .map(|s| s.trim().to_owned())
255}
256
257#[expect(
258 clippy::result_large_err,
259 reason = "ApiError carries response context that is intentionally large; boxing here would \
260 propagate to every caller for negligible gain"
261)]
262fn require_analytics(ctx: &AppContext) -> Result<Arc<dyn AnalyticsProvider>, ApiHttpError> {
263 ctx.analytics_provider()
264 .ok_or_else(|| ApiHttpError::internal_error("analytics provider unavailable"))
265}