---
title: Add Authentik OIDC to an Application
type: runbook
tags:
  - auth
aliases: []
related:
  - [[authentik]]
  - [[authentik-access-groups]]
  - [[authentik-upgrade]]
  - [[mailcow-create-mailbox]]
  - [[expose-service-home]]
updated: 2026-06-18
---
# Add Authentik OIDC to an Application

Fully automated via [[authentik]] API. No web UI interaction required.

**Prerequisite:** [[dns]] must already exist for the service (run expose-service-contabo.md or expose-service-home.md first).

**Authentik instance:** https://auth.echo6.co (Contabo, 100.64.0.1)

---

## Inputs

Prompt the user for all of these before executing any steps:

```
SERVICE_NAME=               # Human-readable (e.g., "Vaultwarden", "Headplane")
SERVICE_SLUG=               # URL-safe, lowercase (e.g., "vaultwarden", "headplane")
SERVICE_URL=                # Base URL (e.g., "https://vault.echo6.co")
OIDC_CALLBACK_PATH=         # App's OIDC callback (e.g., "/oidc/callback")
NEEDS_OFFLINE_ACCESS=       # yes/no — does the app need refresh tokens?
CLIENT_TYPE=                # confidential (server-side) or public (SPA/mobile)
```

The redirect URI is `${SERVICE_URL}${OIDC_CALLBACK_PATH}`.

### When to set NEEDS_OFFLINE_ACCESS=yes

- The app stores sessions that must survive service restarts (Headscale, Vaultwarden)
- The app uses refresh tokens for long-lived sessions
- Users shouldn't have to re-authenticate after every restart

### Reserved slugs

These conflict with Authentik's internal OAuth2 endpoints and **cannot be used**: `authorize`, `token`, `device`, `userinfo`, `introspect`, `revoke`.

---

## Step 1: Get API Token

Create an API token from the Authentik admin account. This only needs to happen once — reuse the token across all OIDC setups.

```bash
ssh root@100.64.0.1 "docker exec authentik-server \
  ak create_token --user akadmin --identifier oidc-automation --expiring 2>/dev/null \
  || echo 'Token may already exist — check credentials file'"
```

If the token already exists, retrieve it from `/home/zvx/projects/.ref/credentials` (`AUTHENTIK_API_TOKEN`).

Store it for use in subsequent steps:

```bash
AK_TOKEN="<token>"
AK_API="https://auth.echo6.co/api/v3"
```

---

## Step 2: Look Up Authentik Internal IDs

The API requires UUIDs for flows, scope mappings, and signing keys. These are stable per Authentik instance but must be looked up once.

### Authorization flow

```bash
ssh root@100.64.0.1 "curl -s \
  -H 'Authorization: Bearer $AK_TOKEN' \
  '$AK_API/flows/instances/?slug=default-provider-authorization-implicit-consent' \
  | jq -r '.results[0].pk'"
```

Store as `AUTH_FLOW_PK`.

### Scope mappings

```bash
# Get all scope mapping UUIDs at once
ssh root@100.64.0.1 "curl -s \
  -H 'Authorization: Bearer $AK_TOKEN' \
  '$AK_API/propertymappings/provider/scope/?ordering=scope_name' \
  | jq -r '.results[] | select(.scope_name == \"openid\" or .scope_name == \"email\" or .scope_name == \"profile\" or .scope_name == \"offline_access\") | \"\(.scope_name): \(.pk)\"'"
```

Store each UUID: `SCOPE_OPENID_PK`, `SCOPE_EMAIL_PK`, `SCOPE_PROFILE_PK`, `SCOPE_OFFLINE_PK`.

### Signing key

```bash
ssh root@100.64.0.1 "curl -s \
  -H 'Authorization: Bearer $AK_TOKEN' \
  '$AK_API/crypto/certificatekeypairs/?name=authentik+Self-signed+Certificate&has_key=true' \
  | jq -r '.results[0].pk'"
```

Store as `SIGNING_KEY_PK`.

### Gate

All five values must be non-null. If any are missing, Authentik's default objects may not have been created yet — check that the instance is healthy.

---

## Step 3: Create the OAuth2 Provider

Build the scope mappings array based on whether offline_access is needed:

