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
134pub async fn session_pat(
135    ctx: AppContext,
136    Json(body): Json<SessionPatBody>,
137) -> Result<Json<DevicePatResponse>, ApiHttpError> {
138    let code = body.code.trim();
139    if code.is_empty() {
140        return Err(ApiHttpError::bad_request("missing exchange code"));
141    }
142
143    let device_name = body
144        .device_name
145        .as_deref()
146        .map(str::trim)
147        .filter(|s| !s.is_empty())
148        .unwrap_or("bridge device-link");
149
150    let pat = mint_device_pat(&ctx, code, device_name).await?;
151    Ok(Json(DevicePatResponse { pat }))
152}
153
154async fn mint_device_pat(
155    ctx: &AppContext,
156    code: &str,
157    device_name: &str,
158) -> Result<String, ApiHttpError> {
159    let repo = &ctx.oauth_repositories().oauth;
160    let user_id = repo
161        .consume_bridge_exchange_code(&hash_exchange_code(code))
162        .await?
163        .ok_or_else(|| {
164            ApiHttpError::unauthorized("exchange code invalid, expired, or already consumed")
165        })?;
166
167    let service = ApiKeyService::new(Arc::clone(ctx.user_repository()));
168    let issued = service
169        .issue(IssueApiKeyParams {
170            user_id: &user_id,
171            name: device_name,
172            expires_at: None,
173        })
174        .await?;
175
176    Ok(issued.secret)
177}
178
179pub async fn provision_oauth_client(
180    jwt_extractor: Arc<JwtContextExtractor>,
181    ctx: AppContext,
182    request: Request,
183) -> Result<Json<BridgeOAuthClient>, ApiHttpError> {
184    let bearer = extract_bearer(request.headers())
185        .ok_or_else(|| ApiHttpError::unauthorized("Missing Authorization: Bearer <bridge-jwt>"))?;
186
187    let (claims, _user) = jwt_extractor
188        .decode_for_gateway(&JwtToken::new(bearer))
189        .await?;
190
191    let token_endpoint = build_token_endpoint(request.headers())?;
192
193    let result = provision_bridge_oauth_client(
194        &ctx.oauth_repositories().oauth,
195        &claims.user_id,
196        token_endpoint,
197    )
198    .await?;
199
200    Ok(Json(result))
201}
202
203#[expect(
204    clippy::result_large_err,
205    reason = "ApiError carries response context that is intentionally large; boxing here would \
206              propagate to every caller for negligible gain"
207)]
208// Why: the endpoint must reflect the host the client dialled — formatting
209// `api_external_url` hands a remote client a loopback address. `resolve` falls
210// back to the configured URL for a host outside the allowlist, so a forged
211// `Host` cannot redirect the mint.
212fn build_token_endpoint(headers: &HeaderMap) -> Result<String, ApiHttpError> {
213    let cfg = Config::get().map_err(|e| ApiHttpError::internal_error(e.to_string()))?;
214    let configured = url::Url::parse(&cfg.api_external_url)
215        .map_err(|e| ApiHttpError::internal_error(e.to_string()))?;
216    let raw_host = headers.get(header::HOST).and_then(|v| v.to_str().ok());
217    let base = request_base_url::resolve(raw_host, &configured);
218    Ok(format!("{}/api/v1/core/oauth/token", base.as_str()))
219}
220
221pub async fn mtls(
222    ctx: AppContext,
223    ClientIp(caller_ip): ClientIp,
224    headers: HeaderMap,
225    Json(body): Json<MtlsRequestBody>,
226) -> Result<Json<AuthResponse>, ApiHttpError> {
227    let fingerprint = body.device_cert_fingerprint.trim();
228    if fingerprint.is_empty() {
229        return Err(ApiHttpError::bad_request("missing device_cert_fingerprint"));
230    }
231
232    let service = DeviceCertService::new(Arc::clone(ctx.user_repository()));
233    let record = service
234        .verify(fingerprint)
235        .await?
236        .ok_or_else(|| ApiHttpError::unauthorized("device certificate not enrolled or revoked"))?;
237
238    let analytics = require_analytics(&ctx)?;
239    let result = issue_bridge_access(
240        &ctx.oauth_repositories().oauth,
241        analytics.as_ref(),
242        &headers,
243        caller_ip,
244        &record.user_id,
245    )
246    .await?;
247
248    Ok(Json(result.into()))
249}
250
251fn extract_bearer(hdrs: &HeaderMap) -> Option<String> {
252    let auth = hdrs.get(headers::AUTHORIZATION)?.to_str().ok()?;
253    auth.strip_prefix(BEARER_PREFIX)
254        .map(|s| s.trim().to_owned())
255}
256
257#[expect(
258    clippy::result_large_err,
259    reason = "ApiError carries response context that is intentionally large; boxing here would \
260              propagate to every caller for negligible gain"
261)]
262fn require_analytics(ctx: &AppContext) -> Result<Arc<dyn AnalyticsProvider>, ApiHttpError> {
263    ctx.analytics_provider()
264        .ok_or_else(|| ApiHttpError::internal_error("analytics provider unavailable"))
265}