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//!
16//! Copyright (c) systemprompt.io — Business Source License 1.1.
17//! See <https://systemprompt.io> for licensing details.
18
19use 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
141/// Durable variant of [`session`]: mint a long-lived PAT instead of a JWT.
142///
143/// The PAT is returned once; the bridge stores it and refreshes JWTs silently
144/// from then on, with no recurring browser consent.
145pub 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}