Files
dezky/docs/AUTHENTIK-SETUP.md
T
Ronni Baslund 0b269e7ea7 feat(auth): enforce operator/partner platform isolation
A partner or tenant admin could complete the dezky-operator OIDC flow and
land on the operator portal. The platform-api OperatorGuard already 403s
their data, but the login/UI layer had no authorization check at all — the
only gate was a manual Authentik UI setting with nothing in git enforcing it.

Close it with defense-in-depth across three independent layers:

1. IdP — operator-application.yaml blueprint binds an
   ak_is_group_member("dezky-platform-admins") policy to the dezky-operator
   app, so Authentik denies the OIDC flow for non-admins. The blueprint also
   provisions the provider + application (state: created, so a fresh env is
   built from code while an existing hand-made provider is left untouched).
   Wire OPERATOR_OIDC_* into both authentik containers and mount the
   blueprints dir on the worker (it applies blueprints, and previously lacked
   the mount).

2. Operator app — require-platform-admin.global.ts requires platformAdmin and
   routes a non-admin to not-authorized.vue, which triggers a full sign-out
   (local + Authentik IdP) for shared-workstation safety. Fails open on a
   transient /api/me error by design, to avoid mass-signout on platform-api
   restarts; layers 1 and 3 contain the exposure.

3. platform-api — OperatorGuard (unchanged) requires dezky-operator audience
   plus platformAdmin resolved from the DB on every request.

