sui-id-core 0.59.0

Authentication / authorization core (OIDC / OAuth2 + PKCE) for sui-id, a self-hosted Rust OIDC provider.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
//! Forgot-password / password-reset flow.
//!
//! Three pure functions:
//!
//! - [`request_reset`] — issued from `POST /forgot-password`. Looks
//!   up a user by email, generates a token, persists its hash,
//!   sends the reset link mail, returns. Always returns `Ok(())`
//!   externally (user-enumeration protection); failures are
//!   audit-logged.
//! - [`validate_token`] — issued from `GET /reset-password?token=…`
//!   to gate rendering the new-password form. Verifies the token
//!   without consuming it.
//! - [`consume_and_reset_password`] — issued from
//!   `POST /reset-password`. Verifies the token, replaces the user's
//!   password, marks the token consumed, all in one logical step.
//!
//! ## Token shape
//!
//! - 32 random bytes from `OsRng` → URL-safe base64 (no padding).
//!   The plaintext only ever exists in the user's email and the
//!   user's clipboard / browser.
//! - SHA-256 of the plaintext is stored in
//!   `password_reset_tokens.token_hash`. A backup leak does not
//!   yield live tokens. SHA-256 is sufficient: the underlying
//!   token is 32 bytes of CSPRNG output, so we only need preimage
//!   resistance, not a slow KDF.
//! - 30-minute TTL by default.
//! - Single-use: `consumed_at` set on redemption; replays land on
//!   a "consumed" check that returns `InvalidCredentials`.
//!
//! ## User enumeration
//!
//! `request_reset` returns `Ok(())` whether the email matched a
//! user or not, takes roughly the same time in both branches, and
//! emits a `auth.password.reset_requested` event in either case.
//! The handler always shows a generic "if an account exists, we've
//! sent the link" page.

use getrandom;
use crate::errors::{CoreError, CoreResult};
use crate::hibp::{self, HibpClient, HibpEnforcement};
use crate::events::{self, Context, SecurityEvent};
use crate::mail::{MailSender, OutgoingMail};
use crate::password;
use crate::time::SharedClock;
use base64ct::{Base64UrlUnpadded, Encoding};
use chrono::Duration;
use sha2::{Digest, Sha256};
use sui_id_shared::ids::{PasswordResetTokenId, UserId};
use sui_id_store::models::{CredentialRow, HibpMode, PasswordResetTokenRow};
use sui_id_store::repos::{credentials, password_reset_tokens, refresh_tokens, sessions, smtp_config, users};
use sui_id_store::Database;

/// 30 minutes — a balance between user-friendly delivery delays
/// and a reasonably tight attack window.
pub const DEFAULT_TOKEN_TTL: Duration = Duration::minutes(30);

/// Outstanding-token ceiling per user. Above this, we silently
/// stop issuing new tokens (the response is still 200 so a probe
/// can't tell). Prevents a single user's inbox from being spammed.
const MAX_OUTSTANDING_TOKENS_PER_USER: i64 = 3;

fn mint_random_token() -> (String, Vec<u8>) {
    let mut bytes = [0u8; 32];
    getrandom::fill(&mut bytes).expect("system RNG unavailable");
    let plaintext = Base64UrlUnpadded::encode_string(&bytes);
    let hash = Sha256::digest(plaintext.as_bytes()).to_vec();
    (plaintext, hash)
}

fn hash_token(plaintext: &str) -> Vec<u8> {
    Sha256::digest(plaintext.as_bytes()).to_vec()
}

