Skip to main content

assay_auth/oidc_provider/
types.rs

1//! Shared POD types for the OIDC provider.
2//!
3//! Each table defined in [`crate::schema::PG_DDL_V4`] / [`crate::schema::SQLITE_DDL_V4`]
4//! has a matching `pub struct` here so handlers and stores share the same
5//! shape. Timestamps are `f64` seconds since UNIX epoch (matches the rest
6//! of the auth schema).
7
8use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12/// Token-endpoint authentication method per OIDC Core §9. The `none`
13/// variant marks a public client (PKCE-only — no shared secret).
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum TokenAuthMethod {
17    /// HTTP Basic header (the OIDC default).
18    ClientSecretBasic,
19    /// Form-encoded `client_secret` field.
20    ClientSecretPost,
21    /// No client authentication — public client; PKCE is mandatory.
22    None,
23    /// JWT bearer assertion signed with the client's registered key.
24    /// Reserved for v0.2.0+; not exercised by the v0.2.0 test suite.
25    PrivateKeyJwt,
26}
27
28impl TokenAuthMethod {
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Self::ClientSecretBasic => "client_secret_basic",
32            Self::ClientSecretPost => "client_secret_post",
33            Self::None => "none",
34            Self::PrivateKeyJwt => "private_key_jwt",
35        }
36    }
37
38    pub fn parse(s: &str) -> Option<Self> {
39        match s {
40            "client_secret_basic" => Some(Self::ClientSecretBasic),
41            "client_secret_post" => Some(Self::ClientSecretPost),
42            "none" => Some(Self::None),
43            "private_key_jwt" => Some(Self::PrivateKeyJwt),
44            _ => None,
45        }
46    }
47}
48
49/// Registered consumer application — one row in `auth.oidc_clients`.
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
51pub struct OidcClient {
52    pub client_id: String,
53    /// Argon2id PHC string. `None` for public clients (PKCE-only); when
54    /// present, [`crate::oidc_provider::token`] verifies presented secrets
55    /// against this column with [`crate::password::PasswordHasher`].
56    pub client_secret_hash: Option<String>,
57    pub redirect_uris: Vec<String>,
58    pub name: String,
59    pub logo_url: Option<String>,
60    pub token_endpoint_auth_method: TokenAuthMethod,
61    pub default_scopes: Vec<String>,
62    pub require_consent: bool,
63    pub grant_types: Vec<String>,
64    pub response_types: Vec<String>,
65    pub pkce_required: bool,
66    pub backchannel_logout_uri: Option<String>,
67    pub created_at: f64,
68}
69
70impl OidcClient {
71    /// Convenience constructor with sensible defaults — the most common
72    /// shape (confidential client, code + refresh, PKCE on, consent on).
73    /// Caller still has to set `client_secret_hash` / `redirect_uris`.
74    pub fn new(client_id: impl Into<String>, name: impl Into<String>, created_at: f64) -> Self {
75        Self {
76            client_id: client_id.into(),
77            client_secret_hash: None,
78            redirect_uris: Vec::new(),
79            name: name.into(),
80            logo_url: None,
81            token_endpoint_auth_method: TokenAuthMethod::ClientSecretBasic,
82            default_scopes: vec!["openid".to_string()],
83            require_consent: true,
84            grant_types: vec![
85                "authorization_code".to_string(),
86                "refresh_token".to_string(),
87            ],
88            response_types: vec!["code".to_string()],
89            pkce_required: true,
90            backchannel_logout_uri: None,
91            created_at,
92        }
93    }
94
95    /// Whether `redirect_uri` matches the registered list verbatim. OIDC
96    /// Core §3.1.2.1 mandates exact match — no prefix matching, no host
97    /// promotion, no trailing-slash normalisation.
98    pub fn redirect_matches(&self, redirect_uri: &str) -> bool {
99        self.redirect_uris.iter().any(|u| u == redirect_uri)
100    }
101
102    /// Whether `grant` is in the client's registered grant_types list.
103    pub fn allows_grant(&self, grant: &str) -> bool {
104        self.grant_types.iter().any(|g| g == grant)
105    }
106}
107
108/// Federated upstream provider — one row in `auth.upstream_providers`.
109/// Mirrors [`crate::oidc::UpstreamProvider`] shape; this struct adds the
110/// admin-facing fields (`display_name`, `icon_url`, `enabled`) that the
111/// in-memory registry doesn't carry.
112///
113/// `scopes` is space-separated at storage time, parsed into a `Vec` here.
114/// `auth_params` carries IdP-specific authorize-URL parameters
115/// (e.g. `prompt`, `hd`, `domain_hint`); validated against the
116/// [`crate::oidc_provider::auth_params`] whitelist on every write.
117#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
118pub struct UpstreamProvider {
119    pub slug: String,
120    pub issuer: String,
121    pub client_id: String,
122    pub client_secret: String,
123    pub display_name: String,
124    pub icon_url: Option<String>,
125    pub enabled: bool,
126    #[serde(default)]
127    pub scopes: Vec<String>,
128    #[serde(default)]
129    pub auth_params: BTreeMap<String, String>,
130}
131
132/// One issued (and not-yet-consumed) authorization code — row in
133/// `auth.oidc_authorization_codes`.
134#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
135pub struct AuthorizationCode {
136    pub code: String,
137    pub client_id: String,
138    pub user_id: String,
139    pub redirect_uri: String,
140    pub scopes: Vec<String>,
141    pub code_challenge: String,
142    pub code_challenge_method: String,
143    pub nonce: Option<String>,
144    pub state: Option<String>,
145    pub issued_at: f64,
146    pub expires_at: f64,
147    pub consumed: bool,
148}
149
150/// One issued refresh token — row in `auth.oidc_refresh_tokens`.
151/// `token_hash` is SHA-256 hex of the bearer the consumer presents; the
152/// bearer itself never round-trips the DB.
153#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
154pub struct RefreshToken {
155    pub token_hash: String,
156    pub client_id: String,
157    pub user_id: String,
158    pub scopes: Vec<String>,
159    pub issued_at: f64,
160    pub expires_at: f64,
161    pub revoked: bool,
162}
163
164/// One SSO session row — `auth.oidc_sessions`. The `sid` matches the
165/// `sid` claim baked into the issued id_token so back-channel logout
166/// can target a specific session.
167#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
168pub struct OidcSession {
169    pub sid: String,
170    pub user_id: String,
171    pub client_id: String,
172    pub assay_session_id: Option<String>,
173    pub issued_at: f64,
174    pub backchannel_logout_uri: Option<String>,
175}
176
177/// One per-(user, client) consent grant — row in `auth.oidc_consents`.
178/// Used by [`crate::oidc_provider::authorize`] to skip the consent screen
179/// when a prior grant exists and the requested scopes are a subset of
180/// the granted ones.
181#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
182pub struct ConsentGrant {
183    pub user_id: String,
184    pub client_id: String,
185    pub scopes: Vec<String>,
186    pub granted_at: f64,
187}
188
189/// One in-flight upstream-federation login — row in
190/// `auth.oidc_upstream_states`. Created by `start_upstream_login`,
191/// consumed (and deleted) by `complete_upstream_login`.
192///
193/// `binding_hash` is the SHA-256 hex of the cookie-bound binding
194/// token; empty string is the migration sentinel for in-flight rows
195/// created before the binding-check deploy (those skip the check; the
196/// 5-minute row TTL bounds the bypass window).
197#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
198pub struct UpstreamLoginState {
199    pub state: String,
200    pub provider_slug: String,
201    pub nonce: String,
202    pub pkce_verifier: String,
203    pub return_to: Option<String>,
204    pub created_at: f64,
205    pub expires_at: f64,
206    #[serde(default)]
207    pub binding_hash: String,
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn token_auth_method_round_trip() {
216        for m in [
217            TokenAuthMethod::ClientSecretBasic,
218            TokenAuthMethod::ClientSecretPost,
219            TokenAuthMethod::None,
220            TokenAuthMethod::PrivateKeyJwt,
221        ] {
222            assert_eq!(TokenAuthMethod::parse(m.as_str()), Some(m));
223        }
224        assert_eq!(TokenAuthMethod::parse("garbage"), None);
225    }
226
227    #[test]
228    fn oidc_client_new_defaults_to_confidential_pkce() {
229        let c = OidcClient::new("c1", "App", 1.0);
230        assert_eq!(c.client_id, "c1");
231        assert!(c.pkce_required);
232        assert!(c.require_consent);
233        assert_eq!(
234            c.token_endpoint_auth_method,
235            TokenAuthMethod::ClientSecretBasic
236        );
237        assert!(c.allows_grant("authorization_code"));
238        assert!(c.allows_grant("refresh_token"));
239        assert!(!c.allows_grant("client_credentials"));
240    }
241
242    #[test]
243    fn redirect_matches_is_exact() {
244        let mut c = OidcClient::new("c1", "App", 0.0);
245        c.redirect_uris = vec!["https://app.example.com/cb".to_string()];
246        assert!(c.redirect_matches("https://app.example.com/cb"));
247        // Trailing slash differs — no match.
248        assert!(!c.redirect_matches("https://app.example.com/cb/"));
249        // Prefix match — no match.
250        assert!(!c.redirect_matches("https://app.example.com/cb/extra"));
251    }
252}