Skip to main content

systemprompt_api/routes/gateway/
auth.rs

1//! Bridge authentication handlers for the gateway router.
2//!
3//! Exposes the credential-exchange endpoints a bridge uses to obtain a
4//! credential: [`pat`] (personal access token), [`session`] (one-time exchange
5//! code), [`session_pat`] (durable variant that mints a long-lived PAT),
6//! [`mtls`] (enrolled device certificate), and [`provision_oauth_client`]
7//! (dynamic OAuth client registration), plus [`capabilities`] advertising the
8//! supported modes.
9//!
10//! The JWT/session paths funnel through `systemprompt_oauth`'s
11//! `issue_bridge_access`. The durable PAT path consumes the same exchange code,
12//! then mints a first-class API key via the users `ApiKeyService` — the two
13//! domains are composed here, at the entry layer, rather than wiring an
14//! `oauth → users` edge into either domain crate.
15
16use 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
133/// Durable variant of [`session`]: mint a long-lived PAT instead of a JWT.
134///
135/// The PAT is returned once; the bridge stores it and refreshes JWTs silently
136/// from then on, with no recurring browser consent.
137pub 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}