/// Issue a password-reset token for the given email, send the
/// reset-link mail, and emit an audit event.
///
/// The exterior contract is **unconditional success**: even when
/// the email doesn't match a user, or the user has no email, or
/// SMTP is unconfigured, this returns `Ok(())`. Internal failures
/// are recorded as audit events but never surfaced. The handler
/// maps every internal outcome to the same neutral 200-response
/// page so `POST /forgot-password` cannot be a user-enumeration
/// oracle.
pub async fn request_reset(
    db: &Database,
    clock: &SharedClock,
    mailer: &dyn MailSender,
    email: &str,
    requester_ip: Option<&str>,
) -> CoreResult<()> {
    let normalized_email = sui_id_shared::normalize_email(email);
    let now = clock.now();

    let mut ctx = Context::default();
    if let Some(ip) = requester_ip {
        ctx = ctx.with_client_ip(ip);
    }

    // Look up by email.
    let user_row = users::find_by_email_normalized(db, &normalized_email).await?;
    let Some(user_row) = user_row else {
        events::emit(
            db,
            clock,
            &ctx,
            SecurityEvent::PasswordResetRequested { user_id: None },
        ).await;
        return Ok(());
    };

    if user_row.is_disabled || user_row.is_deleted {
        events::emit(
            db,
            clock,
            &ctx.clone().with_actor(user_row.id),
            SecurityEvent::PasswordResetRequested {
                user_id: Some(user_row.id),
            },
        ).await;
        return Ok(());
    }

    // Outstanding-token throttle.
    let outstanding =
        password_reset_tokens::count_active_for_user(db, user_row.id, now).await?;
    if outstanding >= MAX_OUTSTANDING_TOKENS_PER_USER {
        events::emit(
            db,
            clock,
            &ctx.clone().with_actor(user_row.id),
            SecurityEvent::PasswordResetThrottled {
                user_id: user_row.id,
                outstanding,
            },
        ).await;
        return Ok(());
    }

    // Mint a token, persist its hash.
    let (plaintext, hash) = mint_random_token();
    let row = PasswordResetTokenRow {
        id: PasswordResetTokenId::new(),
        user_id: user_row.id,
        token_hash: hash,
        issued_at: now,
        expires_at: now + DEFAULT_TOKEN_TTL,
        consumed_at: None,
        requester_ip: requester_ip.map(str::to_owned),
    };
    password_reset_tokens::insert(db, &row).await?;

    // Build the reset link from `smtp_config.base_url` (the
    // user-facing origin, not necessarily the OIDC issuer URL).
    let base_url = match smtp_config::get(db).await? {
        Some(c) if c.enabled => c.base_url,
        _ => {
            // SMTP disabled / unconfigured. Still return Ok so the
            // exterior shape is constant; record the actual outcome.
            events::emit(
                db,
                clock,
                &ctx.clone().with_actor(user_row.id),
                SecurityEvent::PasswordResetEmailFailed {
                    user_id: user_row.id,
                    reason: "smtp_unconfigured".into(),
                },
            ).await;
            return Ok(());
        }
    };
    let link = format!(
        "{}/reset-password?token={}",
        base_url.trim_end_matches('/'),
        plaintext
    );

    // Compose and dispatch the mail. The recipient's locale is
    // their `preferred_lang` if set, otherwise the server default
    // — we don't have a per-request browser context here (this
    // runs inline with the POST handler but the recipient may not
    // be the requester). Falling through to server default if the
    // user has expressed no preference matches the resolution
    // chain in `core::i18n::resolve`.
    let default_locale = sui_id_store::repos::server_settings::get(db).await
        .ok()
        .and_then(|s| sui_id_i18n::Locale::parse(&s.default_lang))
        .unwrap_or_default();
    let recipient_locale = user_row
        .preferred_lang
        .as_deref()
        .and_then(sui_id_i18n::Locale::parse)
        .unwrap_or(default_locale);
    let t = recipient_locale.strings();
    let display = user_row
        .display_name
        .as_deref()
        .unwrap_or(&user_row.username);
    let greeting = if t.email_greeting_suffix.is_empty() {
        display.to_string()
    } else {
        format!("{} {}", display, t.email_greeting_suffix)
    };
    let mail = OutgoingMail {
        // Deliver to the original-case address the user registered with;
        // the normalised form was only needed for the lookup.
        to: user_row.email.clone().unwrap_or_else(|| normalized_email.clone()),
        subject: t.email_subject_password_reset.to_string(),
        text_body: format!(
            "{greeting}\n\
             \n\
             {intro}\n\
             \n\
             {link}\n\
             \n\
             {disregard}\n\
             ",
            greeting = greeting,
            intro = t.email_password_reset_intro,
            link = link,
            disregard = t.email_password_reset_disregard,
        ),
        html_body: Some(format!(
            "<p>{greeting_esc}</p>\
             <p>{intro}</p>\
             <p><a href=\"{link_esc}\">{link_label}</a></p>\
             <p>{disregard}</p>",
            greeting_esc = html_escape(&greeting),
            intro = t.email_password_reset_intro,
            link_esc = html_escape(&link),
            link_label = t.email_password_reset_link_label,
            disregard = t.email_password_reset_disregard,
        )),
        locale: None,
    };

    match mailer.send(mail).await {
        Ok(_outcome) => {
            events::emit(
                db,
                clock,
                &ctx.clone().with_actor(user_row.id),
                SecurityEvent::PasswordResetEmailSent {
                    user_id: user_row.id,
                },
            ).await;
        }
        Err(e) => {
            events::emit(
                db,
                clock,
                &ctx.clone().with_actor(user_row.id),
                SecurityEvent::PasswordResetEmailFailed {
                    user_id: user_row.id,
                    reason: e.to_string(),
                },
            ).await;
        }
    }
    Ok(())
}

