Paperless-ngx — CF Access SSO Setup Plan
Paperless-ngx — CF Access SSO Setup Plan
STATUS: PLAN — NOT YET EXECUTED Written: 2026-05-15 by John (AI Director) Execution: CEO terminal (az vm run-command + CF API) Prerequisite: review this page fully before executing
Current State (verified 2026-05-15)
| Component | Current Config |
|---|---|
| URL | https://archive.alai.no |
| CF Access app | "All ALAI Services" wildcard *.alai.no (id: cd7cf0f0) |
| Dedicated archive app | None — wildcard catches all |
| IdP | Email OTP only (alai-no.cloudflareaccess.com) |
| Human login | Username + password (user alembasic, superuser) |
| API auth | DRF Token (c9ec30192db3c95802349335edea4bca864a937a) |
| IMAP pipe auth | CF service token (BW: e4fd63de) + Paperless API token |
| SSO | Not configured |
| Browser access | IP bypass fires for LAN (92.221.168.61) — no CF auth challenge |
Key finding: CF Access only injects Cf-Access-Authenticated-User-Email when the allow policy fires. When IP bypass matches first, no identity header is set. Current bypass-first config means SSO cannot work for LAN browser sessions without restructuring the CF app.
Architecture Decision: Dedicated CF Access App
Create a separate CF Access app for archive.alai.no that:
- Authenticates CEO via Email OTP (allow policy, no IP bypass for browsers)
- Bypasses for the IMAP pipe service token (machine-to-machine remains token-based)
- Does NOT inherit the IP bypass from the wildcard app (exact-match app takes precedence)
The wildcard *.alai.no app continues to handle all other services and IP-bypass API access.
Header Chain (after SSO enabled)
CEO Browser
↓
Cloudflare CF Access (Email OTP challenge — once per 24h)
↓ injects: Cf-Access-Authenticated-User-Email: [email protected]
Caddy reverse proxy (archive.alai.no → paperless:8000)
↓ forwards all headers by default
Paperless-ngx (Django) reads: HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL
↓ matches username "[email protected]" → auto-login
CEO is logged in, no password prompt
Execution Script
Run from CEO terminal (has full az auth). Do NOT execute all at once — verify each phase.
Phase 1: Rename Paperless user (preserve document ownership)
# SSH to Azure VM
ssh -i ~/.ssh/azure_alai [email protected]
# Rename 'alembasic' → '[email protected]'
docker exec alai-paperless-1 python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
u = User.objects.get(username='alembasic')
print('Before:', u.username, u.email)
u.username = '[email protected]'
u.email = '[email protected]'
u.save()
print('After:', u.username)
"
# Expected output: After: [email protected]
# Verify: list users
docker exec alai-paperless-1 python manage.py shell -c "
from django.contrib.auth import get_user_model
for u in get_user_model().objects.all():
print(u.id, u.username, u.is_superuser, u.is_active)
"
Phase 2: Update Paperless env vars for trusted-header SSO
# On Azure VM — find docker compose file
ls /opt/alai/ /home/alai-admin/ 2>/dev/null
# Likely: /opt/alai/docker-compose.yml or /home/alai-admin/docker-compose.yml
# Add/update these env vars in the paperless service:
# PAPERLESS_ENABLE_HTTP_REMOTE_USER=true
# PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL
# Example edit (adjust path as needed):
# In docker-compose.yml, under paperless service environment:
# - PAPERLESS_ENABLE_HTTP_REMOTE_USER=true
# - PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL
# Restart paperless (NOT the whole stack — don't restart redis/gotenberg/tika):
docker compose -f /path/to/docker-compose.yml restart alai-paperless-1
Phase 3: Verify Caddy forwards the header
# Test from Azure VM (loopback):
# Simulate what CF Access would inject:
curl -s -o /dev/null -w "%{http_code}" \
-H "Cf-Access-Authenticated-User-Email: [email protected]" \
-H "Cf-Access-JWT-Assertion: test" \
http://localhost:8000/accounts/login/
# This should NOT auto-login (no Caddy = no trusted proxy check) — that's expected
# The real test is through Caddy (HTTPS from browser)
# Check Caddy config:
cat /opt/alai/Caddyfile 2>/dev/null || docker exec alai-caddy-1 cat /etc/caddy/Caddyfile 2>/dev/null
# Verify archive.alai.no block does NOT strip headers explicitly
# Caddy default: all request headers are forwarded to upstream
Phase 4: Create dedicated CF Access app for archive.alai.no
# Use CF API to create the dedicated app
CF_ACCOUNT_ID="d0ac2afb6bb5b298723b85a114151a04"
CF_EMAIL="[email protected]"
CF_API_KEY="05cbcc7422e60351457a75623b4181c1faccd"
OTP_IDP_ID="ff0a28e6-2220-4de2-a82f-48385d88b163"
PIPE_TOKEN_ID="9d63505b-2e07-49e4-beb6-28b545a93bef"
curl -s -X POST \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/access/apps" \
-H "X-Auth-Email: $CF_EMAIL" \
-H "X-Auth-Key: $CF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "archive.alai.no — Paperless SSO",
"domain": "archive.alai.no",
"type": "self_hosted",
"session_duration": "24h",
"auto_redirect_to_identity": false,
"http_only_cookie_attribute": true,
"same_site_cookie_attribute": "lax",
"app_launcher_visible": true,
"allowed_idps": ["'"$OTP_IDP_ID"'"],
"policies": [
{
"name": "archive-pipe service token bypass",
"decision": "bypass",
"precedence": 1,
"include": [{"service_token": {"token_id": "'"$PIPE_TOKEN_ID"'"}}]
},
{
"name": "CEO alembasic access",
"decision": "allow",
"precedence": 2,
"include": [{"email": {"email": "[email protected]"}}]
}
]
}'
# Save the returned app id — needed if you want to update or delete this app
Phase 5: Verify SSO works
# From CEO browser (Mac Air, NOT Mac Studio with VPN):
# 1. Clear cookies for archive.alai.no
# 2. Navigate to https://archive.alai.no
# 3. Should see CF Access OTP challenge — enter [email protected]
# 4. Enter OTP from email
# 5. Should land directly on Paperless dashboard (logged in as [email protected])
# 6. Check: Profile → Settings — should show [email protected] as username
# API/pipe verification (no regression):
source ~/.config/alai/paperless-token.env
curl -s --interface "$PAPERLESS_BIND_INTERFACE" \
-H "Authorization: Token $PAPERLESS_TOKEN" \
"$PAPERLESS_BASE/api/documents/?page_size=1" | grep '"count"'
# Should return document count — confirms API token auth still works
Rollback Procedure
If SSO breaks login:
# Method 1: Disable SSO via env (SSH or az run-command)
# Edit docker-compose.yml: set PAPERLESS_ENABLE_HTTP_REMOTE_USER=false
# docker compose restart alai-paperless-1
# Then login with [email protected] + password
# Method 2: Emergency password reset (if locked out completely)
az vm run-command invoke \
--resource-group RG-ALAI-SUPPORT \
--name vm-alai-support \
--command-id RunShellScript \
--scripts "docker exec alai-paperless-1 python manage.py changepassword [email protected]"
# Method 3: Delete the dedicated CF Access app (reverts to wildcard + IP bypass)
# Get the app id from Phase 4 output, then:
curl -s -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/access/apps/<APP_ID>" \
-H "X-Auth-Email: $CF_EMAIL" \
-H "X-Auth-Key: $CF_API_KEY"
Risk Table
| Risk | Likelihood | Mitigation |
|---|---|---|
| API token breaks after user rename | Low | Tokens bound to DB user ID (int), not username |
| Caddy strips CF header | Low | Default Caddy forwards all headers; verify Caddyfile |
| CEO locked out after SSO enable | Medium | Emergency: az run-command changepassword |
| IMAP pipe breaks | Low | Pipe uses service token + API token, unaffected by SSO |
| OTP fatigue | Low | 24h session — one OTP per day max |
*.alai.no wildcard still matches |
Low | Exact-match app takes CF routing precedence |
| SSO header spoofing | Low | CF Access validates JWT; only CF can inject this header. Caddy only listens on localhost |
What We Are NOT Doing
- Not adding Google as IdP. Email OTP is the only configured IdP. Google OAuth would require a Google Cloud project + OAuth consent screen setup. Out of scope for now.
- Not using PAPERLESS_SOCIALACCOUNT_ approach. Trusted header is simpler and doesn't require OAuth app registration.
- Not enabling PAPERLESS_APPS=allauth. The HTTP remote user approach is the documented "trusted header" method for internal proxies.
Related Pages
- archive.alai.no — Paperless-ngx Setup & Operations — main ops runbook (page 2737)
- IMAP → Paperless Archive Pipe — IMAP pipe (page 2862)
- CF Access App ID (wildcard): cd7cf0f0-ab37-4b06-8d51-9f042fd7a4f6
- CF IdP (Email OTP): ff0a28e6-2220-4de2-a82f-48385d88b163
- BW: CF global key = "Cloudflare Global API Key", archive pipe token = e4fd63de