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:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
38
Dockerfile
Normal file
38
Dockerfile
Normal 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
21
LICENSE
Normal 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
173
README.md
Normal 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
1545
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal 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
151
src/approve.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import type { NumberMode } from "./numbers.ts";
|
||||||
|
|
||||||
|
const escapeHtml = (s: string): string =>
|
||||||
|
s.replace(/[&<>"']/g, (c) =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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)} — 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 · 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 — 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
116
src/config.ts
Normal 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
284
src/index.ts
Normal 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) => ({ "&": "&", "<": "<", ">": ">" })[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
44
src/numbers.ts
Normal 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
112
src/oidc.ts
Normal 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
125
src/panel.ts
Normal 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…</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 — signing you in…</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
191
src/sessions.ts
Normal 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
94
test/sessions.test.ts
Normal 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
26
tsconfig.json
Normal 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
13
tsconfig.test.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user