/// Verify a token without consuming it. Used by the GET handler
/// that decides whether to render the new-password form or a
/// "this link is invalid or expired" page.
pub async fn validate_token(
    db: &Database,
    clock: &SharedClock,
    plaintext_token: &str,
) -> CoreResult<UserId> {
    let hash = hash_token(plaintext_token);
    let row = password_reset_tokens::find_by_hash(db, &hash).await?
        .ok_or(CoreError::InvalidCredentials)?;
    if row.consumed_at.is_some() {
        return Err(CoreError::InvalidCredentials);
    }
    if row.expires_at < clock.now() {
        return Err(CoreError::InvalidCredentials);
    }
    Ok(row.user_id)
}

/// Verify the token, set the user's new password, mark the token consumed,
/// and revoke all existing sessions and refresh tokens for the user — all
/// in a single atomic transaction.
///
/// Revoking prior sessions is essential: the user completed this flow
/// precisely because they lost control of their credentials. An attacker
/// who holds a stolen session cookie or refresh token must not retain
/// access after the legitimate user has recovered the account.
///
/// The revoke matches the behaviour of the admin-driven and self-service
/// password-change paths, which both revoke on write.
pub async fn consume_and_reset_password(
    db: &Database,
    clock: &SharedClock,
    mailer: &dyn MailSender,
    hibp_client: Option<&dyn HibpClient>,
    hibp_mode: HibpMode,
    plaintext_token: &str,
    new_password: &str,
    requester_ip: Option<&str>,
) -> CoreResult<()> {
    password::check_password_policy(new_password)?;

    // RFC 003: HIBP breach check on token-based password reset.
    // Fail-open: network failures let the reset through.
    if matches!(
        hibp::enforce_hibp(hibp_mode, hibp_client, new_password).await,
        HibpEnforcement::Blocked { .. }
    ) {
        return Err(CoreError::BadRequest(
            "New password found in known data breaches. Please choose a different password.".into(),
        ));
    }
    let hash = hash_token(plaintext_token);
    let row = password_reset_tokens::find_by_hash(db, &hash).await?
        .ok_or(CoreError::InvalidCredentials)?;
    let now = clock.now();
    if row.consumed_at.is_some() || row.expires_at < now {
        return Err(CoreError::InvalidCredentials);
    }

    // Hash the new password before entering the transaction so a slow
    // Argon2id derivation doesn't hold the DB mutex longer than necessary.
    let new_hash = password::hash_password(new_password)?;

    // Atomically: update credential, consume token, revoke all sessions and
    // refresh tokens. Either everything commits or nothing does — the user
    // is never left in a half-recovered state.
    let row_user_id = row.user_id;
    let row_id = row.id;
    let new_hash_owned = new_hash.clone();
    db.with_tx(move |tx| {
        credentials::upsert_within_tx(
            tx,
            &CredentialRow {
                user_id: row_user_id,
                password_hash: new_hash_owned,
                must_change: false,
                updated_at: now,
            },
        )?;
        password_reset_tokens::mark_consumed_within_tx(tx, row_id, now)?;
        sessions::revoke_all_for_user_within_tx(tx, row_user_id, now)?;
        refresh_tokens::revoke_all_for_user_within_tx(tx, row_user_id, now)?;
        Ok(())
    }).await?;

    let mut ctx = Context::default().with_actor(row.user_id);
    if let Some(ip) = requester_ip {
        ctx = ctx.with_client_ip(ip);
    }
    events::emit(
        db,
        clock,
        &ctx,
        SecurityEvent::PasswordResetCompleted {
            user_id: row.user_id,
        },
    ).await;

    // Best-effort post-reset notification mail. Failures here do
    // not affect the password change itself. The recipient's
    // locale comes from their `preferred_lang` if set, falling
    // through to the server default.
    if let Ok(Some(user_row)) = users::find_by_id_opt(db, row.user_id).await {
        if let Some(email) = user_row.email.as_deref() {
            let default_locale_pw = sui_id_store::repos::server_settings::get(db).await
                .ok()
                .and_then(|s| sui_id_i18n::Locale::parse(&s.default_lang))
                .unwrap_or_default();
        let recipient_locale = user_row
                .preferred_lang
                .as_deref()
                .and_then(sui_id_i18n::Locale::parse)
                .unwrap_or(default_locale_pw);
            let _ = notify_password_changed(
                mailer,
                email,
                &user_row.display_name,
                recipient_locale,
            ).await;
        }
    }

    Ok(())
}

