Skip to content
Clone

refactor(auth): refresh crate across features, handler, pow and metrics

zhenyi committed
parent 5c68e9f commit 1b7f02f Browse files

34 files changed

+43 −389

File tree 34
Modified lib/auth/features/agreement/impls.rs View lib/auth/features/agreement/impls.rs
@@ -30,9 +30,7 @@
3030 .await
3131 }
3232
33 /// Internal helper used by other features (register, oauth) to record
34 /// that a user accepted every currently configured agreement.
35 pub(crate) async fn record_agreement(
33 pub(crate) async fn record_agreement(
3634 &self,
3735 user_id: Uuid,
3836 key: &str,
Modified lib/auth/features/agreement/usecase.rs View lib/auth/features/agreement/usecase.rs
@@ -12,8 +12,6 @@
1212
1313use super::{AcceptAgreementCmd, AcceptAgreementResp};
1414
15/// Return every agreement doc the service currently publishes (terms +
16/// privacy). Callers should diff with what they have already accepted.
1715pub fn list_agreements(config: &AuthConfig) -> Vec<crate::config::AgreementDoc> {
1816 vec![
1917 config.agreements.terms.clone(),
@@ -21,9 +19,6 @@
2119 ]
2220}
2321
24/// Record that the user accepted a specific version of an agreement. The
25/// agreement must match a known current version in the configuration or the
26/// call is rejected.
2722pub async fn accept_agreement(
2823 cmd: AcceptAgreementCmd,
2924 db: &DatabaseConnection,
@@ -58,8 +53,6 @@
5853 )));
5954 }
6055
61 // Idempotent insert: ignore conflict on the (user_id, agreement_key,
62 // agreement_version) unique key.
6356 let key = cmd.agreement_key.clone();
6457 let version = cmd.agreement_version.clone();
6558 let am: AcceptActiveModel = AcceptActiveModel::builder()
@@ -69,7 +62,6 @@
6962 .set_agreement_version(cmd.agreement_version)
7063 .set_accepted_at(OffsetDateTime::now_utc())
7164 .into();
72 // Try insert; if a unique-key collision already exists, treat as success.
7365 match am.insert(db).await {
7466 Ok(_) => Ok(AcceptAgreementResp { recorded: true }),
7567 Err(_) => {
Modified lib/auth/features/email_code/types.rs View lib/auth/features/email_code/types.rs
@@ -7,8 +7,6 @@
77
88use crate::features::TempKey;
99
10/// Input for issuing a verification code: the email and the code purpose. A
11/// Proof-of-Work solution must accompany the request (handled by the caller).
1210#[derive(Debug, Clone, Deserialize, ToSchema)]
1311pub struct IssueCodeCmd {
1412 pub email: String,
@@ -18,8 +16,6 @@
1816 pub user_id: Uuid,
1917}
2018
21/// What the caller needs to verify the code: the temporary key returned to the
22/// client plus the actual code that must be sent via email.
2319#[derive(Debug, Clone)]
2420pub struct IssueCodeArtifacts {
2521 pub temp_key: TempKey,
@@ -28,10 +24,6 @@
2824 pub expires_at: OffsetDateTime,
2925}
3026
31/// Input for verifying a code: the email, the purpose, the user-supplied code,
32/// and the temporary key value (read from the client cookie). The `user_id` is
33/// optional: it may be omitted when the code itself is the only proof of
34/// mailbox control (reclaim flow); if absent it is resolved from the email row.
3527#[derive(Debug, Clone, Deserialize, ToSchema)]
3628pub struct VerifyCodeCmd {
3729 pub email: String,
Modified lib/auth/features/email_code/usecase.rs View lib/auth/features/email_code/usecase.rs
@@ -53,10 +53,6 @@
5353 rand::rng().fill_bytes(&mut buf);
5454 let mut out = String::with_capacity(digits);
5555 for b in buf.iter() {
56 // Rejection sampling: only accept values in [0, 250) so that
57 // `b % 10` is uniform across 0..=9. The previous unconditional
58 // `% 10` skewed toward 0-5 because 256 / 10 leaves a 6-element
59 // remainder.
6056 let v = if *b < 250 {
6157 *b % 10
6258 } else {
@@ -88,9 +84,6 @@
8884 diff == 0
8985}
9086
91/// Issue a verification code: store the raw value in Redis under a UUID key,
92/// persist the SHA-256 hash in `user_email_verification_codes`, and return the
93/// [`TempKey`] the caller turns into a cookie.
9487pub async fn issue_code(
9588 cmd: IssueCodeCmd,
9689 db: &DatabaseConnection,
@@ -123,12 +116,6 @@
123116 .await
124117 .map_err(map_redis)?;
125118
126 // VULN-012: invalidate every prior unused code for the same (email,
127 // purpose) pair so a reissued code cannot be shadowed by an older
128 // one that the caller still has. Redis-side we cannot index old
129 // temp_ids cheaply; mark the DB rows as used to short-circuit
130 // verify_code's UsedAt.is_null() filter. Single round-trip
131 // UPDATE ... WHERE email = ? AND purpose = ? AND used_at IS NULL.
132119 let stale_mark = CodeActiveModel {
133120 used_at: Set(Some(OffsetDateTime::now_utc())),
134121 ..Default::default()
@@ -184,12 +171,6 @@
184171 })
185172}
186173
187/// Verify a code: fetch Redis value via `temp_key_value`, constant-time
188/// compare against user input. On match, consume the Redis entry and mark the
189/// DB row `used_at = now`. Each attempt bumps a per-code counter; past
190/// `max_verify_attempts` the code is destroyed and further attempts are
191/// rejected. For `Signup` purposes a successful verification also activates
192/// the account (B1).
193174pub async fn verify_code(
194175 cmd: VerifyCodeCmd,
195176 db: &DatabaseConnection,
@@ -226,8 +207,6 @@
226207 temp_id.simple()
227208 );
228209
229 // B2: lockout — every attempt (correct or not) bumps the counter; past
230 // the cap we destroy the code and reject further tries.
231210 let attempts = kv
232211 .incr_with_ttl(&attempts_key, config.verify_code_ttl_secs)
233212 .await
@@ -250,8 +229,6 @@
250229 kv.delete(&key).await.map_err(map_redis)?;
251230
252231 let token_hash = sha256_hex(cmd.code.as_bytes());
253 // B5: the code is proof of mailbox control, so callers may omit the user
254 // id (reclaim flow); resolve it from the email row when absent.
255232 let user_id = match cmd.user_id {
256233 Some(uid) => uid,
257234 None => EmailEntity::find()
@@ -277,8 +254,6 @@
277254 let now = OffsetDateTime::now_utc();
278255
279256 let result = if cmd.purpose == EmailVerificationPurpose::Signup {
280 // B1: activation is atomic with code consumption — a verified signup
281 // code activates the account and primary email.
282257 let txn = db.begin().await?;
283258 let mut am: CodeActiveModel = row.into();
284259 am.used_at = Set(Some(now));
Modified lib/auth/features/forgot_password/types.rs View lib/auth/features/forgot_password/types.rs
@@ -10,9 +10,6 @@
1010 pub pow: PowProof,
1111}
1212
13/// Response is always 200-shaped and never reveals whether the email exists.
14/// The temp_key must be presented as an HttpOnly+Secure cookie on the
15/// reset-password request.
1613#[derive(Debug, Clone, Serialize, ToSchema)]
1714pub struct ForgotPasswordResp {
1815 pub sent: bool,
Modified lib/auth/features/forgot_password/usecase.rs View lib/auth/features/forgot_password/usecase.rs
@@ -86,8 +86,6 @@
8686 Ok(())
8787}
8888
89/// Issue a password-reset token. Returns a constant-shape response whether or
90/// not the email exists to avoid leaking which addresses are registered.
9189pub async fn forgot_password(
9290 cmd: ForgotPasswordCmd,
9391 svc: &AuthService,
@@ -137,13 +135,11 @@
137135 let token = generate_random_token(svc.config.reset_token_bytes);
138136 let token_hash = sha256_hex(token.as_bytes());
139137
140 // Redis: temp_id -> token (authoritative live).
141138 svc.kv
142139 .set_bytes(&redis_key(&temp_id), token.as_bytes().to_vec(), ttl)
143140 .await
144141 .map_err(map_redis)?;
145142
146 // DB audit row.
147143 let row: TokenActiveModel = TokenActiveModel::builder()
148144 .set_id(Uuid::new_v4())
149145 .set_user_id(em.user_id)
@@ -158,7 +154,6 @@
158154 tracing::warn!(?e, "reset email send failed");
159155 }
160156 }
161 // If the email is unknown, return the same shape with no email sent.
162157
163158 Ok(ForgotPasswordResp {
164159 sent: true,
Modified lib/auth/features/login/types.rs View lib/auth/features/login/types.rs
@@ -2,11 +2,6 @@
22use utoipa::ToSchema;
33use uuid::Uuid;
44
5/// Public-safe subset of `users` returned in login responses. Privileged
6/// fields (`is_admin`, `is_restricted`, `is_active`) are intentionally
7/// excluded so they cannot be read from a third-party script in the
8/// browser or a leaked access token. Use [`UserMe`] on the `/me` endpoint
9/// when those are needed.
105#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
116pub struct UserPublic {
127 #[schema(value_type = String)]
@@ -33,8 +28,6 @@
3328 }
3429}
3530
36/// Full user view served only on the authenticated `/me` endpoint, behind
37/// the access-token middleware.
3831#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
3932pub struct UserMe {
4033 #[schema(value_type = String)]
@@ -69,8 +62,7 @@
6962
7063#[derive(Debug, Clone, Deserialize, ToSchema)]
7164pub struct LoginCmd {
72 /// Either email or username.
73 pub identifier: String,
65 pub identifier: String,
7466 pub password: String,
7567 pub pow: crate::features::register::PowProof,
7668 #[serde(default)]
Modified lib/auth/features/login/usecase.rs View lib/auth/features/login/usecase.rs
@@ -37,11 +37,8 @@
3737 }
3838 let lower = ident.to_ascii_lowercase();
3939 if ident.contains('@') {
40 // Try email table first.
4140 let by_email = EmailEntity::find()
4241 .filter(EmailEntity::COLUMN.lower_email.eq(lower.clone()))
43 // VULN-011: exclude soft-deleted emails so a removed handle
44 // cannot still be used to authenticate.
4542 .filter(EmailEntity::COLUMN.deleted_at.is_null())
4643 .find_also_related(UserEntity)
4744 .one(db)
@@ -67,14 +64,6 @@
6764 }
6865}
6966
70/// Authenticate an existing user with email/username + password.
71///
72/// Security guards:
73/// - PoW required (anti-bruteforce on the endpoint).
74/// - Unknown identifier → `InvalidCredentials` (no user enumeration).
75/// - Inactive, restricted, prohibit_login users are rejected.
76/// - Passwordless accounts (`passwd_hash` empty) MUST NOT sign in via password
77/// and are told to use a third-party provider.
7867pub async fn login(
7968 cmd: LoginCmd,
8069 svc: &AuthService,
@@ -118,7 +107,6 @@
118107 return Err(AuthError::InvalidCredentials);
119108 }
120109
121 // Issue the token pair via jwt rotation.
122110 let cache = svc.jwt.cache.read().await;
123111 let ctx = RotationContext {
124112 db: &db,
@@ -136,11 +124,10 @@
136124 )
137125 .await?;
138126
139 // Update last_login_at.
140127 let mut am: UserActiveModel = user.clone().into();
141128 am.last_login_at = Set(Some(OffsetDateTime::now_utc()));
142129 am.updated_at = Set(OffsetDateTime::now_utc());
143 let _ = am.update(&db).await; // best-effort
130 let _ = am.update(&db).await;
144131
145132 Ok(build_resp(pair.into(), user))
146133 }
Modified lib/auth/features/logout/usecase.rs View lib/auth/features/logout/usecase.rs
@@ -14,12 +14,7 @@
1414 AuthError::Internal(format!("redis: {e}"))
1515}
1616
17/// Revoke the session bound to a refresh token. Idempotent: an unknown token
18/// yields `revoked = false` without error so that retries are safe.
19///
20/// When an access token is supplied (best-effort), its `jti` is also placed on
2117/// the deny-list so the already-issued, stateless access token stops validating
22/// immediately instead of remaining live until expiry.
2318pub async fn logout(
2419 cmd: LogoutCmd,
2520 svc: &AuthService,
@@ -54,8 +49,6 @@
5449 None => LogoutResp { revoked: false },
5550 };
5651
57 // VULN-005: revoke the presented access token too (best-effort — an expired
58 // or malformed token is simply ignored).
5952 if let Some(token) = access_token
6053 && let Some(akid) = extract_kid(&token)
6154 && let Some(acached) = svc.jwt.cache.read().await.get(&akid)
Modified lib/auth/features/oauth/oauth_apple/usecase.rs View lib/auth/features/oauth/oauth_apple/usecase.rs
@@ -28,7 +28,6 @@
2828 .map_err(|e| AuthError::Internal(format!("apple b64: {e}")))
2929}
3030
31/// Apple returns an RSA public key set at https://appleid.apple.com/auth/keys.
3231#[derive(Debug, Deserialize)]
3332struct AppleKey {
3433 kid: String,
@@ -86,12 +85,6 @@
8685 Ok(kid.to_string())
8786}
8887
89/// Build an ES256-signed `client_secret` JWT for Apple's `/auth/token`
90/// endpoint. Apple requires:
91/// header: alg=ES256, kid=<key id>
92/// payload: iss=<team_id>, iat, exp, aud=https://appleid.apple.com, sub=<client_id>
93/// The signing key is supplied via the provider config's `extra` map under
94/// `es256_private_key` (PEM, PKCS#8).
9588fn apple_client_secret(cfg: &OAuthProviderConfig) -> Result<String, AuthError> {
9689 let pem = cfg.extra.get("es256_private_key").ok_or_else(|| {
9790 AuthError::Internal("apple provider missing es256_private_key in extra".into())
@@ -114,7 +107,7 @@
114107 let claims = serde_json::json!({
115108 "iss": team,
116109 "iat": now,
117 "exp": now + 60 * 60, // 1 hour
110 "exp": now + 60 * 60,
118111 "aud": "https://appleid.apple.com",
119112 "sub": cfg.client_id,
120113 });
@@ -122,8 +115,6 @@
122115 .map_err(|e| AuthError::Internal(format!("apple client_secret encode: {e}")))
123116}
124117
125/// Exchange an Apple authorization code for tokens. Decode the `id_token`
126/// (RS256) to extract `sub` / `email` / `email_verified`.
127118pub async fn fetch_apple_identity(
128119 code: &str,
129120 code_verifier: &str,
Modified lib/auth/features/oauth/oauth_github/usecase.rs View lib/auth/features/oauth/oauth_github/usecase.rs
@@ -49,8 +49,6 @@
4949 Ok(v)
5050}
5151
52/// Exchange a GitHub authorization code for an access token, fetch the user
53/// profile, and resolve a primary verified email when needed.
5452pub async fn fetch_github_identity(
5553 code: &str,
5654 code_verifier: &str,
Modified lib/auth/features/oauth/oauth_google/impls.rs View lib/auth/features/oauth/oauth_google/impls.rs
@@ -1,8 +1,4 @@
1// Method-style entry point on AuthService is provided by the parent `oauth`
21// module (`oauth_authorize`, `oauth_callback`). Provider-specific helpers live
3// in `usecase.rs` and are consumed by the dispatch logic there. Keeping this
4// file explicit so the {mod,types,usecase,impls} layout is uniform across
5// features.
62
73#[allow(unused_imports)]
84use super::usecase::fetch_google_identity;
Modified lib/auth/features/oauth/oauth_google/usecase.rs View lib/auth/features/oauth/oauth_google/usecase.rs
@@ -25,8 +25,6 @@
2525 picture: Option<String>,
2626}
2727
28/// Exchange a Google authorization code for an access token, then fetch the
29/// user's profile. Returns a normalised [`ProviderIdentity`].
3028pub async fn fetch_google_identity(
3129 code: &str,
3230 code_verifier: &str,
Modified lib/auth/features/oauth/types.rs View lib/auth/features/oauth/types.rs
@@ -38,29 +38,20 @@
3838 pub authorize_url: String,
3939}
4040
41/// Input for `oauth_authorize`.
4241#[derive(Debug, Clone, Deserialize, ToSchema)]
4342pub struct AuthorizeCmd {
4443 pub provider: OAuthProvider,
4544 #[serde(default)]
4645 pub app_id: Option<String>,
47 /// PKCE verifier (RFC 7636). The caller MUST generate a high-entropy
48 /// URL-safe base64 string (43-128 chars) per request. The server
49 /// derives `code_challenge = BASE64URL(SHA256(verifier))` and stores
50 /// the verifier server-side so the callback can present it to the
51 /// provider's token endpoint.
52 pub code_verifier: String,
46 pub code_verifier: String,
5347}
5448
5549#[derive(Debug, Clone, Serialize, ToSchema)]
5650pub struct AuthorizationUrlResp {
5751 pub url: String,
58 /// TempKey the caller must deliver as an HttpOnly+Secure cookie so the
59 /// callback can read back the state value.
60 pub temp_key: TempKey,
52 pub temp_key: TempKey,
6153}
6254
63/// Input for `oauth_callback`.
6455#[derive(Debug, Clone, Deserialize, ToSchema)]
6556pub struct CallbackCmd {
6657 pub provider: OAuthProvider,
@@ -71,7 +62,6 @@
7162 pub app_id: Option<String>,
7263}
7364
74/// Normalised third-party identity fetched by each provider module.
7565#[derive(Debug, Clone)]
7666pub struct ProviderIdentity {
7767 pub provider: OAuthProvider,
@@ -93,6 +83,5 @@
9383 pub expires_in: i64,
9484 #[schema(value_type = String)]
9585 pub user_id: Uuid,
96 /// `true` when an account was auto-created because no local user matched.
97 pub auto_registered: bool,
86 pub auto_registered: bool,
9887}
Modified lib/auth/features/oauth/usecase.rs View lib/auth/features/oauth/usecase.rs
@@ -78,7 +78,6 @@
7878 email[..at].to_string()
7979}
8080
81/// Return the enabled providers (those configured + flagged enabled).
8281pub fn list_providers(config: &AuthConfig) -> Vec<OAuthProviderInfo> {
8382 let mut out = Vec::new();
8483 if let Some(p) = config.oauth.google.as_ref()
@@ -130,9 +129,6 @@
130129 Ok(cfg)
131130}
132131
133/// Build the provider's authorize URL with `state` embedded, store `state` in
134/// Redis keyed by a fresh UUID, and return the URL + a TempKey the caller
135/// turns into an HttpOnly+Secure cookie.
136132pub async fn oauth_authorize(
137133 cmd: AuthorizeCmd,
138134 svc: &AuthService,
@@ -146,14 +142,12 @@
146142 let state = generate_state();
147143 let ttl = svc.config.oauth_state_ttl_secs;
148144
149 // VULN-005: derive code_challenge = BASE64URL(SHA256(verifier)) per RFC 7636.
150145 let code_challenge = {
151146 use sha2::Digest;
152147 let digest = sha2::Sha256::digest(cmd.code_verifier.as_bytes());
153148 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
154149 };
155150
156 // Persist state + verifier together so the callback can recover both.
157151 let payload = serde_json::json!({
158152 "state": state,
159153 "code_verifier": cmd.code_verifier,
@@ -204,10 +198,6 @@
204198 result
205199}
206200
207/// Fetch the provider identity for the given code. Dispatches to the
208/// provider-specific implementation. `code_verifier` is the PKCE verifier
209/// recovered from server-side state and must be sent to the provider's
210/// token endpoint.
211201async fn fetch_identity(
212202 provider: OAuthProvider,
213203 code: &str,
@@ -222,15 +212,10 @@
222212 }
223213}
224214
225/// Resolve the local user for an OAuth identity. If the identity links to an
226/// existing account, returns that user. Otherwise, if `email_verified` is
227/// true and an existing local account owns the email, links the provider to
228/// that user. Otherwise auto-creates a passwordless account.
229215async fn resolve_or_create_user(
230216 identity: &ProviderIdentity,
231217 db: &DatabaseConnection,
232218) -> Result<(db::user::users::Model, bool), AuthError> {
233 // 1. Existing link?
234219 let link = ProviderEntity::find()
235220 .filter(ProviderEntity::COLUMN.provider.eq(identity.provider.as_str().to_string()))
236221 .filter(ProviderEntity::COLUMN.provider_user_id.eq(identity.provider_user_id.clone()))
@@ -242,7 +227,6 @@
242227 return Ok((user, false));
243228 }
244229
245 // 2. Email match (only when provider verified the email).
246230 if identity.email_verified
247231 && let Some(ref email) = identity.email
248232 {
@@ -254,7 +238,6 @@
254238 .one(db)
255239 .await?
256240 {
257 // Link provider identity to existing user.
258241 let now = OffsetDateTime::now_utc();
259242 let am: ProviderActiveModel = ProviderActiveModel::builder()
260243 .set_id(Uuid::new_v4())
@@ -273,9 +256,6 @@
273256 .set_updated_at(now)
274257 .set_last_used_at(Some(now))
275258 .into();
276 // TOCTOU: a concurrent request may have already linked this
277 // provider identity. ON CONFLICT DO NOTHING turns the unique
278 // constraint violation into a no-op; re-read the winning row.
279259 let inserted = ProviderEntity::insert(am)
280260 .on_conflict_do_nothing_on([
281261 ProviderColumn::Provider,
@@ -310,7 +290,6 @@
310290 }
311291 }
312292
313 // 3. Auto-register a passwordless account.
314293 metrics::record_oauth_user_resolution(OAuthResolutionBranch::AutoRegister);
315294 let now = OffsetDateTime::now_utc();
316295 let uid = Uuid::new_v4();
@@ -394,10 +373,6 @@
394373 .set_updated_at(now)
395374 .set_last_used_at(Some(now))
396375 .into();
397 // TOCTOU: a concurrent request may have already created this provider
398 // identity between our lookup and this transaction. ON CONFLICT DO
399 // NOTHING makes the loser roll back its partial user/email rows and
400 // re-read the winner's link instead of failing with a unique violation.
401376 let inserted_provider = ProviderEntity::insert(provider_am)
402377 .on_conflict_do_nothing_on([
403378 ProviderColumn::Provider,
@@ -434,8 +409,6 @@
434409 Ok((user, true))
435410}
436411
437/// Final OAuth callback: validates state, exchanges code for identity, links
438/// or auto-creates a local user, and returns a JWT token pair.
439412pub async fn oauth_callback(
440413 cmd: CallbackCmd,
441414 svc: &AuthService,
@@ -446,7 +419,6 @@
446419 let result: Result<OAuthLoginResp, AuthError> = async {
447420 let db = svc.db.writer();
448421
449 // Validate state.
450422 let temp_id = Uuid::parse_str(&cmd.temp_key_value)?;
451423 let key = redis_state_key(&temp_id);
452424 let stored_raw = svc
@@ -481,7 +453,6 @@
481453 return Err(AuthError::Forbidden("account inactive".into()));
482454 }
483455
484 // Issue tokens.
485456 let cache = svc.jwt.cache.read().await;
486457 let ctx = RotationContext {
487458 db: &db,
@@ -502,7 +473,6 @@
502473
503474 let resp: TokenPairResponse = pair.into();
504475
505 // Auto-record configured agreements on successful login.
506476 for doc in svc.config.agreements.iter() {
507477 if svc.accept_agreement(user.uid, &doc.key, &doc.version).await.is_ok() {
508478 metrics::record_agreement_record(&doc.key);
Modified lib/auth/features/password_hash/impls.rs View lib/auth/features/password_hash/impls.rs
@@ -1,8 +1,3 @@
1//! Method-style wrappers around [`crate::features::password_hash::usecase`].
2//!
3//! Most of the heavy lifting is exposed as free functions; the impl block here
4//! is for ergonomic call sites that hold an [`AuthService`].
5
61use super::{PasswordHashString, PasswordInput, ValidatePasswordRequest};
72use super::usecase::{hash_password, validate_password, verify_password};
83use crate::error::AuthError;
Modified lib/auth/features/password_hash/usecase.rs View lib/auth/features/password_hash/usecase.rs
@@ -9,8 +9,6 @@
99
1010use super::{PasswordHashString, PasswordInput, ValidatePasswordRequest};
1111
12/// Hash a password using Argon2id with a fresh random salt. The result is a
13/// PHC-formatted string containing the algorithm parameters and the salt.
1412pub fn hash_password(input: PasswordInput<'_>) -> Result<PasswordHashString, AuthError> {
1513 let _timer = PasswordOpTimer::start(PasswordOp::Hash);
1614 let mut bytes = [0u8; 16];
@@ -25,7 +23,6 @@
2523 Ok(PasswordHashString(hash.to_string()))
2624}
2725
28/// Verify a password against a stored PHC string.
2926pub fn verify_password(
3027 input: PasswordInput<'_>,
3128 hash: &PasswordHashString,
@@ -39,8 +36,6 @@
3936 .is_ok())
4037}
4138
42/// Validate password strength against the configured policy. Returns an
43/// [`AuthError::BadRequest`] with a human-readable message on failure.
4439pub fn validate_password(
4540 req: ValidatePasswordRequest<'_>,
4641 policy: &PasswordPolicy,
Modified lib/auth/features/reclaim/types.rs View lib/auth/features/reclaim/types.rs
@@ -4,18 +4,12 @@
44use crate::features::TempKey;
55use crate::features::register::PowProof;
66
7/// Request to reclaim an account that was registered but never activated
8/// (email squatting). Requires a fresh, client-bound PoW solution.
97#[derive(Debug, Clone, Deserialize, ToSchema)]
108pub struct ReclaimCmd {
119 pub email: String,
1210 pub pow: PowProof,
1311}
1412
15/// Constant 200-shaped response: a `Signup` verification code is issued to the
16/// mailbox only when the address exists and the account is still inactive.
17/// Unknown and already-active emails return the same shape (no code) so the
18/// endpoint does not leak which addresses are registered.
1913#[derive(Debug, Clone, Serialize, ToSchema)]
2014pub struct ReclaimResp {
2115 pub sent: bool,
Modified lib/auth/features/reclaim/usecase.rs View lib/auth/features/reclaim/usecase.rs
@@ -43,13 +43,6 @@
4343 Ok(())
4444}
4545
46/// Let the real owner of a mailbox reclaim an account that was registered but
47/// never activated (email squatting). A `Signup`-purpose code is issued only
48/// when the address exists and the account is still inactive; otherwise the
49/// response keeps a constant shape to avoid leaking which emails are
50/// registered or already active. Verification goes through the standard
51/// `/auth/email/verify` endpoint, whose successful `Signup` verification
52/// activates the account (B1).
5346pub async fn reclaim(
5447 cmd: ReclaimCmd,
5548 svc: &AuthService,
@@ -82,8 +75,6 @@
8275
8376 let ttl = svc.config.verify_code_ttl_secs;
8477
85 // Constant 200-shaped response: a code is only issued for inactive
86 // accounts whose email row still exists.
8778 let mut sent = false;
8879 let mut temp_key = crate::features::TempKey {
8980 name: crate::features::email_code::usecase::cookie_name(
Modified lib/auth/features/refresh/usecase.rs View lib/auth/features/refresh/usecase.rs
@@ -16,9 +16,6 @@
1616 let db = svc.db.writer();
1717 let cache = svc.jwt.cache.read().await;
1818
19 // VULN-007: decode the refresh token to learn user_id, then look up
20 // the live passwd_version so rotate_refresh can reject a token whose
21 // claim lags behind a password reset.
2219 let user_id = {
2320 let kid = extract_kid(&cmd.refresh_token)
2421 .ok_or_else(|| AuthError::Unauthorized("invalid refresh token".into()))?;
@@ -40,9 +37,6 @@
4037 .one(&db)
4138 .await?
4239 .ok_or_else(|| AuthError::Unauthorized("unknown user".into()))?;
43 // VULN-002/B4: suspended / restricted / deactivated accounts must not be
44 // able to keep their session alive through refresh. Mirror the login gate
45 // so a suspended user cannot mint fresh token pairs indefinitely.
4640 if user.is_restricted {
4741 return Err(AuthError::Forbidden("account restricted".into()));
4842 }
Modified lib/auth/features/register/types.rs View lib/auth/features/register/types.rs
@@ -8,8 +8,6 @@
88use crate::pow::Challenge;
99use crate::pow::Solution;
1010
11/// Proof-of-Work: the client must echo back the original challenge along with
12/// the solution so the server can re-verify.
1311#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
1412pub struct PowProof {
1513 #[schema(value_type = String)]
@@ -18,31 +16,22 @@
1816 pub solution: Solution,
1917}
2018
21/// Caller-supplied payload to create a new user account.
2219#[derive(Debug, Clone, Deserialize, ToSchema)]
2320pub struct RegisterCmd {
24 /// Optional display name. Falls back to the local-part of the email when
25 /// omitted.
26 pub username: Option<String>,
21 pub username: Option<String>,
2722 pub email: String,
2823 pub password: String,
2924 pub pow: PowProof,
30 /// Agreement keys the user explicitly accepts (terms, privacy).
31 #[serde(default)]
25 #[serde(default)]
3226 pub accept_agreements: Vec<String>,
3327}
3428
35/// Result of a successful registration. The account exists but is not active
36/// until the email code is verified; `pending` is `true` in that case.
3729#[derive(Debug, Clone, Serialize, ToSchema)]
3830pub struct RegisterResp {
3931 #[schema(value_type = String)]
4032 pub uid: Uuid,
4133 pub pending: bool,
42 /// TempKey the caller must deliver as an HttpOnly+Secure cookie so the
43 /// subsequent verify-email request can read it back.
44 pub temp_key: TempKey,
45 /// The purpose assigned to the verification code, for the caller to log.
46 #[schema(value_type = String)]
34 pub temp_key: TempKey,
35 #[schema(value_type = String)]
4736 pub purpose: EmailVerificationPurpose,
4837}
Modified lib/auth/features/register/usecase.rs View lib/auth/features/register/usecase.rs
@@ -109,9 +109,6 @@
109109 Ok(())
110110}
111111
112/// Register a new user. The account is created with `is_active = false` and a
113/// verification code is emailed. The caller must complete verification before
114/// the user can sign in with a password.
115112pub async fn register(
116113 cmd: RegisterCmd,
117114 svc: &crate::features::AuthService,
@@ -131,8 +128,6 @@
131128 verify_pow(&svc.pow, &cmd.pow, expected_cid).await?;
132129
133130 let db = svc.db.writer();
134 // VULN-006: return identical messages for email and username collisions
135 // so an attacker cannot enumerate which addresses / handles are taken.
136131 if email_exists(&db, &cmd.email).await? {
137132 return Err(AuthError::Conflict("registration failed".into()));
138133 }
@@ -147,7 +142,6 @@
147142 password: &cmd.password,
148143 })?;
149144
150 // Transactional insert: users + user_email.
151145 let txn = db.begin().await?;
152146 let user_am: UserActiveModel = UserActiveModel::builder()
153147 .set_uid(uid)
@@ -195,7 +189,6 @@
195189 email_am.insert(&txn).await?;
196190 txn.commit().await?;
197191
198 // Issue verification code (Redis + DB row).
199192 let artifacts = crate::features::email_code::usecase::issue_code(
200193 crate::features::email_code::types::IssueCodeCmd {
201194 email: cmd.email.clone(),
@@ -208,17 +201,12 @@
208201 )
209202 .await?;
210203
211 // VULN-008: keep the account alive even if the email send fails.
212 // The user can re-issue the verification code through the resend
213 // endpoint; rolling the user back would let an attacker create and
214 // immediately abandon accounts to spam the email pipeline.
215204 if let Err(e) =
216205 send_verification_email(&svc.email, &cmd.email, &artifacts.code).await
217206 {
218207 tracing::warn!(?e, email = %cmd.email, "verification email send failed; account kept pending, caller may resend");
219208 }
220209
221 // Record accepted agreements (best-effort; ignore unknown keys).
222210 for key in &cmd.accept_agreements {
223211 if let Some(doc) = svc.config.agreements.iter().find(|d| &d.key == key)
224212 && svc.record_agreement(uid, &doc.key, &doc.version).await.is_ok()
@@ -242,7 +230,6 @@
242230 result
243231}
244232
245// Helper trait to iterate agreements (Terms + Privacy) uniformly.
246233impl crate::config::AgreementsConfig {
247234 pub fn iter(&self) -> impl Iterator<Item = &crate::config::AgreementDoc> {
248235 vec![&self.terms, &self.privacy].into_iter()
Modified lib/auth/features/resend_code/usecase.rs View lib/auth/features/resend_code/usecase.rs
@@ -64,11 +64,6 @@
6464 Ok(())
6565}
6666
67/// Re-issue a verification code for an already-registered user.
68///
69/// The response is always 200-shaped regardless of whether the email
70/// exists, mirroring the forgot-password flow, to avoid leaking which
71/// addresses are registered. Requires a fresh PoW solution.
7267pub async fn resend_code(
7368 cmd: ResendCodeCmd,
7469 svc: &AuthService,
@@ -98,7 +93,6 @@
9893 .one(&db)
9994 .await?;
10095
101 // Always build a temp_key so the response shape is constant.
10296 let ttl = svc.config.verify_code_ttl_secs;
10397 let temp_id = uuid::Uuid::new_v4();
10498 let temp_key = crate::features::TempKey {
Modified lib/auth/features/reset_password/usecase.rs View lib/auth/features/reset_password/usecase.rs
@@ -46,9 +46,6 @@
4646 diff == 0
4747}
4848
49/// Reset a user's password using a token previously delivered via the
50/// forgot-password flow. On success, every active session for the user is
51/// revoked so the new credentials take effect immediately.
5249pub async fn reset_password(
5350 cmd: ResetPasswordCmd,
5451 svc: &AuthService,
@@ -96,7 +93,6 @@
9693 return Err(AuthError::BadRequest("reset token expired".into()));
9794 }
9895
99 // Resolve user via email; if the user was deleted out-of-band we hard-fail.
10096 let lower = cmd.email.trim().to_ascii_lowercase();
10197 let (em, user) = EmailEntity::find()
10298 .filter(EmailEntity::COLUMN.lower_email.eq(lower))
@@ -115,9 +111,6 @@
115111 password: &cmd.new_password,
116112 })?;
117113
118 // VULN-006/B5: a reset by the real owner of the email also activates an
119 // account that was registered but never verified (email squatting). This
120 // is the forgot-password recovery lane for unclaimed addresses.
121114 let activate = !user.is_active;
122115 let txn = db.begin().await?;
123116 let mut am: UserActiveModel = user.into();
@@ -139,16 +132,11 @@
139132 tam.used_at = Set(Some(OffsetDateTime::now_utc()));
140133 tam.update(&txn).await?;
141134
142 // Revoke every existing session atomically with the password change.
143 // A failure here aborts the transaction, preventing the caller from
144 // receiving a success response while stale refresh tokens remain live.
145135 jwt_session::delete_sessions_by_user(&txn, em.user_id).await?;
146136
147137 txn.commit().await?;
148138
149139 // VULN-005: put the user on the access-token deny-list so any already-issued
150 // stateless access tokens stop validating immediately after the password
151 // change (the DB sessions were revoked inside the transaction above).
152140 let ttl = svc.jwt.settings.access_ttl.whole_seconds().max(1) as u64;
153141 rate_limit::AccessTokenBlacklist::new(svc.kv.clone())
154142 .revoke_user(em.user_id, ttl)
Modified lib/auth/features/mod.rs View lib/auth/features/mod.rs
@@ -23,9 +23,6 @@
2323pub mod resend_code;
2424pub mod reset_password;
2525
26/// A temporary storage key/value marker returned to callers so they can deliver
27/// the value to the client via an `HttpOnly`, `Secure` cookie. The actual value
28/// lives only in Redis under a server-controlled key.
2926#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
3027pub struct TempKey {
3128 pub name: String,
Modified lib/auth/handler/middleware/metrics.rs View lib/auth/handler/middleware/metrics.rs
@@ -1,9 +1,3 @@
1//! HTTP-level metrics middleware for the `auth` router.
2//!
3//! Records `auth_http_requests_total` and `auth_http_request_duration_seconds`
4//! with `route` (via `MatchedPath`, so path params stay low-cardinality),
5//! `method`, and `status_class` (2xx/4xx/5xx/other).
6
71use std::time::Instant;
82
93use axum::{
Modified lib/auth/handler/oauth/callback.rs View lib/auth/handler/oauth/callback.rs
@@ -12,12 +12,9 @@
1212
1313#[derive(Debug, Deserialize, utoipa::IntoParams)]
1414pub(crate) struct CallbackQuery {
15 /// OAuth provider name.
16 pub provider: OAuthProvider,
17 /// Authorization code returned by the provider.
18 pub code: String,
19 /// State value returned by the provider (must match the cookie).
20 pub state: String,
15 pub provider: OAuthProvider,
16 pub code: String,
17 pub state: String,
2118}
2219
2320#[utoipa::path(
Modified lib/auth/handler/openapi.rs View lib/auth/handler/openapi.rs
@@ -18,7 +18,6 @@
1818use export::auth::reset_password::{ResetPasswordCmd, ResetPasswordResp};
1919use crate::handler::pow::PowChallengeResp;
2020
21/// Error body returned by every handler on failure.
2221#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
2322pub struct ErrorBody {
2423 pub error: String,
Modified lib/auth/handler/pow.rs View lib/auth/handler/pow.rs
@@ -5,14 +5,11 @@
55use crate::pow::Challenge;
66use export::data::AppResponseData;
77
8/// A freshly issued, client-bound PoW challenge for the caller to solve.
98#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
109pub struct PowChallengeResp {
11 /// The signed challenge to solve (echoed back verbatim in `PowProof`).
12 #[schema(value_type = String)]
10 #[schema(value_type = String)]
1311 pub challenge: Challenge,
14 /// How long the challenge remains valid, in seconds.
15 pub ttl_seconds: u64,
12 pub ttl_seconds: u64,
1613}
1714
1815#[utoipa::path(
Modified lib/auth/pow/config.rs View lib/auth/pow/config.rs
@@ -1,32 +1,16 @@
11use serde::{Deserialize, Serialize};
22use time::Duration;
33
4/// Configuration for the Altcha Proof-of-Work CAPTCHA module.
5///
6/// All fields have sensible defaults, so a [`PowSettings`] can be built with
7/// `PowSettings::default()` and only the HMAC secret overridden.
84#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct PowSettings {
10 /// HMAC secret used to sign challenges and used to verify submitted
11 /// solutions. This MUST be set to a high-entropy value in production.
12 pub hmac_secret: String,
13 /// Key derivation algorithm, e.g. `"PBKDF2/SHA-256"`, `"SHA-256"`,
14 /// `"ARGON2ID"`.
15 pub algorithm: String,
16 /// Algorithm-specific cost parameter. For `"PBKDF2/SHA-256"` this is the
17 /// iteration count; for `"ARGON2ID"` it is the time cost.
18 pub cost: u32,
19 /// Length of the derived key in bytes.
20 pub key_length: usize,
21 /// Required hex prefix the derived key must start with. Lower values are
22 /// harder to satisfy.
23 pub key_prefix: String,
24 /// Memory cost in KiB for memory-hard algorithms (Argon2id, Scrypt).
25 pub memory_cost: Option<u32>,
26 /// Parallelism factor for Argon2id and Scrypt.
27 pub parallelism: Option<u32>,
28 /// How long a generated challenge remains valid before it expires.
29 pub challenge_ttl: Duration,
6 pub hmac_secret: String,
7 pub algorithm: String,
8 pub cost: u32,
9 pub key_length: usize,
10 pub key_prefix: String,
11 pub memory_cost: Option<u32>,
12 pub parallelism: Option<u32>,
13 pub challenge_ttl: Duration,
3014}
3115
3216impl Default for PowSettings {
Modified lib/auth/pow/error.rs View lib/auth/pow/error.rs
@@ -1,34 +1,23 @@
11use thiserror::Error;
22
3/// Errors that can occur while generating or verifying an Altcha PoW CAPTCHA.
43#[derive(Debug, Error)]
54pub enum PowError {
6 /// The HMAC secret is not configured. Challenges cannot be signed or
7 /// verified without one.
8 #[error("pow hmac secret is not configured")]
5 #[error("pow hmac secret is not configured")]
96 MissingSecret,
10 /// The configured algorithm is not supported by the underlying library.
11 #[error("unsupported pow algorithm: {0}")]
7 #[error("unsupported pow algorithm: {0}")]
128 UnsupportedAlgorithm(String),
13 /// The challenge has expired.
14 #[error("pow challenge expired")]
9 #[error("pow challenge expired")]
1510 Expired,
16 /// The challenge signature is missing or invalid.
17 #[error("invalid pow challenge signature")]
11 #[error("invalid pow challenge signature")]
1812 InvalidSignature,
19 /// The submitted solution does not satisfy the challenge.
20 #[error("invalid pow solution")]
13 #[error("invalid pow solution")]
2114 InvalidSolution,
22 /// The challenge was not issued by the server (or was already consumed).
23 #[error("pow challenge was not issued or already used")]
15 #[error("pow challenge was not issued or already used")]
2416 NotIssued,
25 /// The challenge is bound to a different client than the caller.
26 #[error("pow challenge was issued for a different client")]
17 #[error("pow challenge was issued for a different client")]
2718 ClientMismatch,
28 /// The one-time challenge store reported an error.
29 #[error("pow store error: {0}")]
19 #[error("pow store error: {0}")]
3020 Storage(String),
31 /// The underlying altcha library reported an error.
32 #[error("pow error: {0}")]
21 #[error("pow error: {0}")]
3322 Altcha(#[from] altcha::Error),
3423}
Modified lib/auth/pow/mod.rs View lib/auth/pow/mod.rs
@@ -1,29 +1,3 @@
1//! Altcha Proof-of-Work (PoW) CAPTCHA module.
2//!
3//! Provides a type-safe wrapper around the [`altcha`] crate for generating
4//! PoW challenges and verifying client-submitted solutions.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use auth::pow::{PowService, PowSettings};
10//!
11//! let settings = PowSettings {
12//! hmac_secret: "a-very-secret-value".to_string(),
13//! ..PowSettings::default()
14//! };
15//! let service = PowService::new(settings);
16//!
17//! // Issue a challenge to the client.
18//! let challenge = service.generate_challenge().unwrap();
19//! // ... the client solves it and returns a `Solution` ...
20//! let solution = altcha::solve_challenge(altcha::SolveChallengeOptions::new(&challenge))
21//! .unwrap()
22//! .unwrap();
23//! let result = service.verify_solution(&challenge, &solution).unwrap();
24//! assert!(result.verified);
25//! ```
26
271mod config;
282mod error;
293
@@ -46,14 +20,6 @@
4620use sha2::{Digest, Sha256};
4721use uuid::Uuid;
4822
49/// A service for generating and verifying Altcha PoW CAPTCHAs.
50///
51/// Cheap to clone; hold a single instance in shared application state.
52///
53/// When a Redis handle is attached (via [`PowService::with_kv`]) the service
54/// additionally provides client-bound, single-use challenges: each issued
55/// challenge is recorded in Redis and consumed atomically on verification, and
56/// the issuing client id is embedded in the challenge data.
5723#[derive(Clone)]
5824pub struct PowService {
5925 settings: PowSettings,
@@ -61,24 +27,19 @@
6127}
6228
6329impl PowService {
64 /// Create a new service from the given settings.
65 pub fn new(settings: PowSettings) -> Self {
30 pub fn new(settings: PowSettings) -> Self {
6631 Self {
6732 settings,
6833 kv: None,
6934 }
7035 }
7136
72 /// Attach a Redis handle so the service can issue single-use,
73 /// client-bound challenges.
74 pub fn with_kv(mut self, kv: Arc<RedisCache>) -> Self {
37 pub fn with_kv(mut self, kv: Arc<RedisCache>) -> Self {
7538 self.kv = Some(kv);
7639 self
7740 }
7841
79 /// The one-time issuance key for a challenge, derived from its canonical
80 /// serialization so the client may echo the challenge verbatim.
81 fn issued_key(challenge: &Challenge) -> String {
42 fn issued_key(challenge: &Challenge) -> String {
8243 let json = serde_json::to_string(challenge).unwrap_or_default();
8344 let digest = Sha256::digest(json.as_bytes());
8445 format!(
@@ -87,22 +48,15 @@
8748 )
8849 }
8950
90 /// The settings backing this service.
91 pub fn settings(&self) -> &PowSettings {
51 pub fn settings(&self) -> &PowSettings {
9252 &self.settings
9353 }
9454
95 /// Generate a new signed PoW challenge for the client to solve.
96 ///
97 /// Returns an error if the HMAC secret is not configured or the underlying
98 /// library fails.
99 pub fn generate_challenge(&self) -> Result<Challenge, PowError> {
55 pub fn generate_challenge(&self) -> Result<Challenge, PowError> {
10056 self.generate_challenge_with(None)
10157 }
10258
103 /// Generate a challenge with an optional set of extra metadata embedded in
104 /// the challenge's `data` field.
105 pub fn generate_challenge_with(
59 pub fn generate_challenge_with(
10660 &self,
10761 data: Option<std::collections::BTreeMap<String, serde_json::Value>>,
10862 ) -> Result<Challenge, PowError> {
@@ -125,13 +79,7 @@
12579 create_challenge(options).map_err(PowError::from)
12680 }
12781
128 /// Verify a client-submitted solution against a previously issued challenge.
129 ///
130 /// Returns an error only when verification could not be performed (e.g. the
131 /// HMAC secret is missing or the library failed). A non-`Err` result may
132 /// still have `verified == false`; inspect [`VerifySolutionResult`] for the
133 /// reason (expired, invalid signature, invalid solution).
134 pub fn verify_solution(
82 pub fn verify_solution(
13583 &self,
13684 challenge: &Challenge,
13785 solution: &Solution,
@@ -142,9 +90,7 @@
14290 verify_solution(options).map_err(PowError::from)
14391 }
14492
145 /// Convenience wrapper that returns `Ok(true)` only when the solution is
146 /// verified and not expired, mapping every failure to a [`PowError`].
147 pub fn verify(&self, challenge: &Challenge, solution: &Solution) -> Result<bool, PowError> {
93 pub fn verify(&self, challenge: &Challenge, solution: &Solution) -> Result<bool, PowError> {
14894 let result = self.verify_solution(challenge, solution)?;
14995 if result.verified {
15096 Ok(true)
@@ -157,12 +103,7 @@
157103 }
158104 }
159105
160 /// Issue a challenge bound to a specific client id.
161 ///
162 /// The challenge embeds `cid` (the caller's rate-limit cookie value) in its
163 /// `data` and records a single-use marker in Redis so a later verification
164 /// can atomically consume it. Returns the challenge and its TTL in seconds.
165 pub async fn issue_challenge_bound(
106 pub async fn issue_challenge_bound(
166107 &self,
167108 cid: &str,
168109 ) -> Result<(Challenge, u64), PowError> {
@@ -190,14 +131,7 @@
190131 Ok((challenge, ttl))
191132 }
192133
193 /// Verify a solution against a client-bound, single-use challenge.
194 ///
195 /// Atomically consumes the issuance marker first — a challenge that was
196 /// never issued (or was already used / replayed) is rejected with
197 /// [`PowError::NotIssued`]. When `expected_cid` is provided the challenge's
198 /// embedded `data.cid` must match it, rejecting challenges issued for a
199 /// different client.
200 pub async fn verify_solution_once(
134 pub async fn verify_solution_once(
201135 &self,
202136 challenge: &Challenge,
203137 solution: &Solution,
Modified lib/auth/error.rs View lib/auth/error.rs
@@ -9,7 +9,6 @@
99
1010use crate::pow::PowError;
1111
12/// Result alias used by every auth use case.
1312pub type AuthResult<T> = Result<T, AuthError>;
1413
1514#[derive(Debug, Error)]
Modified lib/auth/metrics.rs View lib/auth/metrics.rs
@@ -1,20 +1,9 @@
1//! Observability facade for the `auth` crate.
2//!
3//! All metric emissions go through the [`metrics`] facade and are no-ops when
4//! no recorder is installed. `lib/obs` (or any host binary) is responsible for
5//! installing a recorder; this module never touches one directly.
6//!
7//! Cardinality contract: every label here is a bounded enum. Never add a
8//! high-cardinality label (user id, email, ip, jti, …) without an explicit
9//! cardinality review.
10
111use std::sync::Once;
122
133use metrics::{Unit, counter, describe_counter, describe_histogram, histogram};
144
155use crate::error::AuthError;
166
17// -- feature constants --------------------------------------------------------
187
198pub const FEATURE_LOGIN: &str = "login";
209pub const FEATURE_REGISTER: &str = "register";
@@ -35,17 +24,14 @@
3524
3625pub const OUTCOME_SUCCESS: &str = "success";
3726
38// -- lazy one-shot description ----------------------------------------------
3927
4028static DESCRIBE_ONCE: Once = Once::new();
4129
42/// Idempotently register metric descriptions. Called on first metric emit.
4330pub fn ensure_described() {
4431 DESCRIBE_ONCE.call_once(describe_all);
4532}
4633
4734fn describe_all() {
48 // Usecase layer
4935 describe_counter!(
5036 "auth_usecase_invocations_total",
5137 "Total auth usecase invocations partitioned by feature and outcome"
@@ -60,7 +46,6 @@
6046 "Total auth usecase errors partitioned by feature and error kind"
6147 );
6248
63 // HTTP layer
6449 describe_counter!(
6550 "auth_http_requests_total",
6651 "Total HTTP requests handled by the auth router"
@@ -71,7 +56,6 @@
7156 "End-to-end HTTP request duration inside the auth router"
7257 );
7358
74 // PoW
7559 describe_counter!(
7660 "auth_pow_challenges_issued_total",
7761 "Total PoW challenges issued by the auth subsystem"
@@ -86,7 +70,6 @@
8670 "Duration of PoW verification attempts"
8771 );
8872
89 // Password hash
9073 describe_histogram!(
9174 "auth_password_hash_duration_seconds",
9275 Unit::Seconds,
@@ -97,7 +80,6 @@
9780 "Total passwords rejected by the password policy partitioned by rule"
9881 );
9982
100 // Email codes
10183 describe_counter!(
10284 "auth_email_codes_issued_total",
10385 "Total email verification codes issued partitioned by channel"
@@ -107,7 +89,6 @@
10789 "Total email code verification attempts partitioned by outcome"
10890 );
10991
110 // OAuth
11192 describe_counter!(
11293 "auth_oauth_authorize_total",
11394 "Total OAuth authorize attempts partitioned by provider and outcome"
@@ -126,7 +107,6 @@
126107 "Total OAuth user-resolution branches taken"
127108 );
128109
129 // Agreement
130110 describe_counter!(
131111 "auth_agreement_accepts_total",
132112 "Total agreement acceptance events"
@@ -136,7 +116,6 @@
136116 "Total agreement record events"
137117 );
138118
139 // JWT session lifecycle
140119 describe_counter!(
141120 "auth_jwt_pair_issued_total",
142121 "Total JWT pairs issued partitioned by kind"
@@ -151,9 +130,7 @@
151130 );
152131}
153132
154// -- helpers ------------------------------------------------------------------
155133
156/// Scoped histogram timer. Records on `Drop` (RAII) and on explicit `observe()`.
157134#[must_use = "timer is dropped silently if not bound"]
158135pub struct Timer {
159136 feature: &'static str,
@@ -188,8 +165,6 @@
188165 }
189166}
190167
191/// Record one usecase invocation with its outcome. Safe to call with any
192/// `Result<T, AuthError>` regardless of `T`.
193168pub fn record_outcome<T>(feature: &'static str, result: &Result<T, AuthError>) {
194169 ensure_described();
195170 let outcome = match result {
@@ -212,7 +187,6 @@
212187 }
213188}
214189
215// -- fine-grained helpers ----------------------------------------------------
216190
217191pub fn record_pow_challenge_issued() {
218192 ensure_described();