authentik-qr-login: cross-device QR sign-in for authentik

Scan a QR on your authentik login page with your phone, approve it with a
passkey and a fingerprint, and the laptop signs itself in.

The service holds NO authentik credential. A flow policy calls it with a
session id and gets back the username that approved it, or nothing -- so there
is no standing credential to steal. The obvious alternative, authentik's
recovery-link API, effectively requires a superuser and was rejected for that
reason.

See README.md for the traps this had to work around, including the two
flow-binding settings that are counter-intuitive and load-bearing, and an
honest account of what QR sign-in cannot defend against.
This commit is contained in:
2026-08-16 17:20:57 +01:00
commit 44061918ac
16 changed files with 2965 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
*.log

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# authentik-qr-login. Build context is services/authentik-qr-login.
#
# Registries are fully qualified: podman enforces short-name resolution and
# fails a non-interactive build with "cannot prompt without a TTY" rather than
# choosing one.
FROM docker.io/library/node:22-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.test.json ./
COPY src ./src
COPY test ./test
# Typecheck and test INSIDE the image build. The service is outside the root
# tsconfig's include, so nothing else would check it — and an image that
# compiles but fails its own tests should not reach a registry.
RUN npm run typecheck
RUN npm test
RUN npm run build
# Drop devDependencies from what gets copied forward.
RUN npm prune --omit=dev
FROM docker.io/library/node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /src/node_modules ./node_modules
COPY --from=build /src/dist ./dist
COPY --from=build /src/package.json ./package.json
# The node image ships a `node` user at uid 1000; the Deployment pins the same
# uid with a read-only root filesystem.
USER node
EXPOSE 8080
CMD ["node", "dist/index.js"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Michal
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

173
README.md Normal file
View File

@@ -0,0 +1,173 @@
# authentik-qr-login
Scan a QR code on your laptop's login page with your phone, approve it with a
passkey and a fingerprint, and the laptop signs itself in. No typing on the
laptop, no password anywhere.
authentik has no such feature — there is no QR login and no push/number-match
stage — and this adds one **without forking authentik and without holding any
authentik credential**.
```
LAPTOP THIS SERVICE PHONE
sso.example.com/if/flow/… sso.example.com/qr-login/
┌──────────────────────┐
│ authentik login page │ POST /api/session
│ ┌────────────────┐ │──────────────► id + approval token + number
│ │ ▄▄▄▄▄ ▄ ▄▄ │ │
│ │ █ ▄ █ ▀█▄ ▀█ │ │ GET /a/:token ◄──── scan
│ │ █▄▄▄█ █ ▄▀▄█ │ │ │
│ │ 27 │ │ passkey + fingerprint
│ └────────────────┘ │ │
│ [ Continue ] │ "Approve sign-in?
│ │ from 192.168.8.51
│ │ Firefox on Fedora"
│ │ → type 27
│ ◄────────── approved ──────────────────────┘
│ signs itself in │
└──────────────────────┘
```
## Why it is safe
**authentik asks the service; the service never tells authentik anything.**
An authentik flow policy calls `POST /api/assert` with a session id and gets
back the username that approved it, or nothing. The service holds no API token,
so there is no standing credential to steal, and the most it can ever say is
*"whoever holds this id approved as \<user\>"*.
The tempting alternative — having the service call authentik's recovery-link API
to mint a one-time login URL — was tried and rejected. That endpoint effectively
requires a **superuser**: `reset_user_password` (global *and* object-level),
`view_user`, `change_user`, `impersonate`, `add_flowtoken` and `view_flow` all
return 403, while `is_superuser` succeeds immediately. A leak of that token mints
a session for any account in the directory.
The passkey is the **only** authentication in the whole chain, so the phone-side
OIDC client pins its `authentication_flow` to a passwordless/passkey flow and
every authorize request carries `prompt=login&max_age=0`. Without both, an
existing session or a password login would satisfy the client and the guarantee
disappears silently.
## Honest limitations
**QR sign-in is phishable and this cannot fix it.** An attacker can render this
service's QR on their own page. The victim scans it and authenticates *for real*
against the *real* identity provider — so passkey origin binding does not help —
sees a number the attacker's page displays, and approves. The attacker's browser
gets the session.
WebAuthn's own hybrid transport avoids this with a Bluetooth proximity check.
There is no equivalent here. What is offered instead:
| Mitigation | What it actually buys |
|---|---|
| Keep the IdP off the public internet | By far the strongest control. The attacker must already be inside. |
| Laptop IP + reverse DNS shown **above** the number entry | The only thing standing between a phished QR and a stolen session is the user noticing the device is not theirs. |
| Number matching | Defeats blind approval-bombing. Nothing else — it does not authenticate the requester. |
| Same-subnet check | A warning by default; a hard block on request. |
| 90-second TTL, single use | The attacker must be live and synchronous. |
**Number matching defaults to typing, not tapping.** Three buttons is
log₂(3) = 1.6 bits: with one attempt and a session cap an attacker still wins
about 70% of the time, which is not a control. Two typed digits is 6.5 bits and
about 3.3%. Set `QR_NUMBER_MODE=choice` for the friendlier version, with eyes
open. A wrong answer denies outright — a retry loop would undo the arithmetic.
**Sessions live in memory.** They are 90-second capabilities to become a user;
one that outlives the process is a liability. Run a single replica with
`strategy: Recreate`. A restart drops in-flight sign-ins, which costs a retry.
## Requirements, and they are not negotiable
**1. It must be served on the same origin as authentik**, e.g.
`https://sso.example.com/qr-login`. Not a subdomain — the same origin.
The panel is an iframe on authentik's login page, and when the phone approves it
navigates the *top-level* window. A cross-origin frame cannot do that without a
user gesture (Chrome's framebusting intervention; Chrome, Firefox and Safari all
differ on the specifics). A same-origin frame is explicitly exempt. The service
refuses to start if `QR_OIDC_ISSUER` is not on `QR_PUBLIC_ORIGIN`.
**2. If your ingress merges Ingresses per host, the second one still needs its
own `tls:` block.** On Cilium's shared-mode ingress controller, an Ingress
sharing a host but omitting `tls` has its route silently dropped from the secure
listener, and the path falls through to authentik with no error anywhere. Since
cross-namespace TLS secret references are disallowed, the simplest answer is to
deploy into authentik's own namespace and reuse its certificate.
## authentik configuration
Four objects. A Pulumi example is in `deploy/`.
1. **An OIDC provider + application** for the phone, with
`authentication_flow` pinned to your passkey flow, and redirect URI
`https://<host>/qr-login/oidc/callback`.
2. **A completion flow** (`qr-login-complete`, designation `authentication`,
authentication `none`) containing a **user-login stage**.
3. **An expression policy** bound to that stage's binding — see below.
4. **A prompt stage** with one `static` field whose value is an iframe pointing
at `/qr-login/panel`, bound to your login flow. It has no inputs and no
policies, so if this service is down the browser renders an empty box and
authentik's own Continue button still works.
### The two settings everyone gets wrong
On the **login stage's flow-stage binding**:
```
evaluate_on_plan = false # NOT true
re_evaluate_policies = true
```
- With `evaluate_on_plan: true` the policy runs **twice**, and since the assert
*consumes* the session id, the second pass finds it spent and drops the stage.
A flow whose plan ends up empty redirects to `/` — so the phone approves, the
laptop navigates, and lands back on the login page.
- You cannot tell the two passes apart: `request.context["flow_plan"]` exists at
plan time too, contrary to what you might assume.
- And authentik **caches flow plans** (`cache.timeout_flows`, 300s by default)
under a key that is identical for every anonymous user — so a plan-time
evaluation is served from cache and the policy never runs at all.
`ReevaluateMarker` sets `engine.use_cache = False`, which makes the stage-time
run the only evaluation guaranteed to happen.
In the policy itself, assigning to `request.context["pending_user"]` is
**silently discarded**. It must be:
```python
request.context["flow_plan"].context["pending_user"] = user
```
## Configuration
| Variable | Default | |
|---|---|---|
| `QR_PUBLIC_ORIGIN` | — | e.g. `https://sso.example.com` |
| `QR_BASE_PATH` | `/qr-login` | path prefix |
| `QR_OIDC_ISSUER` | — | must be on `QR_PUBLIC_ORIGIN` |
| `QR_OIDC_CLIENT_ID` / `QR_OIDC_CLIENT_SECRET` | — | the phone-side client |
| `QR_REQUIRED_GROUP` | — | group the ID token must list; empty is rejected, never "allow all" |
| `QR_ASSERT_SECRET` | — | proves an `/api/assert` caller is authentik's policy |
| `QR_COOKIE_SECRET` | — | signs the phone's short-lived cookie |
| `QR_SESSION_TTL_SECONDS` | `90` | |
| `QR_NUMBER_MODE` | `type` | or `choice` |
| `QR_REQUIRE_SAME_SUBNET` | `false` | hard-block instead of warn |
| `QR_MAX_SESSIONS` | `10000` | bound, so a flood is bounded memory |
## Development
```bash
npm install
npm run typecheck
npm test # 9 tests, no network
npm run build
```
The Dockerfile runs the typecheck and the tests, so a service that does not
compile or does not pass cannot produce an image.
## Licence
MIT. See `LICENSE`.

1545
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "authentik-qr-login",
"version": "1.0.0",
"private": true,
"description": "Cross-device QR sign-in for authentik: scan on your phone, approve with a passkey, and the laptop signs itself in. Holds no authentik credential.",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
"test": "node --test --experimental-strip-types test/*.test.ts",
"start": "node dist/index.js"
},
"engines": {
"node": "22.x"
},
"dependencies": {
"cookie-session": "^2.1.0",
"express": "^5.1.0",
"openid-client": "^5.6.5",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@types/cookie-session": "^2.0.49",
"@types/express": "^5.0.6",
"@types/node": "^22.10.0",
"@types/qrcode": "^1.5.5",
"typescript": "^5.9.3"
}
}