Also harden the partner surface: it shares the dezky-portal client with tenant
users so it has no IdP gate, and its /partner/* route middleware now fails
CLOSED when identity can't be confirmed.

Docs (AUTHENTIK-SETUP.md) and .env.example updated; the operator client secret
must be set before first boot since the blueprint now consumes it.
2026-05-30 15:48:01 +02:00

340 lines
14 KiB
Markdown

# Authentik First-Time Setup
After the bootstrap script completes, Authentik is running but needs to be configured. This guide walks through the initial setup.
## 1. Access Authentik
Open https://auth.dezky.local in your browser.
If you see a TLS warning, mkcert root CA isn't trusted yet. Run:
```bash
mkcert -install
```
Then restart your browser.
## 2. Initial admin setup
Authentik bootstraps with admin credentials from `.env`:
- **URL:** https://auth.dezky.local/if/flow/initial-setup/
- **Email:** admin@dezky.local
- **Password:** Value of `AUTHENTIK_BOOTSTRAP_PASSWORD` in `.env`
On first login, change the password immediately.
## 3. Configure OIDC providers
Each Dezky service that uses SSO needs an OIDC provider configured in Authentik.
### 3.1 Create OCIS provider
1. Go to **Admin Interface****Applications****Providers**
2. Click **Create**
3. Select **OAuth2/OpenID Provider**
4. Configure:
- **Name:** `ocis-provider`
- **Authorization flow:** `default-provider-authorization-implicit-consent`
- **Client type:** Public
- **Client ID:** `ocis-web`
- **Redirect URIs:**
```
https://files.dezky.local/
https://files.dezky.local/oidc-callback
```
- **Signing Key:** `authentik Self-signed Certificate`
- **Scopes:** openid, profile, email
5. Save
### 3.2 Create OCIS application
1. Go to **Applications** → **Applications**
2. Click **Create**
3. Configure:
- **Name:** `OCIS Files`
- **Slug:** `ocis`
- **Provider:** `ocis-provider` (just created)
- **Launch URL:** https://files.dezky.local
4. Save
### 3.3 Create portal provider
Same steps as OCIS, but with:
- **Provider name:** `dezky-portal`
- **Client ID:** `dezky-portal`
- **Redirect URIs:** `https://app.dezky.local/api/auth/callback`
- **Client type:** Confidential (Authentik will generate a Client Secret)
Then create the matching application:
- **Name:** `Dezky Portal` → slug auto-generates as `dezky-portal`
- **Provider:** `dezky-portal` (from above)
- **Launch URL:** `https://app.dezky.local`
The resulting issuer URL is `https://auth.dezky.local/application/o/dezky-portal/` — note the slug includes `dezky-`.
After creating, copy the generated client secret into `.env`:
```
PORTAL_OIDC_CLIENT_ID=dezky-portal
PORTAL_OIDC_CLIENT_SECRET=<paste from Authentik provider page>
PORTAL_OIDC_ISSUER=https://auth.dezky.local/application/o/dezky-portal/
```
`docker-compose.yml` passes these to the portal container as `NUXT_OIDC_*`, which `nuxt-oidc-auth` (added in Phase 2) consumes.
### Scripted alternative
If you don't want to click through the UI, this one-shot uses the API token from section 4:
```bash
TOKEN=$(grep ^AUTHENTIK_BOOTSTRAP_TOKEN .env | cut -d= -f2)
BASE=https://auth.dezky.local/api/v3
AUTH="Authorization: Bearer $TOKEN"
# Pull the same authorization flow + signing key + scope mappings that OCIS uses
FLOW=$(curl -k -s -H "$AUTH" "$BASE/flows/instances/?slug=default-provider-authorization-implicit-consent" | jq -r '.results[0].pk')
KEY=$(curl -k -s -H "$AUTH" "$BASE/providers/oauth2/?search=ocis" | jq -r '.results[0].signing_key')
MAPS=$(curl -k -s -H "$AUTH" "$BASE/providers/oauth2/?search=ocis" | jq -c '.results[0].property_mappings')
# Create provider
PK=$(curl -k -s -X POST -H "$AUTH" -H "Content-Type: application/json" "$BASE/providers/oauth2/" -d "{
\"name\": \"dezky-portal\",
\"client_id\": \"dezky-portal\",
\"client_type\": \"confidential\",
\"authorization_flow\": \"$FLOW\",
\"signing_key\": \"$KEY\",
\"redirect_uris\": [{\"matching_mode\": \"strict\", \"url\": \"https://app.dezky.local/api/auth/callback\"}],
\"property_mappings\": $MAPS,
\"sub_mode\": \"hashed_user_id\",
\"issuer_mode\": \"per_provider\"
}" | jq -r '.pk')
# Create app
curl -k -s -X POST -H "$AUTH" -H "Content-Type: application/json" "$BASE/core/applications/" -d "{
\"name\": \"Dezky Portal\",
\"slug\": \"dezky-portal\",
\"provider\": $PK,
\"meta_launch_url\": \"https://app.dezky.local\"
}" >/dev/null
# Read the generated secret and write to .env
SECRET=$(curl -k -s -H "$AUTH" "$BASE/providers/oauth2/$PK/" | jq -r '.client_secret')
cat >> .env <<EOF
PORTAL_OIDC_CLIENT_ID=dezky-portal
PORTAL_OIDC_CLIENT_SECRET=$SECRET
PORTAL_OIDC_ISSUER=https://auth.dezky.local/application/o/dezky-portal/
EOF
```
### 3.4 Create Stalwart provider (when wiring mail SSO later)
Note: Stalwart's OIDC integration is configured in `infrastructure/docker-compose/configs/stalwart/config.toml`. For local dev with internal users, OIDC is optional.
### 3.5 Operator application (blueprint-managed)
Unlike the providers above, the **operator** application is **not** created by
hand — it's provisioned by an Authentik blueprint that ships in the repo:
```
infrastructure/docker-compose/configs/authentik/blueprints/operator-application.yaml
```
The `authentik-worker` mounts the blueprints directory and applies the file
automatically on boot. It provisions, in one place:
- the `dezky-operator` OAuth2 provider (mirrors the portal provider's shape:
implicit-consent flow, default self-signed signing key, openid/email/profile
scope mappings, hashed sub, per-provider issuer)
- the `dezky-operator` application (slug `dezky-operator`)
- the `dezky-platform-admins` group
- an expression policy `operator-require-platform-admin` and its binding to the
application — **this is what restricts who can log into the operator portal**
(see [Operator portal isolation](#operator-portal-isolation-three-layers)).
**Create-only semantics.** The provider and application use Authentik's
`state: created` — *create if absent, never update if present*. So:
- On a **fresh** environment, the blueprint builds the whole operator app for
you. No manual clicks.
- On an **existing** environment where the operator provider was made by hand,
the blueprint leaves the live provider (its client secret, its scope
mappings) **untouched**, and only adds/reconciles the group, policy, and
binding.
The provider/app are matched by `client_id` / `slug` (globally unique, stable),
so create-only reliably recognizes the existing objects regardless of their
display name.
**Env it reads.** On a fresh provision the blueprint sets the provider's
client credentials from these vars, which the `authentik-server` and
`authentik-worker` containers receive (see `docker-compose.yml`):
```
OPERATOR_OIDC_CLIENT_ID=dezky-operator
OPERATOR_OIDC_CLIENT_SECRET=<must match what the operator container uses>
```
**On a fresh environment, set `OPERATOR_OIDC_CLIENT_SECRET` in `.env` BEFORE
first boot** (`.env.example` ships a placeholder with an `openssl rand -hex 64`
note). This is the inverse of the portal flow: the portal's secret is
*generated by Authentik* and copied out into `.env`, whereas the operator
secret is *supplied by you* and consumed by the blueprint when it creates the
provider. The operator container authenticates with the same
`OPERATOR_OIDC_CLIENT_SECRET` (passed in as its `NUXT_OIDC_CLIENT_SECRET`), so
the two only agree if the value is present before the blueprint runs — leave it
empty and the blueprint provisions the provider with an empty secret and the
OIDC handshake fails.
On your existing environment these are already populated in `.env` (and the
blueprint's `state: created` won't touch the live provider anyway), so nothing
changes for you.
The resulting issuer URL is `https://auth.dezky.local/application/o/dezky-operator/`.
platform-api accepts both the portal and operator issuers/audiences — see
`AUTHENTIK_ISSUER` / `AUTHENTIK_AUDIENCE` in `docker-compose.yml`.
**Apply / verify:**
```bash
# Worker picks up the mount + env and applies the blueprint
docker compose -f infrastructure/docker-compose/docker-compose.yml up -d authentik-worker
# Watch it apply (look for the blueprint name / any !Find that failed to resolve)
docker compose -f infrastructure/docker-compose/docker-compose.yml logs authentik-worker | grep -i blueprint
```
Then in the UI: **Applications → dezky-operator → Bindings** should list
`operator-require-platform-admin` (enabled).
## Operator portal isolation (three layers)
The operator portal (`operator.dezky.local`) carries elevated, cross-tenant
privilege. A partner or a tenant admin must **never** be able to log into it.
That guarantee is defense-in-depth across three independent layers — a single
misconfiguration in any one of them does not open the door:
1. **IdP (Authentik) — stops the login itself.** The
`operator-require-platform-admin` policy bound to the `dezky-operator`
application (§3.5) is evaluated *before* Authentik issues the authorization.
A user who is not in `dezky-platform-admins` is denied during the OIDC flow
and never reaches the app. This is the primary gate.
2. **Operator app — refuses to render for a non-admin.** The global middleware
`apps/operator/middleware/require-platform-admin.global.ts` resolves the
signed-in user's profile and requires `platformAdmin=true`. A non-admin who
somehow holds a valid operator session is routed to
`apps/operator/pages/not-authorized.vue`, which triggers a **full sign-out**
(local nuxt-oidc-auth session **and** the Authentik IdP session) — never
leaving a live operator session parked on a shared workstation.
3. **platform-api — refuses the data.** `OperatorGuard`
(`services/platform-api/src/auth/operator.guard.ts`) requires **both** a
`dezky-operator` token audience **and** `platformAdmin=true` resolved from
the DB on every request. The DB check means revoking someone's admin status
takes effect immediately, without waiting for their token to expire.
`dezky-platform-admins` is the single source of truth tying these together:
Authentik gates membership (layer 1), and platform-api maps that group to the
`platformAdmin` flag it enforces (layers 2 and 3).
**Partner ⇄ customer note.** The partner surface lives *inside* the shared
`dezky-portal` OAuth client (tenant admins authenticate there legitimately), so
it cannot have an IdP-level gate like the operator app. Its isolation is the
portal's fail-closed `/partner/*` route middleware plus platform-api's
per-endpoint `partnerId` checks. If the partner surface ever needs the same
IdP-level isolation as operator, it must become its own OAuth client/application
(at which point this same blueprint pattern applies to it).
## 4. Get the API token for platform-api
platform-api needs to call Authentik's API to create tenants, users, and applications. `.env` holds a pre-generated value in `AUTHENTIK_BOOTSTRAP_TOKEN`, but Authentik 2025.10 does **not** materialize that env var into a usable API token on first boot. You need to create the token once and bind it to `akadmin`.
### One-time setup
Run this from the project root — it reads the value from `.env` and inserts it as a non-expiring API token for `akadmin`:
```bash
TOKEN=$(grep ^AUTHENTIK_BOOTSTRAP_TOKEN .env | cut -d= -f2)
docker compose -f infrastructure/docker-compose/docker-compose.yml exec -T \
-e BOOTSTRAP_TOKEN="$TOKEN" authentik-server ak shell -c "
import os
from authentik.core.models import User, Token, TokenIntents
admin = User.objects.get(username='akadmin')
Token.objects.update_or_create(
identifier='dezky-bootstrap-token',
defaults={
'user': admin,
'intent': TokenIntents.INTENT_API,
'expiring': False,
'key': os.environ['BOOTSTRAP_TOKEN'],
},
)
print('Token bound to akadmin')
"
```
Alternative: create the token through the UI — **Directory → Tokens & App passwords → Create**, set `Intent: API`, `User: akadmin`, then copy the key into `.env` and restart platform-api.
### Verify it works
```bash
curl -k -H "Authorization: Bearer $(grep ^AUTHENTIK_BOOTSTRAP_TOKEN .env | cut -d= -f2)" \
https://auth.dezky.local/api/v3/core/users/
```
A 200 response with the user list (including `akadmin`) means the token is live. A 403 `{"detail":"Token invalid/expired"}` means the one-time setup above hasn't been run yet.
### When you need to re-run this
After `docker compose down -v` (or any reset that wipes the postgres volume), the token row is gone and you'll need to recreate it. The value in `.env` doesn't need to change — re-running the shell snippet above re-binds it.
## 5. Multi-tenancy strategy
For local dev, you can either:
**Option A: Single Authentik tenant, multiple groups**
- All Dezky test users in one Authentik instance
- Tenants modeled as Authentik groups
- Simpler for dev, less realistic
**Option B: Authentik tenants (production-mode)**
- Each Dezky customer = Authentik tenant
- Tenant subdomain pattern: `{tenant}.auth.dezky.local`
- More realistic but more setup overhead
For dev, start with Option A. platform-api should be built to support Option B from day one (data model includes `tenantId`).
## 6. Test SSO flow end-to-end
1. Open incognito browser to https://files.dezky.local
2. OCIS should redirect to https://auth.dezky.local for login
3. Log in with admin@dezky.local
4. Should redirect back to OCIS, logged in
If this works, OIDC integration is solid.
## Common issues
### "Issuer URL does not match"
OCIS expects exact match between `OCIS_OIDC_ISSUER` and the `iss` claim in the JWT.
Check the issuer in Authentik:
- Admin Interface → Providers → ocis-provider → Configure
- Note the **Issuer URL** at the bottom
- Update `OCIS_OIDC_ISSUER` in `docker-compose.yml` to match exactly
### "Client authentication failed"
Public clients (Client Type: Public) don't need a client secret.
Confidential clients need the secret added to the consumer's config.
For OCIS, use Public type.
For the portal (which has server-side auth), use Confidential.
### TLS verification fails between containers
Inside the Docker network, services use Docker-internal hostnames (e.g. `authentik-server`). TLS verification can fail because the cert is for `auth.dezky.local`, not `authentik-server`.
For service-to-service auth, use the issuer URL with `OCIS_INSECURE=true` only for dev. Production will use proper certs.