Skip to main content

auth_oidc/
repositories.rs

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}