From 686d004d15b58b9686fc8b861167bc537aa75347 Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Sun, 22 Feb 2026 14:18:29 -0600 Subject: [PATCH] feat: OIDC client management GUI, setup wizard, and auth regression tests Add admin dashboard pages for OIDC client CRUD (list, create, edit, delete, rotate secret), a setup wizard with 10 app presets (Audiobookshelf, Jellyfin, Nextcloud, Gitea, Immich, Grafana, WikiJS, Portainer, Outline, Mealie), and a forward-auth proxy quick-setup wizard with nginx/k8s config snippets. Add integration tests for /auth/init redirect_uri handling per RCA-2026-02-22 to prevent regression of the bug where downstream app callbacks were passed as OAuth redirect_uri to the AT Protocol PDS. 262 tests passing, type check clean. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 246 ++++-- gateway/src/data/oidc-presets.ts | 190 +++++ gateway/src/routes/admin-dashboard.test.ts | 459 +++++++++++ gateway/src/routes/admin-dashboard.ts | 892 ++++++++++++++++++++- gateway/src/routes/auth.test.ts | 186 +++++ 5 files changed, 1920 insertions(+), 53 deletions(-) create mode 100644 gateway/src/data/oidc-presets.ts create mode 100644 gateway/src/routes/auth.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 998edca..913ca9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ Why: The user's PDS is at `arcnode.xyz`. If ATAuth is also on `arcnode.xyz`, the ## Architecture +### OIDC Provider Flow ``` OIDC Client (e.g., Audiobookshelf) ↓ redirects to @@ -23,10 +24,26 @@ ATAuth (receives AT Proto tokens, issues OIDC tokens) OIDC Client (receives OIDC tokens) ``` -- **Gateway**: Node.js/Express 5, TypeScript, SQLite +### Forward-Auth SSO Proxy Flow +``` +Browser → Protected Service (e.g., SearXNG) + ↓ nginx auth_request subrequest +ATAuth /auth/verify (checks _atauth_proxy cookie) + ↓ no valid session? +ATAuth /auth/login → AT Protocol OAuth → callback + ↓ user authenticates, access rules evaluated +Session created → _atauth_proxy cookie set + ↓ ticket exchange +Protected service receives X-Auth-DID, X-Auth-Handle headers +``` + +- **Gateway**: Node.js/Express 5, TypeScript, SQLite (better-sqlite3) - **Identity Source**: AT Protocol OAuth (user's PDS, e.g., arcnode.xyz or bsky.social) -- **Token Format**: ES256 signed JWTs +- **Token Format**: ES256 signed JWTs (OIDC), HMAC-SHA256 signed cookies (proxy) - **PKCE**: Required for all flows +- **Forward-Auth Proxy**: nginx `auth_request` based SSO for arbitrary services +- **Access Control**: Per-user DID and handle pattern rules, deny-overrides evaluation +- **Admin Dashboard**: Server-rendered HTML at `/admin/dashboard` (cookie or Bearer token auth) ## Deployment @@ -39,49 +56,84 @@ OIDC Client (receives OIDC tokens) - **Registry**: `registry.digitalocean.com/ghostmesh-registry` - **Image**: `registry.digitalocean.com/ghostmesh-registry/atauth:latest` - **Storage**: SQLite on `do-block-storage` PVC -- **Backups**: Every 6 hours to Backblaze B2 (`age` encrypted, cronjob in `backups` namespace) - -### Deployment Process (Manual) +- **Backups**: Every 2 hours to Backblaze B2 (`age` encrypted, cronjob in `backups` namespace) +- **Strategy**: Recreate (RWO PVC for SQLite -- cannot use RollingUpdate) +- **Pre-deploy backup**: `kubectl exec -n atauth deploy/atauth -- cp /app/data/gateway.db /app/data/gateway.db.backup` +- **Rollback**: `kubectl rollout undo deployment/atauth -n atauth` (10 revisions kept) + +### CI/CD (Gitea Actions) +- **Workflow**: `.gitea/workflows/deploy.yml` +- **Pipeline**: test (typecheck + lint + vitest) -> build Docker -> push to DO registry -> deploy +- **Runner**: act_runner v0.2.11 on pv4 LXC 111 (labels: `ubuntu-latest`, `host`) +- **Known issue**: `needs` job scheduling can stall. Fix: restart Gitea then act_runner on LXC 111 + +### Deployment Process (Manual Fallback) ```bash -# Build and push -docker build --platform linux/amd64 -t registry.digitalocean.com/ghostmesh-registry/atauth:latest . +# Build and push (from gateway/ directory) +docker build --platform linux/amd64 \ + -t registry.digitalocean.com/ghostmesh-registry/atauth:$(git rev-parse --short HEAD) \ + -t registry.digitalocean.com/ghostmesh-registry/atauth:latest \ + gateway/ +docker push registry.digitalocean.com/ghostmesh-registry/atauth:$(git rev-parse --short HEAD) docker push registry.digitalocean.com/ghostmesh-registry/atauth:latest # Deploy -kubectl rollout restart deployment/atauth-gateway -n atauth +kubectl rollout restart deployment/atauth -n atauth +kubectl rollout status deployment/atauth -n atauth --timeout=120s ``` ### Local k3s (Suspended) -Previously ran on k3s (Proxmox VMs 321/322) with two namespaces: `atauth-staging` (standalone) -and `apricot` (integrated with PDS). Local k3s VMs shut down as of Feb 12, 2026. +Previously ran on k3s (Proxmox VMs 321/322). Shut down Feb 12, 2026. Config preserved in `gateway/k8s/overlays/staging/` and `gateway/k8s/overlays/production/`. ## Admin Access +### Admin Dashboard (Web UI) +- **URL**: `https://apricot.workingtitle.zip/admin/login` +- **Auth**: Admin token from Vaultwarden (sets `_atauth_admin` cookie, 24h TTL) +- **Pages**: Overview, Origins, Access Rules, Sessions, Access Check Tool +- **CSRF**: All forms include HMAC-signed hidden tokens (1h validity) + ### Admin Token The admin token is stored in **Vaultwarden**: - **URL**: https://vaultwarden.cloudforest-basilisk.ts.net -- **Location**: `ATAuth` folder → `Admin Token - Staging` or `Admin Token - Production` +- **Location**: `ATAuth` folder -> `Admin Token - Staging` or `Admin Token - Production` +- **k8s secret**: `atauth-secrets` key `ATAUTH_ADMIN_TOKEN` in namespace `atauth` ### Using Admin API ```bash -# Set token from Vaultwarden +# Set token from Vaultwarden (or from k8s secret) export ADMIN_TOKEN="" # List OIDC clients -curl -s "https://auth-staging.workingtitle.zip/admin/oidc/clients" \ +curl -s "https://apricot.workingtitle.zip/admin/oidc/clients" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq . + +# List proxy origins +curl -s "https://apricot.workingtitle.zip/admin/proxy/origins" \ -H "Authorization: Bearer $ADMIN_TOKEN" | jq . -# Create/update client -curl -X POST "https://auth-staging.workingtitle.zip/admin/oidc/clients" \ +# List access rules +curl -s "https://apricot.workingtitle.zip/admin/proxy/access" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq . + +# Create access rule (allow a specific DID) +curl -X POST "https://apricot.workingtitle.zip/admin/proxy/access" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"rule_type":"allow","subject_type":"did","subject_value":"did:plc:abc123"}' + +# Create access rule (allow all handles on a PDS domain) +curl -X POST "https://apricot.workingtitle.zip/admin/proxy/access" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"rule_type":"allow","subject_type":"handle_pattern","subject_value":"*.arcnode.xyz"}' + +# Dry-run access check +curl -X POST "https://apricot.workingtitle.zip/admin/proxy/access/check" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d '{ - "client_name": "My App", - "redirect_uris": ["https://myapp.example.com/callback"], - "grant_types": ["authorization_code", "refresh_token"], - "token_endpoint_auth_method": "client_secret_basic" - }' + -d '{"did":"did:plc:abc123","handle":"user.bsky.social","origin":"https://search.arcnode.xyz"}' ``` ## OIDC Client Configuration @@ -108,6 +160,46 @@ All secrets in **Vaultwarden** (LXC 120 @ vaultwarden.cloudforest-basilisk.ts.ne - `ATAuth/OIDC Clients/Audiobookshelf` - `ATAuth/OIDC Key Secret` +## Forward-Auth Proxy + +### Access Control Model +- **Table**: `proxy_access_rules` in SQLite +- **Default**: No rules = open access (backward compatible). First rule triggers default-deny. +- **Deny overrides allow**: Deny rules always win regardless of order +- **Per-origin + global**: Rules can target a specific origin (`origin_id`) or apply globally (`origin_id = NULL`) + +**Evaluation order**: +1. Deny rules (per-origin + global) -- if any match, reject +2. Per-origin allow rules -- if any match, allow +3. Global allow rules -- if any match, allow +4. No match -- deny + +**Handle pattern matching**: `*` = all, `*.arcnode.xyz` = suffix match, `bkb.arcnode.xyz` = exact + +### Cookie Types (HMAC-SHA256 signed, `typ` discriminator prevents confusion) +| Cookie | Purpose | TTL | +|--------|---------|-----| +| `_atauth_session` | OAuth flow session state | configurable | +| `_atauth_proxy` | Proxy auth for protected services | `SESSION_TTL` (default 7 days) | +| `_atauth_admin` | Admin dashboard session | 24 hours | + +### Key Files +| File | Purpose | +|------|---------| +| `src/routes/proxy-auth.ts` | Forward-auth routes (`/auth/verify`, `/auth/login`, `/auth/callback`) + `enforceAccess` | +| `src/utils/proxy-auth.ts` | Cookie create/verify, ticket functions, parsing utilities | +| `src/utils/access-check.ts` | Pure `matchHandlePattern` + `checkAccess` functions | +| `src/routes/admin.ts` | Admin API (Bearer + cookie auth), login/logout routes | +| `src/routes/admin-dashboard.ts` | Server-rendered HTML dashboard (all pages + CSRF) | +| `src/services/database.ts` | SQLite schema, migrations, all DB methods | +| `src/types/proxy.ts` | `ProxyAccessRule`, `AccessCheckResult`, proxy config types | + +### Important Notes +- **Do NOT restart ATAuth to rotate HMAC secrets** -- use admin API `PUT /admin/apps/:id` with `{"rotate_secret": true}`, then update the downstream service's k8s secret and restart that service only +- **HMAC encoding**: ATAuth signs with `createHmac('sha256', secretString)` (UTF-8). Consumers must use UTF-8 encoding of the hex secret string +- `/auth/verify` is called on every nginx subrequest -- no rate limiting on this endpoint (mounted before rate limit middleware) +- Access denied attempts are logged: `[Proxy ACL] Denied (): ` + ## Infrastructure ### Cloudflare Configuration @@ -127,13 +219,19 @@ All secrets in **Vaultwarden** (LXC 120 @ vaultwarden.cloudforest-basilisk.ts.ne ```bash # Check deployment kubectl -n atauth get pods -kubectl -n atauth logs -l app.kubernetes.io/part-of=atauth +kubectl -n atauth logs deploy/atauth --tail=50 + +# Check secrets +kubectl -n atauth get secret atauth-secrets -o jsonpath='{.data}' | python3 -m json.tool -# View/edit config -kubectl -n atauth get configmap atauth-gateway-config -o yaml +# Restart +kubectl -n atauth rollout restart deployment/atauth -# Restart after config change -kubectl -n atauth rollout restart deployment atauth-gateway +# Rollback +kubectl -n atauth rollout undo deployment/atauth + +# SQLite access +kubectl exec -n atauth deploy/atauth -- sqlite3 /app/data/gateway.db ".tables" ``` ## Related Services @@ -158,28 +256,6 @@ kubectl -n atauth rollout restart deployment atauth-gateway - **Location**: LXC 120 - **URL**: https://vaultwarden.cloudforest-basilisk.ts.net -## Testing - -### Run Tests -```bash -cd gateway -npm run test:unit # Unit tests only -npm run test:e2e # E2E tests only -npm run test # All tests with watch -npm run test:run # All tests once -``` - -### Manual OIDC Flow Test -```bash -# 1. Check discovery -curl -s "https://auth-staging.workingtitle.zip/.well-known/openid-configuration" | jq . - -# 2. Check JWKS -curl -s "https://auth-staging.workingtitle.zip/.well-known/jwks.json" | jq . - -# 3. Test in browser - go to Audiobookshelf and click "Login with OpenID" -``` - ## API Endpoints ### OIDC Endpoints @@ -192,17 +268,65 @@ curl -s "https://auth-staging.workingtitle.zip/.well-known/jwks.json" | jq . | `/oauth/userinfo` | User claims | | `/oauth/revoke` | Token revocation | -### Admin Endpoints (requires Bearer token) +### Forward-Auth Proxy Endpoints | Endpoint | Description | |----------|-------------| +| `GET /auth/verify` | nginx `auth_request` target (checks proxy cookie) | +| `GET /auth/login` | Initiates AT Protocol OAuth for proxy auth | +| `GET /auth/callback` | OAuth callback, creates proxy session | + +### Admin Endpoints (requires Bearer token or `_atauth_admin` cookie) +| Endpoint | Description | +|----------|-------------| +| `GET /admin/login` | Login page (HTML) | +| `POST /admin/login` | Authenticate with admin token, set cookie | +| `GET /admin/logout` | Clear admin cookie | +| `GET /admin/dashboard` | Overview page (HTML) | +| `GET /admin/dashboard/origins` | Manage proxy origins (HTML) | +| `GET /admin/dashboard/access` | Manage access rules (HTML) | +| `GET /admin/dashboard/sessions` | Manage proxy sessions (HTML) | +| `GET /admin/dashboard/check` | Access check tool (HTML) | | `GET /admin/oidc/clients` | List OIDC clients | | `POST /admin/oidc/clients` | Create OIDC client | | `GET /admin/oidc/clients/:id` | Get client details | | `PUT /admin/oidc/clients/:id` | Update client | | `DELETE /admin/oidc/clients/:id` | Delete client | -| `GET /admin/sessions` | List sessions | +| `GET /admin/proxy/origins` | List proxy origins (JSON) | +| `POST /admin/proxy/origins` | Add proxy origin | +| `DELETE /admin/proxy/origins/:id` | Remove proxy origin | +| `GET /admin/proxy/access` | List access rules (JSON) | +| `POST /admin/proxy/access` | Create access rule | +| `DELETE /admin/proxy/access/:id` | Delete access rule | +| `POST /admin/proxy/access/check` | Dry-run access check | +| `GET /admin/proxy/sessions` | List proxy sessions | +| `DELETE /admin/proxy/sessions/:id` | Revoke proxy session | +| `GET /admin/sessions` | List OIDC sessions | | `GET /admin/keys` | List signing keys | +## Testing + +### Test Suite (233 tests) +```bash +cd gateway +npm run test:run # All tests once +npm run test:unit # Unit tests only (src/) +npm run test:e2e # E2E tests only (tests/) +npm run test # All tests with watch +npm run typecheck # TypeScript type check +npm run lint # ESLint +``` + +### Test Files +| File | Tests | Coverage | +|------|-------|----------| +| `src/utils/access-check.test.ts` | 16 | matchHandlePattern, checkAccess evaluation | +| `src/utils/proxy-auth.test.ts` | 16 | Cookie create/verify, tickets, parsing, admin cookie | +| `src/routes/admin-dashboard.test.ts` | 16 | Dashboard pages, CRUD forms, CSRF, cookie auth | +| `src/routes/admin.proxy.test.ts` | 19 | Access rules API, cookie auth login/logout | +| `src/routes/proxy-auth.test.ts` | ~50 | Forward-auth flows, access control integration | +| `src/services/database.proxy.test.ts` | 14 | Access rules CRUD, cascade delete, partitioning | +| `tests/oidc-flow.test.ts` | ~80 | Full OIDC provider E2E | + ## Troubleshooting ### "Forbidden sec-fetch-site header same-site" @@ -216,3 +340,25 @@ curl -s "https://auth-staging.workingtitle.zip/.well-known/jwks.json" | jq . ### Token refresh failures / Users logged out **Cause**: iOS app sends chunked encoding with empty body on refreshSession. **Fix**: nginx with `proxy_pass_request_body off` for that endpoint (handled on PDS side). + +### Access rules lock everyone out +**Fix**: Delete all rules to restore open mode: +```bash +kubectl exec -n atauth deploy/atauth -- sqlite3 /app/data/gateway.db "DELETE FROM proxy_access_rules;" +``` +Or via admin API: `DELETE /admin/proxy/access/:id` with Bearer token. + +### CI pipeline stuck (Gitea Actions) +**Cause**: `needs` job scheduling stalls after test job completes. +**Fix**: Restart Gitea then act_runner on LXC 111: +```bash +ssh root@pv4.cloudforest-basilisk.ts.net "pct exec 111 -- bash -c 'systemctl restart gitea && sleep 5 && systemctl restart act_runner'" +``` +If still stuck, deploy manually (see Deployment Process above). + +### Dockerfile still builds admin-ui stage +The `admin-ui-builder` stage in the Dockerfile builds the old static admin UI. It's now unused (replaced by server-rendered dashboard) but harmless. Can be removed in a future cleanup. + +## Pending Work +- **Dependency upgrades**: nodemailer 6->8 (CVE-2025-14874), @simplewebauthn/server 10->13, uuid 9->13, better-sqlite3 11->12 +- **Dockerfile cleanup**: Remove `admin-ui-builder` stage and `admin-ui/` static files diff --git a/gateway/src/data/oidc-presets.ts b/gateway/src/data/oidc-presets.ts new file mode 100644 index 0000000..61861fc --- /dev/null +++ b/gateway/src/data/oidc-presets.ts @@ -0,0 +1,190 @@ +/** + * OIDC App Presets + * + * Pre-configured settings for common self-hosted applications. + * Used by the setup wizard to simplify OIDC client registration. + */ + +export interface OIDCAppPreset { + /** URL-safe key used in wizard routes */ + key: string; + /** Display name */ + name: string; + /** Short description */ + description: string; + /** Suggested client_id */ + suggested_client_id: string; + /** Recommended grant types */ + grant_types: string[]; + /** Recommended scopes */ + scopes: string[]; + /** Redirect URI template -- {{DOMAIN}} is replaced by user input */ + redirect_uri_template: string; + /** Token endpoint auth method */ + token_endpoint_auth_method: 'client_secret_basic' | 'client_secret_post' | 'none'; + /** Whether PKCE should be required */ + require_pkce: boolean; + /** Token TTLs in seconds */ + id_token_ttl_seconds: number; + access_token_ttl_seconds: number; + refresh_token_ttl_seconds: number; + /** Setup instructions shown after client creation */ + setup_notes: string; +} + +export const OIDC_APP_PRESETS: OIDCAppPreset[] = [ + { + key: 'audiobookshelf', + name: 'Audiobookshelf', + description: 'Audiobook and podcast server', + suggested_client_id: 'audiobookshelf', + grant_types: ['authorization_code', 'refresh_token'], + scopes: ['openid', 'profile', 'email'], + redirect_uri_template: 'https://{{DOMAIN}}/auth/openid/callback', + token_endpoint_auth_method: 'client_secret_basic', + require_pkce: true, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 604800, + setup_notes: 'In Audiobookshelf: Settings > Authentication > OpenID.\n- Issuer URL: Your ATAuth discovery URL\n- Client ID: The client ID above\n- Client Secret: The secret above\n- Auto Launch: Enable for automatic redirect\n- Button Text: "Sign in with AT Protocol"', + }, + { + key: 'jellyfin', + name: 'Jellyfin', + description: 'Media server (requires SSO plugin)', + suggested_client_id: 'jellyfin', + grant_types: ['authorization_code', 'refresh_token'], + scopes: ['openid', 'profile'], + redirect_uri_template: 'https://{{DOMAIN}}/sso/OID/redirect/atauth', + token_endpoint_auth_method: 'client_secret_post', + require_pkce: true, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 604800, + setup_notes: 'Requires the SSO Authentication plugin.\n1. Install "SSO Authentication" from the Jellyfin plugin catalog\n2. Restart Jellyfin\n3. In Dashboard > Plugins > SSO, add an OpenID provider:\n - Provider name: atauth\n - OID Discovery URL: Your ATAuth discovery URL\n - Client ID and Client Secret from above', + }, + { + key: 'nextcloud', + name: 'Nextcloud', + description: 'File sync and collaboration platform', + suggested_client_id: 'nextcloud', + grant_types: ['authorization_code', 'refresh_token'], + scopes: ['openid', 'profile', 'email'], + redirect_uri_template: 'https://{{DOMAIN}}/apps/oidc_login/oidc', + token_endpoint_auth_method: 'client_secret_basic', + require_pkce: true, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 604800, + setup_notes: 'Requires the "OpenID Connect Login" app.\n1. Install from Nextcloud Apps\n2. Settings > Administration > SSO & SAML\n3. Configure:\n - Identifier: atauth\n - Client ID and Client Secret from above\n - Discovery endpoint: Your ATAuth discovery URL\n - Button display name: "ATAuth"', + }, + { + key: 'gitea', + name: 'Gitea / Forgejo', + description: 'Git hosting and DevOps platform', + suggested_client_id: 'gitea', + grant_types: ['authorization_code', 'refresh_token'], + scopes: ['openid', 'profile', 'email'], + redirect_uri_template: 'https://{{DOMAIN}}/user/oauth2/atauth/callback', + token_endpoint_auth_method: 'client_secret_basic', + require_pkce: true, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 604800, + setup_notes: 'In Site Administration > Authentication Sources:\n1. Add Authentication Source > OAuth2\n2. Authentication Name: atauth\n3. OAuth2 Provider: OpenID Connect\n4. Client ID and Client Secret from above\n5. OpenID Connect Auto Discovery URL: Your ATAuth discovery URL\n6. Save', + }, + { + key: 'immich', + name: 'Immich', + description: 'Photo and video management', + suggested_client_id: 'immich', + grant_types: ['authorization_code', 'refresh_token'], + scopes: ['openid', 'profile', 'email'], + redirect_uri_template: 'https://{{DOMAIN}}/auth/login\napp.immich:/', + token_endpoint_auth_method: 'client_secret_basic', + require_pkce: true, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 604800, + setup_notes: 'In Administration > Settings > OAuth:\n1. Enable OAuth Login\n2. Issuer URL: Your ATAuth discovery URL\n3. Client ID and Client Secret from above\n4. Scope: openid profile email\n5. Button Text: "Sign in with ATAuth"\nNote: Both web and mobile redirect URIs are registered.', + }, + { + key: 'grafana', + name: 'Grafana', + description: 'Monitoring and observability dashboards', + suggested_client_id: 'grafana', + grant_types: ['authorization_code', 'refresh_token'], + scopes: ['openid', 'profile', 'email'], + redirect_uri_template: 'https://{{DOMAIN}}/login/generic_oauth', + token_endpoint_auth_method: 'client_secret_basic', + require_pkce: true, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 604800, + setup_notes: 'Set these environment variables (or grafana.ini):\n GF_AUTH_GENERIC_OAUTH_ENABLED=true\n GF_AUTH_GENERIC_OAUTH_NAME=ATAuth\n GF_AUTH_GENERIC_OAUTH_CLIENT_ID=grafana\n GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=\n GF_AUTH_GENERIC_OAUTH_SCOPES=openid profile email\n GF_AUTH_GENERIC_OAUTH_AUTH_URL=/oauth/authorize\n GF_AUTH_GENERIC_OAUTH_TOKEN_URL=/oauth/token\n GF_AUTH_GENERIC_OAUTH_API_URL=/oauth/userinfo\n GF_AUTH_GENERIC_OAUTH_USE_PKCE=true', + }, + { + key: 'wikijs', + name: 'Wiki.js', + description: 'Modern wiki engine', + suggested_client_id: 'wikijs', + grant_types: ['authorization_code', 'refresh_token'], + scopes: ['openid', 'profile', 'email'], + redirect_uri_template: 'https://{{DOMAIN}}/login/atauth/callback', + token_endpoint_auth_method: 'client_secret_basic', + require_pkce: false, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 604800, + setup_notes: 'In Administration > Authentication:\n1. Add Strategy > OpenID Connect / OAuth2\n2. Client ID and Client Secret from above\n3. Authorization Endpoint URL: /oauth/authorize\n4. Token URL: /oauth/token\n5. User Info URL: /oauth/userinfo\nNote: Wiki.js does not support PKCE, so it is disabled for this client.', + }, + { + key: 'portainer', + name: 'Portainer', + description: 'Container management UI', + suggested_client_id: 'portainer', + grant_types: ['authorization_code'], + scopes: ['openid', 'profile'], + redirect_uri_template: 'https://{{DOMAIN}}', + token_endpoint_auth_method: 'client_secret_basic', + require_pkce: false, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 604800, + setup_notes: 'In Settings > Authentication > OAuth:\n1. Provider: Custom\n2. Client ID and Client Secret from above\n3. Authorization URL: /oauth/authorize\n4. Access Token URL: /oauth/token\n5. Resource URL: /oauth/userinfo\n6. Redirect URL: https://your-portainer-domain\n7. Scopes: openid profile', + }, + { + key: 'outline', + name: 'Outline', + description: 'Team knowledge base and wiki', + suggested_client_id: 'outline', + grant_types: ['authorization_code', 'refresh_token'], + scopes: ['openid', 'profile', 'email'], + redirect_uri_template: 'https://{{DOMAIN}}/auth/oidc.callback', + token_endpoint_auth_method: 'client_secret_basic', + require_pkce: true, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 604800, + setup_notes: 'Set these environment variables:\n OIDC_CLIENT_ID=outline\n OIDC_CLIENT_SECRET=\n OIDC_AUTH_URI=/oauth/authorize\n OIDC_TOKEN_URI=/oauth/token\n OIDC_USERINFO_URI=/oauth/userinfo\n OIDC_DISPLAY_NAME=ATAuth\n OIDC_SCOPES=openid profile email', + }, + { + key: 'mealie', + name: 'Mealie', + description: 'Recipe management and meal planning', + suggested_client_id: 'mealie', + grant_types: ['authorization_code', 'refresh_token'], + scopes: ['openid', 'profile', 'email'], + redirect_uri_template: 'https://{{DOMAIN}}/login', + token_endpoint_auth_method: 'client_secret_basic', + require_pkce: true, + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 604800, + setup_notes: 'Set these environment variables:\n OIDC_AUTH_ENABLED=true\n OIDC_CLIENT_ID=mealie\n OIDC_CLIENT_SECRET=\n OIDC_CONFIGURATION_URL=/.well-known/openid-configuration\n OIDC_SIGNUP_ENABLED=true\n OIDC_PROVIDER_NAME=ATAuth', + }, +]; + +export function getPresetByKey(key: string): OIDCAppPreset | undefined { + return OIDC_APP_PRESETS.find(p => p.key === key); +} diff --git a/gateway/src/routes/admin-dashboard.test.ts b/gateway/src/routes/admin-dashboard.test.ts index e10a4f2..a953425 100644 --- a/gateway/src/routes/admin-dashboard.test.ts +++ b/gateway/src/routes/admin-dashboard.test.ts @@ -374,3 +374,462 @@ describe('Dashboard Access Check', () => { expect(res.text).toContain('ACCESS DENIED'); }); }); + +// ===== Helper to create an OIDC client via DB ===== + +function createTestOIDCClient(db: DatabaseService, id = 'test-app', name = 'Test App') { + db.upsertApp({ + id, + name, + hmac_secret: crypto.randomBytes(32).toString('hex'), + token_ttl_seconds: 3600, + callback_url: 'https://app.example.com/callback', + }); + db.updateOIDCClient(id, { + client_type: 'oidc', + client_secret: crypto.createHash('sha256').update('test-secret').digest('hex'), + redirect_uris: ['https://app.example.com/callback'], + grant_types: ['authorization_code', 'refresh_token'], + allowed_scopes: ['openid', 'profile', 'email'], + require_pkce: true, + token_endpoint_auth_method: 'client_secret_basic', + id_token_ttl_seconds: 3600, + access_token_ttl_seconds: 3600, + refresh_token_ttl_seconds: 604800, + }); +} + +async function getCsrf(app: express.Application, path: string): Promise { + const page = await request(app) + .get(path) + .set('Cookie', adminCookie()); + const csrfMatch = page.text.match(/name="_csrf" value="([^"]+)"/); + if (!csrfMatch) throw new Error(`No CSRF token found on ${path}`); + return csrfMatch[1]; +} + +// ===== OIDC Client Management ===== + +describe('Dashboard OIDC Clients', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + }); + + afterEach(() => { + db.close(); + }); + + it('should render client list page', async () => { + const res = await request(app) + .get('/admin/dashboard/clients') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('OIDC Clients'); + expect(res.text).toContain('New Client'); + }); + + it('should list existing clients in table', async () => { + createTestOIDCClient(db); + + const res = await request(app) + .get('/admin/dashboard/clients') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('test-app'); + expect(res.text).toContain('Test App'); + expect(res.text).toContain('app.example.com'); + }); + + it('should render new client form', async () => { + const res = await request(app) + .get('/admin/dashboard/clients/new') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('New OIDC Client'); + expect(res.text).toContain('Client ID'); + expect(res.text).toContain('Redirect URIs'); + expect(res.text).toContain('Grant Types'); + }); + + it('should create client and redirect to secret page', async () => { + const csrf = await getCsrf(app, '/admin/dashboard/clients/new'); + + const res = await request(app) + .post('/admin/dashboard/clients/new') + .set('Cookie', adminCookie()) + .type('form') + .send({ + _csrf: csrf, + id: 'myapp', + name: 'My App', + redirect_uris_text: 'https://myapp.example.com/callback', + grant_types: ['authorization_code', 'refresh_token'], + scopes: ['openid', 'profile'], + auth_method: 'client_secret_basic', + require_pkce: 'on', + id_token_ttl: '3600', + access_token_ttl: '3600', + refresh_token_ttl: '604800', + }); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('/admin/dashboard/clients/created'); + expect(res.headers.location).toContain('id=myapp'); + expect(res.headers.location).toContain('secret='); + + // Verify in DB + const client = db.getOIDCClient('myapp'); + expect(client).not.toBeNull(); + expect(client!.name).toBe('My App'); + expect(client!.redirect_uris).toEqual(['https://myapp.example.com/callback']); + expect(client!.require_pkce).toBe(true); + }); + + it('should display secret on created page', async () => { + const res = await request(app) + .get('/admin/dashboard/clients/created?id=test&secret=abc123hex') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('Client Created'); + expect(res.text).toContain('abc123hex'); + expect(res.text).toContain('This secret will not be shown again'); + expect(res.text).toContain('Discovery URL'); + }); + + it('should reject duplicate client ID', async () => { + createTestOIDCClient(db); + const csrf = await getCsrf(app, '/admin/dashboard/clients/new'); + + const res = await request(app) + .post('/admin/dashboard/clients/new') + .set('Cookie', adminCookie()) + .type('form') + .send({ + _csrf: csrf, + id: 'test-app', + name: 'Duplicate', + redirect_uris_text: 'https://dup.example.com/callback', + grant_types: 'authorization_code', + scopes: 'openid', + auth_method: 'client_secret_basic', + require_pkce: 'on', + id_token_ttl: '3600', + access_token_ttl: '3600', + refresh_token_ttl: '604800', + }); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('error='); + expect(res.headers.location).toContain('already%20exists'); + }); + + it('should reject missing required fields', async () => { + const csrf = await getCsrf(app, '/admin/dashboard/clients/new'); + + const res = await request(app) + .post('/admin/dashboard/clients/new') + .set('Cookie', adminCookie()) + .type('form') + .send({ + _csrf: csrf, + id: '', + name: '', + redirect_uris_text: '', + }); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('error='); + }); + + it('should render edit form with pre-filled values', async () => { + createTestOIDCClient(db); + + const res = await request(app) + .get('/admin/dashboard/clients/test-app/edit') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('Edit Client'); + expect(res.text).toContain('test-app'); + expect(res.text).toContain('Test App'); + expect(res.text).toContain('app.example.com/callback'); + expect(res.text).toContain('Rotate Client Secret'); + }); + + it('should update client via edit form', async () => { + createTestOIDCClient(db); + const csrf = await getCsrf(app, '/admin/dashboard/clients/test-app/edit'); + + const res = await request(app) + .post('/admin/dashboard/clients/test-app/edit') + .set('Cookie', adminCookie()) + .type('form') + .send({ + _csrf: csrf, + name: 'Updated App', + redirect_uris_text: 'https://updated.example.com/callback', + grant_types: 'authorization_code', + scopes: ['openid', 'profile'], + auth_method: 'client_secret_post', + require_pkce: 'on', + id_token_ttl: '7200', + access_token_ttl: '7200', + refresh_token_ttl: '1209600', + }); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('Client+updated'); + + const updated = db.getOIDCClient('test-app'); + expect(updated!.redirect_uris).toEqual(['https://updated.example.com/callback']); + expect(updated!.token_endpoint_auth_method).toBe('client_secret_post'); + expect(updated!.id_token_ttl_seconds).toBe(7200); + }); + + it('should rotate secret and show new secret', async () => { + createTestOIDCClient(db); + const oldClient = db.getOIDCClient('test-app'); + const csrf = await getCsrf(app, '/admin/dashboard/clients/test-app/edit'); + + const res = await request(app) + .post('/admin/dashboard/clients/test-app/rotate-secret') + .set('Cookie', adminCookie()) + .type('form') + .send({ _csrf: csrf }); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('/admin/dashboard/clients/created'); + expect(res.headers.location).toContain('rotated=1'); + expect(res.headers.location).toContain('secret='); + + // Verify secret changed + const newClient = db.getOIDCClient('test-app'); + expect(newClient!.client_secret).not.toBe(oldClient!.client_secret); + }); + + it('should delete client', async () => { + createTestOIDCClient(db); + const csrf = await getCsrf(app, '/admin/dashboard/clients'); + + const res = await request(app) + .post('/admin/dashboard/clients/test-app/delete') + .set('Cookie', adminCookie()) + .type('form') + .send({ _csrf: csrf }); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('Client+deleted'); + expect(db.getOIDCClient('test-app')).toBeNull(); + }); + + it('should reject POST without CSRF', async () => { + const res = await request(app) + .post('/admin/dashboard/clients/new') + .set('Cookie', adminCookie()) + .type('form') + .send({ + id: 'nope', + name: 'Nope', + redirect_uris_text: 'https://nope.com/cb', + grant_types: 'authorization_code', + scopes: 'openid', + }); + + expect(res.status).toBe(403); + expect(res.text).toContain('Invalid or expired form submission'); + }); +}); + +// ===== Setup Wizard ===== + +describe('Dashboard Setup Wizard', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + }); + + afterEach(() => { + db.close(); + }); + + it('should render app selection grid', async () => { + const res = await request(app) + .get('/admin/dashboard/clients/wizard') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('Setup Wizard'); + expect(res.text).toContain('Audiobookshelf'); + expect(res.text).toContain('Jellyfin'); + expect(res.text).toContain('Nextcloud'); + expect(res.text).toContain('Gitea'); + expect(res.text).toContain('Custom'); + }); + + it('should render preset form with pre-filled values', async () => { + const res = await request(app) + .get('/admin/dashboard/clients/wizard/audiobookshelf') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('Setup Audiobookshelf'); + expect(res.text).toContain('audiobookshelf'); + expect(res.text).toContain('/auth/openid/callback'); + expect(res.text).toContain('Your Domain'); + }); + + it('should create client from wizard preset', async () => { + const csrf = await getCsrf(app, '/admin/dashboard/clients/wizard/audiobookshelf'); + + const res = await request(app) + .post('/admin/dashboard/clients/wizard/audiobookshelf') + .set('Cookie', adminCookie()) + .type('form') + .send({ + _csrf: csrf, + preset: 'audiobookshelf', + domain: 'abs.example.com', + id: 'audiobookshelf', + name: 'Audiobookshelf', + grant_types: ['authorization_code', 'refresh_token'], + scopes: ['openid', 'profile', 'email'], + auth_method: 'client_secret_basic', + require_pkce: 'on', + id_token_ttl: '3600', + access_token_ttl: '3600', + refresh_token_ttl: '604800', + }); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('/admin/dashboard/clients/created'); + expect(res.headers.location).toContain('preset=audiobookshelf'); + + const client = db.getOIDCClient('audiobookshelf'); + expect(client).not.toBeNull(); + expect(client!.redirect_uris).toEqual(['https://abs.example.com/auth/openid/callback']); + }); + + it('should show setup notes on created page', async () => { + const res = await request(app) + .get('/admin/dashboard/clients/created?id=abs&secret=abc123&preset=audiobookshelf') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('Setup Instructions'); + expect(res.text).toContain('Audiobookshelf'); + expect(res.text).toContain('Issuer URL'); + }); + + it('should 404 for unknown preset', async () => { + const res = await request(app) + .get('/admin/dashboard/clients/wizard/nonexistent') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(404); + expect(res.text).toContain('Preset not found'); + }); +}); + +// ===== Proxy Wizard ===== + +describe('Dashboard Proxy Wizard', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + }); + + afterEach(() => { + db.close(); + }); + + it('should render proxy setup form', async () => { + const res = await request(app) + .get('/admin/dashboard/proxy-wizard') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('Forward-Auth Proxy Setup'); + expect(res.text).toContain('Service Name'); + expect(res.text).toContain('Origin URL'); + }); + + it('should create origin and show config snippets', async () => { + const csrf = await getCsrf(app, '/admin/dashboard/proxy-wizard'); + + const res = await request(app) + .post('/admin/dashboard/proxy-wizard') + .set('Cookie', adminCookie()) + .type('form') + .send({ + _csrf: csrf, + name: 'SearXNG', + origin: 'https://search.arcnode.xyz', + }); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('/admin/dashboard/proxy-wizard/result'); + + // Verify origin was created + const origins = db.listProxyAllowedOrigins(); + expect(origins).toHaveLength(1); + expect(origins[0].name).toBe('SearXNG'); + }); + + it('should display config snippets on result page', async () => { + const res = await request(app) + .get('/admin/dashboard/proxy-wizard/result?origin=https://search.arcnode.xyz&name=SearXNG&origin_id=1') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('nginx Configuration'); + expect(res.text).toContain('auth_request'); + expect(res.text).toContain('Kubernetes Ingress'); + expect(res.text).toContain('auth-url'); + expect(res.text).toContain('Add Access Rules'); + }); +}); + +// ===== Overview with OIDC Stats ===== + +describe('Dashboard Overview OIDC Stats', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + }); + + afterEach(() => { + db.close(); + }); + + it('should show OIDC client count on overview', async () => { + createTestOIDCClient(db, 'app1', 'App One'); + createTestOIDCClient(db, 'app2', 'App Two'); + + const res = await request(app) + .get('/admin/dashboard') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('OIDC Clients'); + expect(res.text).toContain('>2<'); + expect(res.text).toContain('Setup Wizard'); + expect(res.text).toContain('Add OIDC Client'); + }); +}); diff --git a/gateway/src/routes/admin-dashboard.ts b/gateway/src/routes/admin-dashboard.ts index 04fecb3..bb0f76b 100644 --- a/gateway/src/routes/admin-dashboard.ts +++ b/gateway/src/routes/admin-dashboard.ts @@ -10,6 +10,8 @@ import crypto from 'crypto'; import { Router, Request, Response } from 'express'; import { DatabaseService } from '../services/database.js'; import { checkAccess } from '../utils/access-check.js'; +import { generateHmacSecret } from '../utils/hmac.js'; +import { OIDC_APP_PRESETS, getPresetByKey } from '../data/oidc-presets.js'; export function createAdminDashboardRoutes( db: DatabaseService, @@ -55,11 +57,17 @@ export function createAdminDashboardRoutes( const origins = db.listProxyAllowedOrigins(); const rules = db.listProxyAccessRules(); const sessions = db.getAllProxySessions(undefined, 1000); + const oidcClients = db.getAllOIDCClients(); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(layout('Dashboard', `

Overview

+
+
${oidcClients.length}
+
OIDC Clients
+ Manage +
${origins.length}
Protected Origins
@@ -79,9 +87,10 @@ export function createAdminDashboardRoutes( `)); @@ -469,9 +478,771 @@ export function createAdminDashboardRoutes( `)); }); + // ===== OIDC Client Management ===== + + router.get('/clients', (req: Request, res: Response) => { + const clients = db.getAllOIDCClients(); + const msg = req.query.msg as string | undefined; + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout('Clients', ` + ${msg ? `
${esc(msg)}
` : ''} +

OIDC Clients

+ + ${clients.length === 0 ? '

No OIDC clients configured. Create one above or use the setup wizard.

' : ` + + + + + + + + + + + + + + ${clients.map(c => ` + + + + + + + + + + `).join('')} + +
NameClient IDRedirect URIsScopesPKCECreated
${esc(c.name)}${esc(c.id)}${c.redirect_uris.map(u => `${esc(truncateUri(u))}`).join('
')}
${c.allowed_scopes.map(s => `${esc(s)}`).join(' ')}${c.require_pkce ? 'Yes' : 'No'}${formatDateObj(c.created_at)} + Edit +
+ + +
+
`} + `)); + }); + + // --- Create Client --- + + router.get('/clients/new', (req: Request, res: Response) => { + const csrf = generateCsrfToken(); + const preset = req.query.preset as string | undefined; + const domain = req.query.domain as string | undefined; + const error = req.query.error as string | undefined; + const presetData = preset ? getPresetByKey(preset) : undefined; + + const defaults = presetData && domain ? { + id: presetData.suggested_client_id, + name: presetData.name, + redirect_uris: presetData.redirect_uri_template.replace(/\{\{DOMAIN\}\}/g, domain), + grant_types: presetData.grant_types, + scopes: presetData.scopes, + auth_method: presetData.token_endpoint_auth_method, + require_pkce: presetData.require_pkce, + id_token_ttl: presetData.id_token_ttl_seconds, + access_token_ttl: presetData.access_token_ttl_seconds, + refresh_token_ttl: presetData.refresh_token_ttl_seconds, + } : { + id: '', name: '', redirect_uris: '', + grant_types: ['authorization_code'], + scopes: ['openid'], + auth_method: 'client_secret_basic', + require_pkce: true, + id_token_ttl: 3600, access_token_ttl: 3600, refresh_token_ttl: 604800, + }; + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout('New Client', renderClientForm(csrf, defaults, error, preset))); + }); + + router.post('/clients/new', (req: Request, res: Response) => { + if (!requireCsrf(req, res)) return; + const { id, name, redirect_uris_text, auth_method, id_token_ttl, access_token_ttl, refresh_token_ttl, preset } = req.body; + const grant_types = normalizeCheckboxArray(req.body.grant_types); + const scopes = normalizeCheckboxArray(req.body.scopes); + const require_pkce = req.body.require_pkce === 'on'; + + // Validation + if (!id || !name) { + return renderClientFormWithError(res, req.body, 'Client ID and name are required'); + } + if (!/^[a-z0-9][a-z0-9_-]*$/.test(id) || id.length > 64) { + return renderClientFormWithError(res, req.body, 'Client ID must be lowercase alphanumeric with hyphens/underscores, max 64 chars'); + } + const redirect_uris = parseTextareaLines(redirect_uris_text); + if (redirect_uris.length === 0) { + return renderClientFormWithError(res, req.body, 'At least one redirect URI is required'); + } + for (const uri of redirect_uris) { + if (!isValidRedirectUri(uri)) { + return renderClientFormWithError(res, req.body, `Invalid redirect URI: ${uri}`); + } + } + if (!grant_types.includes('authorization_code')) { + return renderClientFormWithError(res, req.body, 'authorization_code grant type is required'); + } + if (!scopes.includes('openid')) { + return renderClientFormWithError(res, req.body, 'openid scope is required'); + } + + const existing = db.getOIDCClient(id); + if (existing) { + return renderClientFormWithError(res, req.body, `Client ID "${id}" already exists`); + } + + // Create + const clientSecret = crypto.randomBytes(32).toString('hex'); + const clientSecretHash = crypto.createHash('sha256').update(clientSecret).digest('hex'); + + db.upsertApp({ + id, name, + hmac_secret: generateHmacSecret(), + token_ttl_seconds: parseInt(access_token_ttl, 10) || 3600, + callback_url: redirect_uris[0], + }); + + db.updateOIDCClient(id, { + client_type: 'oidc', + client_secret: clientSecretHash, + redirect_uris, + grant_types, + allowed_scopes: scopes, + require_pkce, + token_endpoint_auth_method: auth_method || 'client_secret_basic', + id_token_ttl_seconds: parseInt(id_token_ttl, 10) || 3600, + access_token_ttl_seconds: parseInt(access_token_ttl, 10) || 3600, + refresh_token_ttl_seconds: parseInt(refresh_token_ttl, 10) || 604800, + }); + + const params = new URLSearchParams({ id, secret: clientSecret }); + if (preset) params.set('preset', preset); + res.redirect(`/admin/dashboard/clients/created?${params.toString()}`); + }); + + // --- Secret Display (one-time) --- + + router.get('/clients/created', (req: Request, res: Response) => { + const { id, secret, preset, rotated } = req.query as Record; + if (!id || !secret) { + return res.redirect('/admin/dashboard/clients'); + } + + const presetData = preset ? getPresetByKey(preset) : undefined; + const heading = rotated ? 'Secret Rotated' : 'Client Created'; + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout(heading, ` +

${heading}

+
+
+
Client ID
+
${esc(id)}
+
+
+ +
+ ${esc(secret)} + +
+
+
This secret will not be shown again. Copy it now and store it securely.
+ ${presetData ? ` +
+

Setup Instructions: ${esc(presetData.name)}

+
${esc(presetData.setup_notes)}
+
+ ` : ''} +
+

Discovery URL

+
${esc(getDiscoveryUrl())}
+
+ +
+ `)); + }); + + // --- Edit Client --- + + router.get('/clients/:id/edit', (req: Request, res: Response) => { + const client = db.getOIDCClient(req.params.id); + if (!client) { + return res.redirect('/admin/dashboard/clients?msg=Client+not+found'); + } + + const csrf = generateCsrfToken(); + const msg = req.query.msg as string | undefined; + + const values = { + id: client.id, + name: client.name, + redirect_uris: client.redirect_uris.join('\n'), + grant_types: client.grant_types, + scopes: client.allowed_scopes, + auth_method: client.token_endpoint_auth_method, + require_pkce: client.require_pkce, + id_token_ttl: client.id_token_ttl_seconds, + access_token_ttl: client.access_token_ttl_seconds, + refresh_token_ttl: client.refresh_token_ttl_seconds, + }; + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout('Edit Client', ` + ${msg ? `
${esc(msg)}
` : ''} +

Edit Client: ${esc(client.id)}

+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + + +
+
+
+ + +
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+

Rotate Client Secret

+

Generate a new client secret. The old secret will stop working immediately.

+
+ + +
+
+ `)); + }); + + router.post('/clients/:id/edit', (req: Request, res: Response) => { + if (!requireCsrf(req, res)) return; + const clientId = req.params.id; + const existing = db.getOIDCClient(clientId); + if (!existing) { + return res.redirect('/admin/dashboard/clients?msg=Client+not+found'); + } + + const { name, redirect_uris_text, auth_method, id_token_ttl, access_token_ttl, refresh_token_ttl } = req.body; + const grant_types = normalizeCheckboxArray(req.body.grant_types); + const scopes = normalizeCheckboxArray(req.body.scopes); + const require_pkce = req.body.require_pkce === 'on'; + const redirect_uris = parseTextareaLines(redirect_uris_text); + + if (!name || redirect_uris.length === 0) { + return res.redirect(`/admin/dashboard/clients/${encodeURIComponent(clientId)}/edit?msg=Name+and+redirect+URIs+required`); + } + + // Update name if changed + const app = db.getApp(clientId); + if (app && name !== app.name) { + db.upsertApp({ ...app, name }); + } + + db.updateOIDCClient(clientId, { + redirect_uris, + grant_types, + allowed_scopes: scopes, + require_pkce, + token_endpoint_auth_method: auth_method || existing.token_endpoint_auth_method, + id_token_ttl_seconds: parseInt(id_token_ttl, 10) || existing.id_token_ttl_seconds, + access_token_ttl_seconds: parseInt(access_token_ttl, 10) || existing.access_token_ttl_seconds, + refresh_token_ttl_seconds: parseInt(refresh_token_ttl, 10) || existing.refresh_token_ttl_seconds, + }); + + res.redirect('/admin/dashboard/clients?msg=Client+updated'); + }); + + // --- Rotate Secret --- + + router.post('/clients/:id/rotate-secret', (req: Request, res: Response) => { + if (!requireCsrf(req, res)) return; + const clientId = req.params.id; + const existing = db.getOIDCClient(clientId); + if (!existing) { + return res.redirect('/admin/dashboard/clients?msg=Client+not+found'); + } + + const clientSecret = crypto.randomBytes(32).toString('hex'); + const clientSecretHash = crypto.createHash('sha256').update(clientSecret).digest('hex'); + db.updateOIDCClientSecret(clientId, clientSecretHash); + + const params = new URLSearchParams({ id: clientId, secret: clientSecret, rotated: '1' }); + res.redirect(`/admin/dashboard/clients/created?${params.toString()}`); + }); + + // --- Delete Client --- + + router.post('/clients/:id/delete', (req: Request, res: Response) => { + if (!requireCsrf(req, res)) return; + db.deleteApp(req.params.id); + res.redirect('/admin/dashboard/clients?msg=Client+deleted'); + }); + + // ===== Setup Wizard ===== + + router.get('/clients/wizard', (_req: Request, res: Response) => { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout('Setup Wizard', ` +

Setup Wizard

+

Select an application to auto-configure OIDC settings.

+
+ ${OIDC_APP_PRESETS.map(p => ` +
+

${esc(p.name)}

+

${esc(p.description)}

+ Set Up +
+ `).join('')} +
+

Custom

+

Configure manually with full control over all settings

+ Create Custom +
+
+ `)); + }); + + router.get('/clients/wizard/:preset', (req: Request, res: Response) => { + const presetData = getPresetByKey(req.params.preset); + if (!presetData) { + return res.status(404).setHeader('Content-Type', 'text/html; charset=utf-8').send( + layout('Not Found', '

Preset not found

Back to wizard

') + ); + } + + const csrf = generateCsrfToken(); + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout(`Setup ${presetData.name}`, ` +

Setup ${esc(presetData.name)}

+

${esc(presetData.description)}

+
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ Redirect URI pattern: ${esc(presetData.redirect_uri_template)} +
+
+ Advanced Settings +
+
+ +
+ + +
+
+
+ +
+ + + +
+
+
+ + +
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + Back +
+
+
+
+ After setup:\n${esc(presetData.setup_notes)} +
+ `)); + }); + + router.post('/clients/wizard/:preset', (req: Request, res: Response) => { + if (!requireCsrf(req, res)) return; + const presetData = getPresetByKey(req.params.preset); + if (!presetData) { + return res.redirect('/admin/dashboard/clients/wizard'); + } + + const { id, name, domain, auth_method, id_token_ttl, access_token_ttl, refresh_token_ttl, preset } = req.body; + const grant_types = normalizeCheckboxArray(req.body.grant_types); + const scopes = normalizeCheckboxArray(req.body.scopes); + const require_pkce = req.body.require_pkce === 'on'; + + if (!id || !name || !domain) { + return res.redirect(`/admin/dashboard/clients/wizard/${encodeURIComponent(req.params.preset)}`); + } + if (!/^[a-z0-9][a-z0-9_-]*$/.test(id) || id.length > 64) { + return res.redirect(`/admin/dashboard/clients/wizard/${encodeURIComponent(req.params.preset)}`); + } + + const existing = db.getOIDCClient(id); + if (existing) { + return res.redirect(`/admin/dashboard/clients/wizard/${encodeURIComponent(req.params.preset)}`); + } + + // Build redirect URIs from template + const redirect_uris = presetData.redirect_uri_template + .replace(/\{\{DOMAIN\}\}/g, domain.trim()) + .split('\n') + .map(u => u.trim()) + .filter(u => u.length > 0); + + const clientSecret = crypto.randomBytes(32).toString('hex'); + const clientSecretHash = crypto.createHash('sha256').update(clientSecret).digest('hex'); + + db.upsertApp({ + id, name, + hmac_secret: generateHmacSecret(), + token_ttl_seconds: parseInt(access_token_ttl, 10) || 3600, + callback_url: redirect_uris[0], + }); + + db.updateOIDCClient(id, { + client_type: 'oidc', + client_secret: clientSecretHash, + redirect_uris, + grant_types: grant_types.length > 0 ? grant_types : presetData.grant_types, + allowed_scopes: scopes.length > 0 ? scopes : presetData.scopes, + require_pkce, + token_endpoint_auth_method: auth_method || presetData.token_endpoint_auth_method, + id_token_ttl_seconds: parseInt(id_token_ttl, 10) || presetData.id_token_ttl_seconds, + access_token_ttl_seconds: parseInt(access_token_ttl, 10) || presetData.access_token_ttl_seconds, + refresh_token_ttl_seconds: parseInt(refresh_token_ttl, 10) || presetData.refresh_token_ttl_seconds, + }); + + const params = new URLSearchParams({ id, secret: clientSecret, preset: preset || req.params.preset }); + res.redirect(`/admin/dashboard/clients/created?${params.toString()}`); + }); + + // ===== Forward-Auth Proxy Quick Setup ===== + + router.get('/proxy-wizard', (_req: Request, res: Response) => { + const csrf = generateCsrfToken(); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout('Proxy Setup', ` +

Forward-Auth Proxy Setup

+

Add a service to protect with AT Protocol authentication via nginx forward-auth.

+
+
+ +
+ + +
+
+ + +
+ +
+
+ `)); + }); + + router.post('/proxy-wizard', (req: Request, res: Response) => { + if (!requireCsrf(req, res)) return; + const { origin, name } = req.body; + if (!origin || !name) { + return res.redirect('/admin/dashboard/proxy-wizard'); + } + try { + const parsed = new URL(origin); + if (parsed.origin !== origin) { + return res.redirect('/admin/dashboard/proxy-wizard'); + } + } catch { + return res.redirect('/admin/dashboard/proxy-wizard'); + } + + let originId: number; + try { + const created = db.addProxyAllowedOrigin(origin, name); + originId = created.id; + } catch { + return res.redirect('/admin/dashboard/origins?msg=Origin+already+exists'); + } + + const params = new URLSearchParams({ origin_id: String(originId), origin, name }); + res.redirect(`/admin/dashboard/proxy-wizard/result?${params.toString()}`); + }); + + router.get('/proxy-wizard/result', (req: Request, res: Response) => { + const { origin, name, origin_id } = req.query as Record; + if (!origin) return res.redirect('/admin/dashboard/proxy-wizard'); + + const authUrl = getAuthBaseUrl(); + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout('Proxy Setup Complete', ` +

Proxy Setup: ${esc(name || origin)}

+
Origin added successfully.
+ +
+

nginx Configuration

+

Add this to your nginx server block:

+
location / { + auth_request /auth/verify; + auth_request_set $auth_did $upstream_http_x_auth_did; + auth_request_set $auth_handle $upstream_http_x_auth_handle; + proxy_set_header X-Auth-DID $auth_did; + proxy_set_header X-Auth-Handle $auth_handle; + + proxy_pass http://your-backend:8080; +} + +location = /auth/verify { + internal; + proxy_pass ${esc(authUrl)}/auth/verify; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URL $scheme://$http_host$request_uri; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location /auth/ { + proxy_pass ${esc(authUrl)}/auth/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; +}
+
+ +
+

Kubernetes Ingress Annotations

+

For nginx-ingress controller:

+
nginx.ingress.kubernetes.io/auth-url: "${esc(authUrl)}/auth/verify" +nginx.ingress.kubernetes.io/auth-signin: "${esc(authUrl)}/auth/login?redirect_uri=$scheme://$http_host$request_uri" +nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-DID,X-Auth-Handle"
+
+ +
+ ${origin_id ? `Add Access Rules` : ''} + View All Origins +
+ `)); + }); + return router; } +// ===== Helper Functions ===== + +function normalizeCheckboxArray(val: unknown): string[] { + if (Array.isArray(val)) return val; + if (typeof val === 'string' && val) return [val]; + return []; +} + +function parseTextareaLines(text: string | undefined): string[] { + if (!text) return []; + return text.split('\n').map(l => l.trim()).filter(l => l.length > 0); +} + +function isValidRedirectUri(uri: string): boolean { + // Allow mobile deep links (e.g., app.immich:/) + if (/^[a-z][a-z0-9+.-]*:/.test(uri) && !uri.startsWith('http')) { + return true; + } + try { + new URL(uri); + return true; + } catch { + return false; + } +} + +function truncateUri(uri: string): string { + if (uri.length <= 40) return uri; + return uri.substring(0, 37) + '...'; +} + +function formatDateObj(d: Date): string { + return d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'); +} + +function getDiscoveryUrl(): string { + // Use the configured issuer or a sensible default + return process.env.OIDC_ISSUER + ? `${process.env.OIDC_ISSUER}/.well-known/openid-configuration` + : '/.well-known/openid-configuration'; +} + +function getAuthBaseUrl(): string { + return process.env.OIDC_ISSUER || 'https://your-atauth-domain'; +} + +function renderClientForm( + csrf: string, + values: { id: string; name: string; redirect_uris: string; grant_types: string[]; scopes: string[]; auth_method: string; require_pkce: boolean; id_token_ttl: number; access_token_ttl: number; refresh_token_ttl: number }, + error?: string, + preset?: string, +): string { + return ` + ${error ? `
${esc(error)}
` : ''} +

New OIDC Client

+
+
+ + ${preset ? `` : ''} +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + + +
+
+
+ + +
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + Cancel +
+
+ `; +} + +function renderClientFormWithError(res: Response, _body: Record, error: string): void { + const msg = encodeURIComponent(error); + res.redirect(`/admin/dashboard/clients/new?error=${msg}`); +} + // ===== Template Helpers ===== function esc(str: string): string { @@ -724,6 +1495,116 @@ function layout(title: string, content: string): string { background: #1f0a0a; } .result-denied h3 { color: #fca5a5; } + nav .nav-section { + padding: 0.25rem 1.25rem; + font-size: 0.65rem; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-top: 0.5rem; + } + .secret-box { + background: #0f172a; + border: 2px solid #f59e0b; + border-radius: 8px; + padding: 1rem; + font-family: monospace; + font-size: 0.9rem; + word-break: break-all; + color: #fbbf24; + position: relative; + } + .secret-box .copy-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: #475569; + border: none; + color: #e2e8f0; + padding: 0.25rem 0.5rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.75rem; + } + .secret-box .copy-btn:hover { background: #64748b; } + .callout-warning { + background: #451a03; + border: 1px solid #92400e; + border-radius: 8px; + padding: 0.75rem 1rem; + color: #fbbf24; + font-size: 0.875rem; + margin: 0.75rem 0; + } + .callout-info { + background: #0c1a2e; + border: 1px solid #1e40af; + border-radius: 8px; + padding: 0.75rem 1rem; + color: #93c5fd; + font-size: 0.875rem; + margin: 0.75rem 0; + white-space: pre-wrap; + } + .wizard-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1rem; + } + .wizard-card { + background: #1e293b; + border: 1px solid #334155; + border-radius: 10px; + padding: 1.25rem; + transition: border-color 0.15s; + } + .wizard-card:hover { border-color: #3b82f6; } + .wizard-card h4 { color: #f1f5f9; margin-bottom: 0.25rem; } + .wizard-card p { color: #94a3b8; font-size: 0.8rem; margin-bottom: 0.75rem; } + .code-block { + background: #0f172a; + border: 1px solid #334155; + border-radius: 8px; + padding: 1rem; + font-family: monospace; + font-size: 0.8rem; + color: #7dd3fc; + overflow-x: auto; + white-space: pre; + line-height: 1.4; + position: relative; + } + .form-group textarea { + width: 100%; + padding: 0.5rem 0.625rem; + background: #0f172a; + border: 1px solid #475569; + border-radius: 6px; + color: #e2e8f0; + font-size: 0.875rem; + font-family: monospace; + outline: none; + transition: border-color 0.15s; + resize: vertical; + min-height: 80px; + } + .form-group textarea:focus { border-color: #3b82f6; } + .checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + } + .checkbox-group label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.875rem; + color: #e2e8f0; + cursor: pointer; + } + .checkbox-group input[type="checkbox"] { width: auto; accent-color: #3b82f6; } + .form-group input[readonly] { opacity: 0.7; cursor: not-allowed; } dl { margin-top: 0.75rem; } dt { font-size: 0.75rem; @@ -746,10 +1627,15 @@ function layout(title: string, content: string): string {