Skip to main content

arcly_http_identity/oidc/
client.rs

1//! OIDC client (relying party) registry.
2
3use std::collections::HashMap;
4
5use subtle::ConstantTimeEq;
6
7/// A registered relying party.
8#[derive(Debug, Clone)]
9pub struct OidcClient {
10    pub client_id: String,
11    /// `None` for a public client (SPA / native app) — PKCE is then required.
12    /// `Some(secret)` for a confidential client (server-side).
13    pub client_secret: Option<String>,
14    /// Exact-match allow-list of redirect URIs (no wildcard — open redirects are
15    /// a top OAuth vulnerability).
16    pub redirect_uris: Vec<String>,
17    pub allowed_scopes: Vec<String>,
18}
19
20impl OidcClient {
21    pub fn allows_redirect(&self, uri: &str) -> bool {
22        self.redirect_uris.iter().any(|u| u == uri)
23    }
24    /// Public clients (no secret) must use PKCE.
25    pub fn requires_pkce(&self) -> bool {
26        self.client_secret.is_none()
27    }
28}
29
30/// Client authentication presented at the token endpoint.
31#[derive(Debug, Clone)]
32pub struct ClientAuth {
33    pub client_id: String,
34    /// `None` for public clients using PKCE only.
35    pub client_secret: Option<String>,
36}
37
38/// Frozen registry of clients. Build at boot; look up per request.
39#[derive(Default)]
40pub struct OidcClientRegistry {
41    clients: HashMap<String, OidcClient>,
42}
43
44impl OidcClientRegistry {
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    pub fn register(mut self, client: OidcClient) -> Self {
50        self.clients.insert(client.client_id.clone(), client);
51        self
52    }
53
54    pub fn get(&self, client_id: &str) -> Option<&OidcClient> {
55        self.clients.get(client_id)
56    }
57
58    /// Authenticate a token-endpoint request: verifies the client exists and, for
59    /// confidential clients, that the secret matches (constant-time). Public
60    /// clients authenticate via PKCE at the code-exchange step, not here.
61    pub fn authenticate(&self, auth: &ClientAuth) -> Option<&OidcClient> {
62        let client = self.clients.get(&auth.client_id)?;
63        match (&client.client_secret, &auth.client_secret) {
64            // Confidential: secret must match.
65            (Some(expected), Some(presented)) => {
66                let ok: bool = expected.as_bytes().ct_eq(presented.as_bytes()).into();
67                ok.then_some(client)
68            }
69            // Public: no secret expected, none presented.
70            (None, None) => Some(client),
71            // Mismatched expectation → reject.
72            _ => None,
73        }
74    }
75}