Files
dezky/docs/AUTHENTIK-SETUP.md
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

14 KiB

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:

mkcert -install

Then restart your browser.

2. Initial admin setup

Authentik bootstraps with admin credentials from .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 InterfaceApplicationsProviders
  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 ApplicationsApplications
  2. Click Create
  3. Configure:
  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:

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).

Create-only semantics. The provider and application use Authentik's state: createdcreate 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:

# 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:

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

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.