Skip to main content

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}