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