# 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 \"*. 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:///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`.