Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
1//! Login, callback, handle selection, and logout.
2
3use axum::extract::{Query, State};
4use axum::response::{IntoResponse, Redirect, Response};
5use axum::Form;
6use axum_extra::extract::cookie::{Cookie, SameSite};
7use axum_extra::extract::CookieJar;
8use df_auth::provisioning;
9use df_auth::{oidc, session};
10use serde::Deserialize;
11
12use crate::error::{AppError, AppResult};
13use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
14use crate::views::{self, Chrome};
15
16#[derive(Deserialize)]
17pub struct LoginQuery {
18 /// Where to land after signing in. Validated as a local path.
19 pub next: Option<String>,
20 /// `sso` starts the OIDC round trip. Absent renders the sign-in page.
21 pub r#continue: Option<String>,
22}
23
24/// The sign-in page, and the start of the OIDC flow.
25///
26/// `/login` used to redirect to the provider immediately. It now renders a page
27/// first and starts the flow at `/login?continue=sso`, so the product has a
28/// sign-in screen of its own rather than handing the first impression to the
29/// identity provider. Every existing link to `/login` still lands somewhere
30/// sensible — one click further from the provider than before.
31pub async fn login(
32 State(state): State<AppState>,
33 Query(q): Query<LoginQuery>,
34 CurrentUser(user): CurrentUser,
35 CsrfToken(csrf): CsrfToken,
36 Nonce(nonce): Nonce,
37) -> AppResult<Response> {
38 if user.is_some() {
39 return Ok(Redirect::to("/").into_response());
40 }
41
42 // An unvalidated `next` here would be an open redirect handed to everyone
43 // who follows a login link. Validated once, here, and then carried through
44 // both the page and the flow.
45 let next = q.next.as_deref().and_then(oidc::safe_redirect);
46
47 if q.r#continue.as_deref() == Some("sso") {
48 let url = state.oidc.begin(&state.db, next.as_deref()).await?;
49 return Ok(Redirect::to(&url).into_response());
50 }
51
52 let sso_href = match &next {
53 // `next` has already been validated as a local path, and Maud escapes
54 // the attribute, so this cannot break out of the URL.
55 Some(n) => {
56 let encoded: String = form_urlencoded::byte_serialize(n.as_bytes()).collect();
57 format!("/login?continue=sso&next={encoded}")
58 }
59 None => "/login?continue=sso".to_string(),
60 };
61
62 // A public repository to point at, so "read anything public first" is an
63 // offer with a destination rather than a slogan.
64 let sample: Option<(String, String)> = sqlx::query_as(
65 r#"
66 SELECT COALESCE(ou.handle, og.handle) AS owner, r.name::text
67 FROM repos r
68 LEFT JOIN users ou ON ou.id = r.owner_user_id
69 LEFT JOIN orgs og ON og.id = r.owner_org_id
70 WHERE r.archived = false AND r.visibility = 'public'
71 ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC
72 LIMIT 1
73 "#,
74 )
75 .fetch_optional(&state.db)
76 .await?;
77
78 let sample_path = sample.as_ref().map(|(o, n)| format!("{o}/{n}"));
79 let clone_hint = match &sample {
80 Some((o, n)) => format!("jj git clone {}", state.config.https_clone_url(o, n)),
81 None => format!(
82 "jj git clone {}",
83 state.config.https_clone_url("your-org", "your-repo")
84 ),
85 };
86
87 Ok(views::page(
88 Chrome { title: "Sign in", user: None, csrf: &csrf, nonce: &nonce },
89 views::pages::signin(&sso_href, &clone_hint, sample_path.as_deref()),
90 )
91 .into_response())
92}
93
94#[derive(Deserialize)]
95pub struct CallbackQuery {
96 pub code: Option<String>,
97 pub state: Option<String>,
98 pub error: Option<String>,
99 pub error_description: Option<String>,
100}
101
102/// OIDC redirect target.
103pub async fn callback(
104 State(state): State<AppState>,
105 jar: CookieJar,
106 Query(q): Query<CallbackQuery>,
107 CsrfToken(csrf): CsrfToken,
108 Nonce(nonce): Nonce,
109 headers: axum::http::HeaderMap,
110) -> AppResult<Response> {
111 if let Some(err) = q.error {
112 let detail = q.error_description.unwrap_or_default();
113 tracing::warn!("OIDC provider returned error: {err} {detail}");
114 return Ok(views::error_page(
115 Chrome { title: "Sign-in failed", user: None, csrf: &csrf, nonce: &nonce },
116 "Sign-in failed",
117 "The identity provider rejected the sign-in. Please try again.",
118 )
119 .into_response());
120 }
121
122 let (Some(code), Some(flow_state)) = (q.code, q.state) else {
123 return Err(AppError::BadRequest("missing code or state".into()));
124 };
125
126 let (identity, redirect_after) = state
127 .oidc
128 .complete(&state.db, &flow_state, &code)
129 .await
130 .map_err(|e| {
131 tracing::warn!("completing OIDC login failed: {e:#}");
132 AppError::BadRequest("Sign-in could not be completed. Please try again.".into())
133 })?;
134
135 // Returning user: sign straight in.
136 if let Some(user) = provisioning::find_by_subject(&state.db, &identity.subject).await? {
137 // Claims are not guaranteed on every login, so an account can exist
138 // with no email — which is the one thing that links pushed commits to
139 // it. Any login that does carry the claim repairs that. Never fatal:
140 // failing to backfill must not cost the user their sign-in.
141 if let Err(e) = provisioning::backfill_profile(&state.db, user.id, &identity).await {
142 tracing::warn!(user = %user.id, "backfilling profile failed: {e:#}");
143 }
144 let jar =
145 establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref())
146 .await?;
147 let target = redirect_after.as_deref().unwrap_or("/");
148 return Ok((jar, Redirect::to(target)).into_response());
149 }
150
151 // New identity: apply the invite/allowlist policy before creating anything.
152 let invited = match identity.email.as_deref() {
153 Some(email) => provisioning::has_invitation(&state.db, email).await?,
154 None => false,
155 };
156
157 if let Err(denied) = provisioning::admission(&identity, &state.config.allowlist, invited) {
158 tracing::info!(
159 subject = %identity.subject,
160 "login denied: {denied:?}"
161 );
162 return Ok(views::page(
163 Chrome { title: "Not invited", user: None, csrf: &csrf, nonce: &nonce },
164 views::pages::not_invited(),
165 )
166 .into_response());
167 }
168
169 // Admitted. Derive a handle, or ask for one.
170 let suggested = match identity.suggested_handle() {
171 Some(base) => provisioning::unique_handle(&state.db, &base).await?,
172 None => None,
173 };
174
175 let Some(handle) = suggested else {
176 // Stash the pending identity and let the user choose.
177 let jar = stash_pending(jar, &state, &identity)?;
178 return Ok((
179 jar,
180 views::page(
181 Chrome { title: "Choose a handle", user: None, csrf: &csrf, nonce: &nonce },
182 views::pages::choose_handle(None, &csrf, None),
183 ),
184 )
185 .into_response());
186 };
187
188 let user = provisioning::create_user(&state.db, &identity, &handle).await?;
189 tracing::info!(user = %user.id, handle = %handle, "provisioned new user");
190
191 let jar = establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref())
192 .await?;
193 let target = redirect_after.as_deref().unwrap_or("/");
194 Ok((jar, Redirect::to(target)).into_response())
195}
196
197/// Cookie holding a signed, pending identity between the callback and handle
198/// selection. Short-lived and self-contained, so no extra table is needed.
199const PENDING_COOKIE: &str = "dogfood_pending";
200
201/// How long a pending identity stays usable.
202///
203/// Enforced inside the signature, not only as the cookie's `Max-Age`: `Max-Age`
204/// is a request to the browser, and a copy of the cookie taken anywhere else
205/// would otherwise stay redeemable for an account forever.
206const PENDING_TTL_MINUTES: i64 = 15;
207
208fn stash_pending(
209 jar: CookieJar,
210 state: &AppState,
211 identity: &df_auth::Identity,
212) -> AppResult<CookieJar> {
213 let payload = serde_json::json!({
214 "sub": identity.subject,
215 "email": identity.email,
216 "name": identity.name,
217 "id_token": identity.id_token,
218 "exp": (chrono::Utc::now() + chrono::Duration::minutes(PENDING_TTL_MINUTES)).timestamp(),
219 })
220 .to_string();
221
222 let mac = sign_pending(&state.config.session_secret, &payload);
223 let value = format!("{}.{}", hex::encode(&payload), mac);
224
225 Ok(jar.add(
226 Cookie::build((PENDING_COOKIE, value))
227 .path("/")
228 .secure(state.config.secure_cookies())
229 .http_only(true)
230 .same_site(SameSite::Lax)
231 .max_age(time::Duration::minutes(PENDING_TTL_MINUTES))
232 .build(),
233 ))
234}
235
236fn sign_pending(secret: &[u8], payload: &str) -> String {
237 use hmac::{Hmac, Mac};
238 use sha2::Sha256;
239 let mut mac = <Hmac<Sha256>>::new_from_slice(secret).expect("hmac accepts any key");
240 mac.update(payload.as_bytes());
241 hex::encode(mac.finalize().into_bytes())
242}
243
244fn read_pending(secret: &[u8], jar: &CookieJar) -> Option<df_auth::Identity> {
245 verify_pending(secret, jar.get(PENDING_COOKIE)?.value())
246}
247
248/// Verify and decode a pending-identity cookie value.
249///
250/// Split out from [`read_pending`] so the signature and expiry rules are
251/// testable without building a cookie jar.
252fn verify_pending(secret: &[u8], raw: &str) -> Option<df_auth::Identity> {
253 use subtle::ConstantTimeEq;
254
255 let (payload_hex, mac) = raw.split_once('.')?;
256 let payload = String::from_utf8(hex::decode(payload_hex).ok()?).ok()?;
257
258 let expected = sign_pending(secret, &payload);
259 let ok: bool = expected.as_bytes().ct_eq(mac.as_bytes()).into();
260 if !ok {
261 return None;
262 }
263
264 let v: serde_json::Value = serde_json::from_str(&payload).ok()?;
265
266 // The signature proves we minted it; `exp` is what stops it being minted
267 // once and redeemed indefinitely. A payload without one predates this and
268 // is refused rather than grandfathered.
269 let exp = v.get("exp").and_then(serde_json::Value::as_i64)?;
270 if chrono::Utc::now().timestamp() > exp {
271 return None;
272 }
273
274 Some(df_auth::Identity {
275 subject: v.get("sub")?.as_str()?.to_string(),
276 email: v.get("email").and_then(|e| e.as_str()).map(str::to_string),
277 name: v.get("name").and_then(|e| e.as_str()).map(str::to_string),
278 preferred_username: None,
279 id_token: v.get("id_token").and_then(|e| e.as_str()).map(str::to_string),
280 })
281}
282
283#[derive(Deserialize)]
284pub struct HandleForm {
285 pub handle: String,
286}
287
288/// Complete provisioning with a user-chosen handle.
289pub async fn choose_handle(
290 State(state): State<AppState>,
291 jar: CookieJar,
292 CsrfToken(csrf): CsrfToken,
293 Nonce(nonce): Nonce,
294 headers: axum::http::HeaderMap,
295 Form(form): Form<HandleForm>,
296) -> AppResult<Response> {
297 let Some(identity) = read_pending(&state.config.session_secret, &jar) else {
298 return Ok(Redirect::to("/login").into_response());
299 };
300
301 // Re-check admission: the pending cookie proves who they are, not that
302 // policy still admits them.
303 let invited = match identity.email.as_deref() {
304 Some(e) => provisioning::has_invitation(&state.db, e).await?,
305 None => false,
306 };
307 if provisioning::admission(&identity, &state.config.allowlist, invited).is_err() {
308 return Ok(views::page(
309 Chrome { title: "Not invited", user: None, csrf: &csrf, nonce: &nonce },
310 views::pages::not_invited(),
311 )
312 .into_response());
313 }
314
315 let handle = form.handle.trim().to_lowercase();
316
317 let reject = |msg: &str| -> Response {
318 views::page(
319 Chrome { title: "Choose a handle", user: None, csrf: &csrf, nonce: &nonce },
320 views::pages::choose_handle(Some(&handle), &csrf, Some(msg)),
321 )
322 .into_response()
323 };
324
325 if !valid_handle(&handle) {
326 return Ok(reject(
327 "Handles must start with a letter or digit and contain only lowercase \
328 letters, digits and hyphens.",
329 ));
330 }
331 if !provisioning::handle_available(&state.db, &handle).await? {
332 return Ok(reject("That handle is already taken."));
333 }
334
335 let user = provisioning::create_user(&state.db, &identity, &handle).await?;
336 tracing::info!(user = %user.id, handle = %handle, "provisioned new user (chosen handle)");
337
338 let jar = jar.remove(Cookie::from(PENDING_COOKIE));
339 let jar = establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref())
340 .await?;
341 Ok((jar, Redirect::to("/")).into_response())
342}
343
344/// Mirror of the database CHECK constraint.
345fn valid_handle(h: &str) -> bool {
346 !h.is_empty()
347 && h.len() <= 39
348 && h.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
349 && h.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
350}
351
352async fn establish_session(
353 state: &AppState,
354 jar: CookieJar,
355 user_id: uuid::Uuid,
356 headers: &axum::http::HeaderMap,
357 id_token: Option<&str>,
358) -> AppResult<CookieJar> {
359 let ua = headers
360 .get(axum::http::header::USER_AGENT)
361 .and_then(|v| v.to_str().ok());
362
363 let s = session::create(
364 &state.db,
365 user_id,
366 state.config.session_ttl_days,
367 ua,
368 None,
369 id_token,
370 )
371 .await?;
372 let expires = s.expires_at;
373
374 // The cookie carries the token. The row id never leaves the server.
375 let cookie = Cookie::build((session::COOKIE_NAME, s.token))
376 .path("/")
377 .secure(state.config.secure_cookies())
378 .http_only(true)
379 .same_site(SameSite::Lax)
380 .expires(
381 time::OffsetDateTime::from_unix_timestamp(expires.timestamp())
382 .unwrap_or(time::OffsetDateTime::UNIX_EPOCH),
383 )
384 .build();
385
386 Ok(jar.add(cookie))
387}
388
389/// Sign out: destroy the local session, then hand off to the provider's
390/// end-session endpoint when one is advertised.
391pub async fn logout(
392 State(state): State<AppState>,
393 jar: CookieJar,
394) -> AppResult<Response> {
395 let mut id_token_hint = None;
396 if let Some(raw) = jar.get(session::COOKIE_NAME) {
397 // Deletes the row and hands back the stashed ID token in one statement.
398 match session::destroy(&state.db, raw.value()).await {
399 Ok(hint) => id_token_hint = hint,
400 Err(e) => tracing::warn!("destroying session failed: {e}"),
401 }
402 }
403
404 let jar = jar.remove(Cookie::from(session::COOKIE_NAME));
405
406 // Hydra rejects `post_logout_redirect_uri` without `id_token_hint`
407 // (`invalid_request`), which would otherwise strand the user on an error
408 // page even though the local session above is already gone. Without a
409 // hint to offer, skip the provider round-trip and just go home.
410 let target = match (state.oidc.end_session_endpoint(), id_token_hint) {
411 (Some(endpoint), Some(hint)) => format!(
412 "{endpoint}?id_token_hint={}&post_logout_redirect_uri={}",
413 urlencoding_encode(&hint),
414 urlencoding_encode(&state.config.base_url)
415 ),
416 _ => "/".to_string(),
417 };
418
419 Ok((jar, Redirect::to(&target)).into_response())
420}
421
422fn urlencoding_encode(s: &str) -> String {
423 percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn handle_validation_mirrors_the_db_constraint() {
432 assert!(valid_handle("alice"));
433 assert!(valid_handle("a"));
434 assert!(valid_handle("9lives"));
435 assert!(valid_handle("with-hyphen"));
436
437 assert!(!valid_handle(""));
438 assert!(!valid_handle("-leading"));
439 assert!(!valid_handle("Upper"));
440 assert!(!valid_handle("under_score"));
441 assert!(!valid_handle("with space"));
442 assert!(!valid_handle("üñí"));
443 assert!(!valid_handle(&"a".repeat(40)));
444 assert!(valid_handle(&"a".repeat(39)));
445 }
446
447 #[test]
448 fn pending_identity_round_trips_and_rejects_tampering() {
449 let secret = b"secret-key-for-pending-identity-cookie";
450 let payload = r#"{"sub":"abc","email":"a@b.c","name":null}"#;
451 let mac = sign_pending(secret, payload);
452
453 // A wrong secret must not verify.
454 assert_ne!(mac, sign_pending(b"other-secret", payload));
455 // A changed payload must not verify against the old tag.
456 assert_ne!(mac, sign_pending(secret, r#"{"sub":"attacker"}"#));
457 }
458
459 const PENDING_SECRET: &[u8] = b"secret-key-for-pending-identity-cookie";
460
461 /// Build a cookie value the way `stash_pending` does, with a chosen expiry.
462 fn pending_cookie(exp: i64) -> String {
463 let payload = serde_json::json!({
464 "sub": "abc",
465 "email": "a@b.c",
466 "name": null,
467 "id_token": null,
468 "exp": exp,
469 })
470 .to_string();
471 format!(
472 "{}.{}",
473 hex::encode(&payload),
474 sign_pending(PENDING_SECRET, &payload)
475 )
476 }
477
478 #[test]
479 fn a_live_pending_cookie_verifies() {
480 let raw = pending_cookie(chrono::Utc::now().timestamp() + 600);
481 let identity = verify_pending(PENDING_SECRET, &raw).expect("should verify");
482 assert_eq!(identity.subject, "abc");
483 }
484
485 #[test]
486 fn an_expired_pending_cookie_is_refused_even_though_it_is_signed() {
487 // The attack `Max-Age` alone does not stop: the browser's copy is gone,
488 // but a copy taken anywhere else still carries our signature.
489 let raw = pending_cookie(chrono::Utc::now().timestamp() - 1);
490 assert!(verify_pending(PENDING_SECRET, &raw).is_none());
491 }
492
493 #[test]
494 fn a_pending_cookie_without_an_expiry_is_refused() {
495 // The pre-expiry format. Grandfathering it in would leave the old
496 // indefinitely-redeemable cookie working.
497 let payload = r#"{"sub":"abc","email":"a@b.c","name":null}"#;
498 let raw = format!(
499 "{}.{}",
500 hex::encode(payload),
501 sign_pending(PENDING_SECRET, payload)
502 );
503 assert!(verify_pending(PENDING_SECRET, &raw).is_none());
504 }
505
506 #[test]
507 fn a_pending_cookie_cannot_have_its_expiry_extended() {
508 let raw = pending_cookie(chrono::Utc::now().timestamp() - 1);
509 let (payload_hex, mac) = raw.split_once('.').unwrap();
510 let payload = String::from_utf8(hex::decode(payload_hex).unwrap()).unwrap();
511
512 // Push the expiry out without re-signing, which is all an attacker can do.
513 let forged = payload.replace(
514 &format!("\"exp\":{}", chrono::Utc::now().timestamp() - 1),
515 &format!("\"exp\":{}", chrono::Utc::now().timestamp() + 86_400),
516 );
517 assert_ne!(forged, payload, "the test must actually change the expiry");
518
519 let tampered = format!("{}.{mac}", hex::encode(&forged));
520 assert!(verify_pending(PENDING_SECRET, &tampered).is_none());
521 }
522}

522 lines · Rust