assay_auth/oidc_provider/
types.rs1use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum TokenAuthMethod {
17 ClientSecretBasic,
19 ClientSecretPost,
21 None,
23 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
51pub struct OidcClient {
52 pub client_id: String,
53 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 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 pub fn redirect_matches(&self, redirect_uri: &str) -> bool {
99 self.redirect_uris.iter().any(|u| u == redirect_uri)
100 }
101
102 pub fn allows_grant(&self, grant: &str) -> bool {
104 self.grant_types.iter().any(|g| g == grant)
105 }
106}
107
108#[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#[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#[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#[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#[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#[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 assert!(!c.redirect_matches("https://app.example.com/cb/"));
249 assert!(!c.redirect_matches("https://app.example.com/cb/extra"));
251 }
252}