```bash
# Base scopes (always included)
SCOPES="[\"$SCOPE_OPENID_PK\", \"$SCOPE_EMAIL_PK\", \"$SCOPE_PROFILE_PK\"]"

# Add offline_access if needed
if [ "$NEEDS_OFFLINE_ACCESS" = "yes" ]; then
  SCOPES="[\"$SCOPE_OPENID_PK\", \"$SCOPE_EMAIL_PK\", \"$SCOPE_PROFILE_PK\", \"$SCOPE_OFFLINE_PK\"]"
fi
```

Create the provider:

```bash
PROVIDER_RESPONSE=$(ssh root@100.64.0.1 "curl -s \
  -X POST '$AK_API/providers/oauth2/' \
  -H 'Authorization: Bearer $AK_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    \"name\": \"$SERVICE_NAME\",
    \"authorization_flow\": \"$AUTH_FLOW_PK\",
    \"client_type\": \"$CLIENT_TYPE\",
    \"redirect_uris\": [{
      \"matching_mode\": \"strict\",
      \"url\": \"${SERVICE_URL}${OIDC_CALLBACK_PATH}\"
    }],
    \"signing_key\": \"$SIGNING_KEY_PK\",
    \"property_mappings\": $SCOPES,
    \"access_token_validity\": \"hours=1\",
    \"refresh_token_validity\": \"days=30\"
  }'")

# Extract the values we need
PROVIDER_PK=$(echo "$PROVIDER_RESPONSE" | jq -r '.pk')
CLIENT_ID=$(echo "$PROVIDER_RESPONSE" | jq -r '.client_id')
CLIENT_SECRET=$(echo "$PROVIDER_RESPONSE" | jq -r '.client_secret')

echo "Provider PK:    $PROVIDER_PK"
echo "Client ID:      $CLIENT_ID"
echo "Client Secret:  $CLIENT_SECRET"
```

### Gate

`PROVIDER_PK` must be a number (not null or an error). If the API returns an error, common causes:

- **Duplicate name** — a provider with this name already exists
- **Invalid flow PK** — the authorization flow UUID is wrong
- **Invalid scope PK** — one of the scope mapping UUIDs is wrong

---

## Step 4: Create the Application

```bash
ssh root@100.64.0.1 "curl -s \
  -X POST '$AK_API/core/applications/' \
  -H 'Authorization: Bearer $AK_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    \"name\": \"$SERVICE_NAME\",
    \"slug\": \"$SERVICE_SLUG\",
    \"provider\": $PROVIDER_PK,
    \"meta_launch_url\": \"$SERVICE_URL\"
  }' | jq '{name: .name, slug: .slug, provider: .provider}'"
```

### Gate

Response must include the slug and provider PK. If it fails, the slug may already be in use.

---

## Step 5: Verify Authentik Side

### Discovery endpoint

```bash
curl -s "https://auth.echo6.co/application/o/$SERVICE_SLUG/.well-known/openid-configuration" | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri}'
```

Must return all four fields with valid URLs.

### JWKS endpoint

```bash
curl -s "https://auth.echo6.co/application/o/$SERVICE_SLUG/jwks/" | jq '.keys | length'
```

Must return at least `1`. If it returns `0`, the signing key was not attached to the provider — go back and fix Step 3.

---

## Step 6: Configure the Application

This step varies per application. Use the Client ID, Client Secret, and issuer URL from above.

### OIDC endpoints (all derived from the slug)

```
Issuer:    https://auth.echo6.co/application/o/$SERVICE_SLUG/
Authorize: https://auth.echo6.co/application/o/authorize/
Token:     https://auth.echo6.co/application/o/token/
User Info: https://auth.echo6.co/application/o/userinfo/
JWKS:      https://auth.echo6.co/application/o/$SERVICE_SLUG/jwks/
```

Most apps only need the **Issuer** (or Discovery URL) plus Client ID and Client Secret. The app auto-discovers the rest.

### Common config patterns

**[[environment]] variables (Docker):**

```bash
OIDC_ISSUER=https://auth.echo6.co/application/o/$SERVICE_SLUG/
OIDC_CLIENT_ID=$CLIENT_ID
OIDC_CLIENT_SECRET=$CLIENT_SECRET
OIDC_SCOPES="openid email profile"          # add offline_access if needed
OIDC_REDIRECT_URI=${SERVICE_URL}${OIDC_CALLBACK_PATH}
```

**Config file (YAML):**

```yaml
oidc:
  issuer: "https://auth.echo6.co/application/o/$SERVICE_SLUG/"
  client_id: "$CLIENT_ID"
  client_secret: "$CLIENT_SECRET"
  scope: ["openid", "profile", "email"]     # add "offline_access" if needed
```

