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