diff --git a/.github/workflows/cleanup-deno-dependency-branches.yml b/.github/workflows/cleanup-deno-dependency-branches.yml index fe49b73..a82797e 100644 --- a/.github/workflows/cleanup-deno-dependency-branches.yml +++ b/.github/workflows/cleanup-deno-dependency-branches.yml @@ -39,12 +39,7 @@ jobs: const owner = context.repo.owner; const repo = context.repo.repo; - - const matchingRefs = await github.rest.git.listMatchingRefs({ - owner, - repo, - ref: `heads/${prefix}`, - }); + const branch = pr.head.ref; const openPulls = await github.paginate(github.rest.pulls.list, { owner, @@ -52,30 +47,19 @@ jobs: state: 'open', per_page: 100, }); - const protectedHeads = new Set(openPulls.map((openPr) => openPr.head.ref)); - - let deleted = 0; - - for (const ref of matchingRefs.data) { - const branch = ref.ref.replace('refs/heads/', ''); - - if (branch === 'main') continue; - if (protectedHeads.has(branch)) { - core.info(`Keeping ${branch}: branch is used by an open pull request.`); - continue; - } - - try { - await github.rest.git.deleteRef({ - owner, - repo, - ref: `heads/${branch}`, - }); - core.info(`Deleted ${branch}`); - deleted += 1; - } catch (error) { - core.warning(`Could not delete ${branch}: ${error.message}`); - } + if (openPulls.some((openPr) => openPr.head.ref === branch)) { + core.info(`Keeping ${branch}: branch is used by an open pull request.`); + return; } - core.info(`Cleanup complete. Deleted ${deleted} branch(es).`); + try { + await github.rest.git.deleteRef({ + owner, + repo, + ref: `heads/${branch}`, + }); + core.info(`Deleted ${branch}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.warning(`Could not delete ${branch}: ${message}`); + } diff --git a/.github/workflows/deno-deps-update.yml b/.github/workflows/deno-deps-update.yml index 54451c1..758b730 100644 --- a/.github/workflows/deno-deps-update.yml +++ b/.github/workflows/deno-deps-update.yml @@ -17,7 +17,7 @@ jobs: update: name: Update dependencies and open PR runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 40 steps: - name: Checkout @@ -34,6 +34,20 @@ jobs: - name: Validate updated dependencies run: deno task check + - name: Setup Deno for browser and E2E + uses: denoland/setup-deno@v2 + with: + deno-version: 2.9.6 + + - name: Run e2e suite + run: deno task e2e + + - name: Install Chromium + run: deno task browser:install --with-deps + + - name: Run browser login tests + run: deno task test:browser + - name: Create pull request uses: peter-evans/create-pull-request@v8 with: @@ -45,7 +59,7 @@ jobs: What this PR does: - runs `deno outdated --update --latest` - updates `deno.lock` when needed - - runs `deno task check` before opening the PR + - runs `deno task check`, `deno task e2e`, and `deno task test:browser` before opening the PR branch: chore/deno-dependencies delete-branch: true labels: dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index 910ddb6..3e0769d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to this package are documented in this file. +## 0.4.0 - 2026-10-02 + +- Revalidated every session against `findUserById`. A session ends when the user + is missing, inactive, or no longer matches the stored email, `authVersion`, or + super-admin flag. With RBAC enabled, `permissionsVersion` must match too. + Added `revokeUserSessions(userId)`. +- Stored session ids as SHA-256 hashes. The cookie still carries the raw id. +- Kept one outstanding magic link per user and required its current-user pointer + during verification. Links written before this version cannot be consumed. +- Required the binding secret whenever a link stored one. IP and user-agent + matching remains the path for links issued without a binding secret. +- Canonicalized `requestIp` before rate-limit keys and IP binding, and rejected + missing or invalid addresses. Added a separate verifier-failure limiter. +- Tightened email checks and made `*@domain` match that domain only. Mail is + sent to the normalized address that passed the allowlist. +- Added successful-send quotas of 10 per email and 30 per IP each 15 minutes. + Unknown users do not consume that budget. KV retries stop after 8 attempts. +- Replaced the 30 day cookie default with the 7 day session lifetime. Default + cookie names are `__Host-session` and `__Host-ml-bind`. +- Added `buildVerifyResponseHeaders`, `magicLinkVerifyPath`, + `renderMagicLinkEmail`, and `sessionCookieMaxAgeSeconds()`. The built-in login + email is English. +- Rejected `appBaseUrl` values that contain credentials, a query, or a hash. An + unknown RBAC role no longer throws, and a missing role is no longer stored as + `viewer`. +- Stopped publishing `nodemailer` and `playwright` on the package import map. + Unit tests use in-memory KV. Dependency-update cleanup deletes only the merged + branch, and that workflow also runs the E2E and browser suites. + +Upgrade: existing sessions and magic links stop working, so users need a new +link. HTTP development must set non-prefix cookie names and `secure: false`. +`findUserById` must return the live user or `getSession()` ends the session. +Idle expiry is a fixed deadline measured from issuance. + ## 0.3.0 - 2026-09-10 - Bound newly issued magic links to the user's email and credential version. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7caee25..79e4bde 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -25,11 +25,11 @@ deno task test:docs containerized Deno test app, executes the E2E suite against the running stack, and removes the containers afterward. -`deno task check` runs formatting, lint, unit/security/cookie tests, and -extracted README example tests. The example tests typecheck all five -authentication and authorization examples and execute their login handlers with -isolated KV and mail fixtures. They require permission to launch `deno` -subprocesses. +`deno task check` runs formatting, lint, unit, security, cookie, and hardening +tests, then the extracted README example tests. The example tests typecheck all +five authentication and authorization examples and execute their login handlers +with isolated in-memory KV and mail fixtures. They require permission to launch +`deno` subprocesses and to create temporary files. `deno task test:browser` runs Chromium against an isolated local HTTP fixture. It checks browser cookie acceptance, cross-site email navigation, binding on a @@ -45,8 +45,10 @@ browser and E2E tests while retaining Deno 2.7.11 for unit and documentation compatibility checks. The browser task uses full Deno permissions for Playwright's Linux `/proc` -checks and browser subprocess management. Unit and documentation tasks retain -their narrower permissions. +checks and browser subprocess management. Unit tests need `--unstable-kv` only. +Documentation tests add read, write, and permission to launch `deno`. +`nodemailer` and `playwright` are imported by the E2E app and browser test, not +by the published package. E2E HTTP ports are bound to `127.0.0.1`; SMTP stays inside the Compose network. The E2E app uses the socket peer address rather than forwarding headers, and its @@ -62,21 +64,22 @@ The repository includes four automation files under `.github`: `main` (including merges), and on manual dispatch. The E2E checkout path includes spaces and Unicode to exercise filesystem URL conversion. - `.github/workflows/deno-deps-update.yml` runs every Monday at 05:00 UTC and - opens a PR with `deno outdated --update --latest` changes only after the - `deno task check` gate passes. + opens a PR with `deno outdated --update --latest` changes only after + `deno task check`, `deno task e2e`, and `deno task test:browser` pass. The job + timeout is 40 minutes. - `.github/workflows/cleanup-deno-dependency-branches.yml` runs when a - `chore/deno-dependencies*` pull request is merged into `main` and deletes - orphaned dependency-update branches that are no longer used by open PRs. + `chore/deno-dependencies*` pull request is merged into `main`. It deletes that + pull request's head branch when no open pull request still uses it. - `.github/dependabot.yml` keeps GitHub Actions and Docker dependencies updated. Dependabot does not currently provide a dedicated Deno/JSR ecosystem updater, so Deno dependency updates are handled by the scheduled workflow above. -## Why tests need file permissions +## Why documentation tests need file permissions -The original unit suite opens an isolated local Deno KV database file per test; -the additional security tests use in-memory KV. Documentation tests create and -remove temporary TypeScript fixtures. That is why `--unstable-kv`, -`--allow-read`, and `--allow-write` are required for `deno test`. +Unit, security, cookie, and hardening tests open `Deno.openKv(":memory:")`, so +`deno task test` does not grant filesystem permissions. Documentation tests +write the README examples to a temporary directory and run them with `deno`. +That task needs `--allow-read`, `--allow-write`, and `--allow-run=deno`. ## Release checklist @@ -92,6 +95,8 @@ remove temporary TypeScript fixtures. That is why `--unstable-kv`, - Keep the public API small and explicit. - Prefer readable control flow over clever abstractions. -- Treat redirect handling, token validation, and cookie serialization as - security-sensitive areas. +- Treat redirect handling, token validation, IP canonicalization, binding + checks, and cookie serialization as security-sensitive areas. +- `getSession()` must keep calling `findUserById`. A session lookup that trusts + only the KV record is stale. - Keep package docs current with the published API surface. diff --git a/README.md b/README.md index f69f835..890d4e1 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,16 @@ Maintained by [synit.io](https://www.synit.io). ## Features -- One-time magic-link issuance backed by Deno KV -- Atomic link consumption to prevent replay races -- Session persistence in Deno KV -- Optional email-address and domain allowlist support -- Failed-login rate limiting per originating IP address +- One outstanding magic link per user, stored as a hash and consumed atomically +- Session identifiers stored as hashes, with live user checks on every load +- Optional email-address and exact-domain allowlist support +- Failed-attempt and successful-send limits per canonical client IP - Initial super-admin propagation into verified users and stored sessions -- Optional binding checks with cookie secret or IP plus user-agent matching +- Binding-secret checks when a link was issued with one, otherwise IP plus + user-agent - Optional RBAC with session-cached role and permission snapshots - Pure role and permission helper APIs for request-time checks -- Cookie helpers for session and verification-bound cookies +- `__Host-` cookie helpers for session and verification-bound cookies - Docker Compose backed E2E coverage with Mailpit and a local auth test app - Works well with Fresh, Hono, and custom Deno HTTP services @@ -31,7 +31,7 @@ This package is designed for apps that want: The package keeps the normal authenticated request path small: -- one session read from KV +- one session read from KV, plus the application `findUserById` lookup - zero extra KV reads for RBAC checks after the session is loaded ## Install @@ -82,6 +82,7 @@ bucket. Use the same trusted address resolution for issuance and verification. ```ts import { buildSessionSetCookie, + buildVerifyResponseHeaders, DenoKvMagicLinkAuth, getCookie, } from "jsr:@synitio/kv-magic-link-auth"; @@ -135,8 +136,8 @@ export async function handleRequest( }); return Response.json({ - sent: issued.sent, - // Debug URLs are useful locally. Do not expose them in production APIs. + // Local debug only. Do not return `debugUrl`, `sent`, or `error` from + // a production login endpoint. debugUrl: issued.debugUrl, }); } @@ -153,20 +154,19 @@ export async function handleRequest( return new Response("invalid or expired link", { status: 401 }); } - const headers = new Headers(); - headers.append( - "set-cookie", - buildSessionSetCookie(verified.sessionId, { - secure: true, - sessionCookieName: "__Host-session", - }), - ); - - headers.set( - "location", - new URL(verified.redirectTo, "https://app.example.local").href, - ); - return new Response(null, { status: 302, headers }); + return new Response(null, { + status: 302, + headers: buildVerifyResponseHeaders( + new URL(verified.redirectTo, "https://app.example.local").href, + [ + buildSessionSetCookie(verified.sessionId, { + secure: true, + maxAgeSeconds: auth.sessionCookieMaxAgeSeconds(), + sessionCookieName: "__Host-session", + }), + ], + ), + }); } if (url.pathname === "/me") { @@ -175,7 +175,8 @@ export async function handleRequest( return new Response("not authenticated", { status: 401 }); } - // Keep this to one read per request. Reuse the loaded session object below. + // Loads the hashed session and rechecks the live user. `findUserById` + // must return that account or the session is deleted. const session = await auth.getSession(sessionId); if (!session) { return new Response("session expired", { status: 401 }); @@ -196,12 +197,15 @@ export async function handleRequest( ### How login works -1. `issueMagicLink()` normalizes the login request, checks allowlists and rate - limits, then stores a hashed token record in Deno KV. -2. `verifyMagicLink()` loads the token record, validates the verification - context, atomically marks the token as used, and creates a session. -3. `getSession()` loads the session and enforces idle and absolute expiry - without mutating KV on every request. +1. `issueMagicLink()` canonicalizes the client IP, checks allowlists and rate + limits, then stores one hashed token for that user. A newer link replaces the + previous unconsumed link. +2. `verifyMagicLink()` loads the token, requires the current-user pointer to + match, checks the binding secret or the IP plus user-agent, and atomically + marks the token as used while creating a session. +3. `getSession()` loads the hashed session, enforces the fixed idle and absolute + deadlines, and calls `findUserById`. A missing, inactive, or changed user + deletes the session. The idle deadline does not move on read. ### Why the session is safe to use for RBAC @@ -226,9 +230,12 @@ session payload. The package is tuned to avoid unnecessary KV traffic: -- successful login request: one failed-attempt read plus one magic-link write -- link verification: one link read and one atomic consume-plus-session write -- authenticated request: one session read +- successful login request: failed-attempt read, send-budget reservations, and + one atomic write of the new link plus the current-user pointer +- link verification: link and pointer reads, then one atomic consume that also + writes the hashed session and its user index +- authenticated request: one session read, then `findUserById` in application + code - RBAC helper checks after the session is loaded: zero KV operations ## Basic Auth Example @@ -238,15 +245,16 @@ delivery. Return the same `202` response whether an account exists, is blocked, or receives mail; keep the internal `sent` result out of public responses. The example addresses and mail endpoint are placeholders for your application. -Apply ingress limits per trusted client IP and per recipient before issuing -links. The package's failed-attempt limiter does not limit successful email -issuance, so separate delivery quotas are required to prevent mailbox flooding -and excessive mail or storage costs. +Successful sends are limited to 10 per email address and 30 per IP in a 15 +minute window. Failed attempts have a separate limiter. Unknown addresses do not +consume the send budget. Keep any additional edge limits in front of the +application. ```ts import { buildSessionClearCookie, buildSessionSetCookie, + buildVerifyResponseHeaders, DenoKvMagicLinkAuth, getCookie, } from "jsr:@synitio/kv-magic-link-auth"; @@ -326,20 +334,19 @@ export async function handleRequest( return new Response("invalid or expired link", { status: 401 }); } - const headers = new Headers(); - headers.append( - "set-cookie", - buildSessionSetCookie(verified.sessionId, { - secure: true, - sessionCookieName: "__Host-session", - }), - ); - - headers.set( - "location", - new URL(verified.redirectTo, "https://console.example.local").href, - ); - return new Response(null, { status: 302, headers }); + return new Response(null, { + status: 302, + headers: buildVerifyResponseHeaders( + new URL(verified.redirectTo, "https://console.example.local").href, + [ + buildSessionSetCookie(verified.sessionId, { + secure: true, + maxAgeSeconds: auth.sessionCookieMaxAgeSeconds(), + sessionCookieName: "__Host-session", + }), + ], + ), + }); } if (url.pathname === "/auth/logout" && request.method === "POST") { @@ -388,15 +395,17 @@ declare function lookupUserById(id: string): Promise< ## Advanced Auth Example -This variant adds binding-secret verification. A cookie set on the device that -requested the link can complete verification even if its IP changes. Matching IP -and user-agent values remain an alternative; the cookie is not mandatory. +This variant adds binding-secret verification. A link issued with a binding +secret can be redeemed only with that secret, including after the client IP +changes. IP and user-agent matching applies only to links issued without a +binding secret. The secret must be 16 to 512 characters. ```ts import { buildBindingClearCookie, buildBindingSetCookie, buildSessionSetCookie, + buildVerifyResponseHeaders, DenoKvMagicLinkAuth, getCookie, } from "jsr:@synitio/kv-magic-link-auth"; @@ -472,27 +481,23 @@ export async function handleRequest( return new Response("invalid or expired link", { status: 401 }); } - const headers = new Headers(); - headers.append( - "set-cookie", - buildBindingClearCookie({ - secure: true, - bindingCookieName: "__Host-ml-bind", - }), - ); - headers.append( - "set-cookie", - buildSessionSetCookie(verified.sessionId, { - secure: true, - sessionCookieName: "__Host-session", - }), - ); - - headers.set( - "location", - new URL(verified.redirectTo, "https://console.example.local").href, - ); - return new Response(null, { status: 302, headers }); + return new Response(null, { + status: 302, + headers: buildVerifyResponseHeaders( + new URL(verified.redirectTo, "https://console.example.local").href, + [ + buildBindingClearCookie({ + secure: true, + bindingCookieName: "__Host-ml-bind", + }), + buildSessionSetCookie(verified.sessionId, { + secure: true, + maxAgeSeconds: auth.sessionCookieMaxAgeSeconds(), + sessionCookieName: "__Host-session", + }), + ], + ), + }); } return new Response("not found", { status: 404 }); @@ -527,6 +532,7 @@ stored in the session as a minimal authorization snapshot. ```ts import { buildSessionSetCookie, + buildVerifyResponseHeaders, DenoKvMagicLinkAuth, getCookie, hasPermission, @@ -598,16 +604,16 @@ export async function handleRequest( return new Response(null, { status: 302, - headers: { - location: new URL( - verified.redirectTo, - "https://console.example.local", - ).href, - "set-cookie": buildSessionSetCookie(verified.sessionId, { - secure: true, - sessionCookieName: "__Host-session", - }), - }, + headers: buildVerifyResponseHeaders( + new URL(verified.redirectTo, "https://console.example.local").href, + [ + buildSessionSetCookie(verified.sessionId, { + secure: true, + maxAgeSeconds: auth.sessionCookieMaxAgeSeconds(), + sessionCookieName: "__Host-session", + }), + ], + ), }); } @@ -671,6 +677,7 @@ This example shows: ```ts import { buildSessionSetCookie, + buildVerifyResponseHeaders, DenoKvMagicLinkAuth, getCookie, hasPermission, @@ -731,16 +738,16 @@ export async function handleRequest( return new Response(null, { status: 302, - headers: { - location: new URL( - verified.redirectTo, - "https://console.example.local", - ).href, - "set-cookie": buildSessionSetCookie(verified.sessionId, { - secure: true, - sessionCookieName: "__Host-session", - }), - }, + headers: buildVerifyResponseHeaders( + new URL(verified.redirectTo, "https://console.example.local").href, + [ + buildSessionSetCookie(verified.sessionId, { + secure: true, + maxAgeSeconds: auth.sessionCookieMaxAgeSeconds(), + sessionCookieName: "__Host-session", + }), + ], + ), }); } @@ -819,11 +826,17 @@ declare function lookupCurrentUserById(id: string): Promise< ### `DenoKvMagicLinkAuth` -- `issueMagicLink(input)` issues a one-time login link and stores its hashed - verification record in Deno KV +- `issueMagicLink(input)` issues one login link for the user and stores its + hashed verification record in Deno KV. `issued` means a link is stored. `sent` + means mail was delivered. `error` is for the application only. - `verifyMagicLink(input)` validates and consumes a link, then creates a session -- `getSession(sessionId)` returns a valid session record or `null` -- `revokeSession(sessionId)` deletes a session from Deno KV +- `getSession(sessionId)` returns a current session or `null`. It calls + `findUserById` and deletes the session when that user can no longer sign in. +- `revokeSession(sessionId)` deletes one hashed session +- `revokeUserSessions(userId)` deletes every session issued for that user +- `sessionCookieMaxAgeSeconds()` returns the cookie lifetime shared with the + session record +- `magicLinkVerifyPath` is the verify pathname joined onto `appBaseUrl` ### RBAC helpers @@ -838,14 +851,23 @@ These helpers are pure. They do not read from KV. ### Config highlights - `allowedEmailPatterns` accepts exact addresses such as `"admin@example.com"` - and domain wildcards such as `"*@example.com"` + and domain patterns such as `"*@example.com"`. A domain pattern matches that + domain only. - `initialSuperAdminEmail` marks the matching authenticated user as - `isSuperAdmin` + `isSuperAdmin` when the user record does not set the flag itself - `failedAuthRateLimitMaxAttempts`, `failedAuthRateLimitWindowMinutes`, and - `failedAuthRateLimitBlockMinutes` throttle repeated failed login requests from - the same IP address + `failedAuthRateLimitBlockMinutes` throttle repeated failed login and + verification requests from the same canonical IP address +- `sendRateLimitMaxPerEmail`, `sendRateLimitMaxPerIp`, and + `sendRateLimitWindowMinutes` limit successful login mail +- `magicLinkVerifyPath` sets the verify path joined onto `appBaseUrl` +- `renderMagicLinkEmail` replaces the English login message. The rendered text + or HTML must contain the verification URL. +- `appBaseUrl` is an `http` or `https` origin plus an optional path. + Credentials, query strings, and hashes are rejected. - `rbac` enables optional role-to-permission mapping and session-cached - authorization snapshots + authorization snapshots. Duplicate role names are rejected. A user with no + role is not assigned `viewer` unless `defaultRole` says so. ### Cookie helpers @@ -853,59 +875,85 @@ These helpers are pure. They do not read from KV. - `buildSessionClearCookie` - `buildBindingSetCookie` - `buildBindingClearCookie` +- `buildVerifyResponseHeaders` - `getCookie` +Cookie names default to `__Host-session` and `__Host-ml-bind`. Both require +`Secure`, and `__Host-` cookies use `Path=/`. On local HTTP, choose names +without a `__Host-` or `__Secure-` prefix and set `secure: false`. Pass +`maxAgeSeconds: auth.sessionCookieMaxAgeSeconds()` so the cookie ends with the +session. The default lifetime is 7 days. `getCookie` returns `null` when the +same name appears twice. `buildVerifyResponseHeaders` sets `Location`, +`Referrer-Policy: no-referrer`, `Cache-Control: no-store`, and the session +cookie. The login token stays in the verification query string; the referrer +policy keeps that URL out of the next request. + ## Security Notes -- Email requests can be restricted to explicit addresses or whole domains -- Repeated failed login requests from the same IP are temporarily blocked +- Email requests can be restricted to explicit addresses or one exact domain +- Repeated failed login and verification requests from the same canonical IP are + temporarily blocked - Redirect targets are constrained to the configured application origin - Verification links include only a one-time token in query params and never include an email address -- Link verification requires either a matching binding secret or a matching IP - plus user-agent pair -- Used and expired links are rejected; verification rechecks the current email - allowlist and the user's email and `authVersion` from issuance +- A link issued with a binding secret requires that secret. IP plus user-agent + matching is used only when no binding secret was issued. +- Used, expired, and superseded links are rejected. Verification rechecks the + current email allowlist and the user's email and `authVersion` from issuance. - Session and binding cookies are `HttpOnly`, `Secure`, and `SameSite=Lax` by default, allowing top-level navigation from an email link -- `requestIp` must come from transport metadata or an explicitly trusted proxy, - not an unchecked forwarding header +- `requestIp` is required. Pass a canonical address from transport metadata or + an explicitly trusted proxy, never an unchecked forwarding header. +- `getSession()` keeps a session only while `findUserById` returns the same + active user, email, `authVersion`, and super-admin flag. With RBAC enabled, + `permissionsVersion` must match too. - RBAC permission data is stored as a minimal session snapshot, not as a policy source of truth - -### Upgrading existing integrations - -New magic links are bound to the user's email and `authVersion` at issuance. -Links issued before this change lack that version snapshot and are rejected; ask -users to request a fresh link after deploying the upgrade. Increment -`authVersion` when changing credentials or revoking outstanding links. Existing -sessions still require the application's version check shown in the Advanced -RBAC Example. - -Cookie helpers now default to `SameSite=Lax` so browsers can send the binding -cookie when following a top-level email link. `__Host-` cookies use `Path=/` and -require `Secure`. Keep state-changing routes protected with POST plus origin or -CSRF validation; SameSite alone is not a complete CSRF defense. Clear any legacy -`ml_bind` cookie at `Path=/api/auth/magic-link/verify` when switching a deployed -binding cookie to `Path=/`. - -Set `sameSite: "Strict"` explicitly on cookie helpers only when your login flow -provides an intermediate page that completes verification through a subsequent -same-site request. Use the same cookie configuration when setting and clearing -cookies. - -Update verification handlers to `/api/auth/magic-link/verify`, and return a -response containing both `Location` and `Set-Cookie`. The examples above show -this response and the trusted transport context required for IP checks. +- Login responses that callers can see should carry a generic acceptance. Keep + `sent`, `debugUrl`, and `error` in server logs. + +### Upgrading to 0.4.0 + +Sessions and magic links written by 0.3.0 stop working. Session keys are now +hashes of the cookie value, and a magic link without the current-user pointer is +rejected. Ask users to request a new link after deploying. + +Cookie helpers default to `__Host-session` and `__Host-ml-bind`, with a 7 day +`Max-Age`. HTTP development cannot use those names: set a name without the +prefix and pass `secure: false`. Clear any legacy `ml_bind` cookie at +`Path=/api/auth/magic-link/verify` when a deployed binding cookie moves to +`Path=/`. + +`appBaseUrl` can no longer contain credentials, a query string, or a hash. +`requestIp` must be present and is canonicalized before it is used as a key or a +binding value. A binding secret shorter than 16 characters is rejected at +issuance. The built-in login mail is English unless `renderMagicLinkEmail` is +set. `getSession()` now calls `findUserById` on every load. + +Keep state-changing routes protected with POST plus origin or CSRF validation. +`SameSite=Lax` lets an emailed link carry the binding cookie, and it is not a +complete CSRF defense. Set `sameSite: "Strict"` only when verification finishes +on a later same-site request. Use the same cookie configuration when setting and +clearing cookies. + +Return the verification response from `buildVerifyResponseHeaders` so the +browser receives `Location`, `Set-Cookie`, and `Referrer-Policy: no-referrer` +together. Increment `authVersion` when credentials change, and +`permissionsVersion` when role permissions change. `revokeUserSessions(userId)` +clears every session for one user. ## Low-KV Deployment Guidance - Load the session once per request and pass the loaded object through your - handlers -- Use RBAC helpers on the loaded session object instead of reloading state + handlers. That load is one KV read plus `findUserById`. +- Use RBAC helpers on the loaded session object instead of reading policy from + KV - Keep role-to-permission mapping in application config, not in KV -- Use version invalidation for auth changes instead of rewriting every session -- If you later add sliding sessions, throttle any write-back behavior heavily +- Bump `authVersion` or `permissionsVersion` when authorization changes. + `getSession()` drops sessions that no longer match, and `revokeUserSessions()` + can delete them immediately. +- Idle and absolute deadlines are fixed at issuance. The effective lifetime is + the earlier of the two. ## Development diff --git a/authorization.ts b/authorization.ts index a355ba1..b933757 100644 --- a/authorization.ts +++ b/authorization.ts @@ -22,11 +22,9 @@ export function hasRole(session: SessionRecord, role: string): boolean { if (!normalizedRole) return false; const snapshot = getAuthorizationSnapshot(session); - if (snapshot) { - return normalizeKey(snapshot.role) === normalizedRole; - } - - return normalizeKey(session.role) === normalizedRole; + const sessionRole = normalizeKey(snapshot?.role ?? session.role); + if (!sessionRole) return false; + return sessionRole === normalizedRole; } /** Returns whether the session contains the given permission. */ diff --git a/cookies.ts b/cookies.ts index 8867525..20fc0f0 100644 --- a/cookies.ts +++ b/cookies.ts @@ -1,18 +1,38 @@ +import { + DEFAULT_MAGIC_LINK_VERIFY_PATH, + DEFAULT_SESSION_COOKIE_TTL_DAYS, +} from "./defaults.ts"; + /** Configuration for session and magic-link binding cookies. */ export interface MagicLinkCookieConfig { - /** Cookie name used for persisted login sessions. Defaults to `"session"`. */ + /** Cookie name used for persisted login sessions. Defaults to `"__Host-session"`. */ sessionCookieName?: string; - /** Cookie name used to bind the verification request to the issuing browser. Defaults to `"ml_bind"`. */ + /** Cookie name used to bind the verification request to the issuing browser. Defaults to `"__Host-ml-bind"`. */ bindingCookieName?: string; /** Adds the `Secure` attribute. Enabled by default; set to `false` only for local HTTP development. */ secure?: boolean; /** Same-site policy. Defaults to `"Lax"` so emailed links can carry binding and session cookies. */ sameSite?: "Lax" | "Strict"; - /** Session cookie lifetime in days. Defaults to `30`. */ + /** + * Session cookie lifetime in seconds. Use `DenoKvMagicLinkAuth.sessionCookieMaxAgeSeconds()` + * so the cookie expires with the session. + */ + maxAgeSeconds?: number; + /** + * Session cookie lifetime in days when `maxAgeSeconds` is omitted. + * Defaults to the effective session lifetime of 7 days. + */ sessionAbsoluteTtlDays?: number; + /** + * Path for a non-`__Host-` binding cookie. Must be the verify pathname. + * `__Host-` cookies always use `Path=/`. + */ + bindingCookiePath?: string; } const COOKIE_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const DEFAULT_SESSION_COOKIE_NAME = "__Host-session"; +const DEFAULT_BINDING_COOKIE_NAME = "__Host-ml-bind"; function assertCookieName( name: string, @@ -30,6 +50,35 @@ function assertCookieName( return name; } +function assertPositiveInteger(value: number, label: string): number { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + +function cookiePath(path: string): string { + if ( + !path.startsWith("/") || path.startsWith("//") || /[\s?#\\]/.test(path) + ) { + throw new Error( + "bindingCookiePath must be an absolute path without a query or hash.", + ); + } + return path; +} + +function sessionMaxAgeSeconds(config: MagicLinkCookieConfig): number { + if (config.maxAgeSeconds !== undefined) { + return assertPositiveInteger(config.maxAgeSeconds, "maxAgeSeconds"); + } + const days = assertPositiveInteger( + config.sessionAbsoluteTtlDays ?? DEFAULT_SESSION_COOKIE_TTL_DAYS, + "sessionAbsoluteTtlDays", + ); + return days * 24 * 60 * 60; +} + function cookieBase( maxAgeSeconds: number, secure: boolean, @@ -50,11 +99,13 @@ function bindingCookieBase( secure: boolean, sameSite: "Lax" | "Strict", cookieName: string, + bindingCookiePath: string | undefined, ): string { + const path = /^__Host-/i.test(cookieName) + ? "/" + : cookiePath(bindingCookiePath ?? DEFAULT_MAGIC_LINK_VERIFY_PATH); const parts = [ - /^__Host-/i.test(cookieName) - ? "Path=/" - : "Path=/api/auth/magic-link/verify", + `Path=${path}`, "HttpOnly", `SameSite=${sameSite}`, `Max-Age=${maxAgeSeconds}`, @@ -63,21 +114,24 @@ function bindingCookieBase( return parts.join("; "); } -/** Reads a cookie value from the request headers. Returns `null` if the cookie is missing or malformed. */ +/** Reads a cookie value from the request headers. Returns `null` if the cookie is missing, duplicated, or malformed. */ export function getCookie(headers: Headers, name: string): string | null { const raw = headers.get("cookie"); if (!raw) return null; const chunks = raw.split(";"); + let value: string | null = null; for (const chunk of chunks) { const [key, ...rest] = chunk.trim().split("="); if (key !== name) continue; + // Two cookies with the same name are ambiguous. Fail closed. + if (value !== null) return null; try { - return decodeURIComponent(rest.join("=")); + value = decodeURIComponent(rest.join("=")); } catch { return null; } } - return null; + return value; } /** Builds a `Set-Cookie` header value for the authenticated session cookie. */ @@ -85,16 +139,16 @@ export function buildSessionSetCookie( sessionId: string, config: MagicLinkCookieConfig = {}, ): string { + const secure = config.secure ?? true; const cookieName = assertCookieName( - config.sessionCookieName ?? "session", + config.sessionCookieName ?? DEFAULT_SESSION_COOKIE_NAME, "session", - config.secure ?? true, + secure, ); - const ttlDays = config.sessionAbsoluteTtlDays ?? 30; return `${cookieName}=${encodeURIComponent(sessionId)}; ${ cookieBase( - ttlDays * 24 * 60 * 60, - config.secure ?? true, + sessionMaxAgeSeconds(config), + secure, config.sameSite ?? "Lax", ) }`; @@ -104,13 +158,14 @@ export function buildSessionSetCookie( export function buildSessionClearCookie( config: MagicLinkCookieConfig = {}, ): string { + const secure = config.secure ?? true; const cookieName = assertCookieName( - config.sessionCookieName ?? "session", + config.sessionCookieName ?? DEFAULT_SESSION_COOKIE_NAME, "session", - config.secure ?? true, + secure, ); return `${cookieName}=; ${ - cookieBase(0, config.secure ?? true, config.sameSite ?? "Lax") + cookieBase(0, secure, config.sameSite ?? "Lax") }; Expires=Thu, 01 Jan 1970 00:00:00 GMT`; } @@ -120,17 +175,19 @@ export function buildBindingSetCookie( maxAgeSeconds: number, config: MagicLinkCookieConfig = {}, ): string { + const secure = config.secure ?? true; const cookieName = assertCookieName( - config.bindingCookieName ?? "ml_bind", + config.bindingCookieName ?? DEFAULT_BINDING_COOKIE_NAME, "binding", - config.secure ?? true, + secure, ); return `${cookieName}=${encodeURIComponent(value)}; ${ bindingCookieBase( - maxAgeSeconds, - config.secure ?? true, + assertPositiveInteger(maxAgeSeconds, "maxAgeSeconds"), + secure, config.sameSite ?? "Lax", cookieName, + config.bindingCookiePath, ) }`; } @@ -139,17 +196,42 @@ export function buildBindingSetCookie( export function buildBindingClearCookie( config: MagicLinkCookieConfig = {}, ): string { + const secure = config.secure ?? true; const cookieName = assertCookieName( - config.bindingCookieName ?? "ml_bind", + config.bindingCookieName ?? DEFAULT_BINDING_COOKIE_NAME, "binding", - config.secure ?? true, + secure, ); return `${cookieName}=; ${ bindingCookieBase( 0, - config.secure ?? true, + secure, config.sameSite ?? "Lax", cookieName, + config.bindingCookiePath, ) }; Expires=Thu, 01 Jan 1970 00:00:00 GMT`; } + +/** + * Headers for a magic-link verification response. + * `Referrer-Policy: no-referrer` keeps the one-time token out of the next request. + */ +export function buildVerifyResponseHeaders( + location: string, + cookies: readonly string[], +): Headers { + if ( + /[\r\n]/.test(location) || cookies.some((cookie) => /[\r\n]/.test(cookie)) + ) { + throw new Error("Header values must not contain line breaks."); + } + const headers = new Headers(); + headers.set("location", location); + headers.set("referrer-policy", "no-referrer"); + headers.set("cache-control", "no-store"); + for (const cookie of cookies) { + headers.append("set-cookie", cookie); + } + return headers; +} diff --git a/cookies_test.ts b/cookies_test.ts index 6c2d94c..aae8b10 100644 --- a/cookies_test.ts +++ b/cookies_test.ts @@ -5,6 +5,8 @@ import { buildBindingSetCookie, buildSessionClearCookie, buildSessionSetCookie, + buildVerifyResponseHeaders, + getCookie, type MagicLinkCookieConfig, } from "./cookies.ts"; @@ -13,11 +15,20 @@ function cookieAttributes(cookie: string): string[] { } Deno.test("cookie defaults allow emailed navigation and preserve secure attributes", () => { + const sessionCookie = buildSessionSetCookie("session"); + const bindingCookie = buildBindingSetCookie("binding", 60); + assertEquals(sessionCookie.startsWith("__Host-session="), true); + assertEquals(bindingCookie.startsWith("__Host-ml-bind="), true); + assertEquals( + cookieAttributes(sessionCookie).includes("Max-Age=604800"), + true, + ); + assertEquals(cookieAttributes(bindingCookie).includes("Path=/"), true); for ( const cookie of [ - buildSessionSetCookie("session"), + sessionCookie, buildSessionClearCookie(), - buildBindingSetCookie("binding", 60), + bindingCookie, buildBindingClearCookie(), ] ) { @@ -65,6 +76,45 @@ Deno.test("binding cookie paths satisfy Host prefixes and match when clearing", } }); +Deno.test("duplicate cookie names fail closed and verify headers hide the token", () => { + assertEquals( + getCookie( + new Headers({ cookie: "__Host-session=one; __Host-session=two" }), + "__Host-session", + ), + null, + ); + const headers = buildVerifyResponseHeaders("https://app.example.com/dash", [ + buildSessionSetCookie("abc"), + ]); + assertEquals(headers.get("location"), "https://app.example.com/dash"); + assertEquals(headers.get("referrer-policy"), "no-referrer"); + assertEquals(headers.get("cache-control"), "no-store"); + assertThrows( + () => + buildVerifyResponseHeaders( + "https://app.example.com/\r\nSet-Cookie: a", + [], + ), + Error, + "Header values must not contain line breaks.", + ); + assertThrows( + () => buildSessionSetCookie("abc", { maxAgeSeconds: 1.5 }), + Error, + "maxAgeSeconds must be a positive integer.", + ); + assertThrows( + () => + buildBindingSetCookie("binding-secret-value", 60, { + bindingCookieName: "ml_bind", + bindingCookiePath: "//evil.example", + }), + Error, + "bindingCookiePath must be an absolute path", + ); +}); + Deno.test("all cookie helpers reject secure prefixes with secure disabled", () => { for (const name of ["__Host-auth", "__Secure-auth", "__host-auth"]) { const config = { diff --git a/defaults.ts b/defaults.ts new file mode 100644 index 0000000..a1faf79 --- /dev/null +++ b/defaults.ts @@ -0,0 +1,20 @@ +/** Default verify route appended to `appBaseUrl`. */ +export const DEFAULT_MAGIC_LINK_VERIFY_PATH = "/api/auth/magic-link/verify"; + +/** Fixed session lifetime measured from issuance. This is not a sliding idle timeout. */ +export const DEFAULT_SESSION_IDLE_TTL_DAYS = 7; + +/** Upper bound on session lifetime measured from issuance. */ +export const DEFAULT_SESSION_ABSOLUTE_TTL_DAYS = 30; + +/** + * Cookie lifetime. Matches the effective session lifetime, which is the idle + * deadline when it is shorter than the absolute deadline. + */ +export const DEFAULT_SESSION_COOKIE_TTL_DAYS = DEFAULT_SESSION_IDLE_TTL_DAYS; + +/** Minimum accepted binding secret length. Shorter secrets are rejected. */ +export const BINDING_SECRET_MIN_LENGTH = 16; + +/** Maximum accepted binding secret length. */ +export const BINDING_SECRET_MAX_LENGTH = 512; diff --git a/deno.json b/deno.json index 0d6b094..8a5af54 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@synitio/kv-magic-link-auth", - "version": "0.3.0", + "version": "0.4.0", "description": "Deno KV backed magic-link authentication for server-side Deno apps.", "license": "MIT", "exports": { @@ -8,7 +8,7 @@ }, "tasks": { "check": "deno fmt --check && deno lint && deno task test && deno task test:docs", - "test": "deno test --unstable-kv --allow-read --allow-write mod_test.ts security_test.ts cookies_test.ts", + "test": "deno test --unstable-kv mod_test.ts security_test.ts cookies_test.ts hardening_test.ts", "test:docs": "deno test --unstable-kv --allow-read --allow-write --allow-run=deno readme_test.ts", "e2e": "deno run --allow-run=docker,deno --allow-net=127.0.0.1 e2e/run.ts", "browser:install": "deno run -A playwright install --only-shell chromium", @@ -16,7 +16,7 @@ }, "imports": { "@std/assert": "jsr:@std/assert@^1.0.19", - "nodemailer": "npm:nodemailer@^10.0.12", + "nodemailer": "npm:nodemailer@^10.0.13", "playwright": "npm:playwright@^1.63.0" }, "fmt": { @@ -31,6 +31,7 @@ "mod_test.ts", "security_test.ts", "cookies_test.ts", + "hardening_test.ts", "readme_test.ts" ] } diff --git a/deno.lock b/deno.lock index 63479c8..a6de1ce 100644 --- a/deno.lock +++ b/deno.lock @@ -3,7 +3,7 @@ "specifiers": { "jsr:@std/assert@^1.0.19": "1.0.19", "jsr:@std/internal@^1.0.12": "1.0.12", - "npm:nodemailer@^10.0.12": "10.0.12", + "npm:nodemailer@^10.0.13": "10.0.13", "npm:playwright@^1.63.0": "1.63.0" }, "jsr": { @@ -18,8 +18,8 @@ } }, "npm": { - "nodemailer@10.0.12": { - "integrity": "sha512-PQ46oNbNMuH/Sno7B5IWIU9etytwrO0xdTFvWXpIQFRXhBAXtzD48243AS+BCbQQTPD8y3fqUrgLn0tSo77gvg==" + "nodemailer@10.0.13": { + "integrity": "sha512-SzG86OlvcW/NNhUFC6uROMwRTL4n7MswfQqC/T8mhkmnY1YVa23zUEMYi4ijSeXSl9GLz9ZeTJDUatEDuY5FeQ==" }, "playwright-core@1.63.0": { "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", @@ -36,7 +36,7 @@ "workspace": { "dependencies": [ "jsr:@std/assert@^1.0.19", - "npm:nodemailer@^10.0.12", + "npm:nodemailer@^10.0.13", "npm:playwright@^1.63.0" ] } diff --git a/e2e/Dockerfile b/e2e/Dockerfile index 78c4c2b..d1666d8 100644 --- a/e2e/Dockerfile +++ b/e2e/Dockerfile @@ -3,7 +3,7 @@ FROM denoland/deno:2.9.7 WORKDIR /app COPY deno.json deno.lock ./ -COPY mod.ts service.ts types.ts cookies.ts authorization.ts ./ +COPY mod.ts service.ts types.ts cookies.ts authorization.ts defaults.ts ./ COPY e2e ./e2e RUN deno cache e2e/app.ts diff --git a/e2e/app.ts b/e2e/app.ts index 6175cb4..63c75e4 100644 --- a/e2e/app.ts +++ b/e2e/app.ts @@ -1,4 +1,5 @@ -import nodemailer from "nodemailer"; +// deno-lint-ignore no-import-prefix +import nodemailer from "npm:nodemailer@^10.0.10"; import { DenoKvMagicLinkAuth } from "../mod.ts"; import type { MagicLinkAuthUser } from "../types.ts"; @@ -123,7 +124,10 @@ Deno.serve({ port: PORT }, async (request, info) => { redirectTo: body?.redirectTo, ...requestContext(request, info), }); - return json(issued, { status: issued.sent ? 200 : 401 }); + return json( + { issued: issued.issued, sent: issued.sent }, + { status: issued.sent ? 200 : 401 }, + ); } if ( diff --git a/e2e/browser_test.ts b/e2e/browser_test.ts index 6feb812..002a263 100644 --- a/e2e/browser_test.ts +++ b/e2e/browser_test.ts @@ -1,5 +1,6 @@ import { assert, assertEquals } from "@std/assert"; -import { chromium } from "playwright"; +// deno-lint-ignore no-import-prefix +import { chromium } from "npm:playwright@^1.63.0"; import { buildBindingClearCookie, diff --git a/e2e/e2e_test.ts b/e2e/e2e_test.ts index b3404be..c26a66d 100644 --- a/e2e/e2e_test.ts +++ b/e2e/e2e_test.ts @@ -90,7 +90,7 @@ Deno.test("e2e request and verify flow delivers mail through SMTP and marks supe ); assertEquals(requestResponse.status, 200); - assertEquals(await requestResponse.json(), { sent: true }); + assertEquals(await requestResponse.json(), { issued: true, sent: true }); const message = await findLatestMessage("admin@example.com"); const verificationUrl = extractVerificationUrl(message); @@ -133,7 +133,7 @@ Deno.test("e2e allowlist and failed-attempt throttling are enforced", async () = }), }); assertEquals(allowed.status, 200); - assertEquals(await allowed.json(), { sent: true }); + assertEquals(await allowed.json(), { issued: true, sent: true }); const message = await findLatestMessage("special@outside.example"); assertMatch(message.Text ?? "", /token=/); diff --git a/hardening_test.ts b/hardening_test.ts new file mode 100644 index 0000000..95a8b68 --- /dev/null +++ b/hardening_test.ts @@ -0,0 +1,981 @@ +import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert"; + +import { + buildSessionSetCookie, + buildVerifyResponseHeaders, + DenoKvMagicLinkAuth, + getCookie, + hasRole, +} from "./mod.ts"; +import type { + MagicLinkAuthUser, + SendMailPayload, + SessionRecord, +} from "./types.ts"; + +const NOW = new Date("2026-10-02T12:00:00.000Z"); + +function createUser( + overrides: Partial = {}, +): MagicLinkAuthUser { + return { + id: "user-1", + email: "admin@example.com", + authVersion: 1, + active: true, + role: "admin", + ...overrides, + }; +} + +async function withKv(run: (kv: Deno.Kv) => Promise | void) { + const kv = await Deno.openKv(":memory:"); + try { + await run(kv); + } finally { + kv.close(); + } +} + +function createAuth( + kv: Deno.Kv, + options: { + user?: MagicLinkAuthUser; + config?: ConstructorParameters[0]; + onSendMail?: (payload: SendMailPayload) => { ok: boolean; error?: string }; + users?: MagicLinkAuthUser[]; + } = {}, +) { + let user = options.user ?? createUser(); + const users = options.users; + const auth = new DenoKvMagicLinkAuth( + { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + ...options.config, + }, + { + kv, + now: () => new Date(NOW), + findUserByEmail: (email) => { + if (users) { + return Promise.resolve( + users.find((entry) => entry.email.toLowerCase() === email) ?? null, + ); + } + return Promise.resolve( + user.email.trim().toLowerCase() === email ? user : null, + ); + }, + findUserById: (id) => { + if (users) { + return Promise.resolve( + users.find((entry) => entry.id === id) ?? null, + ); + } + return Promise.resolve(user.id === id ? user : null); + }, + sendMail: options.onSendMail + ? (payload) => Promise.resolve(options.onSendMail!(payload)) + : undefined, + }, + ); + return { + auth, + updateUser(changes: Partial) { + user = { ...user, ...changes }; + }, + }; +} + +async function tokenFrom(auth: DenoKvMagicLinkAuth, input: { + email?: string; + redirectTo?: string; + requestIp?: string; + userAgent?: string; + bindingSecret?: string; +} = {}) { + const issued = await auth.issueMagicLink({ + email: input.email ?? "admin@example.com", + redirectTo: input.redirectTo, + requestIp: input.requestIp ?? "203.0.113.10", + userAgent: input.userAgent ?? "Browser", + bindingSecret: input.bindingSecret, + }); + assert(issued.debugUrl, issued.error); + const token = new URL(issued.debugUrl).searchParams.get("token"); + assert(token); + return { issued, token }; +} + +Deno.test("appBaseUrl rejects credentials, queries, and hashes", async () => { + await withKv((kv) => { + for ( + const appBaseUrl of [ + "https://user:secret@app.example.com", + "https://app.example.com?x=1", + "https://app.example.com#frag", + ] + ) { + assertThrows( + () => { + new DenoKvMagicLinkAuth({ appBaseUrl }, { + kv, + findUserByEmail: () => Promise.resolve(null), + findUserById: () => Promise.resolve(null), + }); + }, + Error, + "appBaseUrl", + ); + } + }); +}); + +Deno.test("verification links join a base path and a custom verify path", async () => { + await withKv(async (kv) => { + const { auth } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com/base", + magicLinkVerifyPath: "/sign-in", + authDevExposeMagicLink: true, + }, + }); + const { issued } = await tokenFrom(auth); + assertEquals( + issued.debugUrl?.split("?")[0], + "https://app.example.com/base/sign-in", + ); + assertEquals(auth.magicLinkVerifyPath, "/base/sign-in"); + }); +}); + +Deno.test("a new magic link supersedes the previous unconsumed link", async () => { + await withKv(async (kv) => { + const { auth } = createAuth(kv); + const first = await tokenFrom(auth); + const second = await tokenFrom(auth); + assertEquals( + await auth.verifyMagicLink({ + token: first.token, + requestIp: "203.0.113.10", + userAgent: "browser", + }), + null, + ); + assert( + await auth.verifyMagicLink({ + token: second.token, + requestIp: "203.0.113.10", + userAgent: "browser", + }), + ); + }); +}); + +Deno.test("a stored binding secret is required even when IP and user agent match", async () => { + await withKv(async (kv) => { + const { auth } = createAuth(kv); + const { token } = await tokenFrom(auth, { + bindingSecret: "binding-secret-value", + }); + assertEquals( + await auth.verifyMagicLink({ + token, + requestIp: "203.0.113.10", + userAgent: "browser", + bindingSecret: "wrong-binding-secret", + }), + null, + ); + const verified = await auth.verifyMagicLink({ + token, + requestIp: "198.51.100.8", + userAgent: "other-browser", + bindingSecret: "binding-secret-value", + }); + assert(verified); + }); +}); + +Deno.test("live sessions end when the user is disabled, changes email, or loses super-admin", async () => { + await withKv(async (kv) => { + const { auth, updateUser } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + initialSuperAdminEmail: "admin@example.com", + }, + }); + const { token } = await tokenFrom(auth); + const verified = await auth.verifyMagicLink({ + token, + requestIp: "203.0.113.10", + userAgent: "browser", + }); + assert(verified); + assertEquals( + (await auth.getSession(verified.sessionId))?.isSuperAdmin, + true, + ); + + updateUser({ isSuperAdmin: false }); + assertEquals(await auth.getSession(verified.sessionId), null); + + const second = await tokenFrom(auth); + const again = await auth.verifyMagicLink({ + token: second.token, + requestIp: "203.0.113.10", + userAgent: "browser", + }); + assert(again); + updateUser({ active: false }); + assertEquals(await auth.getSession(again.sessionId), null); + const sessions = await Array.fromAsync( + kv.list({ prefix: ["dka", "sessions"] }), + ); + assertEquals(sessions.length, 0); + }); +}); + +Deno.test("credential and permission version changes revoke the stored session", async () => { + await withKv(async (kv) => { + const { auth, updateUser } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + rbac: { + enabled: true, + roles: { admin: ["dashboard:read"] }, + permissionsVersion: 1, + }, + }, + }); + const { token } = await tokenFrom(auth); + const verified = await auth.verifyMagicLink({ + token, + requestIp: "203.0.113.10", + userAgent: "browser", + }); + assert(verified); + updateUser({ authVersion: 2 }); + assertEquals(await auth.getSession(verified.sessionId), null); + + updateUser({ authVersion: 1 }); + const nextLink = await tokenFrom(auth); + const next = await auth.verifyMagicLink({ + token: nextLink.token, + requestIp: "203.0.113.10", + userAgent: "browser", + }); + assert(next); + const { auth: upgraded } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + rbac: { + enabled: true, + roles: { admin: ["dashboard:read"] }, + permissionsVersion: 2, + }, + }, + }); + assertEquals(await upgraded.getSession(next.sessionId), null); + }); +}); + +Deno.test("session ids are not stored as KV keys and revokeUserSessions clears them", async () => { + await withKv(async (kv) => { + const { auth } = createAuth(kv); + const first = await tokenFrom(auth); + const firstSession = await auth.verifyMagicLink({ + token: first.token, + requestIp: "203.0.113.10", + userAgent: "browser", + }); + assert(firstSession); + const second = await tokenFrom(auth); + const secondSession = await auth.verifyMagicLink({ + token: second.token, + requestIp: "203.0.113.10", + userAgent: "browser", + }); + assert(secondSession); + + const raw = await kv.get(["dka", "sessions", firstSession.sessionId]); + assertEquals(raw.value, null); + const stored = await Array.fromAsync( + kv.list({ prefix: ["dka", "sessions"] }), + ); + assertEquals(stored.length, 2); + assert(stored.every((entry) => entry.key[2] !== firstSession.sessionId)); + + await auth.revokeUserSessions("user-1"); + assertEquals(await auth.getSession(firstSession.sessionId), null); + assertEquals(await auth.getSession(secondSession.sessionId), null); + }); +}); + +Deno.test("IP literals share one rate-limit bucket and a missing IP fails closed", async () => { + await withKv(async (kv) => { + const { auth } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + failedAuthRateLimitMaxAttempts: 1, + }, + }); + await auth.issueMagicLink({ + email: "missing@example.com", + requestIp: "2001:DB8::1", + userAgent: "Browser", + }); + const mixedCase = await auth.issueMagicLink({ + email: "admin@example.com", + requestIp: "2001:db8::1", + userAgent: "Browser", + }); + assertEquals(mixedCase.error, "rate_limited"); + const keys = await Array.fromAsync( + kv.list({ prefix: ["dka", "failed_auth_attempts"] }), + ); + assertEquals(keys.map((entry) => entry.key[2]), ["2001:db8::1"]); + + const { auth: mapped } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + failedAuthRateLimitMaxAttempts: 1, + keyPrefix: "mapped", + }, + }); + await mapped.issueMagicLink({ + email: "missing@example.com", + requestIp: "203.0.113.010", + userAgent: "Browser", + }); + const canonical = await mapped.issueMagicLink({ + email: "admin@example.com", + requestIp: "::ffff:203.0.113.10", + userAgent: "Browser", + }); + assertEquals(canonical.error, "rate_limited"); + + const missingIp = await auth.issueMagicLink({ + email: "admin@example.com", + userAgent: "Browser", + }); + assertEquals(missingIp.issued, false); + assertEquals(missingIp.debugUrl, undefined); + }); +}); + +Deno.test("allowlist rejects ambiguous addresses and a mismatched directory email", async () => { + await withKv(async (kv) => { + const sent: string[] = []; + const { auth } = createAuth(kv, { + user: createUser({ email: "a@b@example.com" }), + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + allowedEmailPatterns: ["*@example.com"], + }, + onSendMail: (payload) => { + sent.push(payload.to); + return { ok: true }; + }, + }); + const ambiguous = await auth.issueMagicLink({ + email: "a@b@example.com", + requestIp: "203.0.113.10", + userAgent: "Browser", + }); + assertEquals(ambiguous.issued, false); + + const relayed = await new DenoKvMagicLinkAuth({ + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + allowedEmailPatterns: ["*@example.com"], + sendEmailInDebugMode: true, + }, { + kv, + findUserByEmail: (email) => + Promise.resolve( + email === "admin@example.com" + ? createUser({ email: "attacker@evil.test" }) + : null, + ), + findUserById: () => Promise.resolve(null), + sendMail: (payload) => { + sent.push(payload.to); + return Promise.resolve({ ok: true }); + }, + }).issueMagicLink({ + email: "admin@example.com", + requestIp: "203.0.113.44", + userAgent: "Browser", + }); + assertEquals(relayed.issued, false); + assertEquals(sent, []); + }); +}); + +Deno.test("mail failures keep the delivery error and do not leave a hidden live link", async () => { + await withKv(async (kv) => { + const { auth } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: false, + appName: "A\nB ", + }, + onSendMail: () => ({ ok: false, error: "smtp down" }), + }); + const hidden = await auth.issueMagicLink({ + email: "admin@example.com", + requestIp: "203.0.113.10", + userAgent: "Browser", + }); + assertEquals(hidden, { + issued: false, + sent: false, + error: "smtp down", + }); + assertEquals( + (await Array.fromAsync(kv.list({ prefix: ["dka", "magic_links"] }))) + .length, + 0, + ); + + const payloads: SendMailPayload[] = []; + const { auth: debugAuth } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + sendEmailInDebugMode: true, + appName: "A\nB ", + renderMagicLinkEmail: ({ appName, verificationUrl, ttlMinutes }) => ({ + subject: `${appName} login`, + text: verificationUrl, + html: `${ttlMinutes}`, + }), + }, + onSendMail: (payload) => { + payloads.push(payload); + return { ok: false, error: "smtp down" }; + }, + }); + const visible = await debugAuth.issueMagicLink({ + email: "admin@example.com", + requestIp: "203.0.113.11", + userAgent: "Browser", + }); + assertEquals(visible.issued, true); + assertEquals(visible.sent, false); + assertEquals(visible.error, "smtp down"); + assert(visible.debugUrl); + assertEquals(payloads[0]?.subject.includes("\n"), false); + assert(payloads[0]?.text.includes("token=")); + }); +}); + +Deno.test("successful send and failed verification budgets are enforced", async () => { + await withKv(async (kv) => { + const { auth } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + sendRateLimitMaxPerEmail: 2, + failedAuthRateLimitMaxAttempts: 2, + }, + }); + assert((await tokenFrom(auth)).issued.issued); + assert((await tokenFrom(auth)).issued.issued); + const limited = await auth.issueMagicLink({ + email: "admin@example.com", + requestIp: "203.0.113.10", + userAgent: "Browser", + }); + assertEquals(limited.error, "rate_limited"); + assertEquals(limited.debugUrl, undefined); + + const { auth: verifyAuth } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + failedAuthRateLimitMaxAttempts: 2, + keyPrefix: "verify-budget", + }, + }); + const { token } = await tokenFrom(verifyAuth); + for (const attempt of ["bad-token-one", "bad-token-two"]) { + assertEquals( + await verifyAuth.verifyMagicLink({ + token: attempt, + requestIp: "203.0.113.80", + userAgent: "Browser", + }), + null, + ); + } + assertEquals( + await verifyAuth.verifyMagicLink({ + token, + requestIp: "203.0.113.80", + userAgent: "browser", + }), + null, + ); + const blocked = await kv.get([ + "verify-budget", + "failed_verify_attempts", + "203.0.113.80", + ]); + assert(blocked.value); + }); +}); + +Deno.test("an unknown RBAC role does not throw or leave a usable link", async () => { + await withKv(async (kv) => { + const { auth } = createAuth(kv, { + user: createUser({ role: "nope" }), + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + rbac: { enabled: true, roles: { admin: ["dashboard:read"] } }, + }, + }); + const issued = await auth.issueMagicLink({ + email: "admin@example.com", + requestIp: "203.0.113.10", + userAgent: "Browser", + }); + assertEquals(issued.issued, false); + assertEquals( + (await Array.fromAsync(kv.list({ prefix: ["dka", "magic_links"] }))) + .length, + 0, + ); + + const { auth: changing, updateUser } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + rbac: { enabled: true, roles: { admin: ["dashboard:read"] } }, + keyPrefix: "rbac-change", + }, + }); + const { token } = await tokenFrom(changing); + updateUser({ role: "nope" }); + assertEquals( + await changing.verifyMagicLink({ + token, + requestIp: "203.0.113.10", + userAgent: "browser", + }), + null, + ); + assertEquals( + (await Array.fromAsync(kv.list({ prefix: ["rbac-change", "sessions"] }))) + .length, + 0, + ); + }); +}); + +Deno.test("users without a role are not treated as viewers", async () => { + await withKv(async (kv) => { + const { auth } = createAuth(kv, { + user: createUser({ role: undefined }), + }); + const { token } = await tokenFrom(auth); + const verified = await auth.verifyMagicLink({ + token, + requestIp: "203.0.113.10", + userAgent: "browser", + }); + assert(verified); + const session = await auth.getSession(verified.sessionId); + assert(session); + assertEquals(session.role, ""); + assertEquals(hasRole(session, "viewer"), false); + }); +}); + +Deno.test("configuration rejects duplicate roles, inverted TTLs, and short binding secrets", async () => { + await withKv(async (kv) => { + assertThrows( + () => { + new DenoKvMagicLinkAuth({ + appBaseUrl: "https://app.example.com", + sessionIdleTtlDays: 30, + sessionAbsoluteTtlDays: 7, + }, { + kv, + findUserByEmail: () => Promise.resolve(null), + findUserById: () => Promise.resolve(null), + }); + }, + Error, + "sessionIdleTtlDays must not exceed sessionAbsoluteTtlDays.", + ); + assertThrows( + () => { + new DenoKvMagicLinkAuth({ + appBaseUrl: "https://app.example.com", + rbac: { + enabled: true, + roles: { Admin: ["a:one"], admin: ["b:two"] }, + }, + }, { + kv, + findUserByEmail: () => Promise.resolve(null), + findUserById: () => Promise.resolve(null), + }); + }, + Error, + 'rbac role "admin" is declared more than once.', + ); + const { auth } = createAuth(kv); + await assertRejects( + () => + auth.issueMagicLink({ + email: "admin@example.com", + requestIp: "203.0.113.10", + userAgent: "Browser", + bindingSecret: "too-short", + }), + Error, + "bindingSecret must be between 16 and 512 characters.", + ); + }); +}); + +Deno.test("a link without the current-user pointer cannot be consumed", async () => { + await withKv(async (kv) => { + const { auth } = createAuth(kv); + const { token } = await tokenFrom(auth); + for await ( + const entry of kv.list({ prefix: ["dka", "magic_link_current"] }) + ) { + await kv.delete(entry.key); + } + assertEquals( + await auth.verifyMagicLink({ + token, + requestIp: "203.0.113.10", + userAgent: "browser", + }), + null, + ); + }); +}); + +Deno.test("failed-attempt writes stop after the retry cap", async () => { + await withKv(async (kv) => { + let commits = 0; + const wrapped = new Proxy(kv, { + get(target, property, receiver) { + if (property === "atomic") { + return () => { + const atomic = target.atomic(); + atomic.commit = () => { + commits += 1; + return Promise.resolve({ ok: false }); + }; + return atomic; + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const { auth } = createAuth(wrapped); + const started = Date.now(); + const result = await auth.issueMagicLink({ + email: "missing@example.com", + requestIp: "203.0.113.70", + userAgent: "Browser", + }); + assertEquals(result.issued, false); + assert(Date.now() - started < 1_000); + assert(commits > 0); + assert(commits <= 8); + }); +}); + +Deno.test("sessions fail closed when stored state or the live user is no longer valid", async () => { + await withKv(async (kv) => { + let now = new Date(NOW); + const user = createUser({ isSuperAdmin: false }); + const auth = new DenoKvMagicLinkAuth({ + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + initialSuperAdminEmail: "admin@example.com", + }, { + kv, + now: () => now, + findUserByEmail: () => Promise.resolve(user), + findUserById: () => Promise.resolve(user), + }); + const { token } = await tokenFrom(auth); + const verified = await auth.verifyMagicLink({ + token, + requestIp: "203.0.113.10", + userAgent: "browser", + }); + assert(verified); + assertEquals(verified.user.isSuperAdmin, false); + const session = await auth.getSession(verified.sessionId); + assert(session); + assertEquals(session.isSuperAdmin, false); + assertEquals(session.authorization, undefined); + now = new Date(now.getTime() + 60_000); + assertEquals( + (await auth.getSession(verified.sessionId))?.idleExpiresAt, + session.idleExpiresAt, + ); + + const stored = await Array.fromAsync( + kv.list({ prefix: ["dka", "sessions"] }), + ); + assertEquals(stored.length, 1); + await kv.set(stored[0].key, { + ...stored[0].value, + authorization: { + role: "admin", + permissions: ["secret:read"], + permissionsVersion: 1, + }, + }); + assertEquals( + (await auth.getSession(verified.sessionId))?.authorization, + undefined, + ); + + await kv.set(stored[0].key, { + ...stored[0].value, + revokedAt: NOW.toISOString(), + }); + assertEquals(await auth.getSession(verified.sessionId), null); + assertEquals( + (await Array.fromAsync(kv.list({ prefix: ["dka", "sessions"] }))).length, + 0, + ); + assertEquals( + (await Array.fromAsync(kv.list({ prefix: ["dka", "user_sessions"] }))) + .length, + 0, + ); + + const again = await auth.verifyMagicLink({ + token: (await tokenFrom(auth)).token, + requestIp: "203.0.113.10", + userAgent: "browser", + }); + assert(again); + const rows = await Array.fromAsync( + kv.list({ prefix: ["dka", "sessions"] }), + ); + await kv.set(rows[0].key, { ...rows[0].value, idleExpiresAt: "" }); + assertEquals(await auth.getSession(again.sessionId), null); + }); +}); + +Deno.test("delivery and verification keep the remaining review boundaries", async () => { + await withKv(async (kv) => { + const sent: string[] = []; + const { auth } = createAuth(kv, { + user: createUser({ email: "Admin@Example.com" }), + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + sendEmailInDebugMode: true, + allowedEmailPatterns: ["*@example.com"], + failedAuthRateLimitMaxAttempts: 100, + renderMagicLinkEmail: () => ({ + subject: "Hello\r\nBcc: attacker@evil.test", + text: "missing link", + html: "

missing link

", + }), + }, + onSendMail: (payload) => { + sent.push(`${payload.to}|${payload.subject}|${payload.text}`); + return { ok: true }; + }, + }); + + for ( + const email of [ + "admin/root@example.com", + "admin:root@example.com", + "ad min@example.com", + "user@sub.example.com", + "user@example.com.evil", + ] + ) { + assertEquals( + (await auth.issueMagicLink({ + email, + requestIp: "203.0.113.10", + userAgent: "Browser", + })).issued, + false, + email, + ); + } + + const issued = await auth.issueMagicLink({ + email: "admin@example.com", + requestIp: "203.0.113.10", + userAgent: "Browser", + redirectTo: `https://evil.example/${"a".repeat(2048)}`, + }); + assert(issued.debugUrl); + assertEquals(sent[0]?.split("|")[0], "admin@example.com"); + assert(sent[0]?.includes("Your login link")); + assertEquals(sent[0]?.includes("Bcc:"), false); + assert(sent[0]?.includes(issued.debugUrl)); + const verified = await auth.verifyMagicLink({ + token: new URL(issued.debugUrl).searchParams.get("token") ?? "", + requestIp: "203.0.113.10", + userAgent: "browser", + }); + assertEquals(verified?.redirectTo, "/admin/dashboard"); + + assertEquals( + (await auth.issueMagicLink({ + email: "missing@example.com", + requestIp: "203.0.113.90", + userAgent: "Browser", + })).issued, + false, + ); + assertEquals( + (await Array.fromAsync(kv.list({ prefix: ["dka", "send_budget_ip"] }))) + .some((entry) => entry.key[2] === "203.0.113.90"), + false, + ); + + assertEquals( + (await auth.issueMagicLink({ + email: "admin@example.com", + requestIp: "203.0.113.91", + userAgent: "x".repeat(1025), + })).issued, + false, + ); + + const bound = await tokenFrom(auth, { + bindingSecret: "binding-secret-value", + requestIp: "203.0.113.92", + }); + assertEquals( + await auth.verifyMagicLink({ + token: bound.token, + requestIp: "203.0.113.92", + userAgent: "browser", + bindingSecret: "short", + }), + null, + ); + assertEquals( + await auth.verifyMagicLink({ + token: " ", + requestIp: "203.0.113.93", + userAgent: "browser", + }), + null, + ); + assertEquals( + (await Array.fromAsync(kv.list({ + prefix: ["dka", "failed_verify_attempts", "203.0.113.93"], + }))).length, + 0, + ); + + const sanitized: string[] = []; + const { auth: custom } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + sendEmailInDebugMode: true, + keyPrefix: "custom-mail", + renderMagicLinkEmail: ({ verificationUrl }) => ({ + subject: "Sign in\r\nBcc: attacker@evil.test", + text: verificationUrl, + html: `Sign in`, + }), + }, + onSendMail: (payload) => { + sanitized.push(payload.subject); + return { ok: true }; + }, + }); + assert((await tokenFrom(custom)).issued.issued); + assertEquals(sanitized[0], "Sign in Bcc: attacker@evil.test"); + + const { auth: failing } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: false, + keyPrefix: "mail-error", + }, + onSendMail: () => ({ ok: false, error: "smtp\r\ndown" }), + }); + assertEquals( + (await failing.issueMagicLink({ + email: "admin@example.com", + requestIp: "203.0.113.94", + userAgent: "Browser", + })).error, + "smtp down", + ); + + const { auth: zoned } = createAuth(kv, { + config: { + appBaseUrl: "https://app.example.com", + authDevExposeMagicLink: true, + failedAuthRateLimitMaxAttempts: 1, + keyPrefix: "zone", + }, + }); + await zoned.issueMagicLink({ + email: "missing@example.com", + requestIp: "2001:db8::5%eth0", + }); + assertEquals( + (await zoned.issueMagicLink({ + email: "admin@example.com", + requestIp: "2001:DB8::5", + userAgent: "Browser", + })).error, + "rate_limited", + ); + }); +}); + +Deno.test("cookie defaults match the effective session lifetime and reject ambiguous cookies", () => { + const sessionCookie = buildSessionSetCookie("session-id"); + assert(sessionCookie.startsWith("__Host-session=")); + assert(sessionCookie.includes("Max-Age=604800")); + const headers = buildVerifyResponseHeaders("https://app.example.com/admin", [ + sessionCookie, + ]); + assertEquals(headers.get("referrer-policy"), "no-referrer"); + assertEquals(headers.get("cache-control"), "no-store"); + assertEquals( + getCookie(new Headers({ cookie: "session=one; session=two" }), "session"), + null, + ); + const emptyRole: SessionRecord = { + userId: "user-1", + userEmail: "admin@example.com", + role: "", + isSuperAdmin: false, + authVersion: 1, + createdAt: NOW.toISOString(), + idleExpiresAt: NOW.toISOString(), + absoluteExpiresAt: NOW.toISOString(), + }; + assertEquals(hasRole(emptyRole, "viewer"), false); +}); diff --git a/mod.ts b/mod.ts index 79dd2e1..ca130cb 100644 --- a/mod.ts +++ b/mod.ts @@ -1,8 +1,18 @@ +export { + BINDING_SECRET_MAX_LENGTH, + BINDING_SECRET_MIN_LENGTH, + DEFAULT_MAGIC_LINK_VERIFY_PATH, + DEFAULT_SESSION_ABSOLUTE_TTL_DAYS, + DEFAULT_SESSION_COOKIE_TTL_DAYS, + DEFAULT_SESSION_IDLE_TTL_DAYS, +} from "./defaults.ts"; + export { buildBindingClearCookie, buildBindingSetCookie, buildSessionClearCookie, buildSessionSetCookie, + buildVerifyResponseHeaders, getCookie, type MagicLinkCookieConfig, } from "./cookies.ts"; @@ -22,12 +32,15 @@ export type { DenoKvMagicLinkAuthDeps, FailedAuthAttemptRecord, MagicLinkAuthUser, + MagicLinkEmailContent, + MagicLinkEmailContext, MagicLinkIssueInput, MagicLinkIssueResult, MagicLinkRbacConfig, MagicLinkRecord, MagicLinkVerifyInput, MagicLinkVerifyResult, + RenderMagicLinkEmail, SendMailFn, SendMailPayload, SendMailResult, diff --git a/mod_test.ts b/mod_test.ts index 841c71a..b2ed2fb 100644 --- a/mod_test.ts +++ b/mod_test.ts @@ -45,13 +45,11 @@ function createNow(start = "2026-03-20T10:00:00.000Z") { } async function withTestKv(fn: (kv: Deno.Kv) => Promise) { - const path = await Deno.makeTempFile({ suffix: ".sqlite3" }); - const kv = await Deno.openKv(path); + const kv = await Deno.openKv(":memory:"); try { await fn(kv); } finally { kv.close(); - await Deno.remove(path); } } @@ -104,7 +102,7 @@ Deno.test("issueMagicLink normalizes email and exposes debug URL in debug mode", redirectTo: "/dashboard", requestIp: " 127.0.0.1 ", userAgent: " Firefox ", - bindingSecret: " cookie-secret ", + bindingSecret: " binding-secret-value ", }); assertEquals(result.sent, false); @@ -117,7 +115,7 @@ Deno.test("issueMagicLink normalizes email and exposes debug URL in debug mode", token, requestIp: "127.0.0.1", userAgent: "firefox", - bindingSecret: "cookie-secret", + bindingSecret: "binding-secret-value", }); assert(verified); @@ -160,6 +158,7 @@ Deno.test("issueMagicLink only allows configured email addresses and domains", a const allowedByDomain = await auth.issueMagicLink({ email: DEFAULT_USER.email, requestIp: "10.0.0.5", + userAgent: "Browser", }); const blocked = await auth.issueMagicLink({ email: "blocked@outside.example", @@ -168,6 +167,7 @@ Deno.test("issueMagicLink only allows configured email addresses and domains", a const allowedByExactAddress = await auth.issueMagicLink({ email: "allowed@other.example", requestIp: "10.0.0.6", + userAgent: "Browser", }); assert(allowedByDomain.debugUrl); @@ -342,7 +342,7 @@ Deno.test("sendMail is used when enabled and issueMagicLink reports sent=true", assert(result.debugUrl); const payload = sentPayloads[0]; assert(payload); - assertEquals(payload.to, DEFAULT_USER.email); + assertEquals(payload.to, "admin@example.com"); assertMatch(payload.subject, /Synit Auth/); }); }); @@ -488,10 +488,8 @@ Deno.test("authorization helpers fail closed for legacy sessions without an RBAC isSuperAdmin: false, authVersion: 1, createdAt: "2026-03-20T10:00:00.000Z", - lastSeenAt: "2026-03-20T10:00:00.000Z", idleExpiresAt: "2026-03-21T10:00:00.000Z", absoluteExpiresAt: "2026-03-22T10:00:00.000Z", - revokedAt: null, }; assertEquals(hasRole(session, "admin"), true); @@ -516,10 +514,8 @@ Deno.test("super admins bypass RBAC permission checks", () => { permissionsVersion: 1, }, createdAt: "2026-03-20T10:00:00.000Z", - lastSeenAt: "2026-03-20T10:00:00.000Z", idleExpiresAt: "2026-03-21T10:00:00.000Z", absoluteExpiresAt: "2026-03-22T10:00:00.000Z", - revokedAt: null, }; assertEquals(hasRole(session, "admin"), true); diff --git a/security_test.ts b/security_test.ts index ba4b464..9638c6e 100644 --- a/security_test.ts +++ b/security_test.ts @@ -39,6 +39,8 @@ function createAuth( const auth = new DenoKvMagicLinkAuth({ appBaseUrl: APP_BASE_URL, authDevExposeMagicLink: true, + sendRateLimitMaxPerEmail: 100, + sendRateLimitMaxPerIp: 100, ...config, }, { kv, diff --git a/service.ts b/service.ts index 667594d..996e5d8 100644 --- a/service.ts +++ b/service.ts @@ -1,8 +1,17 @@ +import { + BINDING_SECRET_MAX_LENGTH, + BINDING_SECRET_MIN_LENGTH, + DEFAULT_MAGIC_LINK_VERIFY_PATH, + DEFAULT_SESSION_ABSOLUTE_TTL_DAYS, + DEFAULT_SESSION_IDLE_TTL_DAYS, +} from "./defaults.ts"; import type { DenoKvMagicLinkAuthConfig, DenoKvMagicLinkAuthDeps, FailedAuthAttemptRecord, MagicLinkAuthUser, + MagicLinkEmailContent, + MagicLinkEmailContext, MagicLinkIssueInput, MagicLinkIssueResult, MagicLinkRbacConfig, @@ -13,12 +22,69 @@ import type { SessionRecord, } from "./types.ts"; +const EMAIL_MAX_LENGTH = 320; +const TOKEN_MAX_LENGTH = 512; +const USER_AGENT_MAX_LENGTH = 1024; +const REDIRECT_MAX_LENGTH = 2048; +const IP_MAX_LENGTH = 128; +const USER_ID_MAX_LENGTH = 256; +const KEY_PREFIX_MAX_LENGTH = 64; +const APP_NAME_MAX_LENGTH = 120; +const ERROR_MAX_LENGTH = 300; +const VERIFY_PATH_MAX_LENGTH = 256; +const RATE_LIMIT_ATTEMPTS = 8; +const MAGIC_LINK_TTL_MINUTES_MAX = 10_080; +const SESSION_TTL_DAYS_MAX = 3_650; +const RATE_WINDOW_MINUTES_MAX = 10_080; +const RATE_ATTEMPTS_MAX = 1_000; +const USED_LINK_RETENTION_MS = 24 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; + +interface NormalizedRbacConfig { + enabled: boolean; + roles: Readonly>; + defaultRole: string | null; + permissionsVersion: number; +} + +interface InternalConfig { + appBaseUrl: string; + appName: string; + magicLinkVerifyPath: string; + magicLinkTtlMinutes: number; + sessionIdleTtlDays: number; + sessionAbsoluteTtlDays: number; + authDevExposeMagicLink: boolean; + sendEmailInDebugMode: boolean; + allowedEmailPatterns: string[]; + initialSuperAdminEmail: string; + failedAuthRateLimitMaxAttempts: number; + failedAuthRateLimitWindowMinutes: number; + failedAuthRateLimitBlockMinutes: number; + sendRateLimitMaxPerEmail: number; + sendRateLimitMaxPerIp: number; + sendRateLimitWindowMinutes: number; + keyPrefix: string; + rbac: NormalizedRbacConfig; + renderMagicLinkEmail?: DenoKvMagicLinkAuthConfig["renderMagicLinkEmail"]; +} + +interface WindowCounterRecord { + count: number; + windowStartedAt: string; +} + +interface StoredLink { + token: string; + tokenHash: string; +} + function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } function normalizeOptionalString(value?: string | null): string | null { - if (!value) return null; + if (typeof value !== "string") return null; const normalized = value.trim(); return normalized || null; } @@ -44,11 +110,26 @@ function randomToken(bytes = 32): string { async function sha256Hex(value: string): Promise { const bytes = new TextEncoder().encode(value); const hash = await crypto.subtle.digest("SHA-256", bytes); - return Array.from(new Uint8Array(hash)).map((b) => - b.toString(16).padStart(2, "0") + return Array.from(new Uint8Array(hash)).map((byte) => + byte.toString(16).padStart(2, "0") ).join(""); } +function constantTimeEqual(left: string, right: string): boolean { + const a = new TextEncoder().encode(left); + const b = new TextEncoder().encode(right); + const length = Math.max(a.length, b.length, 1); + let diff = a.length ^ b.length; + for (let index = 0; index < length; index++) { + diff |= (a[index] ?? 0) ^ (b[index] ?? 0); + } + return diff === 0; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function assertPositiveInteger(value: number, label: string): number { if (!Number.isInteger(value) || value <= 0) { throw new Error(`${label} must be a positive integer.`); @@ -56,30 +137,62 @@ function assertPositiveInteger(value: number, label: string): number { return value; } -function assertOptionalEmailPattern( - value: string, +function assertBoundedInteger( + value: number, label: string, -): string { + max: number, +): number { + const integer = assertPositiveInteger(value, label); + if (integer > max) { + throw new Error(`${label} must be at most ${max}.`); + } + return integer; +} + +function isDomain(domain: string): boolean { + if (!domain || !domain.includes(".") || domain.includes("*")) return false; + if ( + domain.startsWith(".") || domain.endsWith(".") || domain.includes("..") + ) { + return false; + } + if (domain.includes("/") || domain.includes(":") || /\s/.test(domain)) { + return false; + } + return true; +} + +function splitEmail( + value: string, +): { local: string; domain: string } | null { + const normalized = normalizeEmail(value); + if ( + !normalized || normalized.length > EMAIL_MAX_LENGTH || + normalized.includes("*") || /[\s/:]/.test(normalized) + ) return null; + const at = normalized.indexOf("@"); + if (at <= 0 || at !== normalized.lastIndexOf("@")) return null; + const local = normalized.slice(0, at); + const domain = normalized.slice(at + 1); + if (!local || !isDomain(domain)) return null; + return { local, domain }; +} + +function assertOptionalEmailPattern(value: string, label: string): string { const normalized = normalizeEmail(value); if (!normalized) { throw new Error(`${label} entries must not be empty.`); } if (normalized.startsWith("*@")) { const domain = normalized.slice(2); - if (!domain || domain.includes("*") || !domain.includes(".")) { + if (!isDomain(domain)) { throw new Error( `${label} wildcard entries must use the format "*@domain.tld".`, ); } return `*@${domain}`; } - if (normalized.includes("*")) { - throw new Error( - `${label} entries must be exact email addresses or "*@domain.tld".`, - ); - } - const atIndex = normalized.indexOf("@"); - if (atIndex <= 0 || atIndex === normalized.length - 1) { + if (!splitEmail(normalized)) { throw new Error( `${label} entries must be exact email addresses or "*@domain.tld".`, ); @@ -89,22 +202,54 @@ function assertOptionalEmailPattern( function assertEmailAddress(value: string, label: string): string { const normalized = normalizeEmail(value); - const atIndex = normalized.indexOf("@"); - if ( - !normalized || normalized.includes("*") || atIndex <= 0 || - atIndex === normalized.length - 1 - ) { + if (!splitEmail(normalized)) { throw new Error(`${label} must be an exact email address.`); } return normalized; } function assertAppBaseUrl(value: string): string { - const url = new URL(value); + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("appBaseUrl must use http or https."); + } if (!/^https?:$/.test(url.protocol)) { throw new Error("appBaseUrl must use http or https."); } - return url.toString().replace(/\/$/, ""); + if (url.username || url.password) { + throw new Error("appBaseUrl must not include credentials."); + } + if (url.search || url.hash) { + throw new Error("appBaseUrl must not include a query or hash."); + } + return `${url.origin}${url.pathname.replace(/\/$/, "")}`; +} + +function assertVerifyPath(value: string, appBaseUrl: string): string { + if ( + value.length > VERIFY_PATH_MAX_LENGTH || !value.startsWith("/") || + value.startsWith("//") || /[\s?#\\]/.test(value) + ) { + throw new Error( + "magicLinkVerifyPath must be a same-origin absolute path.", + ); + } + const base = new URL( + appBaseUrl.endsWith("/") ? appBaseUrl : `${appBaseUrl}/`, + ); + const resolved = new URL(value.replace(/^\/+/, ""), base); + if (resolved.origin !== base.origin || resolved.search || resolved.hash) { + throw new Error( + "magicLinkVerifyPath must be a same-origin absolute path.", + ); + } + const basePath = base.pathname; + if (basePath !== "/" && !resolved.pathname.startsWith(basePath)) { + throw new Error("magicLinkVerifyPath escaped the application base path."); + } + return resolved.pathname; } function sanitizeRedirectTo( @@ -114,7 +259,7 @@ function sanitizeRedirectTo( const fallback = "/admin/dashboard"; if (!value) return fallback; const trimmed = value.trim(); - if (!trimmed) return fallback; + if (!trimmed || trimmed.length > REDIRECT_MAX_LENGTH) return fallback; try { const appUrl = new URL(appBaseUrl); @@ -129,30 +274,6 @@ function sanitizeRedirectTo( } } -interface NormalizedRbacConfig { - enabled: boolean; - roles: Readonly>; - defaultRole: string | null; - permissionsVersion: number; -} - -interface InternalConfig { - appBaseUrl: string; - appName: string; - magicLinkTtlMinutes: number; - sessionIdleTtlDays: number; - sessionAbsoluteTtlDays: number; - authDevExposeMagicLink: boolean; - sendEmailInDebugMode: boolean; - allowedEmailPatterns: string[]; - initialSuperAdminEmail: string; - failedAuthRateLimitMaxAttempts: number; - failedAuthRateLimitWindowMinutes: number; - failedAuthRateLimitBlockMinutes: number; - keyPrefix: string; - rbac: NormalizedRbacConfig; -} - function assertNonEmptyKey(value: string, label: string): string { const normalized = normalizeKey(value); if (!normalized) { @@ -177,6 +298,11 @@ function assertRbacConfig( Record >((acc, [role, permissions]) => { const normalizedRole = assertNonEmptyKey(role, "rbac role"); + if (Object.hasOwn(acc, normalizedRole)) { + throw new Error( + `rbac role "${normalizedRole}" is declared more than once.`, + ); + } if (!Array.isArray(permissions) || permissions.length === 0) { throw new Error( `rbac role "${normalizedRole}" must define at least one permission.`, @@ -212,13 +338,158 @@ function assertRbacConfig( enabled: true, roles: Object.freeze(normalizedRoles), defaultRole, - permissionsVersion: assertPositiveInteger( + permissionsVersion: assertBoundedInteger( value.permissionsVersion ?? 1, "rbac.permissionsVersion", + RATE_ATTEMPTS_MAX, ), }; } +function canonicalIpv4(value: string): string | null { + const parts = value.split("."); + if (parts.length !== 4) return null; + const octets: number[] = []; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const octet = Number(part); + if (octet > 255) return null; + octets.push(octet); + } + return octets.join("."); +} + +function canonicalIpv6(value: string): string | null { + const input = value.toLowerCase(); + const halves = input.split("::"); + if (halves.length > 2) return null; + + const parseSide = (side: string): number[] | null => { + if (!side) return []; + const parts = side.split(":"); + const groups: number[] = []; + for (let index = 0; index < parts.length; index++) { + const part = parts[index]; + if (part.includes(".")) { + if (index !== parts.length - 1) return null; + const dotted = canonicalIpv4(part); + if (!dotted) return null; + const [a, b, c, d] = dotted.split(".").map(Number); + groups.push((a << 8) | b, (c << 8) | d); + continue; + } + if (!/^[0-9a-f]{1,4}$/.test(part)) return null; + groups.push(Number.parseInt(part, 16)); + } + return groups; + }; + + let groups: number[]; + if (halves.length === 2) { + const left = parseSide(halves[0]); + const right = parseSide(halves[1]); + if (!left || !right) return null; + const missing = 8 - left.length - right.length; + if (missing < 1) return null; + groups = [...left, ...new Array(missing).fill(0), ...right]; + } else { + const full = parseSide(input); + if (!full || full.length !== 8) return null; + groups = full; + } + if (groups.length !== 8) return null; + + const mapped = groups.slice(0, 5).every((group) => group === 0) && + groups[5] === 0xffff; + if (mapped) { + const high = groups[6]; + const low = groups[7]; + return [ + (high >> 8) & 255, + high & 255, + (low >> 8) & 255, + low & 255, + ].join("."); + } + + let bestStart = -1; + let bestLength = 0; + let runStart = -1; + for (let index = 0; index <= groups.length; index++) { + if (index < groups.length && groups[index] === 0) { + if (runStart < 0) runStart = index; + continue; + } + if (runStart < 0) continue; + const length = index - runStart; + if (length > bestLength) { + bestStart = runStart; + bestLength = length; + } + runStart = -1; + } + + const parts = groups.map((group) => group.toString(16)); + if (bestLength < 2) return parts.join(":"); + const left = parts.slice(0, bestStart).join(":"); + const right = parts.slice(bestStart + bestLength).join(":"); + if (!left && !right) return "::"; + if (!left) return `::${right}`; + if (!right) return `${left}::`; + return `${left}::${right}`; +} + +function canonicalizeIp(value?: string | null): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > IP_MAX_LENGTH) return null; + const withoutZone = trimmed.split("%", 1)[0] ?? ""; + if (!withoutZone) return null; + if (withoutZone.includes(":")) return canonicalIpv6(withoutZone); + return canonicalIpv4(withoutZone); +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function sanitizeHeader(value: string): string { + return value.replace(/[\r\n]+/g, " ").trim().slice(0, 200); +} + +function sanitizeError(value: string | undefined, fallback: string): string { + const cleaned = (value ?? fallback).replace(/[\r\n]+/g, " ").trim(); + return (cleaned || fallback).slice(0, ERROR_MAX_LENGTH); +} + +function defaultMagicLinkEmail( + context: MagicLinkEmailContext, +): MagicLinkEmailContent { + const safeUrl = escapeHtml(context.verificationUrl); + return { + subject: sanitizeHeader(`${context.appName}: Your login link`), + text: + `Use this link to sign in:\n\n${context.verificationUrl}\n\nThis link expires in ${context.ttlMinutes} minutes.`, + html: + `

Use this link to sign in:

Sign in

This link expires in ${context.ttlMinutes} minutes.

`, + }; +} + +function timestampExpired(value: string | undefined, nowMs: number): boolean { + const parsed = Date.parse(value ?? ""); + return !Number.isFinite(parsed) || parsed <= nowMs; +} + +function denied(error?: string): MagicLinkIssueResult { + return error + ? { issued: false, sent: false, error } + : { issued: false, sent: false }; +} + /** Deno KV backed magic-link authentication service for server-side Deno applications. */ export class DenoKvMagicLinkAuth { private config: InternalConfig; @@ -229,21 +500,46 @@ export class DenoKvMagicLinkAuth { config: DenoKvMagicLinkAuthConfig, deps: DenoKvMagicLinkAuthDeps, ) { + const appBaseUrl = assertAppBaseUrl(config.appBaseUrl); + const sessionIdleTtlDays = assertBoundedInteger( + config.sessionIdleTtlDays ?? DEFAULT_SESSION_IDLE_TTL_DAYS, + "sessionIdleTtlDays", + SESSION_TTL_DAYS_MAX, + ); + const sessionAbsoluteTtlDays = assertBoundedInteger( + config.sessionAbsoluteTtlDays ?? DEFAULT_SESSION_ABSOLUTE_TTL_DAYS, + "sessionAbsoluteTtlDays", + SESSION_TTL_DAYS_MAX, + ); + if (sessionIdleTtlDays > sessionAbsoluteTtlDays) { + throw new Error( + "sessionIdleTtlDays must not exceed sessionAbsoluteTtlDays.", + ); + } + const keyPrefix = normalizeOptionalString(config.keyPrefix) ?? "dka"; + if (keyPrefix.length > KEY_PREFIX_MAX_LENGTH) { + throw new Error( + `keyPrefix must be at most ${KEY_PREFIX_MAX_LENGTH} characters.`, + ); + } + this.config = { - appBaseUrl: assertAppBaseUrl(config.appBaseUrl), - appName: config.appName ?? "App", - magicLinkTtlMinutes: assertPositiveInteger( + appBaseUrl, + appName: sanitizeHeader(config.appName ?? "App").slice( + 0, + APP_NAME_MAX_LENGTH, + ) || "App", + magicLinkVerifyPath: assertVerifyPath( + config.magicLinkVerifyPath ?? DEFAULT_MAGIC_LINK_VERIFY_PATH, + appBaseUrl, + ), + magicLinkTtlMinutes: assertBoundedInteger( config.magicLinkTtlMinutes ?? 15, "magicLinkTtlMinutes", + MAGIC_LINK_TTL_MINUTES_MAX, ), - sessionIdleTtlDays: assertPositiveInteger( - config.sessionIdleTtlDays ?? 7, - "sessionIdleTtlDays", - ), - sessionAbsoluteTtlDays: assertPositiveInteger( - config.sessionAbsoluteTtlDays ?? 30, - "sessionAbsoluteTtlDays", - ), + sessionIdleTtlDays, + sessionAbsoluteTtlDays, authDevExposeMagicLink: config.authDevExposeMagicLink ?? false, sendEmailInDebugMode: config.sendEmailInDebugMode ?? false, allowedEmailPatterns: Array.from( @@ -259,38 +555,82 @@ export class DenoKvMagicLinkAuth { "initialSuperAdminEmail", ) : "", - failedAuthRateLimitMaxAttempts: assertPositiveInteger( + failedAuthRateLimitMaxAttempts: assertBoundedInteger( config.failedAuthRateLimitMaxAttempts ?? 5, "failedAuthRateLimitMaxAttempts", + RATE_ATTEMPTS_MAX, ), - failedAuthRateLimitWindowMinutes: assertPositiveInteger( + failedAuthRateLimitWindowMinutes: assertBoundedInteger( config.failedAuthRateLimitWindowMinutes ?? 15, "failedAuthRateLimitWindowMinutes", + RATE_WINDOW_MINUTES_MAX, ), - failedAuthRateLimitBlockMinutes: assertPositiveInteger( + failedAuthRateLimitBlockMinutes: assertBoundedInteger( config.failedAuthRateLimitBlockMinutes ?? 15, "failedAuthRateLimitBlockMinutes", + RATE_WINDOW_MINUTES_MAX, ), - keyPrefix: normalizeOptionalString(config.keyPrefix) ?? "dka", + sendRateLimitMaxPerEmail: assertBoundedInteger( + config.sendRateLimitMaxPerEmail ?? 10, + "sendRateLimitMaxPerEmail", + RATE_ATTEMPTS_MAX, + ), + sendRateLimitMaxPerIp: assertBoundedInteger( + config.sendRateLimitMaxPerIp ?? 30, + "sendRateLimitMaxPerIp", + RATE_ATTEMPTS_MAX, + ), + sendRateLimitWindowMinutes: assertBoundedInteger( + config.sendRateLimitWindowMinutes ?? 15, + "sendRateLimitWindowMinutes", + RATE_WINDOW_MINUTES_MAX, + ), + keyPrefix, rbac: assertRbacConfig(config.rbac), + renderMagicLinkEmail: config.renderMagicLinkEmail, }; this.deps = deps; } + /** Verify pathname joined onto `appBaseUrl`, including a configured base path. */ + get magicLinkVerifyPath(): string { + return this.config.magicLinkVerifyPath; + } + + /** + * Cookie lifetime in seconds. This is the earlier of the fixed idle and + * absolute deadlines, measured from issuance. + */ + sessionCookieMaxAgeSeconds(): number { + return Math.floor(this.sessionLifetimeMs() / 1000); + } + private now(): Date { return this.deps.now ? this.deps.now() : new Date(); } + private sessionLifetimeMs(): number { + const days = Math.min( + this.config.sessionIdleTtlDays, + this.config.sessionAbsoluteTtlDays, + ); + return days * DAY_MS; + } + private key(scope: string, id: string): Deno.KvKey { return [this.config.keyPrefix, scope, id]; } + private userSessionKey(userId: string, sessionHash: string): Deno.KvKey { + return [this.config.keyPrefix, "user_sessions", userId, sessionHash]; + } + private isEmailAllowed(email: string): boolean { + const parsed = splitEmail(email); + if (!parsed) return false; if (this.config.allowedEmailPatterns.length === 0) return true; return this.config.allowedEmailPatterns.some((pattern) => { - if (pattern.startsWith("*@")) { - return email.endsWith(pattern.slice(1)); - } + if (pattern.startsWith("*@")) return parsed.domain === pattern.slice(2); return email === pattern; }); } @@ -316,18 +656,14 @@ export class DenoKvMagicLinkAuth { private resolveAuthorization( user: MagicLinkAuthUser, - ): SessionAuthorizationSnapshot | undefined { + ): SessionAuthorizationSnapshot | undefined | null { if (!this.config.rbac.enabled) return undefined; // Resolve RBAC state once during login so later permission checks do not - // require additional KV or database lookups. + // require additional KV lookups. A null result rejects the login. const configuredRole = user.role ? normalizeKey(user.role) : ""; const effectiveRole = configuredRole || this.config.rbac.defaultRole || ""; - if (!effectiveRole || !this.config.rbac.roles[effectiveRole]) { - throw new Error( - "Authenticated user does not resolve to a configured RBAC role.", - ); - } + if (!effectiveRole || !this.config.rbac.roles[effectiveRole]) return null; return { role: effectiveRole, @@ -337,11 +673,11 @@ export class DenoKvMagicLinkAuth { } private async getFailedAttemptState( - requestIp: string | null, - ): Promise | null> { - if (!requestIp) return null; + scope: string, + requestIp: string, + ): Promise> { return await this.deps.kv.get( - this.key("failed_auth_attempts", requestIp), + this.key(scope, requestIp), { consistency: "strong" }, ); } @@ -355,19 +691,18 @@ export class DenoKvMagicLinkAuth { } private async registerFailedAttempt( - requestIp: string | null, + scope: string, + requestIp: string, entry: Deno.KvEntryMaybe | null, now: Date, ): Promise { - if (!requestIp) return; - - const key = this.key("failed_auth_attempts", requestIp); + const key = this.key(scope, requestIp); const windowMs = this.config.failedAuthRateLimitWindowMinutes * 60 * 1000; const blockMs = this.config.failedAuthRateLimitBlockMinutes * 60 * 1000; let current = entry; let attemptTime = now; - while (true) { + for (let attempt = 0; attempt < RATE_LIMIT_ATTEMPTS; attempt++) { // A delayed failure must not overwrite a block established by another // request. Recompute from fresh state after every conflicting write. if (this.isBlockedAttempt(current, attemptTime)) return; @@ -386,240 +721,592 @@ export class DenoKvMagicLinkAuth { const tx = await this.deps.kv.atomic() .check({ key, versionstamp: current?.versionstamp ?? null }) - .set(key, record, { - expireIn: Math.max(windowMs, blockMs), - }) + .set(key, record, { expireIn: Math.max(windowMs, blockMs) }) .commit(); if (tx.ok) return; - current = await this.getFailedAttemptState(requestIp); + await delay(Math.min(5 * 2 ** attempt, 40)); + current = await this.getFailedAttemptState(scope, requestIp); attemptTime = this.now(); } } + private async reserveWindowCounter( + scope: string, + id: string, + max: number, + ): Promise { + const key = this.key(scope, id); + let current = await this.deps.kv.get(key, { + consistency: "strong", + }); + const windowMs = this.config.sendRateLimitWindowMinutes * 60 * 1000; + + for (let attempt = 0; attempt < RATE_LIMIT_ATTEMPTS; attempt++) { + const now = this.now(); + const fresh = !current.value || + Date.parse(current.value.windowStartedAt) <= now.getTime() - windowMs; + if (!fresh && current.value && current.value.count >= max) return false; + const record: WindowCounterRecord = { + count: fresh ? 1 : (current.value?.count ?? 0) + 1, + windowStartedAt: fresh + ? now.toISOString() + : current.value?.windowStartedAt ?? now.toISOString(), + }; + const tx = await this.deps.kv.atomic() + .check({ key, versionstamp: current.versionstamp }) + .set(key, record, { expireIn: windowMs }) + .commit(); + if (tx.ok) return true; + await delay(Math.min(5 * 2 ** attempt, 40)); + current = await this.deps.kv.get(key, { + consistency: "strong", + }); + } + return false; + } + + private async reserveSendBudget( + requestIp: string, + email: string, + ): Promise { + const emailKey = await sha256Hex(email); + const emailAllowed = await this.reserveWindowCounter( + "send_budget_email", + emailKey, + this.config.sendRateLimitMaxPerEmail, + ); + if (!emailAllowed) return false; + return await this.reserveWindowCounter( + "send_budget_ip", + requestIp, + this.config.sendRateLimitMaxPerIp, + ); + } + + private bindingSecretForIssue( + value: string | null | undefined, + ): string | null { + if (value == null) return null; + if (typeof value !== "string") { + throw new Error( + `bindingSecret must be between ${BINDING_SECRET_MIN_LENGTH} and ${BINDING_SECRET_MAX_LENGTH} characters.`, + ); + } + const secret = value.trim(); + if ( + secret.length < BINDING_SECRET_MIN_LENGTH || + secret.length > BINDING_SECRET_MAX_LENGTH + ) { + throw new Error( + `bindingSecret must be between ${BINDING_SECRET_MIN_LENGTH} and ${BINDING_SECRET_MAX_LENGTH} characters.`, + ); + } + return secret; + } + + private verificationUrl(token: string): string { + // The stored path is absolute on the origin, so it replaces any base path. + const url = new URL( + this.config.magicLinkVerifyPath, + this.config.appBaseUrl, + ); + url.searchParams.set("token", token); + return url.toString(); + } + + private emailContent(verificationUrl: string): MagicLinkEmailContent { + const context: MagicLinkEmailContext = { + appName: this.config.appName, + verificationUrl, + ttlMinutes: this.config.magicLinkTtlMinutes, + }; + const fallback = defaultMagicLinkEmail(context); + const rendered = this.config.renderMagicLinkEmail?.(context); + if (!rendered) return fallback; + const text = typeof rendered.text === "string" ? rendered.text : ""; + const html = typeof rendered.html === "string" ? rendered.html : ""; + const includesUrl = text.includes(verificationUrl) || + html.includes(verificationUrl) || + html.includes(escapeHtml(verificationUrl)); + if (!includesUrl) return fallback; + return { + subject: sanitizeHeader(rendered.subject || "") || fallback.subject, + text: text || fallback.text, + html: html || fallback.html, + }; + } + + private async storeMagicLink( + user: MagicLinkAuthUser, + recordWithoutTimes: Omit, + ): Promise { + const pointerKey = this.key("magic_link_current", user.id); + const ttlMs = this.config.magicLinkTtlMinutes * 60 * 1000; + let pointer = await this.deps.kv.get(pointerKey, { + consistency: "strong", + }); + + for (let attempt = 0; attempt < RATE_LIMIT_ATTEMPTS; attempt++) { + const token = randomToken(); + const tokenHash = await sha256Hex(token); + const issuedAt = this.now(); + const record: MagicLinkRecord = { + ...recordWithoutTimes, + createdAt: issuedAt.toISOString(), + expiresAt: new Date(issuedAt.getTime() + ttlMs).toISOString(), + }; + const tx = this.deps.kv.atomic().check({ + key: pointerKey, + versionstamp: pointer.versionstamp, + }); + if (pointer.value) tx.delete(this.key("magic_links", pointer.value)); + const committed = await tx + .set(pointerKey, tokenHash, { expireIn: ttlMs }) + .set(this.key("magic_links", tokenHash), record, { expireIn: ttlMs }) + .commit(); + if (committed.ok) return { token, tokenHash }; + await delay(Math.min(5 * 2 ** attempt, 40)); + pointer = await this.deps.kv.get(pointerKey, { + consistency: "strong", + }); + } + return null; + } + + private async deleteLinkIfCurrent( + userId: string, + tokenHash: string, + ): Promise { + const pointerKey = this.key("magic_link_current", userId); + const pointer = await this.deps.kv.get(pointerKey, { + consistency: "strong", + }); + if (pointer.value !== tokenHash) return; + await this.deps.kv.atomic() + .check({ key: pointerKey, versionstamp: pointer.versionstamp }) + .delete(pointerKey) + .delete(this.key("magic_links", tokenHash)) + .commit(); + } + /** Issues a one-time magic link for an active user and stores its verification record in Deno KV. */ async issueMagicLink( input: MagicLinkIssueInput, ): Promise { - const normalizedEmail = normalizeEmail(input.email); - const requestIp = normalizeOptionalString(input.requestIp); - if (!normalizedEmail) { - return { sent: false }; - } + const requestIp = canonicalizeIp(input.requestIp); + if (!requestIp) return denied(); + + const bindingSecret = this.bindingSecretForIssue(input.bindingSecret); + const rawUserAgent = typeof input.userAgent === "string" + ? input.userAgent + : ""; + const userAgent = rawUserAgent.length > USER_AGENT_MAX_LENGTH + ? null + : normalizeUserAgent(rawUserAgent); const now = this.now(); - const failedAttemptEntry = await this.getFailedAttemptState(requestIp); + const failedAttemptEntry = await this.getFailedAttemptState( + "failed_auth_attempts", + requestIp, + ); if (this.isBlockedAttempt(failedAttemptEntry, now)) { - return { sent: false }; + return denied("rate_limited"); } - if (!this.isEmailAllowed(normalizedEmail)) { - await this.registerFailedAttempt(requestIp, failedAttemptEntry, now); - return { sent: false }; + const normalizedEmail = splitEmail( + typeof input.email === "string" ? input.email : "", + ); + if (!normalizedEmail) { + await this.registerFailedAttempt( + "failed_auth_attempts", + requestIp, + failedAttemptEntry, + now, + ); + return denied(); } + const email = `${normalizedEmail.local}@${normalizedEmail.domain}`; - const user = await this.deps.findUserByEmail(normalizedEmail); - if (!user || !user.active) { - await this.registerFailedAttempt(requestIp, failedAttemptEntry, now); - return { sent: false }; + if (!this.isEmailAllowed(email)) { + await this.registerFailedAttempt( + "failed_auth_attempts", + requestIp, + failedAttemptEntry, + now, + ); + return denied(); } - const token = randomToken(); - const tokenHash = await sha256Hex(token); - const nowIso = now.toISOString(); - const ttlMs = this.config.magicLinkTtlMinutes * 60 * 1000; + const user = await this.deps.findUserByEmail(email); + const userEmail = user ? normalizeEmail(user.email) : ""; + if ( + !user || !user.active || typeof user.id !== "string" || !user.id || + user.id.length > USER_ID_MAX_LENGTH || userEmail !== email || + !this.isEmailAllowed(userEmail) || + typeof user.authVersion !== "number" || !Number.isFinite(user.authVersion) + ) { + await this.registerFailedAttempt( + "failed_auth_attempts", + requestIp, + failedAttemptEntry, + now, + ); + return denied(); + } - const normalizedUa = normalizeUserAgent(input.userAgent); - const issuedUserAgentHash = normalizedUa - ? await sha256Hex(normalizedUa) - : null; - const normalizedBindingSecret = normalizeOptionalString( - input.bindingSecret, - ); - const bindingHash = normalizedBindingSecret - ? await sha256Hex(normalizedBindingSecret) - : null; + const resolvedUser = this.resolveUser(user); + if (this.resolveAuthorization(resolvedUser) === null) return denied(); + // A link with neither a binding secret nor a user agent can never be verified. + if (!bindingSecret && !userAgent) return denied(); - const record: MagicLinkRecord = { + if (!await this.reserveSendBudget(requestIp, email)) { + return denied("rate_limited"); + } + + const redirectTo = typeof input.redirectTo === "string" && + input.redirectTo.length <= REDIRECT_MAX_LENGTH + ? input.redirectTo + : undefined; + const issuedUserAgentHash = userAgent ? await sha256Hex(userAgent) : null; + const bindingHash = bindingSecret ? await sha256Hex(bindingSecret) : null; + const stored = await this.storeMagicLink(user, { userId: user.id, - emailNormalized: normalizeEmail(user.email), + emailNormalized: email, authVersion: user.authVersion, - createdAt: nowIso, - expiresAt: new Date(now.getTime() + ttlMs).toISOString(), usedAt: null, - redirectTo: sanitizeRedirectTo(input.redirectTo, this.config.appBaseUrl), + redirectTo: sanitizeRedirectTo(redirectTo, this.config.appBaseUrl), issuedFromIp: requestIp, issuedUserAgentHash, bindingHash, - }; - - await this.deps.kv.set(this.key("magic_links", tokenHash), record, { - expireIn: ttlMs, }); + if (!stored) return denied("rate_limited"); - const verificationUrl = - `${this.config.appBaseUrl}/api/auth/magic-link/verify?token=${ - encodeURIComponent(token) - }`; - - if ( - this.config.authDevExposeMagicLink && !this.config.sendEmailInDebugMode - ) { - return { sent: false, debugUrl: verificationUrl }; + const verificationUrl = this.verificationUrl(stored.token); + const exposeDebugUrl = this.config.authDevExposeMagicLink; + if (exposeDebugUrl && !this.config.sendEmailInDebugMode) { + return { issued: true, sent: false, debugUrl: verificationUrl }; } const sendMail = this.deps.sendMail; if (!sendMail) { + if (!exposeDebugUrl) { + await this.deleteLinkIfCurrent(user.id, stored.tokenHash); + return denied("mail_not_configured"); + } return { + issued: true, sent: false, - debugUrl: this.config.authDevExposeMagicLink - ? verificationUrl - : undefined, + debugUrl: verificationUrl, + error: "mail_not_configured", }; } - const result = await sendMail({ - to: user.email, - subject: `${this.config.appName}: Dein Login-Link`, - text: - `Bitte nutze diesen Link fur den Login:\n\n${verificationUrl}\n\nDieser Link ist zeitlich begrenzt.`, - html: - `

Bitte nutze diesen Link fur den Login:

Jetzt einloggen

Dieser Link ist zeitlich begrenzt.

`, - }); + let delivery; + try { + delivery = await sendMail({ + to: email, + ...this.emailContent(verificationUrl), + }); + } catch (error) { + delivery = { + ok: false, + error: error instanceof Error ? error.message : "mail_failed", + }; + } + if (!delivery.ok) { + if (!exposeDebugUrl) { + await this.deleteLinkIfCurrent(user.id, stored.tokenHash); + return { + issued: false, + sent: false, + error: sanitizeError(delivery.error, "mail_failed"), + }; + } + return { + issued: true, + sent: false, + debugUrl: verificationUrl, + error: sanitizeError(delivery.error, "mail_failed"), + }; + } return { - sent: result.ok, - debugUrl: this.config.authDevExposeMagicLink - ? verificationUrl - : undefined, + issued: true, + sent: true, + debugUrl: exposeDebugUrl ? verificationUrl : undefined, }; } + private async recordVerifyFailure( + requestIp: string, + entry: Deno.KvEntryMaybe, + now: Date, + ): Promise { + await this.registerFailedAttempt( + "failed_verify_attempts", + requestIp, + entry, + now, + ); + return null; + } + /** Verifies and consumes a magic link, then creates a session when the verification context matches. */ async verifyMagicLink( input: MagicLinkVerifyInput, ): Promise { - const token = input.token.trim(); - if (!token) return null; + const requestIp = canonicalizeIp(input.requestIp); + if (!requestIp) return null; - const tokenHash = await sha256Hex(token); - const linkEntry = await this.deps.kv.get( - this.key("magic_links", tokenHash), - { consistency: "strong" }, + const now = this.now(); + const failedAttemptEntry = await this.getFailedAttemptState( + "failed_verify_attempts", + requestIp, ); - if (!linkEntry.value) return null; + if (this.isBlockedAttempt(failedAttemptEntry, now)) return null; - const contextUa = normalizeUserAgent(input.userAgent); - const contextUserAgentHash = contextUa ? await sha256Hex(contextUa) : null; - const normalizedBindingSecret = normalizeOptionalString( - input.bindingSecret, - ); - const contextBindingHash = normalizedBindingSecret - ? await sha256Hex(normalizedBindingSecret) - : null; - const requestIp = normalizeOptionalString(input.requestIp); + const token = typeof input.token === "string" ? input.token.trim() : ""; + if (!token || token.length > TOKEN_MAX_LENGTH) { + if (!token) return null; + return await this.recordVerifyFailure( + requestIp, + failedAttemptEntry, + now, + ); + } - const cookieMatch = Boolean( - linkEntry.value.bindingHash && contextBindingHash && - linkEntry.value.bindingHash === contextBindingHash, - ); - const ipMatch = Boolean( - linkEntry.value.issuedFromIp && requestIp && - linkEntry.value.issuedFromIp === requestIp, - ); - const userAgentMatch = Boolean( - linkEntry.value.issuedUserAgentHash && contextUserAgentHash && - linkEntry.value.issuedUserAgentHash === contextUserAgentHash, - ); + const rawUserAgent = typeof input.userAgent === "string" + ? input.userAgent + : ""; + if (rawUserAgent.length > USER_AGENT_MAX_LENGTH) { + return await this.recordVerifyFailure( + requestIp, + failedAttemptEntry, + now, + ); + } + const tokenHash = await sha256Hex(token); + const linkKey = this.key("magic_links", tokenHash); + const linkEntry = await this.deps.kv.get(linkKey, { + consistency: "strong", + }); + if ( + !linkEntry.value || linkEntry.value.userId.length > USER_ID_MAX_LENGTH + ) { + return await this.recordVerifyFailure( + requestIp, + failedAttemptEntry, + now, + ); + } - if (!cookieMatch && !(ipMatch && userAgentMatch)) { - return null; + const pointerKey = this.key("magic_link_current", linkEntry.value.userId); + const pointer = await this.deps.kv.get(pointerKey, { + consistency: "strong", + }); + if (pointer.value !== tokenHash) { + return await this.recordVerifyFailure( + requestIp, + failedAttemptEntry, + now, + ); } - const now = this.now(); - const nowMs = now.getTime(); + const bindingSecret = normalizeOptionalString(input.bindingSecret); + const bindingAccepted = bindingSecret !== null && + bindingSecret.length >= BINDING_SECRET_MIN_LENGTH && + bindingSecret.length <= BINDING_SECRET_MAX_LENGTH; + const contextBindingHash = bindingAccepted && bindingSecret + ? await sha256Hex(bindingSecret) + : null; + const contextUserAgentHash = normalizeUserAgent(rawUserAgent); + const contextUserAgentDigest = contextUserAgentHash + ? await sha256Hex(contextUserAgentHash) + : null; + const link = linkEntry.value; + const bindingMatch = Boolean( + link.bindingHash && contextBindingHash && + constantTimeEqual(link.bindingHash, contextBindingHash), + ); + const contextMatch = link.bindingHash ? bindingMatch : Boolean( + link.issuedFromIp && + constantTimeEqual(link.issuedFromIp, requestIp) && + link.issuedUserAgentHash && contextUserAgentDigest && + constantTimeEqual(link.issuedUserAgentHash, contextUserAgentDigest), + ); if ( - linkEntry.value.usedAt || Date.parse(linkEntry.value.expiresAt) <= nowMs + !contextMatch || link.usedAt || + timestampExpired(link.expiresAt, now.getTime()) ) { - return null; + return await this.recordVerifyFailure( + requestIp, + failedAttemptEntry, + now, + ); } - const user = await this.deps.findUserById(linkEntry.value.userId); - if (!user || !user.active) return null; - if ( - typeof linkEntry.value.authVersion !== "number" || - linkEntry.value.authVersion !== user.authVersion || - linkEntry.value.emailNormalized !== normalizeEmail(user.email) || - !this.isEmailAllowed(normalizeEmail(user.email)) - ) return null; - const resolvedUser = this.resolveUser(user); + const user = await this.deps.findUserById(link.userId); + const userEmail = user ? normalizeEmail(user.email) : ""; + const identityMatches = Boolean( + user && user.active && userEmail === link.emailNormalized && + this.isEmailAllowed(userEmail) && + typeof link.authVersion === "number" && + user.authVersion === link.authVersion, + ); + const resolvedUser = identityMatches && user + ? this.resolveUser(user) + : null; + const authorization = resolvedUser + ? this.resolveAuthorization(resolvedUser) + : null; + if (!resolvedUser || authorization === null) { + await this.deps.kv.atomic() + .check({ key: linkKey, versionstamp: linkEntry.versionstamp }) + .check({ key: pointerKey, versionstamp: pointer.versionstamp }) + .set(linkKey, { ...link, usedAt: now.toISOString() }, { + expireIn: USED_LINK_RETENTION_MS, + }) + .commit(); + return null; + } - // The session stores only a minimal authorization snapshot so request-time - // RBAC checks can stay in memory after a single session load. - const authorization = this.resolveAuthorization(resolvedUser); const sessionId = randomToken(24); + const sessionHash = await sha256Hex(sessionId); + const nowMs = now.getTime(); const session: SessionRecord = { userId: resolvedUser.id, userEmail: resolvedUser.email, - role: authorization?.role ?? resolvedUser.role ?? "viewer", + role: authorization?.role ?? resolvedUser.role ?? "", isSuperAdmin: resolvedUser.isSuperAdmin ?? false, authVersion: resolvedUser.authVersion, authorization, createdAt: now.toISOString(), - lastSeenAt: now.toISOString(), - idleExpiresAt: new Date( - nowMs + this.config.sessionIdleTtlDays * 24 * 60 * 60 * 1000, - ).toISOString(), + idleExpiresAt: new Date(nowMs + this.config.sessionIdleTtlDays * DAY_MS) + .toISOString(), absoluteExpiresAt: new Date( - nowMs + this.config.sessionAbsoluteTtlDays * 24 * 60 * 60 * 1000, + nowMs + this.config.sessionAbsoluteTtlDays * DAY_MS, ).toISOString(), - revokedAt: null, }; - + const lifetimeMs = this.sessionLifetimeMs(); const tx = await this.deps.kv.atomic() - .check({ - key: this.key("magic_links", tokenHash), - versionstamp: linkEntry.versionstamp, + .check({ key: linkKey, versionstamp: linkEntry.versionstamp }) + .check({ key: pointerKey, versionstamp: pointer.versionstamp }) + .set(linkKey, { ...link, usedAt: now.toISOString() }, { + expireIn: USED_LINK_RETENTION_MS, }) - .set(this.key("magic_links", tokenHash), { - ...linkEntry.value, - usedAt: now.toISOString(), - }, { expireIn: 24 * 60 * 60 * 1000 }) - .set(this.key("sessions", sessionId), session, { - expireIn: this.config.sessionAbsoluteTtlDays * 24 * 60 * 60 * 1000, + .set(this.key("sessions", sessionHash), session, { expireIn: lifetimeMs }) + .set(this.userSessionKey(resolvedUser.id, sessionHash), true, { + expireIn: lifetimeMs, }) .commit(); - if (!tx.ok) return null; return { sessionId, - redirectTo: sanitizeRedirectTo( - linkEntry.value.redirectTo, - this.config.appBaseUrl, - ), + redirectTo: sanitizeRedirectTo(link.redirectTo, this.config.appBaseUrl), user: resolvedUser, }; } - /** Returns the current session record if it exists and has not expired or been revoked. */ + private presentSession(value: SessionRecord): SessionRecord { + return { + userId: value.userId, + userEmail: value.userEmail, + role: value.role, + isSuperAdmin: value.isSuperAdmin, + authVersion: value.authVersion, + authorization: this.config.rbac.enabled ? value.authorization : undefined, + createdAt: value.createdAt, + idleExpiresAt: value.idleExpiresAt, + absoluteExpiresAt: value.absoluteExpiresAt, + }; + } + + private async deleteStoredSession( + sessionHash: string, + userId: string | undefined, + ): Promise { + await this.deps.kv.delete(this.key("sessions", sessionHash)); + if (userId) { + await this.deps.kv.delete(this.userSessionKey(userId, sessionHash)); + } + } + + /** Returns the current session when it is unexpired and the user is still allowed to authenticate. */ async getSession(sessionId: string): Promise { - const entry = await this.deps.kv.get( - this.key("sessions", sessionId), + if ( + typeof sessionId !== "string" || !sessionId || + sessionId.length > TOKEN_MAX_LENGTH + ) { + return null; + } + const sessionHash = await sha256Hex(sessionId); + const entry = await this.deps.kv.get< + SessionRecord & { revokedAt?: string | null } + >( + this.key("sessions", sessionHash), { consistency: "strong" }, ); if (!entry.value) return null; - // Keep session reads side-effect free so normal authenticated requests stay - // at one KV read and do not turn into read-plus-write traffic. const nowMs = this.now().getTime(); - if (entry.value.revokedAt) return null; - if (Date.parse(entry.value.idleExpiresAt) <= nowMs) return null; - if (Date.parse(entry.value.absoluteExpiresAt) <= nowMs) return null; - return entry.value; + const legacyRevoked = Boolean(entry.value.revokedAt); + const expired = legacyRevoked || + timestampExpired(entry.value.idleExpiresAt, nowMs) || + timestampExpired(entry.value.absoluteExpiresAt, nowMs); + if (expired) { + await this.deleteStoredSession(sessionHash, entry.value.userId); + return null; + } + + const user = await this.deps.findUserById(entry.value.userId); + const resolved = user && user.active ? this.resolveUser(user) : null; + const authorizationCurrent = Boolean( + resolved && resolved.email === entry.value.userEmail && + resolved.authVersion === entry.value.authVersion && + (resolved.isSuperAdmin ?? false) === entry.value.isSuperAdmin, + ); + const permissionsCurrent = !this.config.rbac.enabled || + (entry.value.authorization?.permissionsVersion === + this.config.rbac.permissionsVersion); + if (!resolved || !authorizationCurrent || !permissionsCurrent) { + await this.deleteStoredSession(sessionHash, entry.value.userId); + return null; + } + return this.presentSession(entry.value); } - /** Revokes a session by deleting it from Deno KV. */ + /** Revokes a session by deleting its hashed KV record. */ async revokeSession(sessionId: string): Promise { - await this.deps.kv.delete(this.key("sessions", sessionId)); + if ( + typeof sessionId !== "string" || !sessionId || + sessionId.length > TOKEN_MAX_LENGTH + ) { + return; + } + const sessionHash = await sha256Hex(sessionId); + const entry = await this.deps.kv.get( + this.key("sessions", sessionHash), + ); + await this.deleteStoredSession(sessionHash, entry.value?.userId); + } + + /** Revokes every session previously issued for a user. */ + async revokeUserSessions(userId: string): Promise { + if ( + typeof userId !== "string" || !userId || + userId.length > USER_ID_MAX_LENGTH + ) { + return; + } + const prefix = [this.config.keyPrefix, "user_sessions", userId]; + const entries = this.deps.kv.list({ prefix }); + for await (const entry of entries) { + const sessionHash = entry.key[3]; + if (typeof sessionHash === "string" && sessionHash) { + await this.deps.kv.delete(this.key("sessions", sessionHash)); + } + await this.deps.kv.delete(entry.key); + } } } diff --git a/types.ts b/types.ts index 3e894bd..b3be0d3 100644 --- a/types.ts +++ b/types.ts @@ -53,10 +53,10 @@ export interface SessionRecord { authVersion: number; authorization?: SessionAuthorizationSnapshot; createdAt: string; - lastSeenAt: string; + /** Fixed deadline from issuance. It does not move when the session is read. */ idleExpiresAt: string; + /** Fixed deadline from issuance. The effective lifetime is the earlier of the two deadlines. */ absoluteExpiresAt: string; - revokedAt: string | null; } /** Stored failed-auth state for one originating IP address. */ @@ -87,10 +87,37 @@ export interface MagicLinkVerifyInput { /** Result returned after attempting to issue a magic link. */ export interface MagicLinkIssueResult { + /** A usable link was stored and its raw token is available to mail or `debugUrl`. */ + issued: boolean; + /** Mail delivery succeeded. Debug-only issuance leaves this false. */ sent: boolean; debugUrl?: string; + /** + * Application-facing failure detail, such as `rate_limited` or a mailer error. + * Do not copy this value into a public HTTP response. + */ + error?: string; } +/** Message content produced for a magic-link email. */ +export interface MagicLinkEmailContent { + subject: string; + text: string; + html: string; +} + +/** Values supplied to a custom magic-link email renderer. */ +export interface MagicLinkEmailContext { + appName: string; + verificationUrl: string; + ttlMinutes: number; +} + +/** Optional application renderer for the login email. */ +export type RenderMagicLinkEmail = ( + context: MagicLinkEmailContext, +) => MagicLinkEmailContent; + /** Result returned after a successful magic-link verification. */ export interface MagicLinkVerifyResult { sessionId: string; @@ -129,6 +156,16 @@ export interface DenoKvMagicLinkAuthConfig { failedAuthRateLimitMaxAttempts?: number; failedAuthRateLimitWindowMinutes?: number; failedAuthRateLimitBlockMinutes?: number; + /** Successful login emails allowed for one address in the send window. Defaults to `10`. */ + sendRateLimitMaxPerEmail?: number; + /** Successful login emails allowed for one IP in the send window. Defaults to `30`. */ + sendRateLimitMaxPerIp?: number; + /** Window used by the successful-send limits. Defaults to `15` minutes. */ + sendRateLimitWindowMinutes?: number; + /** Absolute path joined onto `appBaseUrl`. Defaults to `/api/auth/magic-link/verify`. */ + magicLinkVerifyPath?: string; + /** Replaces the built-in English login email. */ + renderMagicLinkEmail?: RenderMagicLinkEmail; keyPrefix?: string; rbac?: MagicLinkRbacConfig; }