From be1cb5b0ffe5d36dfea0bf0f828c4d8910332ddb Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Mon, 23 Feb 2026 14:08:54 -0600 Subject: [PATCH] docs: lean CLAUDE.md, rewrite README for OIDC, add skill commands Move deployment, admin API, and troubleshooting procedures from CLAUDE.md into .claude/commands/ skills (on-demand context). Rewrite README.md and HOMELAB.md to reflect current OIDC provider + forward-auth proxy architecture. Bump package.json to 2.0.1. --- .claude/commands/admin-api.md | 99 ++++++++ .claude/commands/deploy.md | 73 ++++++ .claude/commands/troubleshoot.md | 75 ++++++ CHANGELOG.md | 62 ++++- CLAUDE.md | 417 +++++-------------------------- README.md | 287 +++++++-------------- docs/HOMELAB.md | 293 +++++++++------------- gateway/package.json | 2 +- 8 files changed, 560 insertions(+), 748 deletions(-) create mode 100644 .claude/commands/admin-api.md create mode 100644 .claude/commands/deploy.md create mode 100644 .claude/commands/troubleshoot.md diff --git a/.claude/commands/admin-api.md b/.claude/commands/admin-api.md new file mode 100644 index 0000000..42a87c1 --- /dev/null +++ b/.claude/commands/admin-api.md @@ -0,0 +1,99 @@ +# ATAuth Admin API + +Admin token is in Vaultwarden (`ATAuth/Admin Token - Staging`). + +```bash +export ADMIN_TOKEN="" +``` + +## OIDC Clients + +```bash +# List clients +curl -s "https://apricot.workingtitle.zip/admin/oidc/clients" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq . + +# Get client details +curl -s "https://apricot.workingtitle.zip/admin/oidc/clients/audiobookshelf" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq . + +# Create client +curl -X POST "https://apricot.workingtitle.zip/admin/oidc/clients" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "id": "myapp", + "name": "My Application", + "redirect_uris": ["https://myapp.example.com/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "scopes": ["openid", "profile", "email"], + "require_pkce": true, + "token_endpoint_auth_method": "client_secret_basic" + }' + +# Update client +curl -X PUT "https://apricot.workingtitle.zip/admin/oidc/clients/myapp" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"allowed_scopes": ["openid", "profile", "email"]}' + +# Delete client +curl -X DELETE "https://apricot.workingtitle.zip/admin/oidc/clients/myapp" \ + -H "Authorization: Bearer $ADMIN_TOKEN" +``` + +## Proxy Origins & Access Rules + +```bash +# List proxy origins +curl -s "https://apricot.workingtitle.zip/admin/proxy/origins" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq . + +# 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 '{"did":"did:plc:abc123","handle":"user.bsky.social","origin":"https://search.arcnode.xyz"}' +``` + +## Sessions + +```bash +# List OIDC sessions +curl -s "https://apricot.workingtitle.zip/admin/sessions" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq . + +# List proxy sessions +curl -s "https://apricot.workingtitle.zip/admin/proxy/sessions" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq . + +# Revoke a proxy session +curl -X DELETE "https://apricot.workingtitle.zip/admin/proxy/sessions/" \ + -H "Authorization: Bearer $ADMIN_TOKEN" +``` + +## Kubectl Access + +```bash +kubectl -n atauth get pods +kubectl -n atauth logs deploy/atauth --tail=50 +kubectl -n atauth get secret atauth-secrets -o jsonpath='{.data}' | python3 -m json.tool +kubectl exec -n atauth deploy/atauth -- sqlite3 /app/data/gateway.db ".tables" +kubectl -n atauth rollout restart deployment/atauth +``` diff --git a/.claude/commands/deploy.md b/.claude/commands/deploy.md new file mode 100644 index 0000000..8d0fbd2 --- /dev/null +++ b/.claude/commands/deploy.md @@ -0,0 +1,73 @@ +# ATAuth Deployment + +## Automated (Gitea Actions) + +Push to `main` triggers: test (typecheck + lint + vitest) -> build Docker -> push to DO registry -> deploy. + +Workflow: `.gitea/workflows/deploy.yml` + +**Stuck CI**: 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'" +``` + +## Manual Fallback + +```bash +# Pre-deploy backup +kubectl exec -n atauth deploy/atauth -- cp /app/data/gateway.db /app/data/gateway.db.backup + +# Build with unique tag (avoids k8s image caching with :latest) +cd /Users/bryanbrooks/projects/atauth +TAG=$(cd gateway && git rev-parse --short HEAD) +docker build --platform linux/amd64 \ + -t registry.digitalocean.com/ghostmesh-registry/atauth:$TAG \ + -t registry.digitalocean.com/ghostmesh-registry/atauth:latest \ + gateway/ +docker push registry.digitalocean.com/ghostmesh-registry/atauth:$TAG +docker push registry.digitalocean.com/ghostmesh-registry/atauth:latest + +# Deploy +kubectl set image deployment/atauth \ + atauth=registry.digitalocean.com/ghostmesh-registry/atauth:$TAG \ + -n atauth +kubectl rollout status deployment/atauth -n atauth --timeout=120s +``` + +## Verify + +```bash +curl -s https://auth-staging.workingtitle.zip/health | jq . +curl -s https://auth-staging.workingtitle.zip/.well-known/openid-configuration | jq . +curl -s -o /dev/null -w "%{http_code}" https://apricot.workingtitle.zip/admin/login +kubectl -n atauth logs deploy/atauth --tail=20 +``` + +## Rollback + +```bash +# Quick rollback (10 revisions kept) +kubectl -n atauth rollout undo deployment/atauth +kubectl -n atauth rollout status deployment/atauth + +# With database restore +kubectl -n atauth scale deployment/atauth --replicas=0 +# restore gateway.db.backup -> gateway.db via debug pod +kubectl -n atauth rollout undo deployment/atauth +kubectl -n atauth scale deployment/atauth --replicas=1 +``` + +## Monitoring + +```bash +watch kubectl -n atauth get pods +kubectl -n atauth logs -f deployment/atauth +kubectl -n atauth get events --sort-by='.lastTimestamp' +``` + +## Important + +- **Image tag caching**: Always use a unique tag (commit SHA). `:latest` uses `IfNotPresent`. +- **RWO PVC**: Strategy must be `Recreate`. RollingUpdate deadlocks on different nodes. +- **Domain**: Must use `workingtitle.zip`, NOT `arcnode.xyz` (same-site header issue). +- **Platform**: Must use `--platform linux/amd64` (DO nodes are amd64). diff --git a/.claude/commands/troubleshoot.md b/.claude/commands/troubleshoot.md new file mode 100644 index 0000000..935ffb1 --- /dev/null +++ b/.claude/commands/troubleshoot.md @@ -0,0 +1,75 @@ +# ATAuth Troubleshooting + +## "Forbidden sec-fetch-site header same-site" + +**Cause**: ATAuth and user's PDS are on the same registrable domain. +**Fix**: Use `workingtitle.zip` for ATAuth, not `arcnode.xyz`. + +## "invalid_redirect_uri" + +**Cause**: Redirect URI in request doesn't match registered client. +**Fix**: Check client's registered redirect_uris match exactly (including https vs http, port, path). + +## 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 `/deploy` command). + +## "invalid_client" on OIDC token exchange + +**Cause**: Client secret stored as SHA-256 hash but token endpoint compared raw secret. +**Fixed** in `3fc67a7`: `token.ts` and `revoke.ts` now hash incoming secret before comparison. + +## "invalid_grant" redirect_uri mismatch on AT Protocol token exchange + +**Cause**: `@atproto/oauth-client` falls back to `clientMetadata.redirect_uris[0]` during token exchange. Authorization used `/oauth/callback` but exchange used `/auth/callback`. +**Fixed** in `3fc67a7`: `authorize.ts` passes explicit redirect_uri to `handleCallback()`. + +## Userinfo returns empty `preferred_username` + +**Cause**: `db.getUserMapping()` returns nothing for new OIDC sessions; handle defaulted to empty string. +**Fixed** in `3fc67a7`: `userinfo.ts` resolves DID to handle via `app.bsky.actor.getProfile` API. + +## PKCE required error for app that doesn't support PKCE + +**Cause**: OIDC client created with `require_pkce: true` but the app (e.g., Gitea) doesn't send PKCE params. +**Fix**: Disable PKCE for the client: +```bash +curl -X PUT "https://apricot.workingtitle.zip/admin/oidc/clients/" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"require_pkce": false}' +``` + +## Pod not starting / CrashLoopBackOff + +```bash +kubectl -n atauth describe pod +kubectl -n atauth logs deploy/atauth --previous +kubectl -n atauth get events --sort-by='.lastTimestamp' +``` + +## SQLite locked / database busy + +**Cause**: Multiple writers (shouldn't happen with single replica). +**Fix**: Verify only 1 replica running, check WAL mode: +```bash +kubectl exec -n atauth deploy/atauth -- sqlite3 /app/data/gateway.db "PRAGMA journal_mode;" +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index ae3bc7b..d2478b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.1] - 2026-02-23 + +### Fixed + +- Setup wizard double-protocol bug: entering `https://abs.example.com` in domain field no longer produces `https://https://abs.example.com/...` in redirect URIs + +### Added + +- Gitea OIDC client registration (client_id: `gitea`, PKCE disabled for compatibility) +- Regression test for wizard domain protocol stripping + +### Removed + +- `gateway/admin-ui/` -- old React SPA (replaced by server-rendered dashboard) +- `gateway/tekton/` -- old Tekton CI for suspended k3s +- `gateway/helm/` -- unused Helm chart +- `gateway/k8s/` -- old k3s kustomize manifests +- `gateway/.gitea/workflows/build.yaml` -- stale k3s deploy workflow +- `.github/` -- GitHub Actions and issue templates (repo is on Gitea) +- Dockerfile `admin-ui-builder` stage (was a no-op after admin-ui removal) + +### Changed + +- CLAUDE.md: lean rewrite, operational content moved to `.claude/commands/` skills +- README.md: rewritten for OIDC provider reality (removed stale Rust/TS library references) +- Admin token rotated from placeholder to proper random token + ## [2.0.0] - 2026-02-22 ### Added + - **OIDC Provider**: Full OpenID Connect provider using AT Protocol OAuth as identity source - Authorization endpoint with PKCE support - Token endpoint (authorization_code + refresh_token grants) @@ -32,12 +60,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **CI/CD**: Gitea Actions pipeline (test -> build -> push -> deploy to DigitalOcean k8s) ### Changed + - **Infrastructure**: Migrated from local k3s (Proxmox) to DigitalOcean Managed Kubernetes - **Registry**: Moved from Gitea container registry to DigitalOcean Container Registry - **Deployment strategy**: Recreate (RWO PVC for SQLite) - **Test suite**: Expanded from ~50 to 262 tests across 11 test files ### Fixed + - OIDC token endpoint: hash incoming client secret before comparing against stored SHA-256 hash - OIDC authorize: pass explicit redirect_uri to AT Protocol OAuth callback (prevents `@atproto/oauth-client` fallback mismatch) - OIDC userinfo: resolve DID to handle via `app.bsky.actor.getProfile` when no cached mapping exists @@ -45,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Register OIDC and forward-auth callback URIs in NodeOAuthClient metadata ### Security + - Client secrets stored as SHA-256 hashes (not plaintext) - Constant-time comparison for all secret verification - PKCE required for OIDC clients (configurable per-client) @@ -53,41 +84,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.3.0] - 2025-12-15 ### Changed + - **Express 5**: Migrated gateway from Express 4 to Express 5 - **ESLint 9**: Migrated to ESLint 9 flat config with typescript-eslint 8 - **Async Error Handling**: Route handlers now use `throw` pattern instead of try/catch blocks - Added `HttpError` class and `httpError` factory functions for cleaner error handling ### Updated Dependencies -- `express`: 4.18.2 → 5.0.0 -- `helmet`: 7.1.0 → 8.0.0 -- `@types/express`: 4.17.21 → 5.0.0 -- `eslint`: 8.56.0 → 9.0.0 -- `typescript-eslint`: 7.0.0 → 8.0.0 -- `@atproto/oauth-client-node`: 0.1.0 → 0.3.0 -- `better-sqlite3`: 11.0.0 → 11.6.0 -- `typescript`: 5.3.0 → 5.7.0 -- `vitest`: 1.0.0 → 2.0.0 -- `@types/node`: 20.10.0 → 22.0.0 + +- `express`: 4.18.2 -> 5.0.0 +- `helmet`: 7.1.0 -> 8.0.0 +- `@types/express`: 4.17.21 -> 5.0.0 +- `eslint`: 8.56.0 -> 9.0.0 +- `typescript-eslint`: 7.0.0 -> 8.0.0 +- `@atproto/oauth-client-node`: 0.1.0 -> 0.3.0 +- `better-sqlite3`: 11.0.0 -> 11.6.0 +- `typescript`: 5.3.0 -> 5.7.0 +- `vitest`: 1.0.0 -> 2.0.0 +- `@types/node`: 20.10.0 -> 22.0.0 ### Improved + - Enabled `projectService` in typescript-eslint for better type-aware linting performance - Simplified error handling code (~150 lines of boilerplate removed) ## [1.2.0] - 2025-12-14 ### Added + - TypeScript library unit tests for token and validation modules - Security report issue template - GitHub issue labels (security, rust, typescript, gateway, breaking change, ci/cd) ### Changed + - **BREAKING**: `TokenVerifier::new()` now returns `Result` instead of `Self` - Minimum HMAC secret key length enforced at 32 bytes (256 bits) - TypeScript library defaults to `sessionStorage` instead of `localStorage` - Docker compose binds to localhost only (127.0.0.1) by default ### Security + - Enforce minimum 32-byte secret key for HMAC-SHA256 in Rust library - Add HTTPS URL validation for production environments in TypeScript - Add redirect URI validation against registered callbacks in gateway @@ -96,6 +133,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0] - 2025-12-14 ### Added + - Initial release - Rust library for HMAC-SHA256 token verification - TypeScript/JavaScript library for frontend integration @@ -109,12 +147,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CI/CD with GitHub Actions ### Security + - Constant-time signature comparison - CSRF protection via cryptographic nonces - Input validation and sanitization - Rate limiting on all endpoints -[2.0.0]: https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/compare/v1.3.0...main +[2.0.1]: https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/compare/v2.0.0...main +[2.0.0]: https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/compare/v1.3.0...v2.0.0 [1.3.0]: https://github.com/Cache8063/atauth/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/Cache8063/atauth/compare/v1.0.0...v1.2.0 [1.0.0]: https://github.com/Cache8063/atauth/releases/tag/v1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 33f3f75..b6f3918 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,401 +1,96 @@ # ATAuth - AT Protocol OIDC Provider ## Overview -ATAuth is an OpenID Connect (OIDC) Provider that uses AT Protocol OAuth (Bluesky) as the identity source. It allows applications to authenticate users via their Bluesky accounts. +ATAuth is an OpenID Connect (OIDC) Provider that uses AT Protocol OAuth (Bluesky) as the identity source. It also provides a forward-auth SSO proxy for nginx `auth_request`. ## Critical: Domain Configuration -**IMPORTANT**: ATAuth staging MUST use `workingtitle.zip` domain, NOT `arcnode.xyz`. +**IMPORTANT**: ATAuth MUST use `workingtitle.zip` domain, NOT `arcnode.xyz`. -Why: The user's PDS is at `arcnode.xyz`. If ATAuth is also on `arcnode.xyz`, the browser sends `Sec-Fetch-Site: same-site` to the PDS OAuth endpoint, which the PDS rejects. Using a different domain (`workingtitle.zip`) makes it `cross-site`, which is allowed. +Why: The user's PDS is at `arcnode.xyz`. Same registrable domain causes `Sec-Fetch-Site: same-site` which the PDS rejects. Using `workingtitle.zip` makes it `cross-site`. ## Architecture -### OIDC Provider Flow -``` -OIDC Client (e.g., Audiobookshelf) - ↓ redirects to -ATAuth (auth-staging.workingtitle.zip) - OIDC Provider - ↓ redirects to -User's PDS (arcnode.xyz) - AT Protocol OAuth - ↓ user authenticates, redirects back to -ATAuth (receives AT Proto tokens, issues OIDC tokens) - ↓ redirects back to -OIDC Client (receives OIDC tokens) -``` +### OIDC Flow -### 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 +Client App -> ATAuth (OIDC Provider) -> User's PDS (AT Proto OAuth) -> ATAuth -> Client App ``` -- **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 (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 - -### Current Production (DigitalOcean) -- **Public URL**: `https://auth-staging.workingtitle.zip` -- **Apricot Gateway**: `https://apricot.workingtitle.zip` (routes `/auth/*` and `/admin/*` to ATAuth) -- **OIDC Discovery**: `https://auth-staging.workingtitle.zip/.well-known/openid-configuration` -- **Cluster**: DO Managed Kubernetes (`storm-dr-cluster`, nyc1) -- **Namespace**: `atauth` -- **Registry**: `registry.digitalocean.com/ghostmesh-registry` -- **Image**: `registry.digitalocean.com/ghostmesh-registry/atauth:latest` -- **Storage**: SQLite on `do-block-storage` PVC -- **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 (from repo root, use unique tag to avoid k8s image caching) -TAG=$(git rev-parse --short HEAD) -docker build --platform linux/amd64 \ - -t registry.digitalocean.com/ghostmesh-registry/atauth:$TAG \ - -t registry.digitalocean.com/ghostmesh-registry/atauth:latest \ - gateway/ -docker push registry.digitalocean.com/ghostmesh-registry/atauth:$TAG -docker push registry.digitalocean.com/ghostmesh-registry/atauth:latest - -# Deploy with specific tag (avoids IfNotPresent caching of :latest) -kubectl set image deployment/atauth \ - atauth=registry.digitalocean.com/ghostmesh-registry/atauth:$TAG \ - -n atauth -kubectl rollout status deployment/atauth -n atauth --timeout=120s +### Forward-Auth Proxy Flow ``` - -### Local k3s (Suspended) -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, OIDC Clients, Setup Wizard, Origins, Access Rules, Sessions, Proxy Setup, 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` -- **k8s secret**: `atauth-secrets` key `ATAUTH_ADMIN_TOKEN` in namespace `atauth` - -### Using Admin API -```bash -# Set token from Vaultwarden (or from k8s secret) -export ADMIN_TOKEN="" - -# List 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 . - -# 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 '{"did":"did:plc:abc123","handle":"user.bsky.social","origin":"https://search.arcnode.xyz"}' +Browser -> nginx auth_request -> ATAuth /auth/verify -> /auth/login -> AT Proto OAuth -> session cookie ``` -## OIDC Client Configuration - -### Registered Clients +- **Stack**: Node.js/Express 5, TypeScript, SQLite (better-sqlite3) +- **Tokens**: ES256 JWTs (OIDC), HMAC-SHA256 cookies (proxy) +- **Admin**: Server-rendered HTML at `/admin/dashboard` -#### Audiobookshelf +## Deployment | Setting | Value | |---------|-------| -| **Client ID** | `audiobookshelf` | -| **Client Secret** | Vaultwarden → `ATAuth/OIDC Clients/Audiobookshelf` | -| **Redirect URI** | `https://audiobookshelf.cloudforest-basilisk.ts.net/auth/openid/callback` | -| **Grant Types** | `authorization_code`, `refresh_token` | -| **Scopes** | `openid`, `profile`, `email` | -| **PKCE** | Required | +| Public URL | `https://auth-staging.workingtitle.zip` | +| Apricot | `https://apricot.workingtitle.zip` (routes `/auth/*`, `/admin/*`) | +| OIDC Issuer | `https://apricot.workingtitle.zip` | +| Discovery | `https://apricot.workingtitle.zip/.well-known/openid-configuration` | +| Namespace | `atauth` (DO k8s) | +| Registry | `registry.digitalocean.com/ghostmesh-registry` | +| Storage | SQLite on `do-block-storage` PVC | +| Strategy | Recreate (RWO PVC) | +| CI/CD | `.gitea/workflows/deploy.yml` | -**Note**: Redirect URI is HTTPS without port (Tailscale serve handles TLS on port 443). +For deployment procedures, rollback, and monitoring: use `/deploy` command. -### Secrets Storage -All secrets in **Vaultwarden** (LXC 120 @ vaultwarden.cloudforest-basilisk.ts.net): -- `ATAuth/Admin Token - Staging` -- `ATAuth/Admin Token - Production` -- `ATAuth/OIDC Clients/Audiobookshelf` -- `ATAuth/OIDC Key Secret` +## Admin -## Forward-Auth Proxy +- **Dashboard**: `https://apricot.workingtitle.zip/admin/login` +- **Token**: Vaultwarden -> `ATAuth/Admin Token - Staging` +- For API usage examples: use `/admin-api` command. -### 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`) +## Registered OIDC Clients -**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 +| Client ID | Redirect URI | PKCE | Notes | +| --- | --- | --- | --- | +| `audiobookshelf` | `https://audiobookshelf.cloudforest-basilisk.ts.net/auth/openid/callback` | Yes | Tailscale serve | +| `gitea` | `https://gitea.cloudforest-basilisk.ts.net/user/oauth2/atauth/callback` | No | Gitea doesn't send PKCE | -**Handle pattern matching**: `*` = all, `*.arcnode.xyz` = suffix match, `bkb.arcnode.xyz` = exact +All client secrets in Vaultwarden under `ATAuth/OIDC Clients/`. -### 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 -### Key Files | File | Purpose | |------|---------| -| `src/routes/proxy-auth.ts` | Forward-auth routes (`/auth/verify`, `/auth/login`, `/auth/callback`) + `enforceAccess` | -| `src/routes/oidc/authorize.ts` | OIDC authorization endpoint + AT Protocol OAuth callback | -| `src/routes/oidc/token.ts` | OIDC token exchange (auth_code + refresh_token grants) | -| `src/routes/oidc/userinfo.ts` | OIDC userinfo endpoint (DID-to-handle resolution) | -| `src/routes/oidc/revoke.ts` | OIDC token revocation | -| `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/data/oidc-presets.ts` | OIDC setup wizard app presets (ABS, Jellyfin, Gitea, etc.) | -| `src/services/database.ts` | SQLite schema, migrations, all DB methods | -| `src/services/oauth.ts` | AT Protocol OAuth client (`generateAuthUrl`, `handleCallback`) | -| `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 -- **Account ID**: `abc90e3f264c71019a7f68204a5c4454` -- **Tunnel ID**: `600f069c-4c53-4a59-b1fd-57c5ad09c044` -- **Routes**: - - `auth-staging.workingtitle.zip` → `http://atauth-gateway.atauth.svc.cluster.local:80` (DO cluster) - - `apricot.workingtitle.zip` → `http://atauth-gateway.atauth.svc.cluster.local:80` (DO cluster) - - `auth-staging.arcnode.xyz` → (DEPRECATED, causes same-site issues) - -### Security (Cloudflare) -- **WAF**: Cloudflare Managed Ruleset + OWASP Core (block mode) -- **Rate Limiting**: 10 req/min on `/oauth/token`, `/oauth/authorize`, `/auth/*` -- **TLS**: Minimum 1.3, Strict mode - -### Cluster Resources -```bash -# Check deployment -kubectl -n atauth get pods -kubectl -n atauth logs deploy/atauth --tail=50 +| `src/routes/oidc/authorize.ts` | OIDC authorization + AT Proto OAuth callback | +| `src/routes/oidc/token.ts` | Token exchange (auth_code + refresh_token) | +| `src/routes/oidc/userinfo.ts` | User claims (DID-to-handle resolution) | +| `src/routes/proxy-auth.ts` | Forward-auth routes + `enforceAccess` | +| `src/routes/admin.ts` | Admin API (Bearer + cookie auth) | +| `src/routes/admin-dashboard.ts` | Server-rendered dashboard + CSRF | +| `src/data/oidc-presets.ts` | Setup wizard presets (ABS, Jellyfin, Gitea, etc.) | +| `src/services/database.ts` | SQLite schema, migrations, DB methods | +| `src/services/oauth.ts` | AT Proto OAuth client | +| `src/utils/access-check.ts` | `matchHandlePattern` + `checkAccess` | +| `src/utils/proxy-auth.ts` | Cookie create/verify, tickets, parsing | + +## Testing (263 tests) -# Check secrets -kubectl -n atauth get secret atauth-secrets -o jsonpath='{.data}' | python3 -m json.tool - -# Restart -kubectl -n atauth rollout restart deployment/atauth - -# Rollback -kubectl -n atauth rollout undo deployment/atauth - -# SQLite access -kubectl exec -n atauth deploy/atauth -- sqlite3 /app/data/gateway.db ".tables" -``` - -## Related Services - -### User's PDS (arcnode.xyz) -- **Server**: Hetzner Cloud (pds-hetzner) -- **Public IP**: 46.224.33.19 -- **Tailscale**: 100.89.0.112 (pds-hetzner) -- **SSH**: `ssh -i ~/.ssh/vaultnode_ed25519 root@100.89.0.112` -- **OAuth Endpoint**: `https://arcnode.xyz/oauth/authorize` -- **DID**: `did:plc:k23ujfuppr3hr4pxvtaz7jro` -- **Handle**: `@bkb.arcnode.xyz` - -### Audiobookshelf -- **Location**: LXC 107 on `pv4` -- **Tailscale IP**: 100.115.188.60 -- **URL**: `https://audiobookshelf.cloudforest-basilisk.ts.net` (via Tailscale serve → ABS:13378 directly) -- **OIDC**: Configured with ATAuth (`client_id: audiobookshelf`, `authOpenIDAutoRegister: true`) -- **SSH via pv4**: `ssh root@pv4.cloudforest-basilisk.ts.net "pct exec 107 -- "` - -### Vaultwarden -- **Location**: LXC 120 -- **URL**: https://vaultwarden.cloudforest-basilisk.ts.net - -## API Endpoints - -### OIDC Endpoints -| Endpoint | Description | -|----------|-------------| -| `/.well-known/openid-configuration` | Discovery document | -| `/.well-known/jwks.json` | Public signing keys | -| `/oauth/authorize` | Authorization endpoint | -| `/oauth/token` | Token exchange | -| `/oauth/userinfo` | User claims | -| `/oauth/revoke` | Token revocation | - -### 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/dashboard/clients` | OIDC client list (HTML) | -| `GET /admin/dashboard/clients/new` | Create OIDC client form (HTML) | -| `GET /admin/dashboard/clients/:id/edit` | Edit OIDC client form (HTML) | -| `GET /admin/dashboard/clients/wizard` | Setup wizard app selection (HTML) | -| `GET /admin/dashboard/clients/wizard/:preset` | Setup wizard configure form (HTML) | -| `GET /admin/dashboard/proxy-wizard` | Forward-auth proxy setup wizard (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/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 (262 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 test:run # All tests +npm run typecheck # TypeScript 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` | 31 | Cookie create/verify, tickets, parsing, admin cookie | -| `src/routes/admin-dashboard.test.ts` | 37 | Dashboard pages, OIDC client CRUD, wizard, proxy wizard, CSRF | -| `src/routes/admin.proxy.test.ts` | 32 | Access rules API, cookie auth login/logout | -| `src/routes/auth.test.ts` | 8 | Auth redirect_uri handling, regression tests | -| `src/routes/proxy-auth.test.ts` | 28 | Forward-auth flows, access control integration | -| `src/services/database.proxy.test.ts` | 23 | Access rules CRUD, cascade delete, partitioning | -| `src/services/oidc/tokens.test.ts` | 20 | OIDC token generation and verification | -| `src/services/oidc/claims.test.ts` | 28 | UserInfo claims building, scope filtering | -| `src/services/oidc/pkce.test.ts` | 17 | PKCE challenge/verifier generation and validation | -| `tests/oidc-flow.test.ts` | 22 | Full OIDC provider E2E flow | - -## Troubleshooting - -### "Forbidden sec-fetch-site header same-site" -**Cause**: ATAuth and user's PDS are on the same registrable domain. -**Fix**: Use `workingtitle.zip` for ATAuth, not `arcnode.xyz`. - -### "invalid_redirect_uri" -**Cause**: Redirect URI in request doesn't match registered client. -**Fix**: Check client's registered redirect_uris match exactly (including https vs http, port, path). - -### 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). - -### "invalid_client" on OIDC token exchange - -**Cause**: Client secret stored as SHA-256 hash but token endpoint compared raw secret. -**Fixed**: `token.ts` and `revoke.ts` now hash incoming secret before comparison (`3fc67a7`). - -### "invalid_grant" redirect_uri mismatch on AT Protocol token exchange - -**Cause**: `@atproto/oauth-client` falls back to `clientMetadata.redirect_uris[0]` during token exchange. Authorization used `/oauth/callback` but exchange used `/auth/callback`. -**Fixed**: `authorize.ts` now passes explicit redirect_uri to `handleCallback()` (`3fc67a7`). - -### Userinfo returns empty `preferred_username` +11 test files across `src/` (unit) and `tests/` (E2E). -**Cause**: `db.getUserMapping()` returns nothing for new OIDC sessions; handle defaulted to empty string. -**Fixed**: `userinfo.ts` resolves DID to handle via `app.bsky.actor.getProfile` API (`3fc67a7`). +## Important Notes -### 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. +- **Do NOT restart ATAuth to rotate HMAC secrets** -- use admin API, then restart the downstream service only +- **HMAC encoding**: Both sides must use UTF-8 encoding of the hex secret string +- **OIDC issuer is `apricot.workingtitle.zip`**, not `auth-staging.workingtitle.zip` +- `/auth/verify` is mounted before rate limit middleware (called on every nginx subrequest) +- Client secrets stored as SHA-256 hashes (not plaintext) +- PKCE is per-client configurable (some apps like Gitea don't support it) -## 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 -- **Stale deployment configs**: `gateway/tekton/` (old Tekton CI), `gateway/helm/` (unused Helm chart), `.github/` (old GitHub Actions) -- can be removed -- **Rotate admin token**: Current placeholder `atauth-admin-token-2026-change-in-production` should be replaced with a proper random token -- **Setup wizard bug**: Double-protocol in redirect_uri when domain input includes `https://` prefix +For troubleshooting: use `/troubleshoot` command. diff --git a/README.md b/README.md index abb40a1..d20f835 100644 --- a/README.md +++ b/README.md @@ -1,238 +1,137 @@ -# ATAuth - Decentralized Authentication for AT Protocol +# ATAuth - OIDC Provider for AT Protocol -A complete, plug-and-play authentication system using AT Protocol (Bluesky) OAuth. Use it with Bluesky or **run your own PDS** for fully self-hosted, decentralized identity. +An OpenID Connect (OIDC) Provider that uses AT Protocol OAuth (Bluesky) as the identity source. Authenticate users via their `@handle` -- works with Bluesky or any self-hosted PDS. -## Why ATAuth? +## What It Does -**No more password databases.** Users authenticate with their AT Protocol identity (`@user.bsky.social` or `@alice.your-pds.com`). You verify tokens - the identity provider handles the rest. - -| Traditional Auth | ATAuth | -|-----------------|--------| -| You store passwords | PDS manages identity | -| You implement MFA | PDS handles MFA | -| Password resets, account recovery | Not your problem | -| LDAP, Active Directory complexity | Simple token verification | - -**With a self-hosted PDS**, ATAuth becomes a fully independent auth system - no different from running your own Keycloak, but with portable, decentralized identities. - -## Quick Start (Docker) - -```bash -# Clone and configure -git clone https://github.com/Cache8063/atauth.git -cd atauth -cp .env.example .env -echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> .env - -# Start the gateway -docker compose up -d - -# Register your first app -curl -X POST http://localhost:3100/admin/apps \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"id": "myapp", "name": "My Application"}' -``` - -See [docs/HOMELAB.md](docs/HOMELAB.md) for complete deployment guide. - -## Components - -| Component | Description | -|-----------|-------------| -| **[gateway/](gateway/)** | Node.js OAuth gateway server ([Docker image](https://ghcr.io/cache8063/atauth-gateway)) | -| **[src/](src/)** | Rust token verification library | -| **[ts/](ts/)** | TypeScript/React frontend utilities | - -## Architecture +ATAuth sits between your apps and AT Protocol identity: ```text -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Your Apps │────▶│ ATAuth Gateway │────▶│ PDS │ -│ (any stack) │ │ (one place) │ │ (yours or bsky) │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ - │ │ - │ HMAC token │ AT Protocol OAuth - ▼ ▼ -┌─────────────────┐ ┌─────────────────┐ -│ Your Backend │────▶│ Token Verify │ -│ (Rust/Node/?) │ │ (atauth lib) │ -└─────────────────┘ └─────────────────┘ +Your App (OIDC Client) + | + v +ATAuth (OIDC Provider) + | + v +User's PDS (Bluesky / self-hosted) + | + v +User authenticates with their AT Proto identity + | + v +ATAuth issues standard OIDC tokens back to your app ``` -**The gateway handles the complex OAuth flow once.** Your apps receive simple HMAC-signed tokens to verify. +Any app that supports OpenID Connect can use ATAuth. No custom integration needed. -## Self-Hosted PDS = Full Independence - -When you run your own PDS: -- Users get handles like `@alice.your-domain.com` -- No dependency on Bluesky servers -- Works on air-gapped networks -- Same security model as enterprise OAuth -- Your identity, your infrastructure - -```yaml -# Add to your docker-compose.yml -services: - pds: - image: ghcr.io/bluesky-social/pds:latest - # ... configure for your domain -``` +## Features -## Installation +- **Standard OIDC Provider**: Discovery, authorization, token, userinfo, revocation, JWKS endpoints +- **Forward-Auth SSO Proxy**: nginx `auth_request` based single sign-on for any web service +- **Admin Dashboard**: Web UI with setup wizard for common self-hosted apps +- **Access Control**: Per-user DID and handle pattern rules with deny-overrides +- **PKCE Support**: Configurable per-client +- **ES256 Signed JWTs**: Proper key rotation via JWKS endpoint -### Docker (Recommended) +## Quick Start ```bash -# From GitHub Container Registry -docker pull ghcr.io/cache8063/atauth-gateway:latest +git clone https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth.git +cd atauth +cp .env.example .env +echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> .env -# From Gitea Container Registry (self-hosted mirror) -docker pull your-gitea-instance.example.com/arcnode.xyz/atauth-gateway:latest +docker compose up -d ``` -### Rust Library +Then open the admin dashboard at `http://localhost:3100/admin/login` and use the setup wizard to register your first app. -```toml -[dependencies] -atauth = { git = "https://github.com/Cache8063/atauth" } -``` +## Supported Apps -### TypeScript/JavaScript +The setup wizard includes presets for: -```bash -npm install atauth -``` +| App | Auth Method | +| --- | --- | +| Audiobookshelf | OIDC (OpenID Connect) | +| Jellyfin | OIDC (via SSO plugin) | +| Gitea / Forgejo | OIDC (built-in) | +| Nextcloud | OIDC (via app) | +| Immich | OIDC (built-in) | +| Grafana | OIDC (built-in) | +| Wiki.js | OIDC (built-in) | +| Portainer | OIDC (built-in) | +| Outline | OIDC (built-in) | +| Mealie | OIDC (built-in) | +| Any web service | Forward-auth proxy (nginx `auth_request`) | -## Usage +## OIDC Discovery -### Gateway - Register Apps +Once running, your OIDC discovery document is at: -```bash -curl -X POST https://auth.example.com/admin/apps \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "jellyfin", - "name": "Jellyfin", - "callback_url": "https://jellyfin.example.com/sso/callback" - }' -# Returns: { "hmac_secret": "..." } - save this! +```text +https://your-domain/.well-known/openid-configuration ``` -### Rust - Verify Tokens +Configure any OIDC-compatible app with this URL and it will auto-discover all endpoints. -```rust -use atauth::TokenVerifier; +## Forward-Auth Proxy -// Secret must be at least 32 bytes (256 bits) for security -let verifier = TokenVerifier::new(b"your-hmac-secret-at-least-32-bytes!") - .expect("Secret must be at least 32 bytes"); +For apps without OIDC support, ATAuth provides an nginx `auth_request` proxy: -match verifier.verify(token) { - Ok(payload) => { - println!("Welcome, {}!", payload.handle); - // payload.did, payload.user_id, payload.app_id available - } - Err(e) => eprintln!("Auth failed: {}", e), +```nginx +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-app; } -``` - -### TypeScript - Frontend Integration - -```tsx -import { useAuthStore } from 'atauth/react'; - -function LoginButton() { - const { isAuthenticated, user, login, logout } = useAuthStore(); - - if (isAuthenticated) { - return ( - <> - Welcome, {user?.handle}! - - - ); - } - return ; +location = /auth/verify { + internal; + proxy_pass http://atauth:3100; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URL $scheme://$http_host$request_uri; } ``` -### Node.js - Verify Tokens +The admin dashboard generates these config snippets for you. -```javascript -import crypto from 'crypto'; +## Self-Hosted PDS -function verifyToken(token, secret) { - const [payloadB64, signature] = token.split('.'); - const expected = crypto - .createHmac('sha256', secret) - .update(payloadB64) - .digest('base64url'); +With a self-hosted PDS, ATAuth becomes a fully independent auth system: - // Constant-time comparison - if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { - return null; - } - - const payload = JSON.parse(Buffer.from(payloadB64, 'base64url')); - return payload.exp > Date.now() / 1000 ? payload : null; -} -``` - -## Features - -- **Multi-App Support**: One gateway serves all your apps with isolated secrets -- **Rate Limiting**: Built-in IP-based rate limiting (configurable) -- **Session Management**: SQLite or PostgreSQL session stores -- **Security Hardened**: Constant-time comparison, CSRF protection, secure token transport -- **Docker Ready**: Multi-arch images (amd64/arm64) -- **Homelab Friendly**: Works with Traefik, Caddy, nginx +- Users get handles like `@alice.your-domain.com` +- No dependency on Bluesky servers +- Works on air-gapped networks +- Same security model as enterprise OAuth -## Token Format +## Architecture -Simple HMAC-signed tokens (not full JWT): +| Component | Description | +| --- | --- | +| **Gateway** | Node.js/Express 5, TypeScript, SQLite | +| **Identity** | AT Protocol OAuth (user's PDS) | +| **OIDC Tokens** | ES256 signed JWTs | +| **Proxy Cookies** | HMAC-SHA256 signed, typed | +| **Access Control** | Deny-overrides, per-origin + global rules | -``` -base64url(payload).base64url(hmac_sha256(payload, secret)) -``` +## Security -Payload: -```json -{ - "did": "did:plc:abc123...", - "handle": "user.bsky.social", - "user_id": 42, - "app_id": "myapp", - "iat": 1699900000, - "exp": 1699903600, - "nonce": "random-string" -} -``` +- Client secrets stored as SHA-256 hashes +- PKCE support (configurable per-client) +- Constant-time comparison for all secret verification +- HMAC-signed CSRF tokens on all dashboard forms +- Rate limiting on auth endpoints +- WAF-compatible (Cloudflare Managed Ruleset + OWASP) ## Documentation -- [Homelab Deployment Guide](docs/HOMELAB.md) - Docker, reverse proxy configs, self-hosted PDS -- [Security Policy](SECURITY.md) - Reporting vulnerabilities -- [Contributing](CONTRIBUTING.md) - How to contribute - -## Use Cases - -- **Homelab SSO**: Single sign-on for Jellyfin, NextCloud, Gitea, etc. -- **Multi-tenant Apps**: Users bring their own identity -- **AT Protocol Apps**: Games, social apps, tools for the ATmosphere -- **Enterprise**: Self-hosted PDS for internal identity - -## Security - -- HMAC-SHA256 with constant-time verification -- Rate limiting on all endpoints -- CSRF protection via cryptographic nonces -- Tokens in URL fragments (not query params) -- Sanitized error responses - -See [SECURITY.md](SECURITY.md) for reporting vulnerabilities. +- [Homelab Deployment Guide](docs/HOMELAB.md) +- [Security Policy](SECURITY.md) +- [Changelog](CHANGELOG.md) ## License diff --git a/docs/HOMELAB.md b/docs/HOMELAB.md index 4075f77..3991330 100644 --- a/docs/HOMELAB.md +++ b/docs/HOMELAB.md @@ -1,83 +1,113 @@ # ATAuth Homelab Deployment Guide -ATAuth provides decentralized authentication for your homelab using AT Protocol. Instead of managing passwords, users authenticate with their AT Protocol identity (like `@alice.your-pds.com`). +ATAuth provides OIDC-based authentication for your homelab using AT Protocol identities. Users sign in with their `@handle` (Bluesky or self-hosted PDS) and your apps receive standard OIDC tokens. -## Why AT Protocol for Homelab Auth? - -| Traditional SSO | ATAuth + AT Protocol | -|----------------|---------------------| -| You manage passwords | PDS manages identity | -| You handle MFA | PDS handles MFA | -| You store credentials | No credentials stored | -| Single point of failure | Decentralized identity | -| Complex setup (LDAP, etc.) | Simple gateway deployment | - -**With a self-hosted PDS**, you have: -- Full control over your identity infrastructure -- No external dependencies (works offline) -- Same security model as enterprise OAuth -- Portable identity across the AT Protocol network - -## Quick Start - -### 1. Prerequisites +## Prerequisites - Docker and Docker Compose -- A domain name (e.g., `auth.homelab.local`) -- Reverse proxy with TLS (Caddy, Traefik, or nginx) -- Optional: Your own PDS for full independence +- A domain name with TLS (reverse proxy: Caddy, Traefik, or nginx) +- Optional: Your own PDS for fully self-hosted identity -### 2. Deploy the Gateway +## Deploy ```bash -# Clone the repository -git clone https://github.com/Cache8063/atauth.git +git clone https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth.git cd atauth - -# Configure environment cp .env.example .env # Generate admin token echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> .env -# Edit .env with your domain -nano .env +# Set your public URL +# Edit .env: OAUTH_CLIENT_ID and OAUTH_REDIRECT_URI -# Start the gateway docker compose up -d ``` -### 3. Configure Your Domain +The gateway runs on port 3100. Put a reverse proxy with TLS in front of it. -Update `.env`: -```env -OAUTH_CLIENT_ID=https://auth.homelab.local/client-metadata.json -OAUTH_REDIRECT_URI=https://auth.homelab.local/auth/callback -CORS_ORIGINS=https://jellyfin.homelab.local,https://nextcloud.homelab.local -``` +## Register Apps + +### Option 1: Admin Dashboard (Recommended) + +Open `https://your-atauth-domain/admin/login`, enter your admin token, then use the **Setup Wizard** to register apps. It includes presets for: + +- Audiobookshelf, Jellyfin, Gitea/Forgejo, Nextcloud, Immich +- Grafana, Wiki.js, Portainer, Outline, Mealie -### 4. Register Your Apps +Each preset auto-fills the correct redirect URI, scopes, and grant types. + +### Option 2: Admin API ```bash -# Register Jellyfin -curl -X POST https://auth.homelab.local/admin/apps \ +curl -X POST https://your-atauth-domain/admin/oidc/clients \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "id": "jellyfin", - "name": "Jellyfin Media Server", - "callback_url": "https://jellyfin.homelab.local/sso/callback" + "name": "Jellyfin", + "redirect_uris": ["https://jellyfin.example.com/sso/OID/redirect/ATAuth"], + "grant_types": ["authorization_code", "refresh_token"], + "scopes": ["openid", "profile", "email"], + "require_pkce": true, + "token_endpoint_auth_method": "client_secret_basic" }' +``` + +Save the returned `client_secret` -- it is only shown once. + +## Configure Your App + +Point your app's OIDC settings to ATAuth's discovery URL: -# Save the returned hmac_secret for your app's backend +```text +https://your-atauth-domain/.well-known/openid-configuration +``` + +This auto-discovers all endpoints. Most apps need: + +| Setting | Value | +| --- | --- | +| Discovery URL | `https://your-atauth-domain/.well-known/openid-configuration` | +| Client ID | The ID you chose when registering | +| Client Secret | The secret returned at registration | +| Scopes | `openid profile email` | + +## Forward-Auth Proxy + +For apps without OIDC support, ATAuth provides nginx `auth_request` based SSO. + +1. Register the origin in the admin dashboard under **Proxy Origins** +2. Add access rules under **Access Rules** +3. Use the **Proxy Setup Wizard** to generate nginx config snippets + +Example nginx config: + +```nginx +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-app; +} + +location = /auth/verify { + internal; + proxy_pass http://atauth:3100; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URL $scheme://$http_host$request_uri; +} ``` ## Reverse Proxy Configuration -### Caddy (Recommended) +### Caddy ```caddyfile -auth.homelab.local { +auth.example.com { reverse_proxy atauth:3100 } ``` @@ -85,10 +115,9 @@ auth.homelab.local { ### Traefik ```yaml -# docker-compose.yml labels labels: - "traefik.enable=true" - - "traefik.http.routers.atauth.rule=Host(`auth.homelab.local`)" + - "traefik.http.routers.atauth.rule=Host(`auth.example.com`)" - "traefik.http.routers.atauth.entrypoints=websecure" - "traefik.http.routers.atauth.tls.certresolver=letsencrypt" - "traefik.http.services.atauth.loadbalancer.server.port=3100" @@ -99,10 +128,10 @@ labels: ```nginx server { listen 443 ssl http2; - server_name auth.homelab.local; + server_name auth.example.com; - ssl_certificate /etc/ssl/certs/auth.homelab.local.crt; - ssl_certificate_key /etc/ssl/private/auth.homelab.local.key; + ssl_certificate /etc/ssl/certs/auth.example.com.crt; + ssl_certificate_key /etc/ssl/private/auth.example.com.key; location / { proxy_pass http://atauth:3100; @@ -115,155 +144,57 @@ server { } ``` -## Self-Hosted PDS Setup - -For complete independence from Bluesky, run your own PDS: - -```yaml -# docker-compose.pds.yml -services: - pds: - image: ghcr.io/bluesky-social/pds:latest - environment: - - PDS_HOSTNAME=pds.homelab.local - - PDS_JWT_SECRET=your-jwt-secret - - PDS_ADMIN_PASSWORD=your-admin-password - - PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=your-key - - PDS_DATA_DIRECTORY=/pds - - PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks - - PDS_DID_PLC_URL=https://plc.directory - - PDS_BSKY_APP_VIEW_URL=https://api.bsky.app - - PDS_BSKY_APP_VIEW_DID=did:web:api.bsky.app - - PDS_REPORT_SERVICE_URL=https://mod.bsky.app - - PDS_REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac - - PDS_CRAWLERS=https://bsky.network - volumes: - - pds-data:/pds - ports: - - "3000:3000" -``` - -Then your users can create accounts like `@alice.pds.homelab.local` and authenticate through ATAuth. - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Your Homelab Network │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Jellyfin │ │ NextCloud │ │ Gitea │ │ -│ │ │ │ │ │ │ │ -│ │ (app_id: │ │ (app_id: │ │ (app_id: │ │ -│ │ jellyfin) │ │ nextcloud) │ │ gitea) │ │ -│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ -│ │ │ │ │ -│ └─────────────────┼─────────────────┘ │ -│ │ │ -│ ┌───────▼───────┐ │ -│ │ ATAuth │ │ -│ │ Gateway │ │ -│ │ (one place) │ │ -│ └───────┬───────┘ │ -│ │ │ -│ ┌───────▼───────┐ │ -│ │ Your PDS or │ │ -│ │ Bluesky │ │ -│ └───────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` +## Self-Hosted PDS -## Integrating Apps +With your own PDS, ATAuth becomes fully independent: -### Generic OAuth Integration +- Users get handles like `@alice.your-domain.com` +- No dependency on Bluesky +- Works on air-gapped networks -Most apps with OAuth support can use ATAuth: +The gateway auto-discovers the user's PDS from their handle. No special configuration needed -- just ensure the PDS is reachable. -1. **Register the app** with ATAuth admin API -2. **Configure OAuth** in the app: - - Authorization URL: `https://auth.homelab.local/auth/init` - - Callback URL: Your app's callback -3. **Verify tokens** in your app's backend using the HMAC secret +## Access Control -### Token Verification (Backend) +ATAuth supports per-user access rules: -For apps you control, verify tokens server-side: +- **Allow/deny by DID**: Target specific AT Protocol identities +- **Allow/deny by handle pattern**: `*.arcnode.xyz` matches all handles on a PDS domain +- **Deny overrides**: Deny rules always win regardless of order +- **Per-origin scoping**: Rules can target a specific origin or apply globally -**Node.js:** -```javascript -import crypto from 'crypto'; +Manage rules via the admin dashboard or API. -function verifyToken(token, secret) { - const [payloadB64, signature] = token.split('.'); - const expected = crypto - .createHmac('sha256', secret) - .update(payloadB64) - .digest('base64url'); - - if (signature !== expected) return null; - - const payload = JSON.parse(Buffer.from(payloadB64, 'base64url')); - if (payload.exp < Date.now() / 1000) return null; - - return payload; // { did, handle, user_id, app_id, exp } -} -``` +## Backup -**Rust:** -```rust -use atauth::TokenVerifier; +```bash +# Backup the SQLite database +docker compose exec atauth cp /app/data/gateway.db /app/data/gateway.db.backup -let verifier = TokenVerifier::new(b"your-hmac-secret"); -match verifier.verify(token) { - Ok(payload) => println!("Authenticated: {}", payload.handle), - Err(e) => println!("Invalid token: {}", e), -} +# Or backup the volume +docker run --rm -v atauth_atauth-data:/data -v $(pwd):/backup alpine \ + tar czf /backup/atauth-backup.tar.gz -C /data . ``` -## Security Considerations - -1. **Always use TLS** - OAuth requires HTTPS -2. **Protect admin token** - Only use for initial app registration -3. **HMAC secrets** - Store securely, rotate periodically -4. **Rate limiting** - Built-in, but consider additional WAF rules -5. **Network isolation** - Keep ATAuth on a trusted network segment - -## Troubleshooting - -### OAuth Error: "Client not found" +## Updating -Your `OAUTH_CLIENT_ID` must be a publicly accessible URL that returns valid client metadata. Verify: ```bash -curl https://auth.homelab.local/client-metadata.json +docker compose pull +docker compose up -d ``` -### Users Can't Authenticate +## Troubleshooting -1. Check PDS is reachable from ATAuth container -2. Verify user's handle resolves correctly -3. Check ATAuth logs: `docker compose logs atauth` +### OIDC discovery returns 404 -### Token Verification Fails +Ensure your `OAUTH_CLIENT_ID` URL is publicly accessible and the gateway is running. -1. Ensure HMAC secret matches between gateway and your app -2. Check token hasn't expired -3. Verify `app_id` matches the registered app +### Users can't authenticate -## Updating +1. Check the PDS is reachable from the ATAuth container +2. Verify the user's handle resolves correctly +3. Check logs: `docker compose logs atauth` -```bash -docker compose pull -docker compose up -d -``` +### "invalid_redirect_uri" -## Backup - -The gateway stores data in a SQLite database: -```bash -# Backup -docker compose exec atauth cp /app/data/gateway.db /app/data/gateway.db.backup - -# Or backup the volume -docker run --rm -v atauth_atauth-data:/data -v $(pwd):/backup alpine \ - tar czf /backup/atauth-backup.tar.gz -C /data . -``` +The redirect URI in the OIDC request must exactly match one registered for the client (including scheme, host, port, and path). diff --git a/gateway/package.json b/gateway/package.json index dca83e4..a7363e6 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -1,6 +1,6 @@ { "name": "atauth-gateway", - "version": "1.3.0", + "version": "2.0.1", "description": "AT Protocol OAuth gateway for application authentication", "main": "dist/index.js", "types": "dist/index.d.ts", -- knotmirror 0.0.1