151
src/approve.ts Normal file
View File

@@ -0,0 +1,151 @@
import type { NumberMode } from "./numbers.ts";
const escapeHtml = (s: string): string =>
s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string,
);
export interface ApprovePageData {
basePath: string;
/** The IdP host the user is signing in to, e.g. "sso.ad.itaz.eu". */
host: string;
approvalToken: string;
username: string;
laptopIp: string;
laptopUa: string;
requestedSecondsAgo: number;
expiresInSeconds: number;
numberMode: NumberMode;
choices: number[];
differentNetwork: boolean;
}
/**
* The phone page, shown only after a passkey login against authentik.
*
* The device detail sits ABOVE the number entry on purpose. The one attack this
* design cannot prevent is a phished QR: the victim scans an attacker's code
* and authenticates for real against the real IdP, so passkey origin binding
* does not help. All that stands between that and a stolen session is the user
* noticing that the device asking to be signed in is not theirs. Putting the
* address under the keypad, where it is read after the decision, would waste
* the only defence there is.
*/
export function approveHtml(d: ApprovePageData): string {
const keypad =
d.numberMode === "choice"
? `<div class="choices">${d.choices
.map((n) => `<button class="choice" data-n="${n}">${n}</button>`)
.join("")}</div>`
: `<input id="answer" inputmode="numeric" pattern="[0-9]*" maxlength="2"
autocomplete="off" placeholder="00" aria-label="Number shown on your computer">
<button id="go" class="primary">Approve</button>`;
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Approve sign-in</title>
<style>
:root { color-scheme: light dark; --bg:#fff; --fg:#111; --muted:#666; --line:#0002; }
@media (prefers-color-scheme: dark) { :root { --bg:#0b0b0c; --fg:#eee; --muted:#9a9a9a; --line:#fff2; } }
body { margin:0; background:var(--bg); color:var(--fg); padding:1.5rem;
font:16px/1.6 system-ui,-apple-system,sans-serif; max-width:26rem; margin-inline:auto; }
h1 { font-size:1.3rem; margin:0 0 .25rem; }
.who { color:var(--muted); margin-bottom:1.25rem; }
dl { display:grid; grid-template-columns:auto 1fr; gap:.35rem .9rem;
border:1px solid var(--line); border-radius:10px; padding:.9rem; margin:0 0 1rem; }
dt { color:var(--muted); } dd { margin:0; word-break:break-word; }
.warn { border:1px solid #f59e0b; color:#b45309; border-radius:10px;
padding:.7rem .9rem; margin:0 0 1rem; }
@media (prefers-color-scheme: dark) { .warn { color:#fbbf24; } }
label.q { display:block; font-weight:600; margin:0 0 .5rem; }
input { font:700 2rem/1 system-ui,sans-serif; width:4.5rem; text-align:center;
padding:.5rem; border-radius:10px; border:1px solid var(--line);
background:transparent; color:var(--fg); }
button { font:600 1rem system-ui,sans-serif; padding:.8rem 1rem; border-radius:10px;
border:1px solid var(--line); background:transparent; color:var(--fg); cursor:pointer; }
.primary { background:#2563eb; border-color:#2563eb; color:#fff; margin-left:.5rem; }
.choices { display:flex; gap:.6rem; }
.choice { flex:1; font:700 1.6rem system-ui,sans-serif; padding:1rem 0; }
.deny { display:block; width:100%; margin-top:1.5rem; color:#b91c1c; border-color:#b91c1c55; }
#out { margin-top:1.25rem; font-weight:600; }
/* The finished state REPLACES the form rather than appending to it. A
disabled approve button still on screen reads as "did that work?", which
is the one question this page exists to answer. */
.done { text-align:center; padding:2.5rem 0; }
.done .mark { font-size:3.5rem; line-height:1; }
.done h2 { font-size:1.35rem; margin:.75rem 0 .35rem; }
.done p { color:var(--muted); margin:0; }
.ok .mark { color:#16a34a; } .no .mark { color:#b91c1c; }
</style></head><body>
<h1>Approve sign-in</h1>
<div class="who">to ${escapeHtml(d.host)} &mdash; as <b>${escapeHtml(d.username)}</b></div>
<dl>
<dt>From</dt><dd>${escapeHtml(d.laptopIp)}</dd>
<dt>Browser</dt><dd>${escapeHtml(d.laptopUa)}</dd>
<dt>Requested</dt><dd>${d.requestedSecondsAgo}s ago &middot; expires in ${d.expiresInSeconds}s</dd>
</dl>
${d.differentNetwork ? '<div class="warn">This request is from a different network than your phone. If you did not start it, deny.</div>' : ""}
<label class="q" for="answer">${d.numberMode === "choice" ? "Tap the number shown on your computer" : "Enter the number shown on your computer"}</label>
${keypad}
<button class="deny" id="deny">This wasn't me &mdash; deny</button>
<div id="out"></div>
<script>
(function () {
var BASE = ${JSON.stringify(d.basePath)};
var TOKEN = ${JSON.stringify(d.approvalToken)};
var out = document.getElementById('out');
function done(cls, mark, title, detail) {
document.body.innerHTML =
'<div class="done ' + cls + '"><div class="mark">' + mark + '</div>' +
'<h2>' + title + '</h2><p>' + detail + '</p></div>';
}
function send(answer) {
document.querySelectorAll('button,input').forEach(function (el) { el.disabled = true; });
out.textContent = 'Sending\\u2026';
fetch(BASE + '/api/approve', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ approvalToken: TOKEN, number: answer })
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.status === 'approved') {
done('ok', '\\u2713', 'Signed in',
'Your computer is being signed in now. You can close this tab.');
} else if (res.status === 'denied') {
// Say what was NOT done. "Denied" alone leaves people wondering
// whether they just locked themselves out of something.
done('no', '\\u2715', 'Denied',
'Nothing was signed in. If that number did not match what your ' +
'computer showed, someone else may have started this.');
} else {
done('no', '\\u26A0', 'Expired',
'This request is no longer valid. Start again on your computer.');
}
})
.catch(function () {
done('no', '\\u26A0', 'Network error',
'Nothing was approved. Start again on your computer.');
});
}
var go = document.getElementById('go');
if (go) go.onclick = function () {
var v = parseInt(document.getElementById('answer').value, 10);
if (!v) { out.textContent = 'Enter the two-digit number.'; return; }
send(v);
};
document.querySelectorAll('.choice').forEach(function (b) {
b.onclick = function () { send(parseInt(b.getAttribute('data-n'), 10)); };
});
document.getElementById('deny').onclick = function () { send(null); };
})();
</script></body></html>`;
}

116
src/config.ts Normal file
View File

@@ -0,0 +1,116 @@
import type { NumberMode } from "./numbers.ts";
/**
* Configuration.
*
* The service holds NO authentik API credential. That is the whole point of the
* architecture: authentik asks us who approved a session, we answer, and it
* issues the session itself. There is therefore no standing credential here
* that could mint a login for an arbitrary user — the earlier design, which
* used authentik's recovery-link API, required a superuser token to do exactly
* that. `assertSecret` below is a shared secret proving a caller IS authentik,
* not a credential granting us any power over it.
*/
export interface Config {
port: number;
/** Public origin. MUST be authentik's own origin — see basePath. */
publicOrigin: string;
/**
* Path prefix this service is mounted under, e.g. "/qr-login".
*
* Same-origin with authentik is load-bearing, not cosmetic. A cross-origin
* iframe cannot navigate the top-level window without a user gesture (and
* Chrome, Firefox and Safari disagree about the details); a same-origin one
* is explicitly exempt. Mounting under authentik's own host is what makes
* "the laptop logs itself in" possible with no click.
*/
basePath: string;
oidc: {
issuer: string;
clientId: string;
clientSecret: string;
/** Group the ID token must list. Empty is REJECTED, never "allow all". */
requiredGroup: string;
};
/**
* Proves an /api/assert caller is authentik's expression policy.
*
* Without it, anyone who learned a session id could ask us to name its user.
* That alone would not log them in — they would still have to be inside the
* flow — but there is no reason to answer the question for strangers.
*/
assertSecret: string;
cookieSecret: string;
sessionTtlSeconds: number;
numberMode: NumberMode;
/**
* Warn (never block) when the phone and laptop are on different /24s.
*
* Not a hard block by default: the phone is frequently on cellular while the
* laptop is on wifi, and a control that fires on the honest path constantly
* gets switched off. Deployments where the IdP is internal-only may set it.
*/
requireSameSubnet: boolean;
maxSessions: number;
}
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`missing required environment variable ${name}`);
return value;
}
function optionalInt(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer, got ${raw}`);
}
return parsed;
}
export function loadConfig(): Config {
const numberModeRaw = process.env.QR_NUMBER_MODE ?? "type";
if (numberModeRaw !== "type" && numberModeRaw !== "choice") {
throw new Error(`QR_NUMBER_MODE must be "type" or "choice", got ${numberModeRaw}`);
}
const requiredGroup = required("QR_REQUIRED_GROUP");
const basePath = (process.env.QR_BASE_PATH ?? "/qr-login").replace(/\/$/, "");
if (!basePath.startsWith("/")) {
throw new Error(`QR_BASE_PATH must start with "/", got ${basePath}`);
}
const publicOrigin = required("QR_PUBLIC_ORIGIN").replace(/\/$/, "");
const issuer = required("QR_OIDC_ISSUER");
// Same-origin is a correctness requirement, so assert it at boot rather than
// discovering it as a browser silently refusing to navigate.
if (!issuer.startsWith(publicOrigin)) {
throw new Error(
`QR_OIDC_ISSUER (${issuer}) must be on the same origin as QR_PUBLIC_ORIGIN ` +
`(${publicOrigin}) — the panel iframe can only navigate the login page ` +
`when it is same-origin with it`,
);
}
return {
port: optionalInt("PORT", 8080),
publicOrigin,
basePath,
oidc: {
issuer,
clientId: required("QR_OIDC_CLIENT_ID"),
clientSecret: required("QR_OIDC_CLIENT_SECRET"),
requiredGroup,
},
assertSecret: required("QR_ASSERT_SECRET"),
cookieSecret: required("QR_COOKIE_SECRET"),
sessionTtlSeconds: optionalInt("QR_SESSION_TTL_SECONDS", 90),
numberMode: numberModeRaw as NumberMode,
requireSameSubnet: process.env.QR_REQUIRE_SAME_SUBNET === "true",
maxSessions: optionalInt("QR_MAX_SESSIONS", 10_000),
};
}

284
src/index.ts Normal file
View File

@@ -0,0 +1,284 @@
import express, { type Request, type Response } from "express";
import cookieSession from "cookie-session";
import QRCode from "qrcode";
import { approveHtml } from "./approve.ts";
import { loadConfig, type Config } from "./config.ts";
import { beginAuth, completeAuth, isAuthorised } from "./oidc.ts";
import { panelHtml } from "./panel.ts";
import { SessionStore, secretEquals } from "./sessions.ts";
const config = loadConfig();
const store = new SessionStore({
ttlSeconds: config.sessionTtlSeconds,
numberMode: config.numberMode,
maxSessions: config.maxSessions,
});
const app = express();
// Cilium's Envoy sets X-Forwarded-For with the real client address; without
// this express reports the ingress pod's IP and every binding check is a lie.
app.set("trust proxy", true);
app.disable("x-powered-by");
const router = express.Router();
router.use(express.json({ limit: "8kb" }));
/**
* Our own cookie, scoped to our own path.
*
* `path` matters: we are same-origin with authentik, so a cookie without it
* would be sent to the IdP on every request. It carries only the OIDC state
* and the approval token for the phone leg.
*/
router.use(
cookieSession({
name: "qrlogin_phone",
keys: [config.cookieSecret],
path: config.basePath,
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 10 * 60 * 1000,
}),
);
const clientIp = (req: Request): string => req.ip ?? "unknown";
const shortUa = (req: Request): string => (req.get("user-agent") ?? "unknown").slice(0, 180);
/**
* Express 5 types a route param as `string | string[]` — a repeated `:id` in
* the path yields an array. Neither of ours repeats, so collapse to the first
* value rather than sprinkling casts at the call sites.
*/
const param = (req: Request, name: string): string => {
const value = (req.params as Record<string, string | string[]>)[name];
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
};
/** Same /24. Deliberately crude: a hint for the human, never an authorisation. */
function sameSubnet(a: string, b: string): boolean {
const norm = (s: string) => s.replace(/^::ffff:/, "");
const left = norm(a).split(".");
const right = norm(b).split(".");
if (left.length !== 4 || right.length !== 4) return false;
return left[0] === right[0] && left[1] === right[1] && left[2] === right[2];
}
// ── Laptop ────────────────────────────────────────────────────────────────────
router.get("/panel", (req: Request, res: Response) => {
// Where to send the top window once approved. Supplied by the flow's static
// HTML so the completion flow's slug is declared in exactly one place —
// Pulumi — rather than duplicated here where it could drift.
const raw = typeof req.query.complete === "string" ? req.query.complete : "";
// Only a same-origin absolute path is acceptable: this value ends up in
// top.location, so accepting "https://evil/" or "//evil" would turn the login
// page into an open redirect driven by a query parameter.
const completePath = /^\/[^/\\]/.test(raw) ? raw : "/if/flow/qr-login-complete/";
// Our own CSP. authentik sends none, and being same-origin means an XSS here
// would be an XSS on the IdP origin — so this page locks itself down.
res.set(
"content-security-policy",
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; " +
"script-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'self'",
);
res.type("html").send(panelHtml(config.basePath, completePath));
});
router.post("/api/session", async (req: Request, res: Response) => {
try {
const session = store.create(Date.now(), clientIp(req), shortUa(req));
const approveUrl = `${config.publicOrigin}${config.basePath}/a/${session.approvalToken}`;
// Rendered server-side so the approval token never travels in a URL we
// fetch — it exists only in the QR pixels and, later, the phone's address bar.
const qrSvg = await QRCode.toString(approveUrl, {
type: "svg",
margin: 0,
errorCorrectionLevel: "M",
});
res.json({
id: session.id,
number: session.number,
numberMode: config.numberMode,
expiresAt: session.expiresAt,
qrSvg,
});
} catch (error) {
res.status(503).json({ error: (error as Error).message });
}
});
/**
* Long-poll. Returns as soon as the state changes, so it is no slower than SSE
* while being one code path with no reconnect logic and no exposure to proxy
* buffering. Capped well under any sane idle timeout; the panel re-polls.
*/
router.get("/api/session/:id/wait", async (req: Request, res: Response) => {
const deadline = Date.now() + 25_000;
for (;;) {
const session = store.get(param(req, "id"), Date.now());
if (!session) return res.json({ status: "expired" });
if (session.status !== "pending" && session.status !== "scanned") {
return res.json({ status: session.status });
}
if (Date.now() >= deadline) return res.json({ status: session.status });
await new Promise((r) => setTimeout(r, 400));
}
});
// ── Phone ─────────────────────────────────────────────────────────────────────
router.get("/a/:token", async (req: Request, res: Response) => {
const session = store.byToken(param(req, "token"), Date.now());
if (!session) return res.status(410).type("html").send(page("This code has expired."));
store.markScanned(session);
try {
const auth = await beginAuth(config);
req.session = { state: auth.state, codeVerifier: auth.codeVerifier, token: param(req, "token") };
res.redirect(auth.url);
} catch (error) {
res.status(502).type("html").send(page(`Cannot reach the identity provider: ${(error as Error).message}`));
}
});
router.get("/oidc/callback", async (req: Request, res: Response) => {
const held = req.session as { state?: string; codeVerifier?: string; token?: string } | undefined;
if (!held?.state || !held.codeVerifier || !held.token) {
return res.status(400).type("html").send(page("Sign-in did not start here. Scan the code again."));
}
try {
const identity = await completeAuth(config, req, {
state: held.state,
codeVerifier: held.codeVerifier,
});
if (!isAuthorised(config, identity)) {
return res.status(403).type("html").send(page("Your account is not permitted to use phone sign-in."));
}
const session = store.byToken(held.token, Date.now());
if (!session) return res.status(410).type("html").send(page("This code has expired."));
req.session = { ...held, username: identity.username };
res.redirect(`${config.basePath}/approve`);
} catch (error) {
res.status(400).type("html").send(page(`Sign-in failed: ${(error as Error).message}`));
}
});
router.get("/approve", (req: Request, res: Response) => {
const held = req.session as { token?: string; username?: string } | undefined;
if (!held?.token || !held.username) return res.redirect(config.basePath + "/panel");
const now = Date.now();
const session = store.byToken(held.token, now);
if (!session) return res.status(410).type("html").send(page("This code has expired."));
const phoneIp = clientIp(req);
res.type("html").send(
approveHtml({
basePath: config.basePath,
host: new URL(config.publicOrigin).host,
approvalToken: session.approvalToken,
username: held.username,
laptopIp: session.laptopIp,
laptopUa: session.laptopUa,
requestedSecondsAgo: Math.round((now - session.createdAt) / 1000),
expiresInSeconds: Math.max(0, Math.round((session.expiresAt - now) / 1000)),
numberMode: config.numberMode,
choices: session.decoys,
differentNetwork: !sameSubnet(session.laptopIp, phoneIp),
}),
);
});
router.post("/api/approve", (req: Request, res: Response) => {
const held = req.session as { username?: string } | undefined;
if (!held?.username) return res.status(401).json({ error: "not signed in" });
const body = req.body as { approvalToken?: string; number?: number | null };
if (typeof body?.approvalToken !== "string") {
return res.status(400).json({ error: "approvalToken required" });
}
const now = Date.now();
const session = store.byToken(body.approvalToken, now);
if (!session) return res.json({ status: "expired" });
const phoneIp = clientIp(req);
if (config.requireSameSubnet && !sameSubnet(session.laptopIp, phoneIp)) {
return res.json({ status: "denied" });
}
const answer = typeof body.number === "number" ? body.number : null;
res.json({ status: store.decide(session, held.username, phoneIp, answer) });
});
// ── authentik ─────────────────────────────────────────────────────────────────
/**
* Called by authentik's expression policy — never by a browser.
*
* This is the inversion that makes the whole design safe. Earlier revisions had
* this service hold an authentik API token and mint a login link; that endpoint
* requires a superuser, so a leak would have meant a session for any account.
* Now authentik asks US, and the most we can ever say is "the holder of this
* session id approved as <username>". We are told, we do not tell.
*
* `peek` is the plan-time call, which decides whether the login stage applies
* at all; the consuming call happens once, at stage time, when authentik is
* ready to act on the answer.
*/
router.post("/api/assert", (req: Request, res: Response) => {
const presented = req.get("x-qr-assert-secret") ?? "";
if (!secretEquals(presented, config.assertSecret)) {
return res.status(403).json({ error: "forbidden" });
}
const body = req.body as { sessionId?: string; peek?: boolean };
if (typeof body?.sessionId !== "string") {
return res.status(400).json({ error: "sessionId required" });
}
const now = Date.now();
if (body.peek) {
const session = store.get(body.sessionId, now);
const ok = session?.status === "approved" && Boolean(session.username);
return res.json({ ok, username: ok ? session?.username : undefined });
}
const username = store.consume(body.sessionId, now);
return res.json({ ok: Boolean(username), username });
});
router.get("/healthz", (_req: Request, res: Response) => {
res.json({ ok: true, sessions: store.size, numberMode: config.numberMode });
});
function page(message: string): string {
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Phone sign-in</title>
<style>body{font:16px/1.6 system-ui,sans-serif;margin:0;padding:2rem;max-width:26rem;
margin-inline:auto;color-scheme:light dark}</style></head>
<body><p>${message.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c] as string)}</p></body></html>`;
}
app.use(config.basePath, router);
app.listen(config.port, () => {
// eslint-disable-next-line no-console
console.log(
JSON.stringify({
event: "listening",
port: config.port,
basePath: config.basePath,
numberMode: config.numberMode,
ttlSeconds: config.sessionTtlSeconds,
}),
);
});

44
src/numbers.ts Normal file
View File

@@ -0,0 +1,44 @@
import { randomInt } from "node:crypto";
/**
* The number-matching challenge.
*
* The laptop shows ONE number; the phone asks for it. This defeats blind
* approval — an attacker who can trigger a prompt but cannot see the victim's
* screen has to guess.
*
* How much it buys is arithmetic, and worth stating because "number matching"
* sounds stronger than the weak variant is:
*
* mode "type" two digits, entered on a keypad log2(90) = 6.5 bits
* mode "choice" one of three buttons log2(3) = 1.6 bits
*
* With a single attempt and a cap of 3 sessions per source, "choice" leaves an
* attacker at 1 - (2/3)^3 = 70%, which is not a control. "type" leaves them at
* 1 - (89/90)^3 = 3.3%. Hence `type` is the default; `choice` exists because it
* is the friendlier UX and some deployments will want it with eyes open.
*/
export type NumberMode = "type" | "choice";
/** Two digits. Never 0-9: a leading zero reads ambiguously across fonts. */
export function pickNumber(): number {
return randomInt(10, 100);
}
/**
* Two decoys for `choice` mode, distinct from each other and from the answer.
* Returned already shuffled, so the correct one is not positionally biased.
*/
export function pickDecoys(answer: number): number[] {
const chosen = new Set<number>([answer]);
while (chosen.size < 3) chosen.add(pickNumber());
const all = [...chosen];
// Fisher-Yates with a CSPRNG. Math.random would be fine for display order,
// but using it here invites someone to reuse it where it is not.
for (let i = all.length - 1; i > 0; i--) {
const j = randomInt(0, i + 1);
[all[i], all[j]] = [all[j], all[i]];
}
return all;
}

112
src/oidc.ts Normal file
View File

@@ -0,0 +1,112 @@
import type { IncomingMessage } from "node:http";
import { Issuer, generators, type Client } from "openid-client";
import type { Config } from "./config.ts";
let issuerPromise: Promise<Issuer<Client>> | undefined;
/** Discovery, memoised — but NOT on failure, or one early miss poisons the pod. */
export async function getIssuer(config: Config): Promise<Issuer<Client>> {
if (!issuerPromise) {
issuerPromise = Issuer.discover(config.oidc.issuer).catch((error: unknown) => {
issuerPromise = undefined;
throw error;
});
}
return issuerPromise;
}
export function redirectUri(config: Config): string {
return `${config.publicOrigin}${config.basePath}/oidc/callback`;
}
async function clientFor(config: Config): Promise<Client> {
const issuer = await getIssuer(config);
return new issuer.Client({
client_id: config.oidc.clientId,
client_secret: config.oidc.clientSecret,
redirect_uris: [redirectUri(config)],
response_types: ["code"],
});
}
export interface AuthRequest {
url: string;
state: string;
codeVerifier: string;
}
export async function beginAuth(config: Config): Promise<AuthRequest> {
const client = await clientFor(config);
const state = generators.state();
const codeVerifier = generators.codeVerifier();
return {
url: client.authorizationUrl({
scope: "openid email profile",
state,
code_challenge: generators.codeChallenge(codeVerifier),
code_challenge_method: "S256",
/**
* FORCE RE-AUTHENTICATION. This is the load-bearing pair, and omitting it
* silently guts the design: if the phone already holds an authentik
* session, the authorize request would merely *authorize* and the passkey
* + biometric step — the only real authentication in the whole chain —
* would never happen. Approving a sign-in must always cost a fingerprint.
*/
prompt: "login",
max_age: 0,
}),
state,
codeVerifier,
};
}
export interface Identity {
username: string;
groups: string[];
}
export async function completeAuth(
config: Config,
request: IncomingMessage,
expected: { state: string; codeVerifier: string },
): Promise<Identity> {
const client = await clientFor(config);
const uri = redirectUri(config);
const tokenSet = await client.callback(uri, client.callbackParams(request), {
state: expected.state,
code_verifier: expected.codeVerifier,
});
const claims = tokenSet.claims();
const groups = Array.isArray(claims.groups)
? claims.groups.filter((g): g is string => typeof g === "string")
: undefined;
// Fail closed, and say which failure it is. An absent claim means the
// provider is missing its scope mapping; treating that as "no groups" would
// lock everyone out silently, and as "allow" would let everyone in silently.
if (groups === undefined) {
throw new Error(
"ID token carries no `groups` claim — the authentik provider is missing " +
"the profile scope mapping, so membership cannot be checked",
);
}
const username =
typeof claims.preferred_username === "string" ? claims.preferred_username : undefined;
if (!username) {
throw new Error(
"ID token carries no `preferred_username` claim — it is what authentik's " +
"policy resolves back to a user, so approval cannot be attributed",
);
}
return { username, groups };
}
export function isAuthorised(config: Config, identity: Identity): boolean {
return identity.groups.includes(config.oidc.requiredGroup);
}

125
src/panel.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* The panel: the document that renders inside authentik's login page.
*
* It is an iframe rather than script injected into authentik's own DOM. A
* `static` prompt field renders through lit's `unsafeHTML`, so markup we supply
* lands unescaped on the login page — but `<script>` inserted that way never
* executes, and the only way to get JS running would be an inline `onerror`
* handler. That is self-XSS by design: our code would run inside authentik's
* origin with access to its session and DOM. The iframe keeps our logic in a
* document of our own while staying same-origin, which is all we need.
*
* HOW IT FINISHES. When the phone approves, the panel navigates the TOP window
* to the same flow URL with `?qr=<id>` appended. That is a GET, so there is no
* form submission and no CSRF token to obtain. authentik re-plans the flow;
* a policy on the panel stage sees the parameter and skips it, and a policy on
* the login stage exchanges the id with us for a username.
*/
export function panelHtml(basePath: string, completePath: string): string {
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign in with your phone</title>
<style>
:root { color-scheme: light dark; }
body { margin:0; padding:0; font:14px/1.5 system-ui,-apple-system,sans-serif;
display:flex; flex-direction:column; align-items:center; gap:.75rem;
color:#111; background:transparent; }
@media (prefers-color-scheme: dark) { body { color:#eee; } }
#qr { width:190px; height:190px; background:#fff; padding:10px;
border-radius:10px; display:flex; align-items:center; justify-content:center; }
#qr svg { width:100%; height:100%; display:block; }
.num { font:700 2.4rem/1 system-ui,sans-serif; letter-spacing:.1em; }
.hint { opacity:.75; text-align:center; }
.err { color:#b91c1c; text-align:center; }
@media (prefers-color-scheme: dark) { .err { color:#f87171; } }
.bar { width:190px; height:3px; border-radius:2px; background:#0002; overflow:hidden; }
.bar > i { display:block; height:100%; background:currentColor; opacity:.5; }
</style></head><body>
<div id="root"><div class="hint">Preparing&hellip;</div></div>
<script>
(function () {
var BASE = ${JSON.stringify(basePath)};
var COMPLETE = ${JSON.stringify(completePath)};
var root = document.getElementById('root');
function render(html) { root.innerHTML = html; }
function fail(msg) {
// Fail QUIETLY and small. This panel sits on the login page; if our service
// is unreachable the user must still see authentik's own Continue button
// and be able to sign in normally. Shouting here would look like the login
// page is broken when it is not.
render('<div class="err">Phone sign-in unavailable</div>' +
'<div class="hint">' + msg + '</div>');
}
function start() {
fetch(BASE + '/api/session', { method: 'POST', headers: { 'accept': 'application/json' } })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (s) { show(s); wait(s); })
.catch(function (e) { fail(String(e.message || e)); });
}
function show(s) {
render(
'<div id="qr">' + s.qrSvg + '</div>' +
'<div class="hint">Scan with your phone</div>' +
'<div class="num">' + s.number + '</div>' +
'<div class="hint">' +
(s.numberMode === 'choice' ? 'Tap this number on your phone'
: 'Enter this number on your phone') +
'</div>' +
'<div class="bar"><i id="bar" style="width:100%"></i></div>');
var bar = document.getElementById('bar');
var total = s.expiresAt - Date.now();
var tick = setInterval(function () {
var left = s.expiresAt - Date.now();
if (left <= 0) {
clearInterval(tick);
render('<div class="hint">This code expired.</div>' +
'<div class="hint"><a href="#" id="again">Show a new one</a></div>');
document.getElementById('again').onclick = function (e) { e.preventDefault(); start(); };
return;
}
bar.style.width = Math.max(0, (left / total) * 100) + '%';
}, 250);
}
function wait(s) {
fetch(BASE + '/api/session/' + encodeURIComponent(s.id) + '/wait')
.then(function (r) { return r.json(); })
.then(function (state) {
if (state.status === 'approved') return finish(s.id);
if (state.status === 'denied') return fail('Request denied on the phone.');
if (state.status === 'expired') return;
wait(s); // still pending/scanned - poll again
})
.catch(function () { setTimeout(function () { wait(s); }, 2000); });
}
function finish(id) {
render('<div class="hint">Approved &mdash; signing you in&hellip;</div>');
// A flow of our OWN, not the current one with ?qr= appended. Re-planning
// the default login flow would put our login stage alongside its
// identification, password and MFA stages; a dedicated flow keeps this off
// the estate's front door entirely.
var target = COMPLETE + '?qr=' + encodeURIComponent(id);
try {
// Same-origin, so this needs no user gesture. A cross-origin frame is
// blocked here by Chrome's framebusting intervention.
window.top.location.replace(target);
} catch (e) {
// Only reachable if the panel is somehow not same-origin with the page.
render('<div class="hint">Approved. <a href="#" id="go">Continue</a></div>');
document.getElementById('go').onclick = function (ev) {
ev.preventDefault();
window.top.location.href = target;
};
}
}
start();
})();
</script></body></html>`;
}

