0b269e7ea7
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.
340 lines
14 KiB
Markdown
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.
|