1use auth::models::AuthUserId;
2use auth::resolver::session_token_hash;
3use chrono::{DateTime, Utc};
4use platform_core::{AppError, AppResult, DbPool, ErrorCode};
5use std::fmt::Write as _;
6
7#[derive(Debug, Clone)]
8pub struct OidcRepository {
9 pool: DbPool,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct AuthorizationCodeInput {
14 pub user_id: AuthUserId,
15 pub client_id: String,
16 pub redirect_uri: String,
17 pub scope: String,
18 pub code_challenge: String,
19 pub code_challenge_method: String,
20 pub nonce: Option<String>,
21 pub created_at: DateTime<Utc>,
22 pub expires_at: DateTime<Utc>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct AuthorizationCode {
27 pub code: String,
28 pub expires_at: DateTime<Utc>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct AuthorizationCodeRecord {
33 pub user_id: AuthUserId,
34 pub client_id: String,
35 pub redirect_uri: String,
36 pub scope: String,
37 pub code_challenge: String,
38 pub code_challenge_method: String,
39 pub nonce: Option<String>,
40 pub expires_at: DateTime<Utc>,
41}
42
43impl OidcRepository {
44 #[must_use]
45 pub fn new(pool: DbPool) -> Self {
46 Self { pool }
47 }
48
49 pub async fn create_authorization_code(
50 &self,
51 input: AuthorizationCodeInput,
52 ) -> AppResult<AuthorizationCode> {
53 let code = new_authorization_code();
54 let result = sqlx::query(
55 r"
56 insert into auth_oidc.authorization_codes (
57 code_hash,
58 user_id,
59 client_id,
60 redirect_uri,
61 scope,
62 code_challenge,
63 code_challenge_method,
64 nonce,
65 created_at,
66 expires_at,
67 consumed_at
68 )
69 select $1, users.id, $3, $4, $5, $6, $7, $8, $9, $10, null
70 from auth.users users
71 where users.id = $2
72 and (users.disabled_at is null or users.disabled_until <= now())
73 ",
74 )
75 .bind(session_token_hash(&code))
76 .bind(&input.user_id.0)
77 .bind(&input.client_id)
78 .bind(&input.redirect_uri)
79 .bind(&input.scope)
80 .bind(&input.code_challenge)
81 .bind(&input.code_challenge_method)
82 .bind(input.nonce.as_deref())
83 .bind(input.created_at)
84 .bind(input.expires_at)
85 .execute(&self.pool)
86 .await
87 .map_err(map_sql_error)?;
88
89 if result.rows_affected() == 0 {
90 return Err(AppError::new(ErrorCode::Forbidden, "Auth user is disabled"));
91 }
92
93 Ok(AuthorizationCode {
94 code,
95 expires_at: input.expires_at,
96 })
97 }
98
99 pub async fn find_authorization_code(
100 &self,
101 code: &str,
102 now: DateTime<Utc>,
103 ) -> AppResult<Option<AuthorizationCodeRecord>> {
104 let row = sqlx::query_as::<
105 _,
106 (
107 String,
108 String,
109 String,
110 String,
111 String,
112 String,
113 Option<String>,
114 DateTime<Utc>,
115 ),
116 >(
117 r"
118 select
119 user_id,
120 client_id,
121 redirect_uri,
122 scope,
123 code_challenge,
124 code_challenge_method,
125 nonce,
126 expires_at
127 from auth_oidc.authorization_codes
128 where code_hash = $1
129 and consumed_at is null
130 and expires_at > $2
131 ",
132 )
133 .bind(session_token_hash(code))
134 .bind(now)
135 .fetch_optional(&self.pool)
136 .await
137 .map_err(map_sql_error)?;
138
139 Ok(row.map(
140 |(
141 user_id,
142 client_id,
143 redirect_uri,
144 scope,
145 code_challenge,
146 code_challenge_method,
147 nonce,
148 expires_at,
149 )| AuthorizationCodeRecord {
150 user_id: AuthUserId(user_id),
151 client_id,
152 redirect_uri,
153 scope,
154 code_challenge,
155 code_challenge_method,
156 nonce,
157 expires_at,
158 },
159 ))
160 }
161
162 pub async fn consume_authorization_code(
163 &self,
164 code: &str,
165 now: DateTime<Utc>,
166 ) -> AppResult<bool> {
167 let result = sqlx::query(
168 r"
169 update auth_oidc.authorization_codes
170 set consumed_at = $2
171 where code_hash = $1
172 and consumed_at is null
173 and expires_at > $2
174 ",
175 )
176 .bind(session_token_hash(code))
177 .bind(now)
178 .execute(&self.pool)
179 .await
180 .map_err(map_sql_error)?;
181
182 Ok(result.rows_affected() == 1)
183 }
184}
185
186fn new_authorization_code() -> String {
187 let mut bytes = [0u8; 32];
188 getrandom::fill(&mut bytes).expect("OS randomness should be available");
189
190 let mut token = String::with_capacity("oidc_code_".len() + bytes.len() * 2);
191 token.push_str("oidc_code_");
192 for byte in bytes {
193 let _ = write!(token, "{byte:02x}");
194 }
195 token
196}
197
198fn map_sql_error(source: sqlx::Error) -> AppError {
199 AppError::new(ErrorCode::Internal, "Internal server error").with_source(source)
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn authorization_codes_use_oidc_prefix() {
208 assert!(new_authorization_code().starts_with("oidc_code_"));
209 }
210}