securitydept_token_set_context/frontend_oidc_mode/contracts.rs
1//! Cross-boundary contracts for `frontend-oidc` mode.
2//!
3//! These types define the interop contract between the frontend OIDC browser
4//! client and the backend. They are the Rust counterpart of the TS
5//! `@securitydept/token-set-context-client/frontend-oidc-mode` contracts.
6
7use std::{
8 collections::HashMap,
9 sync::{LazyLock, Mutex},
10 time::{Duration, SystemTime},
11};
12
13use securitydept_oidc_client::transpile_claims_script_typescript_to_javascript;
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone)]
17struct CachedFrontendClaimsCheckScript {
18 modified_at: Option<SystemTime>,
19 content: String,
20}
21
22static FRONTEND_CLAIMS_CHECK_SCRIPT_CACHE: LazyLock<
23 Mutex<HashMap<String, CachedFrontendClaimsCheckScript>>,
24> = LazyLock::new(|| Mutex::new(HashMap::new()));
25
26// ---------------------------------------------------------------------------
27// Claims check script
28// ---------------------------------------------------------------------------
29
30/// Structured claims check script for the frontend OIDC client.
31///
32/// The backend resolves the configured file path and embeds the content
33/// inline so the browser client never needs to reach the server filesystem.
34///
35/// This is an extensible enum — future variants (e.g. a signed URL) can be
36/// added without breaking existing `Inline` consumers.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum FrontendOidcModeClaimsCheckScript {
40 /// Script content is embedded directly in the projection.
41 ///
42 /// The backend read the script from the filesystem at projection time
43 /// and inlined it here. The browser evaluates `content` directly.
44 Inline { content: String },
45}
46
47impl FrontendOidcModeClaimsCheckScript {
48 /// Read the script from the given filesystem path and wrap it as `Inline`.
49 pub async fn from_path(path: &str) -> std::io::Result<Self> {
50 let modified_at = tokio::fs::metadata(path)
51 .await
52 .ok()
53 .and_then(|metadata| metadata.modified().ok());
54
55 if let Some(cached) = FRONTEND_CLAIMS_CHECK_SCRIPT_CACHE
56 .lock()
57 .expect("frontend claims-check cache poisoned")
58 .get(path)
59 .cloned()
60 .filter(|cached| cached.modified_at == modified_at)
61 {
62 return Ok(Self::Inline {
63 content: cached.content,
64 });
65 }
66
67 let mut content = tokio::fs::read_to_string(path).await?;
68 if matches!(
69 std::path::Path::new(path)
70 .extension()
71 .and_then(|ext| ext.to_str()),
72 Some("ts" | "mts")
73 ) {
74 content = transpile_claims_script_typescript_to_javascript(path, &content)
75 .await
76 .map_err(|error| std::io::Error::other(error.to_string()))?;
77 }
78
79 FRONTEND_CLAIMS_CHECK_SCRIPT_CACHE
80 .lock()
81 .expect("frontend claims-check cache poisoned")
82 .insert(
83 path.to_string(),
84 CachedFrontendClaimsCheckScript {
85 modified_at,
86 content: content.clone(),
87 },
88 );
89
90 Ok(Self::Inline { content })
91 }
92
93 /// The script content, regardless of variant.
94 pub fn content(&self) -> &str {
95 match self {
96 Self::Inline { content } => content,
97 }
98 }
99}
100
101// ---------------------------------------------------------------------------
102// Config projection
103// ---------------------------------------------------------------------------
104
105/// Backend-to-frontend OIDC configuration projection.
106///
107/// When a deployment uses `frontend-oidc` mode, the backend must tell the
108/// browser client *which* OIDC provider to talk to and *how*. This struct
109/// faithfully reflects the resolved `OidcClientConfig` minus server-only
110/// fields (`pending_store`, `device_poll_interval`).
111///
112/// `client_secret` is **omitted by default** for security — it is only
113/// included when the [`UnsafeFrontendClientSecret`] capability is enabled.
114///
115/// The frontend OIDC client uses this to initialize its own `oauth4webapi`
116/// session — either via discovery (`well_known_url`) or via manual endpoint
117/// overrides.
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
119#[serde(rename_all = "camelCase")]
120pub struct FrontendOidcModeConfigProjection {
121 // --- Provider connectivity (from OAuthProviderRemoteConfig) ---
122 /// OIDC discovery URL (e.g. `https://auth.example.com/.well-known/openid-configuration`).
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub well_known_url: Option<String>,
125 /// Issuer URL. When `well_known_url` is set, this is derived from
126 /// discovery; when not, the frontend should use this directly.
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub issuer_url: Option<String>,
129 /// JWKS URI for direct key fetching without discovery.
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub jwks_uri: Option<String>,
132 /// How often the frontend should refresh provider discovery metadata.
133 /// `0` means no periodic refresh.
134 #[serde(
135 default,
136 skip_serializing_if = "Duration::is_zero",
137 with = "humantime_serde"
138 )]
139 pub metadata_refresh_interval: Duration,
140 /// How often the frontend should refresh the remote JWKS.
141 /// `0` means no time-based refresh.
142 #[serde(
143 default,
144 skip_serializing_if = "Duration::is_zero",
145 with = "humantime_serde"
146 )]
147 pub jwks_refresh_interval: Duration,
148
149 // --- Provider OIDC endpoints (from OAuthProviderOidcConfig) ---
150 /// Authorization endpoint override. `None` means "derived from discovery."
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub authorization_endpoint: Option<String>,
153 /// Token endpoint override. `None` means "derived from discovery."
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub token_endpoint: Option<String>,
156 /// UserInfo endpoint override. `None` means "derived from discovery."
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub userinfo_endpoint: Option<String>,
159 /// Revocation endpoint override. `None` means "derived from discovery."
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub revocation_endpoint: Option<String>,
162 /// Supported token endpoint authentication methods.
163 /// `None` means "use provider discovery."
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub token_endpoint_auth_methods_supported: Option<Vec<String>>,
166 /// Supported algorithms for signing ID tokens.
167 /// `None` means "use provider discovery."
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub id_token_signing_alg_values_supported: Option<Vec<String>>,
170 /// Supported algorithms for signing UserInfo responses.
171 /// `None` means "use provider discovery."
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub userinfo_signing_alg_values_supported: Option<Vec<String>>,
174 /// The `client_id` the frontend should use for authorization requests.
175 pub client_id: String,
176 /// **Unsafe.** Only populated when `UnsafeFrontendClientSecret` capability
177 /// is enabled. Exposing secrets to the browser is a security anti-pattern;
178 /// this exists solely for broken providers that require it.
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub client_secret: Option<String>,
181 /// Scopes the frontend should request (e.g. `["openid", "profile",
182 /// "email"]`).
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub scopes: Vec<String>,
185 /// Scopes that MUST be present in the token endpoint response.
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub required_scopes: Vec<String>,
188 /// The redirect URL the frontend should use for the OIDC callback.
189 pub redirect_url: String,
190 /// Whether PKCE is enabled for the authorization code flow.
191 #[serde(default)]
192 pub pkce_enabled: bool,
193 /// Claims check script for client-side evaluation.
194 ///
195 /// The backend reads the script from the configured filesystem path and
196 /// inlines the content here so the browser never needs to reach the server
197 /// filesystem directly.
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub claims_check_script: Option<FrontendOidcModeClaimsCheckScript>,
200
201 /// Epoch-millisecond timestamp of when this projection was generated by
202 /// the backend. This is the **authoritative freshness signal** for all
203 /// downstream sources (bootstrap_script, persisted, network).
204 ///
205 /// Clients compare this against a max-age policy to decide whether an
206 /// idle revalidation is needed.
207 pub generated_at: u64,
208}
209
210#[cfg(test)]
211mod tests {
212 use std::fs;
213
214 use super::*;
215
216 #[tokio::test]
217 async fn from_path_handles_typescript_claims_check_scripts_explicitly() {
218 let path = std::env::temp_dir().join(format!(
219 "securitydept-frontend-claims-check-{}.mts",
220 uuid::Uuid::new_v4()
221 ));
222 fs::write(
223 &path,
224 r#"
225 interface Claims { sub: string; }
226 export default function claimsCheck(idTokenClaims: Claims) {
227 return { success: true, display_name: idTokenClaims.sub, claims: idTokenClaims };
228 }
229 "#,
230 )
231 .expect("write temp claims script");
232
233 let result = FrontendOidcModeClaimsCheckScript::from_path(
234 path.to_str().expect("temp path should be utf-8"),
235 )
236 .await;
237
238 match result {
239 Ok(FrontendOidcModeClaimsCheckScript::Inline { content }) => {
240 assert!(
241 !content.contains("interface Claims"),
242 "typescript-only syntax should be removed from the inlined script"
243 );
244 assert!(
245 content.contains("export default function claimsCheck"),
246 "transpiled script should still expose the claimsCheck entrypoint"
247 );
248 assert!(
249 content.contains("display_name: idTokenClaims.sub"),
250 "transpiled script should preserve the claims-check logic"
251 );
252 }
253 Err(error) => {
254 assert!(
255 error
256 .to_string()
257 .contains("claims-script feature to be enabled"),
258 "typescript claims script loading should fail with an explicit feature error \
259 when transpilation support is unavailable: {error}"
260 );
261 }
262 }
263
264 let _ = fs::remove_file(path);
265 }
266}