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:
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);
|
||||
});
|
||||
Reference in New Issue
Block a user