# 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, **offline_access**
   - **Advanced → Token validity:**
     - Access token: `hours=1` (default `minutes=5` is too short and causes frequent silent refreshes)
     - Refresh token: `days=30`
5. Save

> **Why offline_access + token validity:** without `offline_access` the OCIS web
> client gets no refresh token, so when the short-lived access token expires the
> session can't renew and the user is logged out (and the upload UI shows "no
> permission"). With it, the SPA silently refreshes for up to the 30-day refresh
> window. OCIS must also request the scope — set on the `ocis` service in
> docker-compose: `WEB_OIDC_SCOPE: openid profile email offline_access`.

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