feat(auth): 2 h login links and a click-to-sign-in page for emailed ones (#11203)

* feat(auth): 2 h login links and a click-to-sign-in page for emailed ones

Raise the login link cap from 15 min to 2 h, so a link sent by email still
works when it is read.

A link minted with `confirm: true` is a /user/login_link page instead of
the API path. Loading the page does nothing; its button POSTs to
/api/auth/login_link/{token}, which spends the link and answers where to go.
Mail scanners that open links on delivery no longer burn them. Links minted
without `confirm` still sign in on open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auth): keep the login link page to design-system components

A tokenless visit bounced off a raw <p>; send it to the page a spent link
already bounces to, and show the modal's own spinner while it goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-18 14:31:16 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent c8c06d8f79
commit 5639187fec
6 changed files with 187 additions and 22 deletions
@@ -115,6 +115,60 @@ async fn login_link_is_single_use_and_same_origin(db: Pool<Postgres>) -> anyhow:
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn confirmed_login_link_is_spent_by_the_click_not_the_page(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
let resp = client()
.post(format!("{base}/users/login_links"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({"email": "test2@windmill.dev", "confirm": true}))
.send()
.await?;
assert_eq!(resp.status(), 201);
let link = resp.json::<serde_json::Value>().await?;
// The URL handed out is the frontend page, not the API path that signs in on a GET.
let token = link["url"]
.as_str()
.unwrap()
.split_once("/user/login_link?token=")
.expect("confirmation page url")
.1
.to_string();
let confirm = || {
client()
.post(format!("{base}/auth/login_link/{token}"))
.send()
};
let resp = confirm().await?;
assert_eq!(resp.status(), 200);
assert!(resp
.headers()
.get_all("set-cookie")
.iter()
.any(|c| c.to_str().unwrap().starts_with("token=")));
assert_eq!(
resp.json::<serde_json::Value>().await?["location"],
"/user/workspaces"
);
let resp = confirm().await?;
assert_eq!(resp.status(), 200);
assert!(resp.headers().get("set-cookie").is_none());
assert_eq!(
resp.json::<serde_json::Value>().await?["location"],
"/user/login_link_expired?reason=used"
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn login_link_mint_can_require_a_login_type(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
+53 -18
View File
@@ -168,7 +168,10 @@ pub fn make_unauthed_service() -> Router {
.route("/logout", post(logout).get(logout))
.route("/is_first_time_setup", get(is_first_time_setup))
.route("/request_password_reset", post(request_password_reset))
.route("/login_link/{token}", get(consume_login_link))
.route(
"/login_link/{token}",
get(consume_login_link).post(confirm_login_link),
)
.route("/is_smtp_configured", get(is_smtp_configured))
.route(
"/is_password_login_disabled",
@@ -3215,9 +3218,12 @@ async fn impersonate(
}
const LOGIN_LINK_DEFAULT_TTL_S: u32 = 600;
const LOGIN_LINK_MAX_TTL_S: u32 = 900;
// Long enough for a link sent by email to still work when it is read. `require_login_type` is
// only checked at mint, so a much longer cap would need re-checking it when the link is opened.
const LOGIN_LINK_MAX_TTL_S: u32 = 7200;
const LOGIN_LINK_DEFAULT_RD: &str = "/user/workspaces";
const LOGIN_LINK_EXPIRED_PAGE: &str = "/user/login_link_expired";
const LOGIN_LINK_CONFIRM_PAGE: &str = "/user/login_link";
#[derive(Deserialize)]
pub struct NewLoginLink {
@@ -3228,6 +3234,9 @@ pub struct NewLoginLink {
/// account it created can require `pending_oauth`, so the link stops working once the
/// owner has set a password or signed in with a provider.
pub require_login_type: Option<String>,
/// Hand out a page that signs in only when its button is clicked. Mail scanners open links
/// on delivery, and opening the plain link spends it, so a link sent by email sets this.
pub confirm: Option<bool>,
}
#[derive(Serialize)]
@@ -3378,11 +3387,12 @@ async fn create_login_link(
.await?;
tx.commit().await?;
let url = format!(
"{}/api/auth/login_link/{}",
(**BASE_URL.load()).clone(),
token
);
let base_url = (**BASE_URL.load()).clone();
let url = if nl.confirm.unwrap_or(false) {
format!("{base_url}{LOGIN_LINK_CONFIRM_PAGE}?token={token}")
} else {
format!("{base_url}/api/auth/login_link/{token}")
};
Ok((StatusCode::CREATED, Json(LoginLink { url, expires_at })))
}
@@ -3628,19 +3638,45 @@ async fn consume_login_link(
Path(token): Path<String>,
Query(query): Query<LoginLinkQuery>,
) -> Result<Response> {
let bounce = |reason: &str| {
Ok(login_link_redirect(format!(
"{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"
)))
};
let location = redeem_login_link(&headers, cookies, &db, &token, query.rd).await?;
Ok(login_link_redirect(location))
}
#[derive(Serialize)]
struct LoginLinkLocation {
location: String,
}
/// The confirmation page's click. It answers with where to go rather than redirecting, and the
/// page navigates there itself.
async fn confirm_login_link(
headers: axum::http::HeaderMap,
cookies: Cookies,
Extension(db): Extension<DB>,
Path(token): Path<String>,
) -> JsonResult<LoginLinkLocation> {
let location = redeem_login_link(&headers, cookies, &db, &token, None).await?;
Ok(Json(LoginLinkLocation { location }))
}
/// Spends the link and sets the session cookie, returning the post-login destination; or
/// returns the explanation page, with no session, when the link cannot be used.
async fn redeem_login_link(
headers: &axum::http::HeaderMap,
cookies: Cookies,
db: &DB,
token: &str,
requested_rd: Option<String>,
) -> Result<String> {
let bounce = |reason: &str| Ok(format!("{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"));
if token.len() != 32 {
return bounce("invalid");
}
let t_hash = hash_token(&token);
let t_hash = hash_token(token);
// The account is unknown until the row is read, so only the global and per-IP tiers
// apply here; a 32-char random token leaves nothing for the per-account tier to guard.
windmill_common::login_rate_limit::check_and_increment_login_attempt(
&headers,
headers,
&t_hash[..TOKEN_PREFIX_LEN],
)?;
@@ -3707,11 +3743,10 @@ async fn consume_login_link(
.await?;
tx.commit().await?;
let rd = link
Ok(link
.rd
.or_else(|| same_origin_rd(query.rd))
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string());
Ok(login_link_redirect(rd))
.or_else(|| same_origin_rd(requested_rd))
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string()))
}
#[derive(Deserialize)]
+31 -1
View File
@@ -529,6 +529,30 @@ paths:
responses:
"302":
description: redirected to the post-login destination, or to /user/login_link_expired when the link is used, expired or unknown
post:
security: []
summary: consume a single-use login link from its confirmation page, set the session cookie and answer where to go
operationId: confirmLoginLink
tags:
- user
parameters:
- name: token
in: path
required: true
schema:
type: string
responses:
"200":
description: the post-login destination, or /user/login_link_expired when the link is used, expired or unknown
content:
application/json:
schema:
type: object
required:
- location
properties:
location:
type: string
/auth/reset_password:
post:
@@ -6753,7 +6777,7 @@ paths:
type: string
expires_in_s:
type: integer
description: link lifetime in seconds, at most 900 (default 600)
description: link lifetime in seconds, at most 7200 (default 600)
rd:
type: string
description: same-origin path the browser lands on after login (default /user/workspaces)
@@ -6763,6 +6787,12 @@ paths:
mint only while the account still has this login type (for example
pending_oauth), so a link stops working once the owner has set a password
or signed in with a provider
confirm:
type: boolean
description: >-
return a /user/login_link page that signs in only when its button is
clicked, instead of a link spent by opening it; set it for links sent by
email, which mail scanners open on delivery (default false)
responses:
"201":
description: login link minted
+4 -2
View File
@@ -29,8 +29,10 @@ Symbols, not line numbers, are cited: they drift less.
the account into a `password` one in the same statement (an account created ahead of its owner
gets its first credential that way, or through the OAuth claim below).
- **Login links** (`login_link` table, `POST /users/login_links` superadmin-only,
`GET /auth/login_link/{token}` unauthenticated): single-use, ≤15 min, a session cookie and a
302 to a same-origin `rd`. `require_login_type` on the mint refuses (409) an account whose
`GET /auth/login_link/{token}` unauthenticated): single-use, ≤2 h, a session cookie and a
302 to a same-origin `rd`. A link minted with `confirm` is the `/user/login_link` page
instead, which spends it only on a click (`POST` to the same path, answering `{location}`), so
a mail scanner opening it does not. `require_login_type` on the mint refuses (409) an account whose
`login_type` has moved on — the way a caller re-entering an account it created stops being
able to once the owner has a password or a provider.
- **Pre-approved trial offer** (`cloud_trial_offer`, cloud-only routes under
@@ -0,0 +1,44 @@
<script lang="ts">
import { onMount } from 'svelte'
import { page } from '$app/state'
import { Button } from '$lib/components/common'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { goto } from '$lib/navigation'
import { UserService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
// Mail scanners open links on delivery. Loading this page must never spend the link: only
// the click below does.
const token = page.url.searchParams.get('token') ?? ''
let signingIn = $state(false)
// No token is no link: the page a spent one bounces to already explains that.
onMount(() => {
if (!token) goto('/user/login_link_expired?reason=invalid', { replaceState: true })
})
async function signIn() {
if (signingIn) return
signingIn = true
try {
const { location } = await UserService.confirmLoginLink({ token })
// A full load, like the redirect a plain link gets, so the app starts from the new session.
window.location.assign(location)
} catch (e) {
console.error('Could not sign in with the link:', e)
sendUserToast('Could not sign in right now, please try again', true)
signingIn = false
}
}
</script>
<CenteredModal
title="Sign in to Windmill"
subtitle="This link signs you in once, then stops working."
loading={!token}
>
{#if token}
<Button variant="accent" unifiedSize="lg" loading={signingIn} onClick={signIn}>Sign in</Button>
{/if}
</CenteredModal>
@@ -17,7 +17,7 @@
<CenteredModal
title="Sign-in link unavailable"
subtitle="{message} Sign-in links work once and for a few minutes: ask for a new one from where you got this link, or sign in another way."
subtitle="{message} Sign-in links work once and expire: ask for a new one from where you got this link, or sign in another way."
>
<Button variant="accent" unifiedSize="lg" onClick={() => goto('/user/login')}>Go to login</Button>
</CenteredModal>