174 lines
8.3 KiB
Markdown
174 lines
8.3 KiB
Markdown
|
|
# 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`.
|