Skip to main content

systemprompt_api/routes/gateway/
auth.rs

1//! Bridge authentication handlers for the gateway router.
2//!
3//! The JWT/session paths funnel through `systemprompt_oauth`'s
4//! `issue_bridge_access`. The durable PAT path consumes the same exchange code,
5//! then mints a first-class API key via the users `ApiKeyService` — the two
6//! domains are composed here, at the entry layer, rather than wiring an
7//! `oauth → users` edge into either domain crate.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use 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
134/// The PAT is returned exactly once; the bridge stores it and refreshes JWTs
135/// silently from then on, with no recurring browser consent.
136pub 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)]
210// Why: the endpoint must reflect the host the client dialled — formatting
211// `api_external_url` hands a remote client a loopback address. `resolve` falls
212// back to the configured URL for a host outside the allowlist, so a forged
213// `Host` cannot redirect the mint.
214fn 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}