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