### Common alternate names for these values

| Concept | Names you'll see |
|---------|-----------------|
| Issuer | `authority`, `issuer_url`, `sso_authority`, `provider_url` |
| Client ID | `client_id`, `oidc_client_id`, `sso_client_id` |
| Client Secret | `client_secret`, `oidc_client_secret`, `sso_client_secret` |
| Redirect URI | `redirect_uri`, `callback_url`, `oidc_redirect_url` |
| Scopes | `scope`, `scopes`, `oidc_scopes`, `sso_scopes` |

---

## Step 7: Test Login

1. Open `$SERVICE_URL` in a browser
2. Click SSO / OIDC login
3. Should redirect to `auth.echo6.co` → authenticate → redirect back to the app
4. Verify user info is correct (email, display name)

---

## Step 8: Store Credentials

```bash
cat >> /home/zvx/projects/.ref/credentials << EOF

# $SERVICE_NAME OIDC
${SERVICE_SLUG^^}_OIDC_CLIENT_ID=$CLIENT_ID
${SERVICE_SLUG^^}_OIDC_CLIENT_SECRET=$CLIENT_SECRET
${SERVICE_SLUG^^}_OIDC_ISSUER=https://auth.echo6.co/application/o/$SERVICE_SLUG/
EOF
```

---

## Troubleshooting

### SSO login redirects back to login page (loop)

Check in order:

1. **Access token validity too short** — increase to at least `hours=1`
2. **Missing `offline_access` scope** — app can't refresh tokens, session expires immediately
3. **Missing signing key** — JWKS endpoint returns empty, app can't verify tokens

Debug via API:

```bash
ssh root@100.64.0.1 "curl -s \
  -H 'Authorization: Bearer $AK_TOKEN' \
  '$AK_API/providers/oauth2/?search=$SERVICE_NAME' \
  | jq '.results[0] | {name, client_id, signing_key, access_token_validity, refresh_token_validity, property_mappings}'"
```

Or via ak shell:

```bash
ssh root@100.64.0.1 "docker exec authentik-server ak shell -c \"
from authentik.providers.oauth2.models import OAuth2Provider
p = OAuth2Provider.objects.get(name='$SERVICE_NAME')
print(f'Access Token: {p.access_token_validity}')
print(f'Refresh Token: {p.refresh_token_validity}')
print(f'Signing Key: {p.signing_key}')
print(f'Scopes: {list(p.property_mappings.values_list(\\\"scope_name\\\", flat=True))}')
\""
```

### "Failed to discover OpenID provider" / discovery error

1. JWKS endpoint is empty → signing key missing from provider
2. Authentik unreachable from the app → test with `curl` from the app's host
3. Wrong issuer URL → must include trailing slash, must match the slug exactly

### "Invalid redirect URI"

The redirect URI in the app config must **exactly** match what's in Authentik — scheme, trailing slashes, path, everything.

### User authenticated but gets "access denied"

User isn't authorized for the application. By default all authenticated users have access. If you've added group restrictions via policy bindings, verify the user is in the correct group:

```bash
ssh root@100.64.0.1 "curl -s \
  -H 'Authorization: Bearer $AK_TOKEN' \
  '$AK_API/core/applications/$SERVICE_SLUG/' \
  | jq '{name, slug, policy_engine_mode}'"
```

### Token/session breaks after service restart

Missing `offline_access` scope. Without refresh tokens, sessions only last as long as the access token validity.

### Delete and recreate (nuclear option)

```bash
# Delete application first (it references the provider)
ssh root@100.64.0.1 "curl -s -X DELETE \
  -H 'Authorization: Bearer $AK_TOKEN' \
  '$AK_API/core/applications/$SERVICE_SLUG/'"

# Then delete provider
ssh root@100.64.0.1 "curl -s -X DELETE \
  -H 'Authorization: Bearer $AK_TOKEN' \
  '$AK_API/providers/oauth2/$PROVIDER_PK/'"
```

Then re-run from Step 3.

---

## Quick Reference: Existing OIDC Applications

| Application | Slug | Redirect URI | offline_access |
|-------------|------|-------------|----------------|
| Headscale | `headscale` | `https://vpn.echo6.co/oidc/callback` | Yes |
| Headplane | `headplane` | `https://vpn.echo6.co/admin/oidc/callback` | No |
| Vaultwarden | `vaultwarden` | `https://vault.echo6.co/identity/connect/oidc-signin` | Yes |
