From 160fda882feb2bd86cb780b338af9c470bd629ec Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Fri, 13 Mar 2026 20:06:17 +0000 Subject: [PATCH] authentik: cleanup unused resources + add invitation enrollment flow [ci skip] Cleanup: - Deleted 5 unused flows (enrollment-inviation, headscale-auth/authz, default-enrollment, oauth-enrollment) - Deleted 8 orphaned stages bound only to deleted flows - Deleted authentik Read-only group and role (0 users) - Deleted 2 unbound policies (map github username, Map Google Attributes) Invitation enrollment: - Created invitation-enrollment flow with 5 stages (invitation validation, identification with social login, prompt, user write, auto-login) - Set all OAuth sources (Google/GitHub/Facebook) enrollment_flow to invitation-enrollment - New users can only sign up via single-use invitation links - Added authentik-invite.sh script for invitation management - Updated reference docs and authentik skill --- .claude/reference/authentik-state.md | 85 ++++++++-- .claude/scripts/authentik-invite.sh | 180 +++++++++++++++++++++ .claude/skills/archived/authentik/SKILL.md | 43 +++++ 3 files changed, 295 insertions(+), 13 deletions(-) create mode 100755 .claude/scripts/authentik-invite.sh diff --git a/.claude/reference/authentik-state.md b/.claude/reference/authentik-state.md index 34fef453..8d535bba 100644 --- a/.claude/reference/authentik-state.md +++ b/.claude/reference/authentik-state.md @@ -2,11 +2,12 @@ > Snapshot of applications, groups, users, and flows. Use `authentik` skill for management tasks. -## Applications (9) +## Applications (10) | Application | Provider Type | Auth Flow | |-------------|--------------|-----------| | Cloudflare Access | OAuth2/OIDC | explicit consent | | Domain wide catch all | Proxy (forward auth) | implicit consent | +| Forgejo | OAuth2/OIDC | explicit consent | | Grafana | OAuth2/OIDC | implicit consent | | Headscale | OAuth2/OIDC | explicit consent | | Immich | OAuth2/OIDC | explicit consent | @@ -18,17 +19,17 @@ ## Groups (9) | Group | Parent | Superuser | Purpose | |-------|--------|-----------|---------| -| Allow Login Users | — | No | Parent group for login-permitted users | -| authentik Admins | — | Yes | Full admin access | -| authentik Read-only | — | No | Read-only access (has role) | +| Allow Login Users | -- | No | Parent group for login-permitted users | +| authentik Admins | -- | Yes | Full admin access | | Headscale Users | Allow Login Users | No | VPN access | | Home Server Admins | Allow Login Users | No | Server admin access | | Wrongmove Users | Allow Login Users | No | Real-estate app access | -| kubernetes-admins | — | No | K8s cluster-admin RBAC | -| kubernetes-power-users | — | No | K8s power-user RBAC | -| kubernetes-namespace-owners | — | No | K8s namespace-owner RBAC | +| kubernetes-admins | -- | No | K8s cluster-admin RBAC | +| kubernetes-power-users | -- | No | K8s power-user RBAC | +| kubernetes-namespace-owners | -- | No | K8s namespace-owner RBAC | +| Task Submitters | -- | No | Task submission access | -## Users (7 real) +## Users (8 real) | Username | Name | Type | Groups | |----------|------|------|--------| | akadmin | authentik Default Admin | internal | authentik Admins, Home Server Admins, Headscale Users | @@ -36,15 +37,73 @@ | emil.barzin@gmail.com | Emil Barzin | internal | Home Server Admins, Headscale Users | | ancaelena98@gmail.com | Anca Milea | external | Wrongmove Users, Headscale Users | | vabbit81@gmail.com | GHEORGHE Milea | external | Headscale Users | -| valentinakolevabarzina@gmail.com | Валентина Колева-Барзина | internal | Headscale Users | -| anca.r.cristian10@gmail.com | — | internal | Wrongmove Users | +| valentinakolevabarzina@gmail.com | Valentina | internal | Headscale Users | +| anca.r.cristian10@gmail.com | -- | internal | Wrongmove Users | | kadir.tugan@gmail.com | Kadir | internal | Wrongmove Users | ## Login Sources -- **Google** (OAuth) — user matching by identifier -- **GitHub** (OAuth) — user matching by email_link -- **Facebook** (OAuth) — user matching by email_link +- **Google** (OAuth) -- user matching by identifier +- **GitHub** (OAuth) -- user matching by email_link +- **Facebook** (OAuth) -- user matching by email_link +- All sources use `invitation-enrollment` as enrollment flow (new users require invitation) ## Authorization Flows - **Explicit consent** (`default-provider-authorization-explicit-consent`): Shows consent screen - **Implicit consent** (`default-provider-authorization-implicit-consent`): Auto-redirects + +## Invitation Enrollment Flow +Slug: `invitation-enrollment` | PK: `7d667321-2b02-4e16-8161-148078a8dac1` + +New users can only sign up via invitation link. Admins generate single-use invite links. + +### Stages (in order) +| Order | Stage | Type | Purpose | +|-------|-------|------|---------| +| 10 | invitation-validation | Invitation | Validates `?itoken=` parameter, blocks without valid token | +| 20 | enrollment-identification | Identification | Shows social login (Google/GitHub/Facebook) + passkey | +| 30 | enrollment-prompt | Prompt | Collects name and email (pre-filled from social login) | +| 40 | enrollment-user-write | User Write | Creates user in `Allow Login Users` group | +| 50 | enrollment-login | User Login | Auto-login after signup | + +### Invitation Management +Script: `.claude/scripts/authentik-invite.sh` + +```bash +# Create invitation (single-use, no expiry) +./authentik-invite.sh create "Headscale Users" + +# Create invitation with expiry +./authentik-invite.sh create "Wrongmove Users" --days 7 + +# Add user to group after enrollment +./authentik-invite.sh assign "Headscale Users" + +# List pending invitations +./authentik-invite.sh list +``` + +Invited users sign up via social login (Google/GitHub/Facebook) or passkey. No username/password enrollment. + +## Cleanup Log (2026-03-13) +### Deleted Flows +- `enrollment-inviation` (typo) -- previous invitation attempt +- `headscale-authentication` -- not used by any provider +- `headscale-authorization` -- not used by any provider +- `default-enrollment-flow` -- password-based, unused +- `oauth-enrollment` -- replaced by invitation-enrollment + +### Deleted Stages +- `enrollment-invitation`, `enrollment-invitation-write` (from old invitation flow) +- `invitation` (unbound) +- `default-enrollment-prompt-first`, `default-enrollment-prompt-second` (from default enrollment) +- `default-enrollment-user-write`, `default-enrollment-email-verification`, `default-enrollment-user-login` + +### Deleted Groups +- `authentik Read-only` -- 0 users, unused role + +### Deleted Policies +- `map github username to email` -- unbound +- `Map Google Attributes` -- unbound + +### Deleted Roles +- `authentik Read-only` -- no group assignment diff --git a/.claude/scripts/authentik-invite.sh b/.claude/scripts/authentik-invite.sh new file mode 100755 index 00000000..25c7b6ab --- /dev/null +++ b/.claude/scripts/authentik-invite.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Authentik Invitation Management Script +# Usage: +# ./authentik-invite.sh create "Group Name" # Single-use, no expiry +# ./authentik-invite.sh create "Group Name" --days 7 # Expires in 7 days +# ./authentik-invite.sh assign "Group Name" # Add user to group +# ./authentik-invite.sh list # Show pending invitations + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INFRA_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +API="https://authentik.viktorbarzin.me/api/v3" +FLOW_SLUG="invitation-enrollment" + +get_token() { + grep authentik_api_token "$INFRA_DIR/terraform.tfvars" | cut -d'"' -f2 +} + +api_get() { + curl -sf -H "Authorization: Bearer $(get_token)" "$API/$1" +} + +api_post() { + curl -sf -X POST \ + -H "Authorization: Bearer $(get_token)" \ + -H "Content-Type: application/json" \ + "$API/$1" -d "$2" +} + +api_patch() { + curl -sf -X PATCH \ + -H "Authorization: Bearer $(get_token)" \ + -H "Content-Type: application/json" \ + "$API/$1" -d "$2" +} + +cmd_create() { + local group_name="${1:?Usage: create [--days N]}" + local days="" + + shift + while [[ $# -gt 0 ]]; do + case "$1" in + --days) days="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac + done + + # Build invitation payload + # Get flow PK + local flow_pk + flow_pk=$(api_get "flows/instances/$FLOW_SLUG/" | python3 -c "import json,sys; print(json.load(sys.stdin)['pk'])") + + local payload + payload=$(python3 -c " +import json, sys, re +from datetime import datetime, timedelta, timezone + +slug = re.sub(r'[^a-z0-9-]', '-', '$group_name'.lower()).strip('-') +data = { + 'name': 'invite-' + slug + '-' + datetime.now(timezone.utc).strftime('%Y%m%d-%H%M'), + 'single_use': True, + 'fixed_data': {'group': '$group_name'}, + 'flow': '$flow_pk' +} + +days = '$days' +if days: + expires = datetime.now(timezone.utc) + timedelta(days=int(days)) + data['expires'] = expires.isoformat() + +print(json.dumps(data)) +") + + local result + result=$(api_post "stages/invitation/invitations/" "$payload") + local token + token=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['pk'])") + + echo "" + echo "Invitation created for group: $group_name" + if [[ -n "$days" ]]; then + echo "Expires in: $days days" + else + echo "Expires: never" + fi + echo "Single-use: yes" + echo "" + echo "Share this link:" + echo " https://authentik.viktorbarzin.me/if/flow/$FLOW_SLUG/?itoken=$token" + echo "" +} + +cmd_assign() { + local username="${1:?Usage: assign }" + local group_name="${2:?Usage: assign }" + + # Find user PK + local user_pk + user_pk=$(api_get "core/users/?search=$username" | python3 -c " +import json, sys +users = json.load(sys.stdin)['results'] +if not users: + print('NOT_FOUND', file=sys.stderr) + sys.exit(1) +print(users[0]['pk']) +") + + # Find group PK and current users + local group_data + group_data=$(api_get "core/groups/?search=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$group_name'))")" | python3 -c " +import json, sys +groups = json.load(sys.stdin)['results'] +matches = [g for g in groups if g['name'] == '$group_name'] +if not matches: + print('NOT_FOUND', file=sys.stderr) + sys.exit(1) +g = matches[0] +users = g.get('users', []) +print(json.dumps({'pk': g['pk'], 'users': users})) +") + + local group_pk + group_pk=$(echo "$group_data" | python3 -c "import json,sys; print(json.load(sys.stdin)['pk'])") + + # Add user to group + local updated_users + updated_users=$(echo "$group_data" | python3 -c " +import json, sys +d = json.load(sys.stdin) +users = d['users'] +uid = $user_pk +if uid not in users: + users.append(uid) +print(json.dumps(users)) +") + + api_patch "core/groups/$group_pk/" "{\"users\": $updated_users}" > /dev/null + + echo "Added $username (pk=$user_pk) to group '$group_name'" +} + +cmd_list() { + api_get "stages/invitation/invitations/?page_size=50" | python3 -c " +import json, sys +data = json.load(sys.stdin) +if not data['results']: + print('No pending invitations.') + sys.exit(0) + +print(f\"{'Token (itoken)':<40} {'Name':<50} {'Single-Use':<12} {'Expires':<25} {'Group'}\") +print('-' * 160) +for inv in data['results']: + token = inv['pk'] + name = inv.get('name', '') + single = 'yes' if inv.get('single_use') else 'no' + expires = inv.get('expires') or 'never' + if expires != 'never': + expires = expires[:19] + group = inv.get('fixed_data', {}).get('group', '—') + print(f'{token:<40} {name:<50} {single:<12} {expires:<25} {group}') +print(f\"\\nTotal: {data['pagination']['count']}\") +" +} + +case "${1:-help}" in + create) shift; cmd_create "$@" ;; + assign) shift; cmd_assign "$@" ;; + list) cmd_list ;; + *) + echo "Authentik Invitation Manager" + echo "" + echo "Usage:" + echo " $0 create [--days N] Create single-use invite link" + echo " $0 assign Add user to group" + echo " $0 list Show pending invitations" + ;; +esac diff --git a/.claude/skills/archived/authentik/SKILL.md b/.claude/skills/archived/authentik/SKILL.md index b4edd770..b8549c7b 100644 --- a/.claude/skills/archived/authentik/SKILL.md +++ b/.claude/skills/archived/authentik/SKILL.md @@ -237,6 +237,49 @@ To protect a service via Authentik + Traefik forward auth: - `X-authentik-name` - `X-authentik-groups` +## Invitation Management + +### Create Invitation +```bash +curl -s -X POST \ + -H "Authorization: Bearer $AUTHENTIK_TOKEN" \ + -H "Content-Type: application/json" \ + "https://authentik.viktorbarzin.me/api/v3/stages/invitation/invitations/" \ + -d '{ + "name": "invite-slug-name", + "single_use": true, + "fixed_data": {"group": "Target Group Name"}, + "flow": "" + }' +# Returns PK which is the itoken +# Link: https://authentik.viktorbarzin.me/if/flow/invitation-enrollment/?itoken= +``` + +### List Invitations +```bash +curl -s -H "Authorization: Bearer $AUTHENTIK_TOKEN" \ + "https://authentik.viktorbarzin.me/api/v3/stages/invitation/invitations/?page_size=50" +``` + +### Delete Invitation +```bash +curl -s -X DELETE -H "Authorization: Bearer $AUTHENTIK_TOKEN" \ + "https://authentik.viktorbarzin.me/api/v3/stages/invitation/invitations//" +``` + +### Helper Script +Use `.claude/scripts/authentik-invite.sh` for invitation management: +```bash +./authentik-invite.sh create "Group Name" [--days N] +./authentik-invite.sh assign "Group Name" +./authentik-invite.sh list +``` + +### Important Notes +- OAuth source `enrollment_flow` is set to `invitation-enrollment` -- new social login users require invitation +- Source updates require Django ORM (PATCH not supported on `sources/oauth//`) +- Invitation `name` field must be a slug (letters, numbers, hyphens, underscores) + ## Gotchas 1. **API pagination**: All list endpoints return paginated results. Use `?page_size=50` or check `pagination.next` for more pages.