191
src/sessions.ts Normal file
View File

@@ -0,0 +1,191 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import { pickDecoys, pickNumber, type NumberMode } from "./numbers.ts";
/**
* The sign-in session store.
*
* IN MEMORY, DELIBERATELY — and this is the one place the house "always
* Postgres" rule is knowingly not followed. The rule exists to stop durable
* state living somewhere it can be lost. Here there is no durable state to
* lose: a record is a 90-second capability to become a user, and a capability
* that survives the process is a liability rather than an asset. Putting a
* database, its operator and WAL archiving *in front of the login page* also
* points the dependency arrow the wrong way — the thing that gates every other
* app should depend on as little as possible.
*
* The cost is honest: one replica, `strategy: Recreate`, and a deploy drops
* in-flight sessions. That failure is fail-open (the login page's Continue
* button is authentik's own DOM and keeps working), and it costs a retry.
*/
export type Status = "pending" | "scanned" | "approved" | "denied" | "expired" | "consumed";
export interface Session {
/** Poll key. Lives in the laptop's DOM and in the flow's hidden field. */
id: string;
/**
* A SECOND, independent secret, carried only in the QR pixels.
*
* Two secrets rather than one because they have different exposure: `id` is
* visible to anyone who can see the laptop's screen or DOM, while
* `approvalToken` only ever reaches the phone. Someone who observes one
* cannot act with the other.
*/
approvalToken: string;
number: number;
/** Only populated in `choice` mode. */
decoys: number[];
status: Status;
createdAt: number;
expiresAt: number;
laptopIp: string;
laptopUa: string;
phoneIp?: string;
/** authentik username, set once the phone has authenticated AND approved. */
username?: string;
/** One wrong number denies outright; a retry loop would undo the arithmetic. */
attempted: boolean;
}
export interface StoreOptions {
ttlSeconds: number;
numberMode: NumberMode;
/** Bound on live sessions, so a creation flood is bounded memory not an OOM. */
maxSessions: number;
}
function token(): string {
return randomBytes(16).toString("base64url");
}
/** Constant-time compare for the secrets that arrive from the network. */
export function secretEquals(a: string, b: string): boolean {
const left = Buffer.from(a);
const right = Buffer.from(b);
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
export class SessionStore {
private readonly sessions = new Map<string, Session>();
private readonly byApprovalToken = new Map<string, string>();
private readonly options: StoreOptions;
// Written out rather than declared as a constructor parameter property:
// the tests run under Node's strip-only type stripping, which rejects
// parameter properties outright (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX).
constructor(options: StoreOptions) {
this.options = options;
}
/** Called on every mutation; cheap because the map is small and short-lived. */
private sweep(now: number): void {
for (const [id, session] of this.sessions) {
if (session.expiresAt <= now) this.drop(id);
}
}
private drop(id: string): void {
const session = this.sessions.get(id);
if (!session) return;
this.byApprovalToken.delete(session.approvalToken);
this.sessions.delete(id);
}
create(now: number, laptopIp: string, laptopUa: string): Session {
this.sweep(now);
if (this.sessions.size >= this.options.maxSessions) {
throw new Error("too many concurrent sign-in sessions");
}
const answer = pickNumber();
const session: Session = {
id: token(),
approvalToken: token(),
number: answer,
decoys: this.options.numberMode === "choice" ? pickDecoys(answer) : [],
status: "pending",
createdAt: now,
expiresAt: now + this.options.ttlSeconds * 1000,
laptopIp,
laptopUa,
attempted: false,
};
this.sessions.set(session.id, session);
this.byApprovalToken.set(session.approvalToken, session.id);
return session;
}
get(id: string, now: number): Session | undefined {
const session = this.sessions.get(id);
if (!session) return undefined;
if (session.expiresAt <= now) {
this.drop(id);
return undefined;
}
return session;
}
byToken(approvalToken: string, now: number): Session | undefined {
const id = this.byApprovalToken.get(approvalToken);
return id ? this.get(id, now) : undefined;
}
markScanned(session: Session): void {
if (session.status === "pending") session.status = "scanned";
}
/**
* Resolve the challenge. Returns the new status.
*
* A wrong number DENIES rather than re-prompting: three guesses against a
* two-digit number is a 3.3% attack, an unbounded retry loop is a 100% one.
*/
decide(session: Session, username: string, phoneIp: string, answer: number | null): Status {
if (session.status !== "pending" && session.status !== "scanned") return session.status;
if (answer === null) {
session.status = "denied";
return session.status;
}
if (session.attempted) {
session.status = "denied";
return session.status;
}
session.attempted = true;
if (answer !== session.number) {
session.status = "denied";
return session.status;
}
session.username = username;
session.phoneIp = phoneIp;
session.status = "approved";
return session.status;
}
/**
* Consume an approved session and hand back the username.
*
* Called ONCE, by authentik's expression policy, at the moment it needs a
* `pending_user`. Single-use by construction: the record moves to `consumed`
* and the username is cleared, so a replay of the same id asserts nobody.
*/
consume(id: string, now: number): string | undefined {
const session = this.get(id, now);
if (!session || session.status !== "approved" || !session.username) return undefined;
const username = session.username;
session.status = "consumed";
session.username = undefined;
this.byApprovalToken.delete(session.approvalToken);
return username;
}
get size(): number {
return this.sessions.size;
}
}

94
test/sessions.test.ts Normal file
View File

@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { pickDecoys, pickNumber } from "../src/numbers.ts";
import { SessionStore, secretEquals } from "../src/sessions.ts";
const opts = { ttlSeconds: 90, numberMode: "type" as const, maxSessions: 10 };
const newStore = (o = {}) => new SessionStore({ ...opts, ...o });
test("a fresh session carries two DIFFERENT secrets", () => {
const s = newStore().create(0, "10.0.0.1", "ua");
// The poll id is visible on the laptop's screen; the approval token is only
// in the QR. Reusing one value for both would collapse that separation.
assert.notEqual(s.id, s.approvalToken);
assert.equal(s.status, "pending");
});
test("the happy path approves and yields the username exactly once", () => {
const store = newStore();
const s = store.create(0, "10.0.0.1", "ua");
assert.equal(store.decide(s, "michal", "10.0.0.2", s.number), "approved");
assert.equal(store.consume(s.id, 1000), "michal");
// Single-use: a replay of the same id must assert nobody. This is what stops
// a captured session id from being redeemed twice.
assert.equal(store.consume(s.id, 1000), undefined);
assert.equal(store.get(s.id, 1000)?.status, "consumed");
});
test("a wrong number denies outright and never allows a retry", () => {
const store = newStore();
const s = store.create(0, "10.0.0.1", "ua");
const wrong = s.number === 99 ? 98 : s.number + 1;
assert.equal(store.decide(s, "michal", "10.0.0.2", wrong), "denied");
// The correct answer afterwards must NOT rescue it: an unbounded retry loop
// would turn 6.5 bits of entropy back into none.
assert.equal(store.decide(s, "michal", "10.0.0.2", s.number), "denied");
assert.equal(store.consume(s.id, 1000), undefined);
});
test("an explicit deny (null) is honoured", () => {
const store = newStore();
const s = store.create(0, "10.0.0.1", "ua");
assert.equal(store.decide(s, "michal", "10.0.0.2", null), "denied");
assert.equal(store.consume(s.id, 1000), undefined);
});
test("only approved sessions can be consumed", () => {
const store = newStore();
const s = store.create(0, "10.0.0.1", "ua");
assert.equal(store.consume(s.id, 1000), undefined, "pending must not assert a user");
});
test("sessions expire, and expiry frees the approval token", () => {
const store = newStore({ ttlSeconds: 1 });
const s = store.create(0, "10.0.0.1", "ua");
assert.ok(store.get(s.id, 500));
assert.equal(store.get(s.id, 1_001), undefined);
assert.equal(store.byToken(s.approvalToken, 1_001), undefined);
assert.equal(store.size, 0);
});
test("the store is bounded, and sweeping reclaims room", () => {
const store = newStore({ maxSessions: 2, ttlSeconds: 1 });
store.create(0, "10.0.0.1", "ua");
store.create(0, "10.0.0.1", "ua");
assert.throws(() => store.create(0, "10.0.0.1", "ua"), /too many/);
// Once the first two lapse the sweep on create() reclaims them, so a flood
// is bounded memory rather than a permanent outage.
assert.ok(store.create(2_000, "10.0.0.1", "ua"));
});
test("numbers are two digits and decoys are distinct from the answer", () => {
for (let i = 0; i < 200; i++) {
const n = pickNumber();
assert.ok(n >= 10 && n <= 99, `${n} out of range`);
}
const answer = pickNumber();
const choices = pickDecoys(answer);
assert.equal(choices.length, 3);
assert.equal(new Set(choices).size, 3, "decoys must not repeat");
assert.ok(choices.includes(answer), "the answer must be among the choices");
});
test("secretEquals rejects different lengths without throwing", () => {
assert.equal(secretEquals("abc", "abcd"), false);
assert.equal(secretEquals("abc", "abc"), true);
assert.equal(secretEquals("abc", "abd"), false);
});

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["ES2023"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": false,
// Relative imports carry `.ts` and TypeScript rewrites them to `.js` on
// emit. This is what lets the SAME source run two ways: compiled to
// dist/ for the image, and under Node's strip-only type stripping for
// `node --test`, which resolves imports by their real filename. Without it
// any src module that imports another src module is untestable.
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
}

13
tsconfig.test.json Normal file
View File

@@ -0,0 +1,13 @@
{
// The tests run under Node's type stripping, which resolves imports by their
// real filename — hence the `.ts` extensions in the test sources. The build
// tsconfig cannot allow those, so tests get their own noEmit config.
// Without this the test files would be the one surface nothing checks.
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"allowImportingTsExtensions": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}