diff --git a/scripts/authentik-setup.sh b/scripts/authentik-setup.sh new file mode 100644 index 0000000..4090626 --- /dev/null +++ b/scripts/authentik-setup.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# One-shot Authentik setup for LisiLou (issues #10 + #12). +# +# Creates (idempotently — safe to re-run): +# 1. OAuth2/OpenID provider "lisilou-portfolio" (confidential, PKCE-friendly) +# with redirect URIs for dev + prod +# 2. Application "LisiLou Portfolio" bound to the provider +# 3. Group "lisilou-admin" (dashboard access) and optionally adds a user +# 4. Enrollment flow "lisilou-enrollment" so clients can self-register (#12), +# and sets it as the brand's enrollment flow (adds a "Sign up" link) +# +# Usage: +# AUTHENTIK_URL=https://auth.jerodrigged.com AUTHENTIK_TOKEN= \ +# bash scripts/authentik-setup.sh [admin-username-to-add] +# +# The API token comes from: Admin interface → Directory → Tokens → Create +# (intent: API). Prints ready-to-paste api/.env lines at the end. + +set -euo pipefail + +: "${AUTHENTIK_URL:?set AUTHENTIK_URL, e.g. https://auth.jerodrigged.com}" +: "${AUTHENTIK_TOKEN:?set AUTHENTIK_TOKEN (Directory → Tokens, intent API)}" +ADMIN_USER="${1:-}" + +BASE="${AUTHENTIK_URL%/}/api/v3" +DEV_REDIRECT="https://dev-lisilou.jerodrigged.com/api/auth/callback" +PROD_REDIRECT="https://lisilou.jerodrigged.com/api/auth/callback" +CLIENT_ID="lisilou-portfolio" + +api() { # method path [json-body] + local method="$1" path="$2" body="${3:-}" + if [ -n "$body" ]; then + curl -sS --fail-with-body -X "$method" "$BASE$path" \ + -H "Authorization: Bearer $AUTHENTIK_TOKEN" \ + -H "Content-Type: application/json" -d "$body" + else + curl -sS --fail-with-body -X "$method" "$BASE$path" \ + -H "Authorization: Bearer $AUTHENTIK_TOKEN" + fi +} + +jget() { python -c "import sys,json;d=json.load(sys.stdin);print(eval(sys.argv[1]))" "$2" <<<"$1"; } + +echo "── Checking API access…" +VERSION=$(api GET /admin/version/ | jget - "d['version_current']" 2>/dev/null || true) +[ -n "$VERSION" ] || { echo "Cannot reach Authentik API — is CT121 running and the token valid?"; exit 1; } +echo " Authentik $VERSION" + +# ── 1. Flows and scope mappings we need to reference ───────────────────────── +AUTHZ_FLOW=$(api GET "/flows/instances/?slug=default-provider-authorization-implicit-consent" | jget - "d['results'][0]['pk']") +INVALIDATION_FLOW=$(api GET "/flows/instances/?slug=default-provider-invalidation-flow" | jget - "d['results'][0]['pk']" 2>/dev/null || echo "") + +SCOPES=$(api GET "/propertymappings/provider/scope/?managed__iexact=goauthentik.io/providers/oauth2/scope-openid" | jget - "d['results'][0]['pk']") +for s in profile email; do + SCOPES="$SCOPES,$(api GET "/propertymappings/provider/scope/?managed__iexact=goauthentik.io/providers/oauth2/scope-$s" | jget - "d['results'][0]['pk']")" +done +SCOPES_JSON=$(python -c "import sys;print(__import__('json').dumps(sys.argv[1].split(',')))" "$SCOPES") + +# ── 2. OAuth2 provider ──────────────────────────────────────────────────────── +echo "── Provider…" +EXISTING=$(api GET "/providers/oauth2/?name=$CLIENT_ID" | jget - "len(d['results'])") +if [ "$EXISTING" -gt 0 ]; then + PROVIDER_PK=$(api GET "/providers/oauth2/?name=$CLIENT_ID" | jget - "d['results'][0]['pk']") + echo " exists (pk=$PROVIDER_PK)" +else + BODY=$(python - "$AUTHZ_FLOW" "$INVALIDATION_FLOW" "$SCOPES_JSON" <<'PY' +import json, sys +authz, inval, scopes = sys.argv[1], sys.argv[2], json.loads(sys.argv[3]) +p = { + "name": "lisilou-portfolio", + "authorization_flow": authz, + "client_type": "confidential", + "client_id": "lisilou-portfolio", + "property_mappings": scopes, + "redirect_uris": [ + {"matching_mode": "strict", "url": "https://dev-lisilou.jerodrigged.com/api/auth/callback"}, + {"matching_mode": "strict", "url": "https://lisilou.jerodrigged.com/api/auth/callback"}, + ], + "sub_mode": "hashed_user_id", + "include_claims_in_id_token": True, +} +if inval: p["invalidation_flow"] = inval +print(json.dumps(p)) +PY +) + RESP=$(api POST /providers/oauth2/ "$BODY" 2>&1) || { + # Older Authentik (<2024.2) wants redirect_uris as a newline-joined string + BODY=$(python -c " +import json,sys +p=json.loads(sys.argv[1]); p['redirect_uris']='\n'.join(u['url'] for u in p['redirect_uris']); print(json.dumps(p))" "$BODY") + RESP=$(api POST /providers/oauth2/ "$BODY") + } + PROVIDER_PK=$(jget "$RESP" "d['pk']") + echo " created (pk=$PROVIDER_PK)" +fi +CLIENT_SECRET=$(api GET "/providers/oauth2/$PROVIDER_PK/" | jget - "d['client_secret']") + +# ── 3. Application ──────────────────────────────────────────────────────────── +echo "── Application…" +if [ "$(api GET "/core/applications/?slug=lisilou" | jget - "len(d['results'])")" -gt 0 ]; then + echo " exists" + api PATCH "/core/applications/lisilou/" "{\"provider\": $PROVIDER_PK}" >/dev/null +else + api POST /core/applications/ "{\"name\": \"LisiLou Portfolio\", \"slug\": \"lisilou\", \"provider\": $PROVIDER_PK, \"meta_launch_url\": \"https://lisilou.jerodrigged.com/my-bookings\"}" >/dev/null + echo " created" +fi + +# ── 4. Admin group (+ optional member) ─────────────────────────────────────── +echo "── Group lisilou-admin…" +if [ "$(api GET "/core/groups/?name=lisilou-admin" | jget - "len(d['results'])")" -gt 0 ]; then + GROUP_UUID=$(api GET "/core/groups/?name=lisilou-admin" | jget - "d['results'][0]['pk']") + echo " exists" +else + GROUP_UUID=$(api POST /core/groups/ '{"name": "lisilou-admin"}' | jget - "d['pk']") + echo " created" +fi +if [ -n "$ADMIN_USER" ]; then + USER_PK=$(api GET "/core/users/?username=$ADMIN_USER" | jget - "d['results'][0]['pk']" 2>/dev/null || echo "") + if [ -n "$USER_PK" ]; then + api POST "/core/groups/$GROUP_UUID/add_user/" "{\"pk\": $USER_PK}" >/dev/null && echo " added $ADMIN_USER" + else + echo " WARNING: user '$ADMIN_USER' not found — add to lisilou-admin manually" + fi +fi + +# ── 5. Enrollment flow (issue #12) ──────────────────────────────────────────── +echo "── Enrollment flow…" +if [ "$(api GET "/flows/instances/?slug=lisilou-enrollment" | jget - "len(d['results'])")" -gt 0 ]; then + FLOW_PK=$(api GET "/flows/instances/?slug=lisilou-enrollment" | jget - "d['results'][0]['pk']") + echo " exists" +else + FLOW_PK=$(api POST /flows/instances/ '{ + "name": "LisiLou client sign-up", + "slug": "lisilou-enrollment", + "title": "Create your LisiLou Photography account", + "designation": "enrollment", + "authentication": "require_unauthenticated", + "compatibility_mode": true + }' | jget - "d['pk']") + + # Prompt fields (created only if a same-key field doesn't already exist) + declare -A FIELD_PKS + make_field() { # key label type order placeholder + local existing + existing=$(api GET "/stages/prompt/prompts/?field_key=$1&name=lisilou-$1" | jget - "d['results'][0]['pk']" 2>/dev/null || echo "") + if [ -n "$existing" ]; then FIELD_PKS[$1]=$existing; return; fi + FIELD_PKS[$1]=$(api POST /stages/prompt/prompts/ "{ + \"name\": \"lisilou-$1\", \"field_key\": \"$1\", \"label\": \"$2\", + \"type\": \"$3\", \"required\": true, \"order\": $4, \"placeholder\": \"$5\" + }" | jget - "d['pk']") + } + make_field username "Username" username 0 "" + make_field name "Full name" text 1 "Jane Smith" + make_field email "Email" email 2 "you@example.com" + make_field password "Password" password 3 "" + make_field password_repeat "Confirm password" password 4 "" + + PROMPT_STAGE=$(api POST /stages/prompt/ "{ + \"name\": \"lisilou-enrollment-prompt\", + \"fields\": [\"${FIELD_PKS[username]}\",\"${FIELD_PKS[name]}\",\"${FIELD_PKS[email]}\",\"${FIELD_PKS[password]}\",\"${FIELD_PKS[password_repeat]}\"] + }" | jget - "d['pk']") + WRITE_STAGE=$(api POST /stages/user_write/ '{"name": "lisilou-enrollment-write", "user_creation_mode": "always_create", "create_users_as_inactive": false}' \ + | jget - "d['pk']" 2>/dev/null || \ + api POST /stages/user_write/ '{"name": "lisilou-enrollment-write", "can_create_users": true}' | jget - "d['pk']") + LOGIN_STAGE=$(api POST /stages/user_login/ '{"name": "lisilou-enrollment-login"}' | jget - "d['pk']") + + for binding in "$PROMPT_STAGE 10" "$WRITE_STAGE 20" "$LOGIN_STAGE 30"; do + set -- $binding + api POST /flows/bindings/ "{\"target\": \"$FLOW_PK\", \"stage\": \"$1\", \"order\": $2}" >/dev/null + done + echo " created (prompt → user_write → login)" +fi + +# Point the active brand's enrollment flow at it (adds "Sign up" on the login page) +BRAND=$(api GET "/core/brands/?default=true" | jget - "d['results'][0]['brand_uuid']" 2>/dev/null || echo "") +if [ -n "$BRAND" ]; then + api PATCH "/core/brands/$BRAND/" "{\"flow_enrollment\": \"$FLOW_PK\"}" >/dev/null + echo " set as brand enrollment flow" +else + echo " NOTE: could not find default brand — set Flows→enrollment manually in Brands" +fi + +# ── Done ────────────────────────────────────────────────────────────────────── +cat </dev/null || python -c "import secrets;print(secrets.token_hex(32))") + +Then: docker compose restart api +EOF