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:
@@ -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
|
||||||
Executable
+21
@@ -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"
|
||||||
@@ -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;
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
Executable
+134
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+512
@@ -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()
|
||||||
Executable
+54
@@ -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
|
||||||
Executable
+527
@@ -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"
|
||||||
@@ -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
@@ -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 ""
|
||||||
Reference in New Issue
Block a user