k8s/iam: add cloudnativepg postgres and vault + authentik

- PostgreSQL 3-replica HA with pgvector
- Vault S3 storage backend (MinIO)
- Authentik federated OIDC provider
- Vault auto-unseal via postStart hook
This commit is contained in:
Story Crater Bot
2026-08-18 15:08:00 -07:00
parent 7da222e243
commit 831dd50805
20 changed files with 3047 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
# CloudNativePG Operator Configuration
# Handles PostgreSQL cluster management with HA replication
#
# Timeout settings tuned for clusters with 5+ second network latency spikes
# Operator deployment
replicaCount: 1
# Operator Pod configuration
podAnnotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
# Resource limits
resources:
limits:
memory: 512Mi
requests:
memory: 256Mi
# Monitoring
monitoring:
enabled: true
serviceMonitor:
enabled: false # disable until we have prometheus-operator CRDs
# ── Configuration for operator behavior ────────────────────────────────────────
# Applied to the cnpg-controller-manager-config ConfigMap
config:
# HTTP client timeout for communicating with Postgres instances
# Default: 30s. Increased to 120s to tolerate 5+ second network latency spikes
instanceManagerHTTPClientTimeout: 120s
# TLS verification for instance manager
instanceManagerTLSInsecureSkipVerify: false
# Allow debug logging to diagnose HTTP communication issues
enableDebugLogging: true
# Pod debugging (disabled; security risk in production)
enablePodDebugging: false
# In-place updates for instance manager (disabled; safer for HA)
enableInstanceManagerInplaceUpdates: false
# Azure PVC updates (not applicable for Longhorn)
enableAzurePVCUpdates: false
# Certificate lifetime (90 days) and renewal threshold (7 days before expiry)
certificateDuration: 90
expiringCheckThreshold: 7
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Safe database initialization script
# Applies init-users.sql with environment variable substitution
# Exit on any error
set -euo pipefail
# Get superuser password from CNPG secret
PG_PASSWORD=$(kubectl get secret -n ddb ddb-cluster-app -o jsonpath='{.data.password}' | base64 -d)
export PGPASSWORD="$PG_PASSWORD"
# Apply SQL with safe variable substitution (psql -v prevents injection)
psql \
-h ddb-cluster-rw.ddb.svc.cluster.local \
-U postgres \
-d postgres \
-v authentik_password="$AUTHENTIK_PG_PASSWORD" \
-v story_crater_password="$STORY_CRATER_PG_PASSWORD" \
-f k8s/ddb/init-users.sql
unset PGPASSWORD
echo "✓ Database initialization complete"
+11
View File
@@ -0,0 +1,11 @@
-- Database initialization for homelab applications
-- Idempotent: safe to re-run
-- Required environment variables:
-- AUTHENTIK_PG_PASSWORD
-- STORY_CRATER_PG_PASSWORD
CREATE ROLE IF NOT EXISTS authentik WITH LOGIN PASSWORD :'authentik_password';
CREATE DATABASE IF NOT EXISTS authentik OWNER authentik;
CREATE ROLE IF NOT EXISTS story_crater WITH LOGIN PASSWORD :'story_crater_password';
CREATE DATABASE IF NOT EXISTS story_crater OWNER story_crater;
+27
View File
@@ -0,0 +1,27 @@
# talos-iam/.env.example
# Copy to talos-iam/.env and fill in. The real .env is gitignored — never commit it.
# These are ADMIN credentials for the homelab identity provider; use strong values.
# Authentik's signing/encryption key. SET ONCE — rotating it invalidates all
# existing sessions, tokens, and encrypted fields. Generate:
# openssl rand -base64 60 | tr -d '\n'
AUTHENTIK_SECRET_KEY=
# Initial password for the built-in admin user 'akadmin'. Change after first login.
# openssl rand -base64 24
AUTHENTIK_BOOTSTRAP_PASSWORD=
# Initial API token for 'akadmin' (used for automation / blueprints).
# openssl rand -hex 32
AUTHENTIK_BOOTSTRAP_TOKEN=
# Password for the bundled PostgreSQL 'authentik' user (system of record).
# openssl rand -base64 24
PG_PASSWORD=
# OAuth2 client secrets for downstream OIDC integrations.
# These are registered with Authentik and injected as K8s secrets
# into the logging and storage namespaces by this bootstrap script.
# openssl rand -hex 32
GRAFANA_OIDC_CLIENT_SECRET=
MINIO_OIDC_CLIENT_SECRET=
+479
View File
@@ -0,0 +1,479 @@
# OAuth App Setup & Management Guide
This guide provides a standardized workflow for registering new services with the homelab OIDC provider (Authentik) and wiring them for Vault JWT authentication.
> **TL;DR:** Run `register_oauth_app.py` with your app's details; it handles Authentik + Vault + K8s secrets automatically.
---
## Architecture Overview
The homelab uses a three-tier authentication stack:
```
User / Service
Authentik (OIDC IdP)
├─ Grafana
├─ MinIO
├─ Forgejo
├─ Argo CD
├─ Your New Service
└─ ...
↓ (device code / authorization code flow)
Vault (KV + JWT auth)
├─ JWT role mapping (sub/groups → policies)
├─ OIDC browser login (vault-browser provider)
└─ KV secrets (secret/cluster/*, secret/mcp/*)
```
**Key concepts:**
- **Authentik** is the OIDC identity provider — it issues tokens and manages users/groups.
- **Vault** validates Authentik's JWT tokens and maps them to policies & secret access.
- **Kubernetes secrets** store OAuth credentials that services read at startup (ConfigMap-based).
- **Groups** in Authentik control access policies and Vault role assignment.
---
## Prerequisites
Before registering a new OAuth service, ensure:
1. **Authentik is running** (in `iam` namespace)
```bash
kubectl get pods -n iam | grep authentik-server
```
2. **Vault is running** (in `storage` namespace)
```bash
kubectl get pods -n storage | grep vault-0
```
3. **SSH/API access to Authentik** — port-forward available
```bash
kubectl port-forward -n iam svc/authentik-server 7000:80
```
4. **Vault bootstrap is complete** (run `setup_vault.sh` if not already done)
5. **Required environment variables** — populate these in `~/.authentik/.env`:
```bash
# Token for Authentik API (from bootstrap)
AUTHENTIK_BOOTSTRAP_TOKEN=<your-token>
# Service-specific client secret (generate via openssl rand -hex 32)
# Example: MY_SERVICE_OIDC_CLIENT_SECRET=<generated-secret>
```
---
## One-Line Setup (Recommended)
For most services, use the automated registration script:
```bash
cd /Users/rockliang/workplace/homelab
# Register an OAuth app with automatic Vault JWT wiring
python3 k8s/talos-iam/register_oauth_app.py \
--service-name my-app \
--namespace my-namespace \
--redirect-uri "https://my-app.riotpiao.homelab.com/oauth2/callback" \
--service-name-in-vault "MY_SERVICE" \
--vault-jwt-policy "shell-secrets"
```
The script will:
1. ✅ Create an OAuth2 provider in Authentik (from credentials in Vault)
2. ✅ Create an Application in Authentik
3. ✅ Create/bind groups for RBAC (optional)
4. ✅ Create a Kubernetes secret with the client credentials
5. ✅ Create a Vault JWT role for the service's JWT auth method
6. ✅ Write policies for the service's scope in Vault
---
## Manual Setup (Step-by-Step)
If you prefer manual control, or if the script doesn't fit your use case:
### Step 1: Generate Credentials
```bash
# Generate a new client secret
CLIENT_SECRET=$(openssl rand -hex 32)
echo "CLIENT_SECRET=$CLIENT_SECRET"
# Store in Vault (required for service initialization)
# Convention: YOURSERVICE_OIDC_CLIENT_SECRET
talos put cluster/YOURSERVICE_OIDC_CLIENT_SECRET YOURSERVICE_OIDC_CLIENT_SECRET="$CLIENT_SECRET"
# Or set in .env temporarily
export YOURSERVICE_OIDC_CLIENT_SECRET="$CLIENT_SECRET"
```
### Step 2: Create Authentik OAuth2 Provider
Access Authentik UI via port-forward:
```bash
kubectl port-forward -n iam svc/authentik-server 7000:80 &
# http://localhost:7000/if/admin/ → login with akadmin
```
**In Authentik UI:**
1. Navigate to **Applications → Providers → Create → OpenID Connect (OAuth2) Provider**
2. Fill in:
- **Name:** `yourservice`
- **Client ID:** `yourservice` (or custom)
- **Client Secret:** (paste from `$CLIENT_SECRET` above)
- **Redirect URIs:** `https://your-app.riotpiao.homelab.com/oauth2/callback` (or your app's callback URL)
- **Sub Mode:** `Hashed User ID`
- **Include claims in ID Token:** ✓ Enabled
- **Grant Types:** `Authorization Code`, `Refresh Token`
- **Signing Key:** (select the homelab-oidc key)
3. **Save** and note the **provider slug** (usually auto-generated from Name).
### Step 3: Create Authentik Application
**In Authentik UI:**
1. Navigate to **Applications → Applications → Create**
2. Fill in:
- **Name:** `Your App Display Name`
- **Slug:** `yourservice` (must match provider slug)
- **Provider:** (select the provider created above)
- **Meta Launch URL:** `https://your-app.riotpiao.homelab.com` (optional, for app launcher)
3. **Save**
### Step 4: Configure OIDC in Your Service
Pass the following environment variables to your service:
```bash
# OIDC endpoint (in-cluster: authentik-server.iam.svc.cluster.local)
OIDC_ISSUER_URL=http://authentik-server.iam.svc.cluster.local/application/o/yourservice/
# OAuth2 credentials (from step 1-2)
OIDC_CLIENT_ID=yourservice
OIDC_CLIENT_SECRET=$CLIENT_SECRET
# Redirect URI (must match what you configured in step 2)
OIDC_REDIRECT_URI=https://your-app.riotpiao.homelab.com/oauth2/callback
# Optionally, token validation endpoint
OIDC_TOKEN_URL=http://authentik-server.iam.svc.cluster.local/application/o/yourservice/token/
OIDC_USERINFO_URL=http://authentik-server.iam.svc.cluster.local/application/o/yourservice/userinfo/
# Optional: JWKS endpoint for offline token validation
OIDC_JWKS_URL=http://authentik-server.iam.svc.cluster.local/application/o/yourservice/jwks/
```
**Store secrets in Vault:**
```bash
# Store the client secret for runtime retrieval
talos put cluster/YOURSERVICE_OIDC_CLIENT_SECRET YOURSERVICE_OIDC_CLIENT_SECRET="$CLIENT_SECRET"
# Store other config if needed
talos put cluster/yourservice-oidc \
client_id=yourservice \
issuer_url="http://authentik-server.iam.svc.cluster.local/application/o/yourservice/" \
callback_uri="https://your-app.riotpiao.homelab.com/oauth2/callback"
```
### Step 5: Mount Credentials in Your Service
**Option A: Kubernetes Secret (ConfigMap-based)**
Create a Kubernetes secret with the credentials:
```bash
kubectl create secret generic yourservice-oidc \
--from-literal=client_id=yourservice \
--from-literal=client_secret="$CLIENT_SECRET" \
-n your-namespace
```
Reference in your Helm values or Pod spec:
```yaml
# In Helm values
env:
- name: OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: yourservice-oidc
key: client_id
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: yourservice-oidc
key: client_secret
```
**Option B: Vault KV Secret (Runtime)**
Store in Vault and retrieve at startup:
```bash
# Store full config
vault kv put secret/services/yourservice \
client_id=yourservice \
client_secret="$CLIENT_SECRET" \
issuer_url="http://authentik-server.iam.svc.cluster.local/application/o/yourservice/"
# Service reads at startup:
# curl -H "Authorization: Bearer $VAULT_TOKEN" \
# http://vault.storage.svc.cluster.local:8200/v1/secret/data/services/yourservice
```
---
## Vault JWT Authentication (For Services)
If your service needs to authenticate to Vault directly (beyond just reading secrets), create a JWT role:
### 1. Create Vault JWT Role
```bash
# Authenticate to Vault (as cluster-admin or root)
kubectl port-forward -n storage svc/vault 8200:8200 &
export VAULT_ADDR=http://127.0.0.1:8200
vault login -method=oidc role=homelab
# Create a service-specific JWT role
vault write auth/jwt/role/yourservice \
role_type=jwt \
bound_audiences="vault" \
user_claim="sub" \
bound_claims='{"client_id":["yourservice"]}' \
policies="shell-secrets,service-read-secrets" \
ttl=4h \
max_ttl=8h
```
### 2. Create Service Policy in Vault
```bash
# Policy that allows the service to read its own secrets
vault policy write service-read-yourservice - <<'EOF'
path "secret/data/services/yourservice" {
capabilities = ["read"]
}
path "secret/data/cluster/minio" {
capabilities = ["read"]
}
EOF
# Update the role to use this policy
vault write auth/jwt/role/yourservice \
policies="service-read-secrets,service-read-yourservice"
```
### 3. Service JWT Authentication Flow
Your service obtains a Vault token from Authentik's JWT:
```python
# Example service code (Python)
import requests
import json
# 1. Authenticate to Authentik OIDC (get ID token)
oidc_response = requests.post(
"http://authentik-server.iam.svc.cluster.local/application/o/yourservice/token/",
data={
"grant_type": "client_credentials",
"client_id": "yourservice",
"client_secret": os.environ["OIDC_CLIENT_SECRET"],
"audience": "vault" # Important: request JWT for Vault
}
)
id_token = oidc_response.json()["access_token"]
# 2. Authenticate to Vault using the JWT
vault_response = requests.post(
"http://vault.storage.svc.cluster.local:8200/v1/auth/jwt/login",
data=json.dumps({
"role": "yourservice",
"jwt": id_token
}),
headers={"Content-Type": "application/json"}
)
vault_token = vault_response.json()["auth"]["client_token"]
# 3. Use Vault token to read secrets
secret_response = requests.get(
"http://vault.storage.svc.cluster.local:8200/v1/secret/data/services/yourservice",
headers={"X-Vault-Token": vault_token}
)
secrets = secret_response.json()["data"]["data"]
```
---
## RBAC & Groups
### Adding Group-Based Access Control
Groups in Authentik map to Vault policies and app bindings:
```bash
# Create a group for your service's administrators
python3 k8s/talos-iam/provision_oidc.py --add-group "yourservice-admins"
# Add users to the group (Authentik UI)
# Applications → Groups → yourservice-admins → Users
# Bind group to app (Authentik UI)
# Applications → Applications → yourservice → Policies → Binding
# Select the group and enable the binding
```
### Group-Based Vault Policy Mapping
Create a Vault policy that checks Authentik group membership:
```bash
vault policy write service-admin-yourservice - <<'EOF'
# Full access for admins
path "secret/data/services/yourservice/*" {
capabilities = ["read", "create", "update", "delete"]
}
path "secret/data/mcp/*" {
capabilities = ["read"]
}
EOF
# Create a separate JWT role for admins
vault write auth/jwt/role/yourservice-admin \
role_type=jwt \
bound_audiences="vault" \
user_claim="sub" \
bound_claims='{"groups":["yourservice-admins"]}' \
policies="shell-secrets,service-admin-yourservice" \
ttl=4h
```
---
## Verification Checklist
After registration, verify everything works:
```bash
# 1. Verify Authentik provider exists
curl -H "Authorization: Bearer $AUTHENTIK_BOOTSTRAP_TOKEN" \
http://localhost:7000/api/v3/providers/oauth2/?name=yourservice
# 2. Verify Authentik application exists
curl -H "Authorization: Bearer $AUTHENTIK_BOOTSTRAP_TOKEN" \
http://localhost:7000/api/v3/core/applications/?slug=yourservice
# 3. Verify K8s secret is mounted
kubectl get secret yourservice-oidc -n your-namespace -o jsonpath='{.data}' | base64 -d
# 4. Verify Vault JWT role
vault read auth/jwt/role/yourservice
# 5. Test OAuth login (requires port-forward to your app)
# Open http://your-app.riotpiao.homelab.com/login
# Should redirect to Authentik, then back to your app
# 6. Test JWT auth to Vault (if applicable)
# Service obtains ID token and authenticates to Vault
vault login -method=jwt role=yourservice jwt=$ID_TOKEN
```
---
## Troubleshooting
### "JWKS URL not found" error in Vault
**Cause:** Authentik is not reachable from Vault pod at the configured URL.
**Fix:**
1. Check Authentik is running: `kubectl get pods -n iam`
2. Verify URL uses in-cluster address: `http://authentik-server.iam.svc.cluster.local`
3. Test DNS from Vault pod:
```bash
kubectl exec -n storage vault-0 -- nslookup authentik-server.iam.svc.cluster.local
```
### "OAuth callback failed" or redirect loop
**Cause:** Redirect URI in Authentik does not match what the service sends.
**Fix:**
1. Check Authentik provider's Redirect URIs: UI → Applications → Providers → Edit
2. Must exactly match the URI your service redirects to (protocol, domain, port, path)
3. Common mistake: `https://` in service but `http://` in Authentik
### Service can't read K8s secret
**Cause:** Secret is in wrong namespace or service account lacks permissions.
**Fix:**
1. Verify secret exists: `kubectl get secret yourservice-oidc -n your-namespace`
2. Check RBAC for service account:
```bash
kubectl describe sa yourservice -n your-namespace
```
### "client_id mismatch" or "client authentication failed"
**Cause:** Client secret in K8s secret does not match what's in Authentik.
**Fix:**
1. Rotate the secret in Authentik (delete and recreate)
2. Update the K8s secret with the new value
3. Restart the service pod(s)
---
## Advanced: Custom Property Mappings
For services that need custom JWT claims (e.g., MinIO's `policy` claim), use property mappings:
```bash
# Create a custom scope with expression
curl -X POST http://localhost:7000/api/v3/propertymappings/provider/scope/ \
-H "Authorization: Bearer $AUTHENTIK_BOOTSTRAP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "yourservice-claims",
"scope_name": "yourservice-custom",
"expression": "return {\"department\": request.user.attributes.get(\"department\", \"unknown\")}"
}'
# Add to provider's property_mappings:
# Authentik UI → Applications → Providers → Edit yourservice
# Select the custom scope in the Property Mappings list
```
---
## Integration Examples
### Integrate with Grafana
See `k8s/logging/grafana-values.yaml` for an example of configuring generic OAuth in Grafana.
### Integrate with MinIO
See `provision_oidc.py` for MinIO's group-based policy claim injection.
### Integrate with Custom Go App
See `k8s/talos-iam/go-example-oidc/` for a minimal Authorization Code flow example.
---
## Related Documentation
- [`README.md`](README.md) — Authentik deployment & key rotation
- [`provision_oidc.py`](provision_oidc.py) — Automated OIDC provisioning for core apps
- [`setup_vault.sh`](setup_vault.sh) — Vault initialization & JWT auth wiring
- [`CLAUDE.md`](../CLAUDE.md) — Homelab architecture & secrets management
+302
View File
@@ -0,0 +1,302 @@
# Talos IAM — Authentik
Standalone **SSO / Identity Provider** for the homelab.
Authentik gives every homelab app one login (OIDC / OAuth2 / SAML / forward-auth). It is an
**identity provider, not a secret vault** — its system of record is PostgreSQL (users, apps,
tokens, policies) with Redis for cache/queue. There is no MinIO/S3 involvement.
> Want a Vault-style secret store instead? That's a different tool — OpenBao / HashiCorp Vault /
> Infisical — and a separate setup. This folder is SSO only.
## Architecture
```
┌─────────────────────────────────────┐
│ namespace: iam │
│ │
Browser / CLI ───────▶│ authentik-server (UI + API :80) │
│ authentik-worker (tasks / flows) │
│ postgresql (Longhorn 8Gi) │
│ redis (ephemeral) │
└───────────────┬─────────────────────┘
│ OIDC / OAuth2
┌─────────────────┼─────────────────┐
▼ ▼ ▼
ns: logging ns: storage ns: iam
Grafana MinIO (CronJob)
client_id=grafana client_id=minio key-rotation
```
**OIDC clients provisioned:**
| App | Namespace | Client ID | Redirect URI |
|---|---|---|---|
| Grafana | `logging` | `grafana` | `http://localhost:3000/login/generic_oauth` |
| MinIO | `storage` | `minio` | `http://localhost:9001/oauth_callback` |
| Portainer | `portainer` | — | placeholder (CE has no OIDC) |
**Groups:**
| Group | Maps to |
|---|---|
| `grafana-admins` | Grafana Admin role |
| `grafana-viewers` | Grafana Viewer role |
| `minio-admins` | MinIO `readwrite` policy |
| `minio-readonly` | MinIO `readonly` policy |
---
## Layout
```
talos-iam/
├── setup_talos_iam.sh # one-shot deploy (namespace → helm → verify → provision)
├── authentik-values.yaml # Helm values (Postgres on Longhorn, Redis ephemeral, tolerations)
├── provision_oidc.py # idempotent OIDC provisioner (providers, groups, K8s secrets)
├── register_oauth_app.py # 🆕 register new services with OAuth + Vault JWT (recommended)
├── example_register_dashboard.py # 🆕 example: register 'dashboard-service' with full RBAC
├── verify_existing_oauth_integrations.sh # 🆕 verify Grafana/MinIO/Forgejo/Argo CD OIDC still work
├── OAUTH_APP_SETUP.md # 🆕 comprehensive guide: manual & automated OAuth setup
├── key_rotate.rs # stdlib-only Rust script — rotates OIDC signing key
├── key-rotation-cronjob.yaml # K8s CronJob running key_rotate.rs quarterly
├── setup_vault.sh # Vault initialization & JWT auth wiring (run after Authentik)
├── go-example-oidc/ # Go Authorization Code flow example against Authentik
│ ├── main.go
│ ├── go.mod
│ └── .env.example
├── .env.example # required secrets — copy to ~/.authentik/.env and fill in
└── README.md # you are here
```
---
## Quickstart
```bash
cp k8s/talos-iam/.env.example ~/.authentik/.env
# fill in the secrets (generators are in .env.example)
bash k8s/talos-iam/setup_talos_iam.sh
```
The script creates the `iam` namespace, installs the Authentik Helm chart (server, worker, bundled
PostgreSQL + Redis), waits for rollout, probes the readiness endpoint, then calls
`provision_oidc.py` to wire up Grafana and MinIO as OIDC clients.
---
## Access
```bash
kubectl port-forward svc/authentik-server -n iam 7000:80
# Admin UI: http://localhost:7000/if/admin/
# Login: akadmin / <AUTHENTIK_BOOTSTRAP_PASSWORD>
```
---
## OIDC Provisioning
`provision_oidc.py` idempotently creates all Authentik resources from the API — safe to re-run.
```bash
# port-forward must be active (localhost:7000)
source ~/.authentik/.env
python k8s/talos-iam/provision_oidc.py
```
What it provisions:
- RSA-4096 signing certificate `homelab-oidc`
- OAuth2 providers for Grafana and MinIO (with the signing cert attached)
- Property mapping that injects a `policy` JWT claim for MinIO access control
- Groups: `grafana-admins`, `grafana-viewers`, `minio-admins`, `minio-readonly`
- K8s secrets `grafana-oidc` (ns: `logging`) and `minio-oidc` (ns: `storage`)
---
## Key Rotation
OIDC signing keys should be rotated periodically. The `key_rotate.rs` script generates a new
RSA-4096 cert in Authentik and patches all providers to use it. The old cert stays in the JWKS
endpoint until you delete it — existing tokens remain valid through their TTL (default 5 min).
**Manual rotation** (port-forward must be active):
```bash
source ~/.authentik/.env
python k8s/talos-iam/provision_oidc.py --rotate
```
**Automated rotation** (quarterly CronJob in-cluster):
```bash
# One-time setup
kubectl create configmap key-rotation-script \
--from-file=rotate_key.rs=k8s/talos-iam/key_rotate.rs \
-n iam --dry-run=client -o yaml | kubectl apply -f -
kubectl create secret generic authentik-key-rotation-token \
--from-literal=AUTHENTIK_BOOTSTRAP_TOKEN="${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-n iam --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f k8s/talos-iam/key-rotation-cronjob.yaml
# Test the job immediately
kubectl create job --from=cronjob/authentik-key-rotation test-rotation -n iam
kubectl logs -n iam -l job-name=test-rotation -f
```
**Validate rotation:**
```bash
# Confirm signing_key is set and changed
curl -s -H "Authorization: Bearer $AUTHENTIK_BOOTSTRAP_TOKEN" \
"http://localhost:7000/api/v3/providers/oauth2/?name=grafana" \
| python3 -c "import json,sys; p=json.load(sys.stdin)['results'][0]; print('signing_key:', p['signing_key'])"
# Confirm JWKS shows both old and new key during transition
curl -s http://localhost:7000/application/o/grafana/.well-known/jwks.json \
| python3 -c "import json,sys; [print('kid:', k['kid']) for k in json.load(sys.stdin)['keys']]"
```
> After rotation, nothing in `~/.authentik/.env` changes. Client secrets, the bootstrap token,
> and `AUTHENTIK_SECRET_KEY` are all separate from the OIDC signing keypair.
---
## Go OIDC Example
A minimal Authorization Code flow demo against Authentik — useful for verifying the IdP
end-to-end or as a starting point for a new OIDC client.
```bash
cp k8s/talos-iam/go-example-oidc/.env.example ~/.authentik/.env
# add OIDC_CLIENT_ID and OIDC_CLIENT_SECRET for an app you register in Authentik
cd k8s/talos-iam/go-example-oidc
go mod tidy && go run .
# open http://localhost:8080/login
```
The callback prints the verified ID token claims as JSON — `email`, `name`, `sub`, and any
custom claims (e.g. the MinIO `policy` claim).
---
## Node Resilience — Auto-Start on Reboot
All four components (server, worker, PostgreSQL, Redis) carry the control-plane toleration:
```yaml
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
```
- **Any node goes down** → Kubernetes reschedules all Authentik pods onto the surviving node.
- **PostgreSQL PVC** (8Gi Longhorn `ReadWriteOnce`) → Longhorn reattaches automatically (~2 min). All user/app/token data is preserved.
- **Redis** is ephemeral (no PVC) — restarts clean, which is correct (cache/queue only).
- **Full cluster reboot** → cp-1 comes up first; Kubernetes reconciles Deployments; Longhorn reattaches. Zero manual action needed.
**Startup order after reboot:**
1. `kubelet` starts on both nodes
2. `etcd` + API server on cp-1
3. Controllers reconcile Authentik Deployments and PostgreSQL StatefulSet
4. PostgreSQL starts (Authentik server/worker wait via init probes)
5. Redis starts
6. Authentik server + worker become ready
---
## Verify
```bash
kubectl get pods -n iam
# authentik-server, authentik-worker, authentik-postgresql-0, authentik-redis-master-0 → Running
curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:7000/-/health/ready/
# 204
```
---
## Registering New Services (OAuth App Workflow)
The homelab provides **automated OAuth registration** for new services via `register_oauth_app.py`:
```bash
# Register a new service with OAuth + Vault JWT auth (recommended)
python3 k8s/talos-iam/register_oauth_app.py \
--service-name my-service \
--namespace my-ns \
--redirect-uri "https://my-service.riotpiao.homelab.com/oauth2/callback" \
--service-name-in-vault MY_SERVICE \
--vault-jwt-policy shell-secrets \
--add-group my-service-admins
```
This automates:
1. ✅ Authentik OAuth2 provider creation (credentials from Vault)
2. ✅ Authentik application binding
3. ✅ Kubernetes secret provisioning (client ID/secret)
4. ✅ Vault JWT role creation (for service → Vault auth)
5. ✅ Group-based RBAC setup (optional)
**For manual control or step-by-step guidance,** see [`OAUTH_APP_SETUP.md`](OAUTH_APP_SETUP.md) which covers both automated and manual workflows.
### Example: Register "dashboard-service"
```bash
# 1. Generate client secret and store in Vault
DASHBOARD_OIDC_CLIENT_SECRET=$(openssl rand -hex 32)
talos put cluster/DASHBOARD_OIDC_CLIENT_SECRET DASHBOARD_OIDC_CLIENT_SECRET="$DASHBOARD_OIDC_CLIENT_SECRET"
# 2. Register with automation
export DASHBOARD_OIDC_CLIENT_SECRET
python3 k8s/talos-iam/register_oauth_app.py \
--service-name dashboard-service \
--namespace apps \
--redirect-uri "https://dashboard.riotpiao.homelab.com/oauth2/callback" \
--service-name-in-vault DASHBOARD \
--vault-jwt-policy shell-secrets \
--add-group dashboard-admins \
--vault-jwt-bound-claims '{"groups":["dashboard-admins"]}'
# 3. Service now has:
# - Authentik provider (dashboard-service)
# - K8s secret (dashboard-service-oidc) in 'apps' namespace
# - Vault JWT role (dashboard-service) with group-based access
```
### Verification
After registration, verify the integration:
```bash
# Check Authentik provider and app
curl -H "Authorization: Bearer $AUTHENTIK_BOOTSTRAP_TOKEN" \
http://localhost:7000/api/v3/core/applications/?slug=my-service | jq .
# Check K8s secret
kubectl get secret my-service-oidc -n my-ns -o yaml
# Check Vault JWT role
vault read auth/jwt/role/my-service
# Test OAuth login
# Browser: https://my-service.riotpiao.homelab.com/login
# Should redirect to Authentik → back to service with session
```
**Full verification script:**
```bash
bash k8s/talos-iam/verify_existing_oauth_integrations.sh
```
---
## Notes
- **`AUTHENTIK_SECRET_KEY` is set-once.** Rotating it invalidates all sessions, tokens, and encrypted fields in the database. Keep `~/.authentik/.env` safe and backed up.
- **PostgreSQL holds everything that matters** — users, providers, groups, tokens, certificates. It lives on an 8Gi Longhorn PVC. Redis is ephemeral by design.
- **No ingress.** Access is via port-forward, matching the rest of the homelab.
- **OIDC signing keys** (rotated by `provision_oidc.py --rotate`) are separate from all `.env` credentials. Rotation requires no client reconfiguration — Grafana and MinIO pick up the new public key from the JWKS endpoint automatically.
- **New app registration** is automated via `register_oauth_app.py` and fully documented in `OAUTH_APP_SETUP.md`. Both manual and automated workflows are supported.
@@ -0,0 +1,24 @@
apiVersion: batch/v1
kind: Job
metadata:
name: authentik-migrations
namespace: iam
spec:
ttlSecondsAfterFinished: 600
backoffLimit: 3
template:
spec:
serviceAccountName: default
restartPolicy: Never
containers:
- name: migrate
image: ghcr.io/goauthentik/server:2026.5.4
imagePullPolicy: IfNotPresent
envFrom:
- secretRef:
name: authentik
command:
- /bin/sh
- -c
- |
python -m manage migrate --noinput && echo "✓ Migrations complete"
+225
View File
@@ -0,0 +1,225 @@
# k8s/talos-iam/authentik-values.yaml
# Authentik — SSO Identity Provider for the homelab.
# Provides OAuth2/OIDC login for Grafana, MinIO, Forgejo, and Argo CD.
# Chart: authentik/authentik from https://charts.goauthentik.io
#
# Architecture: server (UI+API) + worker (background tasks) + PostgreSQL + Redis.
# PostgreSQL is the system of record — must persist. Redis is ephemeral cache/queue.
#
# Secrets injected via helmfile --set (from .env / vsource):
# AUTHENTIK_SECRET_KEY — signs sessions and tokens; set once, never rotate casually
# AUTHENTIK_BOOTSTRAP_PASSWORD — initial akadmin password (used once at first login)
# AUTHENTIK_BOOTSTRAP_TOKEN — API token for the setup_talos_iam.sh bootstrap script
# AUTHENTIK_PG_PASSWORD — PostgreSQL user password
authentik:
# host: the external URL Authentik uses to build redirect URIs in OAuth2 flows.
# Must match what the browser sees — if it returns an internal svc URL,
# the browser's redirect after login will fail (can't reach svc DNS externally).
# HTTP (not HTTPS) because the Authentik ingress has no TLS cert configured.
host: "https://authentik.riotpiao.homelab.com"
error_reporting:
enabled: false # do not phone home to Sentry
# PostgreSQL connection — points at CloudNativePG cluster in ddb namespace.
# password is injected via helmfile --set at deploy time.
postgresql:
host: ddb-cluster-rw.ddb.svc.cluster.local
port: 5432
name: authentik
user: authentik
password: "" # injected via helmfile --set authentik.postgresql.password
# Redis connection — bundled subchart, standalone mode (no sentinel/cluster).
redis:
host: authentik-redis-master
# ── HTTP client timeouts ──────────────────────────────────────────────────────
# Increased to tolerate 5+ second pod-to-pod network latency.
# Affects webhooks, outpost management, SCIM, LDAP sync.
# Default: ~30s — too aggressive when latency spikes hit 5-10s.
log_level: debug # enable debug logging to monitor connection issues
# ── CA trust (shared by server and worker) ────────────────────────────────────
# Authentik (Python/Debian) uses requests + httpx for outgoing HTTPS — webhooks,
# outpost management, SCIM. Both libraries need REQUESTS_CA_BUNDLE / SSL_CERT_FILE
# to point to a bundle that includes homelab-ca, otherwise connections to other
# homelab services fail with "certificate signed by unknown authority".
#
# Strategy: a debian:12-slim init container (run as root) concatenates the
# Debian system Mozilla bundle with homelab-ca.crt into an emptyDir. The main
# container then references /merged/ca-bundle.crt via two env vars that cover
# every Python HTTP library.
_caVolumes: &caVolumes
- name: homelab-ca
configMap:
name: homelab-ca
- name: merged-ca
emptyDir: {}
_caVolumeMounts: &caVolumeMounts
- name: homelab-ca
mountPath: /homelab-ca
readOnly: true
- name: merged-ca
mountPath: /merged
readOnly: true
_caInitContainers: &caInitContainers
- name: merge-ca-certs
image: debian:bookworm
imagePullPolicy: IfNotPresent
securityContext:
runAsUser: 0
command:
- sh
- -c
- (cat /etc/ssl/certs/ca-certificates.crt 2>/dev/null; cat /homelab-ca/homelab-ca.crt) > /merged/ca-bundle.crt
volumeMounts:
- name: homelab-ca
mountPath: /homelab-ca
readOnly: true
- name: merged-ca
mountPath: /merged
- name: authentik-migrate
image: ghcr.io/goauthentik/server:2026.5.4
imagePullPolicy: IfNotPresent
command:
- sh
- -c
- python -m manage migrate --noinput
envFrom:
- secretRef:
name: authentik
volumeMounts: *caVolumeMounts
_caEnv: &caEnv
- name: REQUESTS_CA_BUNDLE
value: /merged/ca-bundle.crt
- name: SSL_CERT_FILE
value: /merged/ca-bundle.crt
# ── Authentik server (UI + API) ───────────────────────────────────────────────
# Handles all browser traffic: login flows, admin UI, OAuth2 authorize/token endpoints.
# NodePort 32172 is a fallback for direct node access during troubleshooting;
# normal access is via nginx ingress (authentik.riotpiao.homelab.com → svc:80).
# Recreate: single replica + RWO-adjacent state — avoids split-brain on redeploy.
server:
replicas: 1
deploymentStrategy:
type: Recreate
service:
type: NodePort
nodePort: 32172
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
volumes: *caVolumes
volumeMounts: *caVolumeMounts
initContainers: *caInitContainers
env: *caEnv
podAnnotations:
configmap.reloader.stakater.com/reload: "homelab-ca"
homelab.io/restart-at: "2026-06-21T13-40"
# Every OIDC login (Grafana, Argo CD, MinIO, Forgejo) depends on this server —
# its request latency/error rate explains SSO-driven slowness on those services.
metrics:
enabled: true
serviceMonitor:
enabled: true
scrapeTimeout: 60s
# ── Authentik worker ──────────────────────────────────────────────────────────
# Runs background tasks: email delivery, LDAP sync, flow policy evaluation,
# event log cleanup, and managed outpost updates. Stateless — no PVC needed.
# Same resource profile as server; Authentik 2023+ merged some worker duties
# into the server process but the worker pod is still required.
worker:
replicas: 1
deploymentStrategy:
type: Recreate
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
volumes: *caVolumes
volumeMounts: *caVolumeMounts
initContainers: *caInitContainers
env: *caEnv
podAnnotations:
configmap.reloader.stakater.com/reload: "homelab-ca"
homelab.io/restart-at: "2026-06-21T13-40"
metrics:
enabled: true
serviceMonitor:
enabled: true
scrapeTimeout: 60s
# ── PostgreSQL (external: CloudNativePG cluster in ddb namespace) ─────────────
# Authentik connects to the centralized ddb-cluster (1 primary + 2 replicas with pgvector).
# Do not use the bundled Bitnami subchart — CNPG is already running.
postgresql:
enabled: false
primary:
persistence:
enabled: true
storageClass: longhorn
size: 8Gi
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
# ── Bundled Redis ─────────────────────────────────────────────────────────────
# Cache and async task queue only — no durable data. If Redis restarts, in-flight
# background tasks are retried and cached tokens are recomputed. Losing Redis
# data does not lose user accounts or flow configuration (that's in PostgreSQL).
# persistence: false saves a PVC and makes restarts faster.
#
# Same prefer-worker / fallback-to-cp scheduling as PostgreSQL.
# architecture: standalone — no Sentinel/cluster overhead for a 3-node homelab.
redis:
enabled: true
master:
persistence:
enabled: false
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
architecture: standalone
# Ingress disabled — rule lives in k8s/ingress/ingress.yaml (authentik.riotpiao.homelab.com).
# For direct access during bootstrap: kubectl -n iam port-forward svc/authentik-server 7000:80
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
Example: Register 'dashboard-service' with OAuth + Vault JWT auth.
This is a complete working example that demonstrates:
1. Creating an Authentik OAuth2 provider and application
2. Creating Kubernetes secrets for OIDC credentials
3. Creating a Vault JWT role for service authentication
4. Group-based RBAC
To use this example:
# 1. Ensure prerequisites
kubectl port-forward -n iam svc/authentik-server 7000:80 &
kubectl port-forward -n storage svc/vault 8200:8200 &
# 2. Generate and store credentials
DASHBOARD_OIDC_CLIENT_SECRET=$(openssl rand -hex 32)
export DASHBOARD_OIDC_CLIENT_SECRET="$DASHBOARD_OIDC_CLIENT_SECRET"
talos put cluster/DASHBOARD_OIDC_CLIENT_SECRET DASHBOARD_OIDC_CLIENT_SECRET="$DASHBOARD_OIDC_CLIENT_SECRET"
# 3. Run registration
python3 register_oauth_app.py \\
--service-name dashboard-service \\
--namespace apps \\
--redirect-uri "https://dashboard.riotpiao.homelab.com/oauth2/callback" \\
--service-name-in-vault DASHBOARD \\
--vault-jwt-policy shell-secrets \\
--add-group dashboard-admins \\
--vault-jwt-bound-claims '{"groups":["dashboard-admins"]}'
# 4. Verify registration
curl -s -H "Authorization: Bearer $AUTHENTIK_BOOTSTRAP_TOKEN" \\
http://localhost:7000/api/v3/core/applications/?slug=dashboard-service | jq .
# 5. Test OAuth callback (requires app running)
# Browser: http://dashboard.riotpiao.homelab.com/login
# Should redirect to Authentik → back to dashboard with session
"""
import subprocess
import sys
import os
def example_dashboard_service():
"""Register the example 'dashboard-service' with full RBAC."""
# Check prerequisites
print("Checking prerequisites...")
required_env = [
"AUTHENTIK_BOOTSTRAP_TOKEN",
"DASHBOARD_OIDC_CLIENT_SECRET",
]
missing = [v for v in required_env if not os.environ.get(v)]
if missing:
print(f"❌ Missing environment variables: {', '.join(missing)}")
print("\nSet them:")
print(" talos get cluster/AUTHENTIK_BOOTSTRAP_TOKEN --key AUTHENTIK_BOOTSTRAP_TOKEN | source")
print(" talos get cluster/DASHBOARD_OIDC_CLIENT_SECRET --key DASHBOARD_OIDC_CLIENT_SECRET | source")
return False
# Call the registration script
cmd = [
"python3",
"register_oauth_app.py",
"--service-name", "dashboard-service",
"--namespace", "apps",
"--redirect-uri", "https://dashboard.riotpiao.homelab.com/oauth2/callback",
"--service-name-in-vault", "DASHBOARD",
"--vault-jwt-policy", "shell-secrets",
"--add-group", "dashboard-admins",
"--vault-jwt-bound-claims", '{"groups":["dashboard-admins"]}',
]
print(f"\nRunning: {' '.join(cmd)}\n")
result = subprocess.run(cmd, check=False)
return result.returncode == 0
def verify_example():
"""Verify that the dashboard-service was registered correctly."""
print("\n" + "="*70)
print("Verification Steps")
print("="*70 + "\n")
print("✅ Check Authentik provider:")
print(" curl -s -H 'Authorization: Bearer $AUTHENTIK_BOOTSTRAP_TOKEN' \\")
print(" http://localhost:7000/api/v3/providers/oauth2/?name=dashboard-service \\")
print(" | jq '.results[0] | {name, client_id, redirect_uris}'")
print()
print("✅ Check Authentik application:")
print(" curl -s -H 'Authorization: Bearer $AUTHENTIK_BOOTSTRAP_TOKEN' \\")
print(" http://localhost:7000/api/v3/core/applications/?slug=dashboard-service \\")
print(" | jq '.results[0] | {name, slug, provider}'")
print()
print("✅ Check Kubernetes secret:")
print(" kubectl get secret dashboard-service-oidc -n apps -o jsonpath='{.data}' \\")
print(" | base64 -d | jq .")
print()
print("✅ Check Vault JWT role:")
print(" vault read auth/jwt/role/dashboard-service")
print()
print("✅ Test Vault JWT authentication:")
print(" # Get ID token from Authentik")
print(" ID_TOKEN=$(curl -s -X POST \\")
print(" http://localhost:7000/application/o/dashboard-service/token/ \\")
print(" -d 'grant_type=client_credentials&client_id=dashboard-service&client_secret=...' \\")
print(" | jq -r '.access_token')")
print()
print(" # Authenticate to Vault with JWT")
print(" vault write auth/jwt/login \\")
print(" role=dashboard-service \\")
print(" jwt=\"$ID_TOKEN\"")
print()
print("✅ Test OAuth redirect (requires app running):")
print(" curl -L https://dashboard.riotpiao.homelab.com/login")
print(" # Should redirect to Authentik, then back to dashboard")
print()
if __name__ == "__main__":
if not example_dashboard_service():
sys.exit(1)
verify_example()
@@ -0,0 +1,8 @@
# Copy to ~/.authentik/.env and fill in your values.
# Port-forward Authentik first: kubectl port-forward svc/authentik-server -n iam 7000:80
AUTHENTIK_BASE_URL=http://localhost:7000
APP_SLUG=go-example
OIDC_CLIENT_ID=go-example
OIDC_CLIENT_SECRET=your-client-secret-here
REDIRECT_URL=http://localhost:8080/callback
+14
View File
@@ -0,0 +1,14 @@
module homelab/go-example-oidc
go 1.22
require (
github.com/coreos/go-oidc/v3 v3.11.0
github.com/joho/godotenv v1.5.1
golang.org/x/oauth2 v0.24.0
)
require (
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
golang.org/x/crypto v0.25.0 // indirect
)
+20
View File
@@ -0,0 +1,20 @@
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+90
View File
@@ -0,0 +1,90 @@
// Minimal OIDC Authorization Code flow against Authentik.
//
// Setup:
// 1. In Authentik create a provider + app (slug "go-example", redirect URI http://localhost:8080/callback).
// 2. Fill in ~/.authentik/.env (see .env.example).
// 3. go mod tidy && go run .
// 4. Open http://localhost:8080/login
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/joho/godotenv"
"golang.org/x/oauth2"
)
// oauthState is a fixed random value for this process — good enough for a local demo.
// In production, generate a per-request random state and store it in a cookie.
var oauthState = "homelab-oidc-example"
func main() {
// Load ~/.authentik/.env; shell env vars already set take precedence.
home, _ := os.UserHomeDir()
godotenv.Load(filepath.Join(home, ".authentik", ".env"))
ctx := context.Background()
// go-oidc discovers the token endpoint, auth endpoint, and JWKS URI automatically
// from Authentik's /.well-known/openid-configuration.
issuer := os.Getenv("AUTHENTIK_BASE_URL") + "/application/o/" + os.Getenv("APP_SLUG")
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
log.Fatalf("OIDC discovery failed (%s): %v", issuer, err)
}
cfg := &oauth2.Config{
ClientID: os.Getenv("OIDC_CLIENT_ID"),
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
RedirectURL: os.Getenv("REDIRECT_URL"),
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "email", "profile"},
}
verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
// /login — redirect the browser to Authentik's authorization endpoint
http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, cfg.AuthCodeURL(oauthState), http.StatusFound)
})
// /callback — Authentik redirects here with ?code=...&state=...
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("state") != oauthState {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
// Exchange the authorization code for tokens
token, err := cfg.Exchange(ctx, r.URL.Query().Get("code"))
if err != nil {
http.Error(w, "token exchange: "+err.Error(), http.StatusInternalServerError)
return
}
// Verify the ID token signature against Authentik's JWKS, then extract claims
rawID, _ := token.Extra("id_token").(string)
idToken, err := verifier.Verify(ctx, rawID)
if err != nil {
http.Error(w, "id_token verify: "+err.Error(), http.StatusInternalServerError)
return
}
var claims map[string]any
idToken.Claims(&claims)
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
enc.Encode(claims)
})
fmt.Println("open http://localhost:8080/login")
log.Fatal(http.ListenAndServe(":8080", nil))
}
+52
View File
@@ -0,0 +1,52 @@
# Rotates the Authentik OIDC signing key quarterly for all homelab providers.
#
# Prerequisites (run once):
# # 1. ConfigMap from the Rust source file
# kubectl create configmap key-rotation-script \
# --from-file=rotate_key.rs=key_rotate.rs \
# -n iam --dry-run=client -o yaml | kubectl apply -f -
#
# # 2. Token secret — fill in your value, never commit it
# kubectl create secret generic authentik-key-rotation-token \
# --from-literal=AUTHENTIK_BOOTSTRAP_TOKEN="${AUTHENTIK_BOOTSTRAP_TOKEN}" \
# -n iam --dry-run=client -o yaml | kubectl apply -f -
#
# Apply: kubectl apply -f key-rotation-cronjob.yaml
# Test: kubectl create job --from=cronjob/authentik-key-rotation test-rotation -n iam
# Logs: kubectl logs -n iam -l job-name=test-rotation -f
apiVersion: batch/v1
kind: CronJob
metadata:
name: authentik-key-rotation
namespace: iam
spec:
schedule: "0 0 1 */3 *" # 00:00 UTC on the 1st of Jan, Apr, Jul, Oct
concurrencyPolicy: Forbid # skip if a previous job is still running
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
volumes:
- name: script
configMap:
name: key-rotation-script
containers:
- name: rotate
image: rust:1.82-slim
command:
- sh
- -c
- rustc /scripts/rotate_key.rs -o /tmp/rotate_key && /tmp/rotate_key
volumeMounts:
- name: script
mountPath: /scripts
env:
- name: AUTHENTIK_BASE_URL
value: "http://authentik-server.iam.svc.cluster.local"
- name: AUTHENTIK_BOOTSTRAP_TOKEN
valueFrom:
secretKeyRef:
name: authentik-key-rotation-token
key: AUTHENTIK_BOOTSTRAP_TOKEN
+114
View File
@@ -0,0 +1,114 @@
use std::env;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::{SystemTime, UNIX_EPOCH};
const SIGNING_CERT_NAME: &str = "homelab-oidc";
const PROVIDERS: &[&str] = &["grafana", "minio"];
fn main() {
let base_url = env::var("AUTHENTIK_BASE_URL")
.unwrap_or_else(|_| "http://localhost:7000".into());
let token = env::var("AUTHENTIK_BOOTSTRAP_TOKEN")
.expect("AUTHENTIK_BOOTSTRAP_TOKEN must be set");
let host = parse_host(&base_url);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let new_name = format!("{}-{}", SIGNING_CERT_NAME, ts);
let body = format!(
r#"{{"common_name":"{}","validity_days":365,"key_size":4096}}"#,
new_name
);
let resp = http(&host, &token, "POST", "/api/v3/crypto/certificatekeypairs/generate/", Some(&body));
let new_pk = extract_str(&resp, "pk").expect("no pk in generate response");
println!("created '{}' pk={}", new_name, new_pk);
for provider in PROVIDERS {
let list = http(
&host, &token, "GET",
&format!("/api/v3/providers/oauth2/?name={}", provider),
None,
);
let provider_pk = match extract_str(&list, "pk") {
Some(pk) => pk,
None => {
eprintln!("WARN: provider '{}' not found — skipping", provider);
continue;
}
};
let patch = format!(r#"{{"signing_key":"{}"}}"#, new_pk);
http(&host, &token, "PATCH",
&format!("/api/v3/providers/oauth2/{}/", provider_pk),
Some(&patch),
);
println!("rotated '{}' → signing_key={}", provider, new_pk);
}
println!("rotation complete");
}
fn parse_host(base_url: &str) -> String {
let stripped = base_url
.trim_start_matches("http://")
.trim_start_matches("https://");
let host = stripped.split('/').next().unwrap_or(stripped);
if host.contains(':') {
host.to_string()
} else {
format!("{}:80", host)
}
}
fn http(host: &str, token: &str, method: &str, path: &str, body: Option<&str>) -> String {
let mut stream = TcpStream::connect(host)
.unwrap_or_else(|e| panic!("connect {}: {}", host, e));
let body_str = body.unwrap_or("");
let hostname = host.split(':').next().unwrap_or(host);
let req = format!(
"{method} {path} HTTP/1.1\r\n\
Host: {hostname}\r\n\
Authorization: Bearer {token}\r\n\
Content-Type: application/json\r\n\
Content-Length: {len}\r\n\
Connection: close\r\n\
\r\n\
{body_str}",
len = body_str.len(),
);
stream.write_all(req.as_bytes()).unwrap();
let mut raw = String::new();
stream.read_to_string(&mut raw).unwrap();
let (head, resp_body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, ""));
let status: u16 = head.lines().next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|s| s.parse().ok())
.unwrap_or(0);
if status >= 400 {
panic!("{} {} → HTTP {}{}", method, path, status, resp_body.trim());
}
resp_body.to_string()
}
// Extracts the value of the first `"key": <value>` match in raw JSON.
// Handles both quoted strings ("pk": "uuid") and bare numbers ("pk": 5).
fn extract_str(json: &str, key: &str) -> Option<String> {
let needle = format!("\"{}\":", key);
let after_colon = json.find(&needle)? + needle.len();
let rest = json[after_colon..].trim_start();
if let Some(inner) = rest.strip_prefix('"') {
Some(inner[..inner.find('"')?].to_string())
} else {
let end = rest.find(|c: char| c == ',' || c == '}' || c.is_ascii_whitespace())?;
Some(rest[..end].to_string())
}
}
+512
View File
@@ -0,0 +1,512 @@
#!/usr/bin/env python3
"""
register_oauth_app.py
Automated OAuth app registration & Vault JWT wiring for homelab services.
This script automates the full workflow of registering a new service with
Authentik OIDC and wiring it for Vault JWT authentication.
Usage:
python3 register_oauth_app.py \\
--service-name my-service \\
--namespace my-ns \\
--redirect-uri "https://my-service.riotpiao.homelab.com/oauth2/callback" \\
[--service-name-in-vault MY_SERVICE] \\
[--vault-jwt-policy shell-secrets]
Requirements:
- Authentik running in iam namespace (port-forward 7000:80 active)
- Vault running in storage namespace
- kubectl configured (KUBECONFIG → cluster-config/kubeconfig)
- Environment variables set:
AUTHENTIK_BOOTSTRAP_TOKEN — Authentik API token
{SERVICE_NAME}_OIDC_CLIENT_SECRET — OAuth client secret (generated or from Vault)
Examples:
# Simple: register a web app with default settings
python3 register_oauth_app.py \\
--service-name myapp \\
--namespace apps \\
--redirect-uri "https://myapp.riotpiao.homelab.com/callback"
# Advanced: full service with Vault JWT auth
python3 register_oauth_app.py \\
--service-name myservice \\
--namespace my-namespace \\
--redirect-uri "https://myservice.riotpiao.homelab.com/oauth2/callback" \\
--service-name-in-vault MYSERVICE \\
--vault-jwt-policy shell-secrets \\
--add-group myservice-admins \\
--vault-jwt-bound-claims '{"groups":["myservice-admins"]}'
"""
import argparse
import json
import os
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Optional
BASE_AUTHENTIK_URL = "http://localhost:7000/api/v3"
VAULT_NAMESPACE = "storage"
IAM_NAMESPACE = "iam"
def _auth_headers() -> dict:
"""Return HTTP headers for Authentik API requests."""
token = os.environ.get("AUTHENTIK_BOOTSTRAP_TOKEN", "")
if not token:
sys.exit(
"ERROR: AUTHENTIK_BOOTSTRAP_TOKEN not set\n"
" Set it in ~/.authentik/.env or export it:\n"
" talos get cluster/AUTHENTIK_BOOTSTRAP_TOKEN --key AUTHENTIK_BOOTSTRAP_TOKEN"
)
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def _request(method: str, path: str, body: Optional[dict] = None, **params) -> dict:
"""HTTP request helper for Authentik API."""
url = BASE_AUTHENTIK_URL + path
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(
url,
data=json.dumps(body).encode() if body else None,
headers=_auth_headers(),
method=method,
)
try:
with urllib.request.urlopen(req) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
print(f"ERROR {method} {path}: HTTP {exc.code}{detail}", file=sys.stderr)
raise
def get_pk(path: str, **filters) -> Optional[str]:
"""Fetch the PK of the first result matching filters."""
results = _request("GET", path, **filters).get("results", [])
return results[0]["pk"] if results else None
def get_or_create(path: str, filters: dict, data: dict) -> str:
"""Create or fetch a resource; returns PK."""
pk = get_pk(path, **filters)
if pk is None:
pk = _request("POST", path, data)["pk"]
print(f"✓ created {path.rstrip('/')} {filters}")
else:
print(f"✓ exists {path.rstrip('/')} {filters}")
return pk
def fetch_flows_and_signing_key() -> tuple[str, str, str]:
"""Fetch required Authentik resource PKs."""
auth_pk = get_pk(
"/flows/instances/",
slug="default-provider-authorization-implicit-consent"
)
inval_pk = get_pk(
"/flows/instances/",
slug="default-provider-invalidation-flow"
)
if not auth_pk or not inval_pk:
sys.exit(
"ERROR: required Authentik flows not found\n"
" Run provision_oidc.py first to initialize defaults"
)
results = _request("GET", "/crypto/certificatekeypairs/", has_key=True).get("results", [])
signing_pk = results[0]["pk"] if results else None
if not signing_pk:
sys.exit(
"ERROR: no signing key found in Authentik\n"
" Create one: Authentik UI → System → Certificates"
)
return auth_pk, inval_pk, signing_pk
def apply_k8s_secret(name: str, namespace: str, literals: dict) -> None:
"""Create or update a Kubernetes secret."""
env = {**os.environ}
manifest = subprocess.run(
[
"kubectl", "create", "secret", "generic", name,
"-n", namespace, "--dry-run=client", "-o", "yaml",
*[f"--from-literal={k}={v}" for k, v in literals.items()],
],
capture_output=True, text=True, check=True, env=env,
).stdout
subprocess.run(
["kubectl", "apply", "-f", "-"],
input=manifest, text=True, check=True, env=env
)
print(f"✓ secret {name} → ns/{namespace}")
def create_authentik_provider(
service_name: str,
client_id: str,
client_secret: str,
redirect_uris: list[str],
auth_flow_pk: str,
inval_flow_pk: str,
signing_key_pk: str,
) -> str:
"""Create OAuth2 provider in Authentik."""
data = {
"name": service_name,
"client_id": client_id,
"client_secret": client_secret,
"authorization_flow": auth_flow_pk,
"invalidation_flow": inval_flow_pk,
"redirect_uris": [{"matching_mode": "strict", "url": u} for u in redirect_uris],
"sub_mode": "hashed_user_id",
"include_claims_in_id_token": True,
"property_mappings": [], # Will be populated by caller if needed
"grant_types": ["authorization_code", "refresh_token"],
"signing_key": signing_key_pk,
}
pk = get_pk("/providers/oauth2/", name=service_name)
if pk is None:
pk = _request("POST", "/providers/oauth2/", data)["pk"]
print(f"✓ created provider={service_name} client_id={client_id}")
else:
_request("PATCH", f"/providers/oauth2/{pk}/", data)
print(f"✓ synced provider={service_name} (credentials from Vault)")
return pk
def create_authentik_application(
display_name: str,
slug: str,
provider_pk: str,
launch_url: Optional[str] = None,
) -> str:
"""Create application in Authentik."""
data = {"name": display_name, "slug": slug, "provider": provider_pk}
if launch_url:
data["meta_launch_url"] = launch_url
return get_or_create(
"/core/applications/",
filters={"slug": slug},
data=data
)
def create_authentik_group(group_name: str) -> str:
"""Create a group in Authentik."""
return get_or_create(
"/core/groups/",
filters={"name": group_name},
data={"name": group_name, "is_superuser": False}
)
def bind_group_to_app(app_pk: str, group_pk: str) -> None:
"""Bind a group to an application (allow-list policy)."""
existing = _request(
"GET", "/policies/bindings/",
target=app_pk, group=group_pk
).get("results", [])
if existing:
print(f"✓ exists binding group={group_pk} → app={app_pk}")
else:
_request("POST", "/policies/bindings/", {
"target": app_pk,
"group": group_pk,
"enabled": True,
"order": 0,
})
print(f"✓ created binding group={group_pk} → app={app_pk}")
def create_vault_jwt_policy(
service_name: str,
vault_addr: str,
) -> str:
"""Create a Vault policy for the service."""
policy_name = f"service-read-{service_name}"
# Policy definition: service can read its own secrets
policy_rules = f"""
path "secret/data/services/{service_name}/*" {{
capabilities = ["read"]
}}
path "secret/data/cluster/*" {{
capabilities = ["read"]
}}
"""
# Write policy via vault CLI (requires auth)
try:
subprocess.run(
["vault", "policy", "write", policy_name, "-"],
input=policy_rules,
text=True,
check=True,
env={**os.environ, "VAULT_ADDR": vault_addr}
)
print(f"✓ created Vault policy={policy_name}")
except subprocess.CalledProcessError as e:
print(f"⚠ warning Failed to create Vault policy: {e}", file=sys.stderr)
return ""
return policy_name
def create_vault_jwt_role(
service_name: str,
vault_addr: str,
policies: list[str],
bound_claims: Optional[dict] = None,
) -> None:
"""Create a Vault JWT auth role for the service."""
bound_claims_json = json.dumps(bound_claims) if bound_claims else "{}"
role_config = {
"role_type": "jwt",
"bound_audiences": ["vault"],
"user_claim": "sub",
"bound_claims": bound_claims,
"token_policies": policies,
"token_ttl": "4h",
"token_max_ttl": "8h",
}
try:
subprocess.run(
["vault", "write", f"auth/jwt/role/{service_name}", "-"],
input=json.dumps(role_config),
text=True,
check=True,
env={**os.environ, "VAULT_ADDR": vault_addr}
)
print(f"✓ created Vault JWT role={service_name} policies={policies}")
except subprocess.CalledProcessError as e:
print(f"⚠ warning Failed to create Vault JWT role: {e}", file=sys.stderr)
def main() -> None:
parser = argparse.ArgumentParser(
description="Register a new OAuth service with Authentik + Vault",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--service-name",
required=True,
help="Service name (used as Authentik provider/app slug)"
)
parser.add_argument(
"--namespace",
required=True,
help="Kubernetes namespace where service runs"
)
parser.add_argument(
"--redirect-uri",
required=True,
help="OAuth2 redirect URI (e.g., https://myapp.riotpiao.homelab.com/callback)"
)
parser.add_argument(
"--service-name-in-vault",
default=None,
help="Service name for Vault (defaults to uppercase service-name)"
)
parser.add_argument(
"--vault-jwt-policy",
default="shell-secrets",
help="Vault policy to attach to JWT role (comma-separated for multiple)"
)
parser.add_argument(
"--add-group",
default=None,
help="Create and bind a group to the app (e.g., 'myservice-admins')"
)
parser.add_argument(
"--vault-jwt-bound-claims",
default=None,
help='JSON string of claims to bind JWT role (e.g., \'{"groups":["myservice-admins"]}\')'
)
parser.add_argument(
"--no-vault",
action="store_true",
help="Skip Vault JWT role creation (just register with Authentik)"
)
parser.add_argument(
"--vault-addr",
default=None,
help="Vault address (defaults to localhost:8200 via port-forward)"
)
args = parser.parse_args()
service_name = args.service_name.lower()
namespace = args.namespace
redirect_uri = args.redirect_uri
vault_service_name = (args.service_name_in_vault or service_name).upper()
vault_addr = args.vault_addr or "http://127.0.0.1:8200"
vault_policies = [p.strip() for p in args.vault_jwt_policy.split(",")]
# Fetch or generate client secret from environment / Vault
client_secret_env = f"{vault_service_name}_OIDC_CLIENT_SECRET"
client_secret = os.environ.get(client_secret_env, "")
if not client_secret:
sys.exit(
f"ERROR: {client_secret_env} not set\n"
f" Generate and store in Vault:\n"
f" talos put cluster/{client_secret_env} "
f"{client_secret_env}=$(openssl rand -hex 32)"
)
print(f"\n{'='*70}")
print(f"OAuth App Registration: {service_name}")
print(f"{'='*70}\n")
print(f"📋 Configuration:")
print(f" Service Name: {service_name}")
print(f" Namespace: {namespace}")
print(f" Redirect URI: {redirect_uri}")
print(f" Vault Service: {vault_service_name}")
print(f" Vault Policies: {', '.join(vault_policies)}")
if args.add_group:
print(f" Group: {args.add_group}")
print()
# Fetch Authentik resources
print("🔍 Fetching Authentik configuration...")
auth_flow_pk, inval_flow_pk, signing_key_pk = fetch_flows_and_signing_key()
print(f"✓ flows & signing key found\n")
# Create Authentik provider
print("🔐 Creating Authentik OAuth2 Provider...")
provider_pk = create_authentik_provider(
service_name=service_name,
client_id=service_name,
client_secret=client_secret,
redirect_uris=[redirect_uri],
auth_flow_pk=auth_flow_pk,
inval_flow_pk=inval_flow_pk,
signing_key_pk=signing_key_pk,
)
print()
# Create Authentik application
print("📱 Creating Authentik Application...")
app_pk = create_authentik_application(
display_name=service_name.replace("-", " ").title(),
slug=service_name,
provider_pk=provider_pk,
launch_url=f"https://{service_name}.riotpiao.homelab.com"
)
print()
# Create and bind group (if requested)
if args.add_group:
print(f"👥 Creating Group: {args.add_group}")
group_pk = create_authentik_group(args.add_group)
bind_group_to_app(app_pk, group_pk)
print()
# Create K8s secret
print("🔑 Creating Kubernetes Secret...")
apply_k8s_secret(
f"{service_name}-oidc",
namespace,
{
"client_id": service_name,
"client_secret": client_secret,
"issuer_url": f"http://authentik-server.iam.svc.cluster.local/application/o/{service_name}/",
"redirect_uri": redirect_uri,
}
)
print()
# Create Vault resources (if not disabled)
if not args.no_vault:
print("🔐 Configuring Vault JWT Authentication...")
# Parse bound claims if provided
bound_claims = None
if args.vault_jwt_bound_claims:
try:
bound_claims = json.loads(args.vault_jwt_bound_claims)
except json.JSONDecodeError as e:
print(f"⚠ warning Invalid JSON for --vault-jwt-bound-claims: {e}", file=sys.stderr)
# Create Vault policy
policy_name = create_vault_jwt_policy(service_name, vault_addr)
# Create Vault JWT role
if policy_name:
all_policies = list(vault_policies) + [policy_name]
create_vault_jwt_role(
service_name,
vault_addr,
all_policies,
bound_claims
)
print()
# Summary
print(f"{'='*70}")
print(f"✅ Registration Complete!")
print(f"{'='*70}\n")
print("📝 Next Steps:\n")
print(f"1. Configure your service with these environment variables:")
print(f" export OIDC_ISSUER_URL='http://authentik-server.iam.svc.cluster.local/application/o/{service_name}/'")
print(f" export OIDC_CLIENT_ID='{service_name}'")
print(f" export OIDC_CLIENT_SECRET='$({vault_service_name}_OIDC_CLIENT_SECRET)'")
print(f" export OIDC_REDIRECT_URI='{redirect_uri}'")
print()
print(f"2. Mount the Kubernetes secret in your Helm values:")
print(f" env:")
print(f" - name: OIDC_CLIENT_SECRET")
print(f" valueFrom:")
print(f" secretKeyRef:")
print(f" name: {service_name}-oidc")
print(f" key: client_secret")
print()
if not args.no_vault:
print(f"3. Test Vault JWT authentication:")
print(f" kubectl port-forward -n {VAULT_NAMESPACE} svc/vault 8200:8200 &")
print(f" vault login -method=oidc role=homelab")
print(f" vault read auth/jwt/role/{service_name}")
print()
print(f"4. Verify OAuth flow:")
print(f" kubectl port-forward -n {IAM_NAMESPACE} svc/authentik-server 7000:80 &")
print(f" # Open Authentik UI: http://localhost:7000/if/admin/")
print(f" # Check: Applications → {service_name}")
print()
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# k8s/talos-iam/setup_talos_iam.sh
# Validates required env vars are set, then delegates to helmfile.
# All provisioning logic lives in helmfile hooks + provision_oidc.py.
#
# Usage:
# vsource .env && bash k8s/talos-iam/setup_talos_iam.sh
#
# To re-provision OIDC without redeploying Authentik:
# vsource .env && helmfile apply -l name=authentik
#
# To patch existing providers (update redirect URIs, scopes):
# vsource .env && helmfile apply -l name=authentik # postsync runs provision_oidc.py --patch implicitly
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
KUBECONFIG="${KUBECONFIG:-${SCRIPT_DIR}/../cluster-config/kubeconfig}"
export KUBECONFIG
# ── Validate all required secrets are loaded from Vault ──────────────────────
MISSING=()
for var in \
AUTHENTIK_SECRET_KEY \
AUTHENTIK_BOOTSTRAP_PASSWORD \
AUTHENTIK_BOOTSTRAP_TOKEN \
AUTHENTIK_PG_PASSWORD \
GRAFANA_OIDC_CLIENT_SECRET \
MINIO_OIDC_CLIENT_SECRET \
AUTHENTIK_FORGEJO_CLIENT_SECRET \
AUTHENTIK_ARGOCD_CLIENT_SECRET; do
[[ -z "${!var:-}" ]] && MISSING+=("$var")
done
if [[ ${#MISSING[@]} -gt 0 ]]; then
echo "ERROR: the following vars are not set — run 'vsource .env' first:" >&2
printf ' %s\n' "${MISSING[@]}" >&2
echo "" >&2
echo "If a var is missing from Vault, store it first:" >&2
echo " talos put cluster/VAR_NAME VAR_NAME=\"\$(openssl rand -hex 32)\"" >&2
exit 1
fi
echo "All required secrets present."
echo ""
echo "Provider credential mapping (client_id → client_secret):"
printf ' %-10s client_id=%-40s secret=%s\n' \
"grafana" "${GRAFANA_OIDC_CLIENT_ID:-"grafana (default)"}" "GRAFANA_OIDC_CLIENT_SECRET" \
"minio" "${MINIO_OIDC_CLIENT_ID:-"minio (default)"}" "MINIO_OIDC_CLIENT_SECRET" \
"forgejo" "${AUTHENTIK_FORGEJO_CLIENT_ID:-"forgejo (default)"}" "AUTHENTIK_FORGEJO_CLIENT_SECRET" \
"argocd" "${AUTHENTIK_ARGOCD_CLIENT_ID:-"argocd (default)"}" "AUTHENTIK_ARGOCD_CLIENT_SECRET"
echo ""
echo "Delegating to helmfile..."
cd "${SCRIPT_DIR}/../.."
helmfile apply -l name=authentik
+527
View File
@@ -0,0 +1,527 @@
#!/usr/bin/env bash
# k8s/talos-iam/setup_vault.sh
# Deploys HashiCorp Vault into the iam namespace, initialises it, and wires
# the JWT auth backend to Authentik so the talos-cli secrets subcommand works.
#
# Prerequisites:
# - Authentik already running in the iam namespace (run setup_talos_iam.sh first)
# - kubectl configured (KUBECONFIG → cluster-config/kubeconfig)
# - helm >= 3.x
# - vault CLI installed locally (https://developer.hashicorp.com/vault/downloads)
# - talos-iam/.env containing (see .env.example):
# MINIO_ROOT_USER=...
# MINIO_ROOT_PASSWORD=...
# The script seeds initial secrets and needs read access to MinIO credentials.
#
# What this script does (in order):
# 1. Create vault-minio-creds K8s Secret (MinIO creds for the S3 backend)
# 2. Helm install hashicorp/vault
# 3. Wait for vault pod to be Running
# 4. vault operator init → capture unseal keys + root token
# 5. vault operator unseal (3 of 5 key shares)
# 6. Login with root token
# 7. Enable KV v2 secret engine at secret/
# 8. Enable JWT auth backend, configure with Authentik JWKS
# 9. Write policies (shell-secrets, mcp-readonly, cluster-admin)
# 10. Create JWT roles (shell, mcp, cluster)
# 11. Seed initial secrets from .env
# 12. Register talos-cli-shell OIDC app in Authentik via API
# 13. Print next steps
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
KUBECONFIG="${KUBECONFIG:-${REPO_ROOT}/cluster-config/kubeconfig}"
export KUBECONFIG
NAMESPACE=storage
RELEASE=vault
AUTHENTIK_URL="http://authentik-server.iam.svc.cluster.local"
VAULT_ADDR_CLUSTER="http://vault.storage.svc.cluster.local:8200"
# Local access via port-forward during setup
VAULT_PORT_FWD_ADDR="http://127.0.0.1:8200"
UNSEAL_KEYS_FILE="$HOME/.vault-data/.vault-init.json" # KEEP OFFLINE after setup
# ── Failure surfacing ─────────────────────────────────────────────────────────
CURRENT_STEP="init"
on_err() {
local rc=$?
echo "[ERROR] step '${CURRENT_STEP}' failed (exit ${rc} at line ${BASH_LINENO[0]})" >&2
exit "${rc}"
}
trap on_err ERR
step() { CURRENT_STEP="$1"; echo ""; echo "==> $2"; }
# ── Load credentials ──────────────────────────────────────────────────────────
step load_env "Loading credentials from talos-iam/.env..."
if [[ -f "$HOME/.authentik/.env" ]]; then
# shellcheck source=/dev/null
source "$HOME/.authentik/.env"
fi
for var in MINIO_ROOT_USER MINIO_ROOT_PASSWORD AUTHENTIK_BOOTSTRAP_TOKEN; do
if [[ -z "${!var:-}" ]]; then
echo "ERROR: ${var} is not set — export it or add it to talos-iam/.env" >&2
exit 1
fi
done
# ── 1. MinIO credentials secret for Vault S3 backend ─────────────────────────
step minio_secret "Creating vault-minio-creds secret..."
kubectl create secret generic vault-minio-creds \
--namespace="${NAMESPACE}" \
--from-literal=access_key="${MINIO_ROOT_USER}" \
--from-literal=secret_key="${MINIO_ROOT_PASSWORD}" \
--dry-run=client -o yaml | kubectl apply -f -
# ── 1b. Placeholder vault-unseal-keys secret (required before Helm install) ───
# extraSecretEnvironmentVars references vault-unseal-keys at pod start time,
# but the real keys only exist after vault operator init. Create empty placeholders
# so the pod starts; step vault_unseal_secret overwrites them with real values.
step vault_unseal_placeholder "Creating placeholder vault-unseal-keys secret..."
kubectl create secret generic vault-unseal-keys \
--namespace="${NAMESPACE}" \
--from-literal=key1="" \
--from-literal=key2="" \
--from-literal=key3="" \
--dry-run=client -o yaml | kubectl apply -f -
# ── 1c. Register talos-cli-shell OIDC app in Authentik ───────────────────────
# Must exist before Helm install so Vault can validate the JWKS URL at jwt/config time.
step authentik_app "Registering talos-cli-shell OIDC provider in Authentik..."
AUTHENTIK_API="http://127.0.0.1:7000/api/v3"
kubectl port-forward -n "${NAMESPACE}" svc/authentik-server 7000:80 &
AK_PF_PID=$!
trap 'kill ${AK_PF_PID} 2>/dev/null' EXIT
sleep 3
# Fetch required flow PKs from Authentik
_auth_flow_pk=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API}/flows/instances/?slug=default-provider-authorization-implicit-consent" \
| jq -r '.results[0].pk // empty')
_inval_flow_pk=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API}/flows/instances/?slug=default-provider-invalidation-flow" \
| jq -r '.results[0].pk // empty')
if [[ -z "${_auth_flow_pk}" || -z "${_inval_flow_pk}" ]]; then
echo "ERROR: required Authentik flows not found — is provision_oidc.py already run?" >&2
exit 1
fi
_signing_key_pk=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API}/crypto/certificatekeypairs/?has_key=true&page_size=1" \
| jq -r '.results[0].pk // empty')
if [[ -z "${_signing_key_pk}" ]]; then
echo "ERROR: no signing key found in Authentik — create one under System → Certificates" >&2
exit 1
fi
_provider_payload=$(jq -n \
--arg auth_flow "${_auth_flow_pk}" \
--arg inval_flow "${_inval_flow_pk}" \
--arg signing_key "${_signing_key_pk}" \
'{
name: "talos-cli-shell",
client_type: "confidential",
grant_types: ["client_credentials"],
token_validity: "minutes=5",
sub_mode: "hashed_user_id",
include_claims_in_id_token: true,
audience: "vault",
authorization_flow: $auth_flow,
invalidation_flow: $inval_flow,
signing_key: $signing_key,
redirect_uris: []
}')
_existing_provider=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API}/providers/oauth2/?name=talos-cli-shell" | jq -r '.results[0].pk // empty')
if [[ -n "${_existing_provider}" ]]; then
echo "Provider talos-cli-shell exists (pk=${_existing_provider}) — patching config..."
PROVIDER_PK="${_existing_provider}"
curl -sf -X PATCH "${AUTHENTIK_API}/providers/oauth2/${PROVIDER_PK}/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "${_provider_payload}" > /dev/null
CLIENT_SECRET="(unchanged — retrieve from Authentik admin UI if needed)"
else
PROVIDER_PK=$(curl -sf -X POST "${AUTHENTIK_API}/providers/oauth2/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "${_provider_payload}" | jq -r '.pk')
CLIENT_SECRET=$(curl -sf -X POST \
"${AUTHENTIK_API}/providers/oauth2/${PROVIDER_PK}/set_secret/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d '{}' | jq -r '.client_secret')
echo "Created provider pk=${PROVIDER_PK}"
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ Authentik OIDC app registered. │"
echo "│ Client ID: talos-cli-shell │"
echo "│ Client Secret: ${CLIENT_SECRET}"
echo "│ Store this secret — it will not be shown again. │"
echo "└─────────────────────────────────────────────────────────────────┘"
fi
# Idempotent: ensure the application exists and is bound to the provider.
# Runs on both create and patch paths so re-runs always produce a consistent state.
_existing_app=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API}/core/applications/?slug=talos-cli-shell" | jq -r '.results[0].pk // empty')
if [[ -n "${_existing_app}" ]]; then
echo "Application talos-cli-shell exists (pk=${_existing_app}) — ensuring provider binding..."
curl -sf -X PATCH "${AUTHENTIK_API}/core/applications/${_existing_app}/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"provider\": ${PROVIDER_PK}}" > /dev/null
else
curl -sf -X POST "${AUTHENTIK_API}/core/applications/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --argjson pk "${PROVIDER_PK}" \
'{"name":"talos-cli-shell","slug":"talos-cli-shell","provider":$pk}')" > /dev/null
echo "Application talos-cli-shell created and bound to provider pk=${PROVIDER_PK}"
fi
kill "${AK_PF_PID}" 2>/dev/null || true
unset AK_PF_PID
# ── 1d. Register vault-browser OIDC app in Authentik (browser / UI login) ────
# Separate from talos-cli-shell: this uses authorization_code grant so humans
# can log in via the Vault UI or `vault login -method=oidc`.
step authentik_vault_browser "Registering vault-browser OIDC provider in Authentik..."
AUTHENTIK_API_BROWSER="http://127.0.0.1:7000/api/v3"
kubectl port-forward -n iam svc/authentik-server 7000:80 &
AK_PF2_PID=$!
trap 'kill ${AK_PF2_PID} 2>/dev/null' EXIT
sleep 3
_auth_flow_pk2=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API_BROWSER}/flows/instances/?slug=default-provider-authorization-implicit-consent" \
| jq -r '.results[0].pk // empty')
_inval_flow_pk2=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API_BROWSER}/flows/instances/?slug=default-provider-invalidation-flow" \
| jq -r '.results[0].pk // empty')
_signing_key_pk2=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API_BROWSER}/crypto/certificatekeypairs/?has_key=true&page_size=1" \
| jq -r '.results[0].pk // empty')
_vault_browser_payload=$(jq -n \
--arg auth_flow "${_auth_flow_pk2}" \
--arg inval_flow "${_inval_flow_pk2}" \
--arg signing_key "${_signing_key_pk2}" \
'{
name: "vault-browser",
client_type: "confidential",
client_id: "vault-browser",
grant_types: ["authorization_code", "refresh"],
token_validity: "hours=8",
sub_mode: "hashed_user_id",
include_claims_in_id_token: true,
authorization_flow: $auth_flow,
invalidation_flow: $inval_flow,
signing_key: $signing_key,
redirect_uris: [
{"matching_mode": "strict", "url": "http://vault.riotpiao.homelab.com/ui/vault/auth/oidc/oidc/callback"},
{"matching_mode": "strict", "url": "http://localhost:8250/oidc/callback"}
]
}')
_existing_vault_browser=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API_BROWSER}/providers/oauth2/?name=vault-browser" | jq -r '.results[0].pk // empty')
if [[ -n "${_existing_vault_browser}" ]]; then
echo "Provider vault-browser exists (pk=${_existing_vault_browser}) — patching..."
VAULT_BROWSER_PK="${_existing_vault_browser}"
curl -sf -X PATCH "${AUTHENTIK_API_BROWSER}/providers/oauth2/${VAULT_BROWSER_PK}/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "${_vault_browser_payload}" > /dev/null
# Re-fetch the client secret from the detail endpoint (list responses mask it)
VAULT_BROWSER_CLIENT_SECRET=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API_BROWSER}/providers/oauth2/${VAULT_BROWSER_PK}/" \
| jq -r '.client_secret')
else
VAULT_BROWSER_PK=$(curl -sf -X POST "${AUTHENTIK_API_BROWSER}/providers/oauth2/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "${_vault_browser_payload}" | jq -r '.pk')
VAULT_BROWSER_CLIENT_SECRET=$(curl -sf -X POST \
"${AUTHENTIK_API_BROWSER}/providers/oauth2/${VAULT_BROWSER_PK}/set_secret/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d '{}' | jq -r '.client_secret')
echo "Created vault-browser provider pk=${VAULT_BROWSER_PK}"
fi
# Bind application
_existing_vault_app=$(curl -sf \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${AUTHENTIK_API_BROWSER}/core/applications/?slug=vault-browser" | jq -r '.results[0].pk // empty')
if [[ -n "${_existing_vault_app}" ]]; then
curl -sf -X PATCH "${AUTHENTIK_API_BROWSER}/core/applications/${_existing_vault_app}/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"provider\": ${VAULT_BROWSER_PK}}" > /dev/null
echo "Application vault-browser updated."
else
curl -sf -X POST "${AUTHENTIK_API_BROWSER}/core/applications/" \
-H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --argjson pk "${VAULT_BROWSER_PK}" \
'{"name":"vault-browser","slug":"vault-browser","provider":$pk}')" > /dev/null
echo "Application vault-browser created."
fi
kill "${AK_PF2_PID}" 2>/dev/null || true
unset AK_PF2_PID
# ── 2. Ensure vault bucket exists in MinIO ────────────────────────────────────
step minio_bucket "Ensuring 'vault' bucket exists in MinIO..."
kubectl run vault-bucket-init --rm -i --restart=Never \
--namespace=storage \
--image=minio/mc:latest \
--env="MC_HOST_local=http://${MINIO_ROOT_USER}:${MINIO_ROOT_PASSWORD}@minio.storage.svc.cluster.local:9000" \
--command -- mc mb --ignore-existing local/vault
# ── 3. Helm install Vault ─────────────────────────────────────────────────────
step helm_install "Installing HashiCorp Vault via Helm..."
helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update hashicorp
helm upgrade --install "${RELEASE}" hashicorp/vault \
--namespace "${NAMESPACE}" \
--values "${SCRIPT_DIR}/vault-values.yaml" \
--wait --timeout=120s
# ── 4. Wait for vault pod ─────────────────────────────────────────────────────
step wait_pod "Waiting for vault-0 pod to be Running..."
kubectl wait pod/vault-0 \
--namespace="${NAMESPACE}" \
--for=condition=Ready=false \
--timeout=60s 2>/dev/null || true # pod starts uninitialized (not Ready) — that's expected
# Give it a moment to bind the port
sleep 5
# ── 5. Port-forward for init/unseal ──────────────────────────────────────────
step port_forward "Starting port-forward to vault-0 on 127.0.0.1:8200..."
kubectl port-forward -n "${NAMESPACE}" pod/vault-0 8200:8200 &
PF_PID=$!
trap 'kill ${PF_PID} 2>/dev/null; on_err' ERR
trap 'kill ${PF_PID} 2>/dev/null' EXIT
sleep 3
export VAULT_ADDR="${VAULT_PORT_FWD_ADDR}"
# ── 6. Init ───────────────────────────────────────────────────────────────────
step vault_init "Initialising Vault (5 key shares, threshold 3)..."
if vault status 2>/dev/null | grep -q "Initialized.*true"; then
echo "Vault is already initialised — skipping init."
else
vault operator init \
-key-shares=5 \
-key-threshold=3 \
-format=json > "${UNSEAL_KEYS_FILE}"
chmod 600 "${UNSEAL_KEYS_FILE}"
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ IMPORTANT: unseal keys + root token saved to: │"
echo "${UNSEAL_KEYS_FILE}"
echo "│ Move this file OFFLINE (USB / password manager) immediately. │"
echo "└─────────────────────────────────────────────────────────────────┘"
fi
# ── 6b. Store unseal keys in K8s Secret for auto-unseal on restart ───────────
step vault_unseal_secret "Storing 3 unseal keys in vault-unseal-keys Secret..."
kubectl create secret generic vault-unseal-keys \
--namespace="${NAMESPACE}" \
--from-literal=key1="$(jq -r '.unseal_keys_b64[0]' "${UNSEAL_KEYS_FILE}")" \
--from-literal=key2="$(jq -r '.unseal_keys_b64[1]' "${UNSEAL_KEYS_FILE}")" \
--from-literal=key3="$(jq -r '.unseal_keys_b64[2]' "${UNSEAL_KEYS_FILE}")" \
--dry-run=client -o yaml | kubectl apply -f -
# ── 7. Unseal ─────────────────────────────────────────────────────────────────
step vault_unseal "Unsealing Vault (3 of 5 shares)..."
if vault status 2>/dev/null | grep -q "Sealed.*false"; then
echo "Vault is already unsealed — skipping."
else
for i in 0 1 2; do
KEY=$(jq -r ".unseal_keys_b64[${i}]" "${UNSEAL_KEYS_FILE}")
vault operator unseal "${KEY}"
done
fi
# ── 8. Login with root token ──────────────────────────────────────────────────
step vault_login "Logging in with root token..."
ROOT_TOKEN=$(jq -r ".root_token" "${UNSEAL_KEYS_FILE}")
vault login "${ROOT_TOKEN}"
# ── 9. Enable KV v2 ──────────────────────────────────────────────────────────
step kv_enable "Enabling KV v2 at secret/..."
vault secrets enable -path=secret kv-v2 2>/dev/null || echo "KV v2 already enabled."
# ── 10. Enable JWT auth backend ───────────────────────────────────────────────
step jwt_enable "Enabling JWT auth backend..."
vault auth enable jwt 2>/dev/null || echo "JWT auth already enabled."
JWKS_URL="${AUTHENTIK_URL}/application/o/talos-cli-shell/jwks/"
vault write auth/jwt/config \
jwks_url="${JWKS_URL}" \
default_role="shell"
echo "JWT auth configured with JWKS at: ${JWKS_URL}"
# ── 11. Write policies ────────────────────────────────────────────────────────
step policies "Writing Vault policies..."
vault policy write shell-secrets - <<'EOF'
# shell-secrets: read MCP keys and cloud tokens for interactive shell sessions
path "secret/data/mcp/*" {
capabilities = ["read"]
}
path "secret/data/cloud/*" {
capabilities = ["read"]
}
EOF
vault policy write mcp-readonly - <<'EOF'
# mcp-readonly: each MCP server reads only its own path
path "secret/data/mcp/{{identity.entity.aliases.*.metadata.client_id}}" {
capabilities = ["read"]
}
EOF
vault policy write cluster-admin - <<'EOF'
# cluster-admin: read cluster service credentials (MinIO, Grafana, etc.)
path "secret/data/cluster/*" {
capabilities = ["read", "update"]
}
path "secret/data/mcp/*" {
capabilities = ["read", "create", "update", "delete"]
}
path "secret/data/cloud/*" {
capabilities = ["read", "create", "update", "delete"]
}
EOF
# ── 12. Create JWT roles ──────────────────────────────────────────────────────
step jwt_roles "Creating JWT roles..."
# shell role: interactive shell sessions via talos-cli
vault write auth/jwt/role/shell \
role_type=jwt \
bound_audiences="vault" \
user_claim="sub" \
policies="shell-secrets" \
ttl=4h \
max_ttl=8h
# mcp role: MCP server processes (short TTL, non-renewable)
vault write auth/jwt/role/mcp \
role_type=jwt \
bound_audiences="vault" \
user_claim="sub" \
policies="mcp-readonly" \
ttl=1h \
max_ttl=1h
# cluster role: admin operations (bootstrap scripts, rotation jobs)
vault write auth/jwt/role/cluster - <<'EOF'
{
"role_type": "jwt",
"bound_audiences": ["vault"],
"user_claim": "sub",
"bound_claims": {"groups": ["homelab-admins"]},
"token_policies": ["cluster-admin"],
"token_ttl": "1h"
}
EOF
# ── 13. Enable OIDC auth (browser / UI login via Authentik) ──────────────────
step oidc_enable "Enabling OIDC auth method for browser login..."
vault auth enable oidc 2>/dev/null || echo "OIDC auth already enabled."
vault write auth/oidc/config \
oidc_discovery_url="http://authentik.riotpiao.homelab.com/application/o/vault-browser/" \
oidc_client_id="vault-browser" \
oidc_client_secret="${VAULT_BROWSER_CLIENT_SECRET}" \
default_role="homelab"
# homelab role: all authenticated Authentik users get shell-secrets + cluster-admin.
# Restrict further by adding bound_claims once group-based mapping is configured.
vault write auth/oidc/role/homelab \
role_type=oidc \
bound_audiences="vault-browser" \
allowed_redirect_uris="http://vault.riotpiao.homelab.com/ui/vault/auth/oidc/oidc/callback,http://localhost:8250/oidc/callback" \
user_claim="sub" \
oidc_scopes="openid,profile,email" \
token_policies="shell-secrets,cluster-admin" \
token_ttl=8h \
token_max_ttl=12h
echo "OIDC auth configured."
echo " Browser login: http://vault.riotpiao.homelab.com → sign in with Authentik"
echo " CLI login: VAULT_ADDR=http://vault.riotpiao.homelab.com vault login -method=oidc"
# ── 14. Seed initial secrets ──────────────────────────────────────────────────
step seed_secrets "Seeding initial secrets from environment (accessible after OIDC login)..."
# Cluster service credentials
vault kv put secret/cluster/minio \
user="${MINIO_ROOT_USER}" \
password="${MINIO_ROOT_PASSWORD}"
if [[ -n "${GRAFANA_ADMIN_PASSWORD:-}" ]]; then
vault kv put secret/cluster/grafana \
password="${GRAFANA_ADMIN_PASSWORD}"
fi
if [[ -n "${DUCKDNS_TOKEN:-}" ]]; then
vault kv put secret/cloud/duckdns \
token="${DUCKDNS_TOKEN}"
fi
# MCP / AI tooling keys (optional — add when available)
if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then
vault kv put secret/mcp/anthropic api_key="${ANTHROPIC_API_KEY}"
fi
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
vault kv put secret/mcp/github token="${GITHUB_TOKEN}"
fi
# ── Done ──────────────────────────────────────────────────────────────────────
echo ""
echo "==> Vault bootstrap complete."
echo ""
echo "Next steps:"
echo " 1. Move ${UNSEAL_KEYS_FILE} OFFLINE (USB / password manager)."
echo " 2. Browser login: open http://vault.riotpiao.homelab.com → choose OIDC → sign in with Authentik"
echo " 3. CLI login: export VAULT_ADDR=http://vault.riotpiao.homelab.com"
echo " vault login -method=oidc"
echo " 4. Read MinIO credentials from Vault after login:"
echo " vault kv get secret/cluster/minio"
echo " 5. talos-cli (JWT): talos secrets login && talos secrets status"
+171
View File
@@ -0,0 +1,171 @@
# k8s/talos-iam/vault-values.yaml
# HashiCorp Vault — secrets backend for the homelab.
# Stores OIDC client secrets, TLS certs, and any other sensitive values.
# Accessed via the `talos` CLI (talos-cli/) which wraps `vault kv get/put`.
#
# Storage backend: MinIO S3 (minio.storage.svc.cluster.local) — no extra PVC.
# Auto-unseal: postStart hook reads unseal keys from vault-unseal-keys Secret
# (written by setup_vault.sh after operator init; operator must run that script
# once after first install to initialize and store the keys).
# ── Global ────────────────────────────────────────────────────────────────────
# tlsDisable: true — TLS terminated at the nginx ingress (vault.riotpiao.homelab.com)
# or at port-forward. In-cluster traffic to Vault is plain HTTP; this is acceptable
# because all clients are on the pod network (not crossing node boundaries).
global:
enabled: true
tlsDisable: true
# ── Agent Injector ────────────────────────────────────────────────────────────
# The injector mutates pods to sidecar Vault Agent for automatic secret injection.
# Not used here — secrets are fetched explicitly via the talos CLI.
# Enabling it would add a webhook that intercepts all pod creates cluster-wide,
# which is unnecessary overhead for a homelab with manual secret management.
injector:
enabled: false
server:
replicas: 1
annotations:
secret.reloader.stakater.com/reload: "vault-unseal-keys"
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
# ── Scheduling ─────────────────────────────────────────────────────────────
# Tolerate cp-1 so Vault can run there if worker-1 is down.
# Prefer worker-1 under normal conditions (keeps Vault off the same node as etcd).
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
# ── Environment variables ───────────────────────────────────────────────────
# extraEnvironmentVars: non-secret config passed directly.
extraEnvironmentVars:
VAULT_LOG_LEVEL: info
# extraSecretEnvironmentVars: pulls values from K8s Secrets into env vars.
# vault-minio-creds is created by the helmfile presync hook from MINIO_ROOT_USER/PASSWORD.
# vault-unseal-keys is a placeholder created at first deploy; setup_vault.sh
# overwrites it with real unseal keys after `vault operator init`.
# Vault reads the keys from env on every pod start and the postStart hook unseals.
extraSecretEnvironmentVars:
- envName: AWS_ACCESS_KEY_ID
secretName: vault-minio-creds
secretKey: access_key
- envName: AWS_SECRET_ACCESS_KEY
secretName: vault-minio-creds
secretKey: secret_key
- envName: VAULT_UNSEAL_KEY_1
secretName: vault-unseal-keys
secretKey: key1
- envName: VAULT_UNSEAL_KEY_2
secretName: vault-unseal-keys
secretKey: key2
- envName: VAULT_UNSEAL_KEY_3
secretName: vault-unseal-keys
secretKey: key3
# ── Auto-unseal ─────────────────────────────────────────────────────────────
# Vault starts sealed after every pod restart and can't serve requests until
# unsealed. postStart runs immediately after the container starts, sleeps 5s
# to let the Vault process bind its port, then feeds the unseal keys one by one.
# `|| true` prevents the hook from failing if a key was already used (idempotent).
# 3-of-5 Shamir unseal is the default — we stored all 3 used keys in the Secret.
postStart:
- /bin/sh
- -c
- |
sleep 5
vault operator unseal "$VAULT_UNSEAL_KEY_1" || true
vault operator unseal "$VAULT_UNSEAL_KEY_2" || true
vault operator unseal "$VAULT_UNSEAL_KEY_3" || true
# ── Vault config (HCL) ──────────────────────────────────────────────────────
standalone:
enabled: true
config: |
ui = false # UI served via Vault's own HTTP; enabled below via ui: enabled: true
listener "tcp" {
address = "0.0.0.0:8200"
tls_disable = "true"
# No auth in front of Vault's metrics endpoint — acceptable since all
# Prometheus scrape traffic stays on the pod network (not exposed via ingress).
telemetry {
unauthenticated_metrics_access = "true"
}
}
telemetry {
prometheus_retention_time = "30s"
disable_hostname = true
}
# S3 storage backend pointing at the in-cluster MinIO service.
# AWS_ env vars (from vault-minio-creds Secret) supply the credentials.
# s3_force_path_style: MinIO uses path-style URLs (not virtual-hosted).
# disable_ssl: MinIO in this cluster has no TLS.
storage "s3" {
endpoint = "http://minio.storage.svc.cluster.local:9000"
bucket = "vault"
region = "us-east-1"
s3_force_path_style = "true"
disable_ssl = "true"
}
# api_addr: the address other Vault nodes (or HA standbys) use to reach
# this node. Single-node standalone, but Vault requires it to be set.
api_addr = "http://vault.storage.svc.cluster.local:8200"
# ── Service ─────────────────────────────────────────────────────────────────
# NodePort 32171 — fallback for direct node access during bootstrap before
# the ingress is up. Normal access is via nginx ingress (vault.riotpiao.homelab.com).
service:
type: NodePort
port: 8200
nodePort: 32171
# ── Persistence ─────────────────────────────────────────────────────────────
# No PVC — all Vault state (secrets, policies, tokens) is stored in MinIO S3.
# This means Vault survives node loss as long as MinIO is healthy.
dataStorage:
enabled: false
auditStorage:
enabled: false
# ── UI ────────────────────────────────────────────────────────────────────────
# Vault's web UI is used for the OIDC browser login flow (Vault as an OIDC
# provider, if configured) and for manual operator inspection.
# Accessible at http://vault.riotpiao.homelab.com or via port-forward.
ui:
enabled: true
# ── Metrics ───────────────────────────────────────────────────────────────────
# vault_core_unsealed is the availability signal (0 after a restart until the
# postStart hook above finishes unsealing). Pairs with the telemetry{} stanzas
# in standalone.config above, which actually turn the /v1/sys/metrics endpoint on.
serverTelemetry:
serviceMonitor:
enabled: true
selectors: {}
interval: 30s
scrapeTimeout: 10s
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env bash
# verify_existing_oauth_integrations.sh
# Verification script that checks existing OIDC integrations
# (Grafana, MinIO, Forgejo, Argo CD) are still working after updates.
#
# Usage:
# bash k8s/talos-iam/verify_existing_oauth_integrations.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
KUBECONFIG="${KUBECONFIG:-${REPO_ROOT}/cluster-config/kubeconfig}"
export KUBECONFIG
echo "╔════════════════════════════════════════════════════════════════════╗"
echo "║ OAuth Integrations Verification ║"
echo "╚════════════════════════════════════════════════════════════════════╝"
echo ""
# Check prerequisites
echo "📋 Checking prerequisites..."
required_cmds=("kubectl" "curl" "jq")
for cmd in "${required_cmds[@]}"; do
if ! command -v "$cmd" &> /dev/null; then
echo "$cmd not found in PATH"
exit 1
fi
done
# Check namespaces exist
for ns in iam logging storage; do
if ! kubectl get ns "$ns" &> /dev/null; then
echo "❌ Namespace $ns not found"
exit 1
fi
done
echo "✓ All prerequisites met"
echo ""
# Function to check pod status
check_pod_status() {
local namespace=$1
local label=$2
local component=$3
echo "Checking $component..."
if kubectl get pods -n "$namespace" -l "$label" -o wide 2>/dev/null | grep -q Running; then
echo " ✓ Running"
return 0
else
echo " ❌ Not running"
return 1
fi
}
# Function to test Authentik API endpoint
test_authentik_api() {
echo "Testing Authentik API..."
if ! kubectl port-forward -n iam svc/authentik-server 7000:80 &> /dev/null & then
sleep 2
if curl -sf http://localhost:7000/-/health/ready/ &> /dev/null; then
echo " ✓ API healthy (HTTP 204)"
else
echo " ❌ API not responding"
return 1
fi
fi
}
# Check core services
echo "🔍 Service Status"
echo "─────────────────────────────────────────────────────────────────────"
check_pod_status iam "app=authentik,component=server" "Authentik Server" || true
check_pod_status iam "app=authentik,component=worker" "Authentik Worker" || true
check_pod_status iam "app=authentik,component=postgresql" "Authentik PostgreSQL" || true
check_pod_status storage "app.kubernetes.io/name=vault" "Vault" || true
check_pod_status logging "app.kubernetes.io/name=grafana" "Grafana" || true
check_pod_status storage "app.kubernetes.io/name=minio" "MinIO" || true
echo ""
# Verify Authentik has expected providers
echo "🔐 Authentik OAuth Providers"
echo "─────────────────────────────────────────────────────────────────────"
if [[ -z "${AUTHENTIK_BOOTSTRAP_TOKEN:-}" ]]; then
echo "⚠ AUTHENTIK_BOOTSTRAP_TOKEN not set — skipping provider verification"
echo " Set it: export AUTHENTIK_BOOTSTRAP_TOKEN=\"$(talos get cluster/AUTHENTIK_BOOTSTRAP_TOKEN --key AUTHENTIK_BOOTSTRAP_TOKEN 2>/dev/null)\""
echo ""
else
# Port-forward to Authentik
if ! pgrep -f "kubectl port-forward.*7000:80" > /dev/null; then
kubectl port-forward -n iam svc/authentik-server 7000:80 > /dev/null 2>&1 &
sleep 2
fi
api_url="http://localhost:7000/api/v3"
# Check for expected providers
for provider in grafana minio forgejo argocd talos-cli-shell; do
response=$(curl -sf -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${api_url}/providers/oauth2/?name=${provider}" 2>/dev/null || echo "{}")
if echo "$response" | jq -e '.results[0]' &> /dev/null; then
pk=$(echo "$response" | jq -r '.results[0].pk')
client_id=$(echo "$response" | jq -r '.results[0].client_id // "N/A"')
echo "${provider} (pk=${pk}, client_id=${client_id})"
else
echo "${provider} NOT FOUND"
fi
done
echo ""
# Check for expected groups
echo "👥 Authentik Groups"
echo "─────────────────────────────────────────────────────────────────────"
for group in homelab-admins grafana-admins grafana-viewers minio-admins minio-readonly; do
response=$(curl -sf -H "Authorization: Bearer ${AUTHENTIK_BOOTSTRAP_TOKEN}" \
"${api_url}/core/groups/?name=${group}" 2>/dev/null || echo "{}")
if echo "$response" | jq -e '.results[0]' &> /dev/null; then
echo "${group}"
else
echo "${group} NOT FOUND"
fi
done
echo ""
fi
# Verify K8s secrets for apps
echo "🔑 Kubernetes Secrets"
echo "─────────────────────────────────────────────────────────────────────"
for secret_spec in "grafana-oidc:logging" "minio-oidc:storage" "authentik-oidc-forgejo:cicd" "oidc-secret:cicd"; do
IFS=':' read -r secret_name ns <<< "$secret_spec"
if kubectl get secret "$secret_name" -n "$ns" &> /dev/null 2>&1; then
size=$(kubectl get secret "$secret_name" -n "$ns" -o jsonpath='{.data}' 2>/dev/null | wc -c)
echo "${secret_name} → ns/${ns} (${size} bytes)"
else
echo "${secret_name} → ns/${ns} NOT FOUND"
fi
done
echo ""
# Verify Vault JWT auth
echo "🔐 Vault JWT Authentication"
echo "─────────────────────────────────────────────────────────────────────"
if command -v vault &> /dev/null; then
# Check if Vault auth method is enabled
vault_status=$(kubectl exec -n storage vault-0 -- vault auth list -format=json 2>/dev/null | jq 'keys' || echo "[]")
if echo "$vault_status" | jq -e '.[] | select(. == "jwt/")' &> /dev/null; then
echo " ✓ JWT auth method enabled"
# List JWT roles
roles=$(kubectl exec -n storage vault-0 -- vault list auth/jwt/role -format=json 2>/dev/null | jq '.[]' || echo "")
if [[ -n "$roles" ]]; then
echo " ✓ JWT roles found:"
echo "$roles" | while read role; do
echo " - ${role}"
done
else
echo " ✗ No JWT roles found"
fi
else
echo " ⚠ JWT auth method not enabled"
fi
else
echo " ⚠ vault CLI not found — skipping Vault checks"
fi
echo ""
# Summary
echo "╔════════════════════════════════════════════════════════════════════╗"
echo "║ Verification Complete ║"
echo "╚════════════════════════════════════════════════════════════════════╝"
echo ""
echo "Next steps:"
echo ""
echo "1. Verify OIDC login flow (Grafana):"
echo " kubectl port-forward -n logging svc/grafana 3000:80"
echo " # Open http://localhost:3000/login"
echo " # Should show 'Sign in with ...' option"
echo ""
echo "2. Verify MinIO OIDC (if configured):"
echo " kubectl port-forward -n storage svc/minio 9001:9001"
echo " # Open http://localhost:9001"
echo " # Should show identity provider option"
echo ""
echo "3. Verify Vault JWT role:"
echo " kubectl port-forward -n storage svc/vault 8200:8200"
echo " export VAULT_ADDR=http://127.0.0.1:8200"
echo " vault read auth/jwt/role/shell"
echo ""
echo "4. Test JWT authentication to Vault:"
echo " # Get ID token from Authentik (requires app integration)"
echo " # Then authenticate: vault login -method=jwt role=shell jwt=\$ID_TOKEN"
echo ""