Episode 15: Pocket ID as identity provider, tinyauth gate, offboarding script

Pocket ID replaces Forgejo as the OIDC provider for Outline, Vaultwarden and
NetBird; Forgejo logs in through it. Uptime Kuma sits behind tinyauth via
Caddy forward_auth (group ops), the public status page stays public.

offboarding/offboard.py switches one person off in every service with an API
and prints what a human still has to do: the shared passwords they could read.
Measured: SSO lock alone left 10 of 13 ways open for 45 minutes; the script
leaves 1 of 14, the vault copy on their laptop.

HARDENING.md: the ufw hairpin that broke Vaultwarden SSO and Kuma, the mesh
route into the docker bridges, and a fail2ban exception for Pocket ID.
This commit is contained in:
Maximilian Grimm 2026-09-23 11:52:17 +02:00
parent 3edef38ba3
commit 12542ecb19
9 changed files with 490 additions and 21 deletions

View file

@ -3,8 +3,10 @@
#
# One server. One file. Your whole company stack:
# caddy reverse proxy + automatic HTTPS
# forgejo git hosting + CI (also acts as SSO/OIDC provider)
# outline team wiki (logs in via Forgejo SSO)
# forgejo git hosting + CI (was the SSO/OIDC provider until episode 15)
# outline team wiki (logs in via Pocket ID)
# pocket-id identity provider, passkeys + groups (episode 15)
# tinyauth login gate for services without SSO (episode 15)
# vaultwarden password manager (Bitwarden-compatible)
# uptime-kuma monitoring + alerts
# ntfy push notifications for those alerts (episode 6)
@ -51,6 +53,8 @@ volumes:
ntfy_data:
ntfy_cache:
n8n_data:
pocket_id_data:
tinyauth_data:
wg_data:
docker_certs:
dind_data:
@ -96,6 +100,12 @@ services:
# the container name has to be listed. "external" keeps ordinary public
# webhook targets working (episode 8).
FORGEJO__webhook__ALLOWED_HOST_LIST: "external,n8n"
# Episode 15: people log in with Pocket ID (auth source "PocketID", added
# once with: forgejo admin auth add-oauth ...). An SSO login is linked to
# the existing account with the same mail address instead of a duplicate.
FORGEJO__oauth2_client__ENABLE_AUTO_REGISTRATION: "true"
FORGEJO__oauth2_client__ACCOUNT_LINKING: auto
FORGEJO__oauth2_client__USERNAME: nickname
ports:
- "2222:22" # git over SSH
volumes:
@ -133,14 +143,14 @@ services:
FILE_STORAGE: local
FILE_STORAGE_LOCAL_ROOT_DIR: /var/lib/outline/data
FORCE_HTTPS: "false" # TLS terminates at caddy
# --- SSO via your own Forgejo (set up the OAuth2 app first, see README) ---
# --- SSO via Pocket ID (episode 15; Forgejo was the provider until then) ---
OIDC_CLIENT_ID: ${OUTLINE_OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OUTLINE_OIDC_CLIENT_SECRET}
OIDC_AUTH_URI: https://git.${DOMAIN}/login/oauth/authorize
OIDC_TOKEN_URI: https://git.${DOMAIN}/login/oauth/access_token
OIDC_USERINFO_URI: https://git.${DOMAIN}/login/oauth/userinfo
OIDC_LOGOUT_URI: https://git.${DOMAIN}/logout
OIDC_DISPLAY_NAME: Forgejo
OIDC_AUTH_URI: https://id.${DOMAIN}/authorize
OIDC_TOKEN_URI: https://id.${DOMAIN}/api/oidc/token
OIDC_USERINFO_URI: https://id.${DOMAIN}/api/oidc/userinfo
OIDC_LOGOUT_URI: https://id.${DOMAIN}/api/oidc/end-session
OIDC_DISPLAY_NAME: Pocket ID
OIDC_SCOPES: openid profile email
volumes:
- outline_data:/var/lib/outline/data
@ -153,20 +163,22 @@ services:
DOMAIN: https://vault.${DOMAIN}
ADMIN_TOKEN: ${VAULTWARDEN_ADMIN_TOKEN}
SIGNUPS_ALLOWED: "${VAULTWARDEN_SIGNUPS_ALLOWED:-true}" # set to false after your team joined!
# --- Single sign-on via your own Forgejo (episode 4) ---
# Create the OAuth2 app in Forgejo FIRST: Site Administration ->
# Applications -> OAuth2. Its redirect URI must be exactly:
# --- Single sign-on (episode 4 with Forgejo, since episode 15 Pocket ID) ---
# Create the OIDC client in Pocket ID FIRST. Its callback URL must be exactly:
# https://vault.${DOMAIN}/identity/connect/oidc-signin
# That path is not documented in the UI. Getting it wrong fails only
# AFTER the Forgejo login, which is why it costs people twenty minutes.
# AFTER the provider login, which is why it costs people twenty minutes.
SSO_ENABLED: "true"
# Just the base URL. Vaultwarden reads Forgejo's discovery document
# Just the base URL. Vaultwarden reads the discovery document
# (/.well-known/openid-configuration) from here on the first login.
SSO_AUTHORITY: https://git.${DOMAIN}
# SSO_AUTH_ONLY_NOT_SESSION stays false (default): every token refresh
# asks the provider again, so a person disabled there is out at the next
# refresh. Episode 15 measured why that alone was not enough with Forgejo.
SSO_AUTHORITY: https://id.${DOMAIN}
SSO_CLIENT_ID: ${VAULTWARDEN_SSO_CLIENT_ID}
SSO_CLIENT_SECRET: ${VAULTWARDEN_SSO_CLIENT_SECRET}
# Vaultwarden prepends "openid" itself, so listing it here again shows up
# duplicated on the Forgejo consent screen. Harmless, but untidy.
# duplicated on the consent screen. Harmless, but untidy.
SSO_SCOPES: "profile email"
# Attach an SSO login to the existing account with the same mail address
# instead of creating a duplicate. Turn this on BEFORE people log in.
@ -247,6 +259,54 @@ services:
- ./backup/backup.log:/backup/backup.log:ro
networks: [proxy]
# Identity provider (episode 15). Forgejo used to be the OIDC provider for
# outline, vaultwarden and netbird. Now everyone logs in here, with a
# passkey, and switching one person off happens in one place. Pocket ID is
# small enough for a 4 GB box; groups decide who gets which service.
pocket-id:
image: ghcr.io/pocket-id/pocket-id:v2.16.0
restart: unless-stopped
environment:
APP_URL: https://id.${DOMAIN}
# Caddy sits in front, so trust X-Forwarded-For for the audit log.
TRUST_PROXY: "true"
# Encrypts the signing keys at rest. Generate once: openssl rand -base64 32
ENCRYPTION_KEY: ${POCKET_ID_ENCRYPTION_KEY}
ANALYTICS_DISABLED: "true"
volumes:
- pocket_id_data:/app/data
networks: [proxy]
# Login gate for services without their own SSO (episode 15). Caddy asks
# tinyauth on every request (forward_auth); tinyauth sends people to Pocket
# ID and lets through only members of the right group. No docker socket,
# apps are configured below, not via labels.
tinyauth:
image: ghcr.io/tinyauthapp/tinyauth:v5.2.0
restart: unless-stopped
environment:
TINYAUTH_APPURL: https://auth.${DOMAIN}
TINYAUTH_LABELPROVIDER: none
TINYAUTH_ANALYTICS_ENABLED: "false"
TINYAUTH_DATABASE_PATH: /data/tinyauth.db
TINYAUTH_AUTH_SECURECOOKIE: "true"
TINYAUTH_AUTH_TRUSTEDPROXIES: 172.18.0.0/16
TINYAUTH_OAUTH_AUTOREDIRECT: pocketid
TINYAUTH_OAUTH_PROVIDERS_POCKETID_NAME: Pocket ID
TINYAUTH_OAUTH_PROVIDERS_POCKETID_CLIENTID: ${TINYAUTH_CLIENT_ID}
TINYAUTH_OAUTH_PROVIDERS_POCKETID_CLIENTSECRET: ${TINYAUTH_CLIENT_SECRET}
TINYAUTH_OAUTH_PROVIDERS_POCKETID_AUTHURL: https://id.${DOMAIN}/authorize
TINYAUTH_OAUTH_PROVIDERS_POCKETID_TOKENURL: https://id.${DOMAIN}/api/oidc/token
TINYAUTH_OAUTH_PROVIDERS_POCKETID_USERINFOURL: https://id.${DOMAIN}/api/oidc/userinfo
TINYAUTH_OAUTH_PROVIDERS_POCKETID_REDIRECTURL: https://auth.${DOMAIN}/api/oauth/callback/pocketid
TINYAUTH_OAUTH_PROVIDERS_POCKETID_SCOPES: openid email profile groups
# Uptime Kuma: only the Pocket ID group "ops" gets past the gate.
TINYAUTH_APPS_KUMA_CONFIG_DOMAIN: status.${DOMAIN}
TINYAUTH_APPS_KUMA_OAUTH_GROUPS: ops
volumes:
- tinyauth_data:/data
networks: [proxy]
# Optional starter VPN — enable with: docker compose --profile vpn up -d
# (The proper Netbird mesh deployment is Episode 5.)
wg-easy: