# Paperless-ngx — CF Access SSO Setup Plan

## Implementation

**Now available as skill `/cf-access-sso`.** Execute via Skill tool with args: subdomain, service, container, vm_rg, vm_name, cf_user_email, [service_token_id]. Manual paste-ready commands below remain as fallback.

Skill path: `~/.claude/skills/cf-access-sso/SKILL.md`

Invoke example:
```
Skill('cf-access-sso',
  subdomain='archive.alai.no',
  service='paperless',
  container='alai-paperless-1',
  vm_rg='RG-ALAI-SUPPORT',
  vm_name='vm-alai-support',
  cf_user_email='alembasic@gmail.com',
  service_token_id='9d63505b-2e07-49e4-beb6-28b545a93bef'
)
```

Skill handles: pre-flight checks, user rename, env apply, container restart, CF Access app creation (with service token bypass + email allow policies), verification gate (curl 302 + Playwright screenshot), rollback script emission.
Evidence written to: `/tmp/evidence-cf-sso-paperless/`

---

# 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: alembasic@gmail.com
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 "alembasic@gmail.com" → 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)

```bash
# SSH to Azure VM
ssh -i ~/.ssh/azure_alai alai-admin@4.223.110.181

# Rename 'alembasic' → 'alembasic@gmail.com'
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 = 'alembasic@gmail.com'
u.email = 'alembasic@gmail.com'
u.save()
print('After:', u.username)
"
# Expected output: After: alembasic@gmail.com

# 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

```bash
# 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

```bash
# 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: alembasic@gmail.com" \
  -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

```bash
# Use CF API to create the dedicated app
CF_ACCOUNT_ID="d0ac2afb6bb5b298723b85a114151a04"
CF_EMAIL="john@basicconsulting.no"
CF_API_KEY="$(bw get item 'Cloudflare Global API Key' --session $(cat /tmp/bw-session) | jq -r '.login.password')"
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": "alembasic@gmail.com"}}]
      }
    ]
  }'
# Save the returned app id — needed if you want to update or delete this app
```

### Phase 5: Verify SSO works

```bash
# 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 alembasic@gmail.com
# 4. Enter OTP from email
# 5. Should land directly on Paperless dashboard (logged in as alembasic@gmail.com)
# 6. Check: Profile → Settings — should show alembasic@gmail.com 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:

```bash
# 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 alembasic@gmail.com + 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 alembasic@gmail.com"

# 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](https://docs.alai.no/books/runbooks/page/archivealaino-paperless-ngx-setup-operations) — main ops runbook (page 2737)
- [IMAP → Paperless Archive Pipe](https://docs.alai.no/books/runbooks/page/imap-paperless-archive-pipe-archivealaino) — 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

---

## Appendix: Auth Strategy — Internal Phase (current)

**Updated: 2026-05-16 by John (AI Director)**

**Status: ACTIVE — Email OTP only, 30-day sessions. Google OAuth deferred.**

### Auth Strategy (Internal Phase — current)

- **IdP:** Email OTP only (`onetimepin`, ID: `ff0a28e6-2220-4de2-a82f-48385d88b163`)
- **Session duration:** 720h (30 days) — CEO logs in once per month, not every visit
- **Allow list:** 3 CEO email aliases: `alembasic@gmail.com`, `alem@alai.no`, `alem@basicconsulting.no`
- **Rationale:** 1 internal user. Manual Google Cloud Console OAuth app setup is not justified for a single user. Email OTP with 30-day sessions provides equivalent UX (login friction ~once/month).

**Session duration change evidence:**
- Before: `24h` (required daily re-auth)
- After: `720h` (30 days) — applied 2026-05-16 via CF API
- Evidence files: `/tmp/evidence-cf-session-fix/app-before.json`, `/tmp/evidence-cf-session-fix/app-after.json`
- CF Access app updated at: 2026-05-16T19:51:55Z

### Root Cause of Email OTP Failure (resolved 2026-05-16)

CF Access evaluates the allow policy **before** dispatching the OTP email. The original policy only had `alembasic@gmail.com`. When CEO entered `alem@alai.no`, CF rejected the request at the policy gate — no email was ever dispatched to Migadu. Migadu mailbox was healthy throughout.

Fix: policy updated to include all 3 CEO aliases. Policy ID: `a9e36b92-5158-4ced-a333-a8d84a67a705`.

### Client-facing IdP Strategy — Deferred

Google OAuth IdP setup is deferred until the client-facing phase. Manual Google Cloud Console setup is not justified for 1 internal user when Email OTP + 30-day sessions already provides low-friction access.

**Trigger to upgrade IdP:**

- First paying customer onboarded to `archive.alai.no`, OR
- ALAI Workspace Google account configured (separate decision by CEO)

**When triggered — build path:**

- Option A (preferred): Build a proper client onboarding skill that creates a Google OAuth app via the Workspace Admin SDK (no Cloud Console UI required — fully automated). Dispatch to CodeCraft/Petter.
- Option B: Offer SAML 2.0 for enterprise clients (per-client IdP config in CF Access).

**Until then:** Email OTP scales to approximately 10 internal users without UX regression, given 30-day session duration.

### IdP Tiers (target state — not yet active)

| Tier | Who | Primary IdP | Fallback | Status |
|------|-----|-------------|----------|--------|
| ALAI Staff | CEO + internal team | Email OTP (30d session) | — | ACTIVE |
| SME Clients | SnowIT and similar | Email OTP | — | Future |
| Enterprise Clients | Custom per-client | SAML 2.0 / OIDC | Email OTP | Future |