Files

174 lines
8.3 KiB
Markdown
Raw Permalink Normal View History

# 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`.