/// Send the "your password has just been changed" notification.
///
/// Best-effort: callers swallow errors and proceed. The audit
/// chain records the underlying password-change action separately.
///
/// `locale` is the recipient's preferred locale — typically
/// resolved from `user.preferred_lang` falling through to the
/// server default. The caller is responsible for that resolution
/// (passing in the locale rather than re-querying here keeps the
/// function pure, testable, and free of DB access).
pub async fn notify_password_changed(
    mailer: &dyn MailSender,
    to_email: &str,
    display_name: &Option<String>,
    locale: sui_id_i18n::Locale,
) -> CoreResult<()> {
    let t = locale.strings();
    let display = display_name.as_deref().unwrap_or("");
    let greeting = if t.email_greeting_suffix.is_empty() {
        display.to_string()
    } else {
        format!("{} {}", display, t.email_greeting_suffix)
    };
    let mail = OutgoingMail {
        to: to_email.to_owned(),
        subject: t.email_subject_password_changed.to_string(),
        text_body: format!(
            "{greeting}\n\
             \n\
             {intro}\n\
             {warning}\n\
             ",
            greeting = greeting,
            intro = t.email_password_changed_intro,
            warning = t.email_password_changed_security_warning,
        ),
        html_body: Some(format!(
            "<p>{greeting_esc}</p>\
             <p>{intro}</p>\
             <p>{warning} <a href=\"/me/security\">{link_label}</a></p>",
            greeting_esc = html_escape(&greeting),
            intro = t.email_password_changed_intro,
            warning = t.email_password_changed_security_warning,
            link_label = t.email_password_changed_link_security,
        )),
        locale: None,
    };
    mailer.send(mail).await.map(|_| ())
}

fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}