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:
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`.
|
||||
Reference in New Issue
Block a user