From f1471c0bf35f90a134728db4fd12bc671a74c3b6 Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Tue, 24 Feb 2026 19:52:06 -0600 Subject: [PATCH] chore: sanitize for FOSS release Remove infrastructure-specific files (.claude/, CLAUDE.md, .gitea/, SAFE-DEPLOY.md). Replace private domains with example.com in tests, docs, and comments. Update clone URLs to GitHub. Co-Authored-By: Claude Opus 4.6 --- .claude/commands/admin-api.md | 99 ------------- .claude/commands/deploy.md | 73 ---------- .claude/commands/troubleshoot.md | 75 ---------- .gitea/workflows/deploy.yml | 132 ----------------- CLAUDE.md | 96 ------------- README.md | 2 +- docs/HOMELAB.md | 4 +- docs/RCA-2026-02-22-redirect-uri.md | 12 +- gateway/SAFE-DEPLOY.md | 152 -------------------- gateway/src/routes/admin-dashboard.test.ts | 30 ++-- gateway/src/routes/admin.ts | 4 +- gateway/src/routes/proxy-auth.test.ts | 54 +++---- gateway/src/services/database.proxy.test.ts | 38 ++--- gateway/src/types/proxy.ts | 2 +- gateway/src/utils/access-check.test.ts | 12 +- 15 files changed, 79 insertions(+), 706 deletions(-) delete mode 100644 .claude/commands/admin-api.md delete mode 100644 .claude/commands/deploy.md delete mode 100644 .claude/commands/troubleshoot.md delete mode 100644 .gitea/workflows/deploy.yml delete mode 100644 CLAUDE.md delete mode 100644 gateway/SAFE-DEPLOY.md diff --git a/.claude/commands/admin-api.md b/.claude/commands/admin-api.md deleted file mode 100644 index 42a87c1..0000000 --- a/.claude/commands/admin-api.md +++ /dev/null @@ -1,99 +0,0 @@ -# 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 deleted file mode 100644 index 8d0fbd2..0000000 --- a/.claude/commands/deploy.md +++ /dev/null @@ -1,73 +0,0 @@ -# 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 deleted file mode 100644 index 935ffb1..0000000 --- a/.claude/commands/troubleshoot.md +++ /dev/null @@ -1,75 +0,0 @@ -# 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/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml deleted file mode 100644 index 8952c6d..0000000 --- a/.gitea/workflows/deploy.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: Deploy ATAuth to DO -# CI/CD: Gitea Actions โ†’ DigitalOcean managed k8s - -on: - push: - branches: [main] - paths-ignore: - - '*.md' - - 'docs/**' - -env: - REGISTRY: registry.digitalocean.com/ghostmesh-registry - IMAGE_NAME: atauth - DEPLOYMENT: atauth - NAMESPACE: atauth - -jobs: - test: - name: Test Gateway - runs-on: ubuntu-latest - defaults: - run: - working-directory: gateway - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install dependencies - run: npm ci - - - name: Type check - run: npm run typecheck - - - name: Lint - run: npm run lint - - - name: Run tests - run: npm run test:run - - - name: Notify test failure - if: failure() - run: | - SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) - FIRST_LINE=$(echo "${{ github.event.head_commit.message }}" | head -1) - MSG=$(jq -n --arg body "๐Ÿงช ${{ github.repository }}: tests FAILED @ ${SHORT_SHA} - โ”” ${FIRST_LINE}" '{msgtype: "m.text", body: $body}') - curl -sf -X PUT \ - "${{ secrets.MATRIX_HOMESERVER_URL }}/_matrix/client/v3/rooms/${{ secrets.MATRIX_CICD_ROOM_ID }}/send/m.room.message/cicd-$(date +%s)-$$" \ - -H "Authorization: Bearer ${{ secrets.MATRIX_ACCESS_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d "$MSG" || true - - build-and-deploy: - name: Build, Push, Deploy - runs-on: host - needs: [test] - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set short SHA - id: sha - run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> "$GITHUB_OUTPUT" - - - name: Notify build started - env: - COMMIT_MSG: ${{ github.event.head_commit.message }} - run: | - FIRST_LINE=$(echo "$COMMIT_MSG" | head -1) - MSG=$(jq -n --arg body "๐Ÿ”จ ${{ github.repository }}: build started @ ${{ steps.sha.outputs.short }} - โ”œ Commit: $FIRST_LINE - โ”” Triggered by: ${{ github.actor }}" '{msgtype: "m.text", body: $body}') - curl -sf -X PUT \ - "${{ secrets.MATRIX_HOMESERVER_URL }}/_matrix/client/v3/rooms/${{ secrets.MATRIX_CICD_ROOM_ID }}/send/m.room.message/cicd-$(date +%s)-$$" \ - -H "Authorization: Bearer ${{ secrets.MATRIX_ACCESS_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d "$MSG" || true - - - name: Login to DO Container Registry - run: echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin - - - name: Build Docker image - run: | - docker build \ - --platform linux/amd64 \ - -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }} \ - -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ - gateway/ - - - name: Push Docker image - run: | - docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }} - docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - - - name: Deploy to DO Kubernetes - run: | - echo "${{ secrets.DO_KUBECONFIG }}" | base64 -d > /tmp/kubeconfig - KUBECONFIG=/tmp/kubeconfig kubectl set image deployment/${{ env.DEPLOYMENT }} \ - ${{ env.DEPLOYMENT }}=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }} \ - -n ${{ env.NAMESPACE }} - KUBECONFIG=/tmp/kubeconfig kubectl rollout status deployment/${{ env.DEPLOYMENT }} -n ${{ env.NAMESPACE }} --timeout=120s - rm -f /tmp/kubeconfig - - - name: Notify build result - if: always() - env: - COMMIT_MSG: ${{ github.event.head_commit.message }} - run: | - if [ "${{ job.status }}" = "success" ]; then - EMOJI="โœ…"; STATUS="deployed" - else - EMOJI="โŒ"; STATUS="FAILED" - fi - FIRST_LINE=$(echo "$COMMIT_MSG" | head -1) - MSG=$(jq -n --arg body "${EMOJI} ${{ github.repository }}: ${STATUS} @ ${{ steps.sha.outputs.short }} - โ”” $FIRST_LINE" '{msgtype: "m.text", body: $body}') - curl -sf -X PUT \ - "${{ secrets.MATRIX_HOMESERVER_URL }}/_matrix/client/v3/rooms/${{ secrets.MATRIX_CICD_ROOM_ID }}/send/m.room.message/cicd-$(date +%s)-$$" \ - -H "Authorization: Bearer ${{ secrets.MATRIX_ACCESS_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d "$MSG" || true - - - name: Clean up Docker images - if: always() - run: | - docker rmi ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }} 2>/dev/null || true - docker rmi ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 2>/dev/null || true diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b6f3918..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,96 +0,0 @@ -# ATAuth - AT Protocol OIDC Provider - -## Overview -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 MUST use `workingtitle.zip` domain, NOT `arcnode.xyz`. - -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 Flow - -``` -Client App -> ATAuth (OIDC Provider) -> User's PDS (AT Proto OAuth) -> ATAuth -> Client App -``` - -### Forward-Auth Proxy Flow -``` -Browser -> nginx auth_request -> ATAuth /auth/verify -> /auth/login -> AT Proto OAuth -> session cookie -``` - -- **Stack**: Node.js/Express 5, TypeScript, SQLite (better-sqlite3) -- **Tokens**: ES256 JWTs (OIDC), HMAC-SHA256 cookies (proxy) -- **Admin**: Server-rendered HTML at `/admin/dashboard` - -## Deployment - -| Setting | Value | -|---------|-------| -| 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` | - -For deployment procedures, rollback, and monitoring: use `/deploy` command. - -## Admin - -- **Dashboard**: `https://apricot.workingtitle.zip/admin/login` -- **Token**: Vaultwarden -> `ATAuth/Admin Token - Staging` -- For API usage examples: use `/admin-api` command. - -## Registered OIDC Clients - -| 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 | - -All client secrets in Vaultwarden under `ATAuth/OIDC Clients/`. - -## Key Files - -| File | Purpose | -|------|---------| -| `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) - -```bash -cd gateway -npm run test:run # All tests -npm run typecheck # TypeScript check -npm run lint # ESLint -``` - -11 test files across `src/` (unit) and `tests/` (E2E). - -## Important Notes - -- **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) - -For troubleshooting: use `/troubleshoot` command. diff --git a/README.md b/README.md index ce44391..aa84ffd 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Any app that supports OpenID Connect can use ATAuth. No custom integration neede ## Quick Start ```bash -git clone https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth.git +git clone https://github.com/Cache8063/atauth.git cd atauth/gateway cp .env.example .env ``` diff --git a/docs/HOMELAB.md b/docs/HOMELAB.md index f13bc0b..f05bbe0 100644 --- a/docs/HOMELAB.md +++ b/docs/HOMELAB.md @@ -11,7 +11,7 @@ ATAuth provides OIDC-based authentication for your homelab using AT Protocol ide ## Deploy ```bash -git clone https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth.git +git clone https://github.com/Cache8063/atauth.git cd atauth/gateway cp .env.example .env ``` @@ -168,7 +168,7 @@ The gateway auto-discovers the user's PDS from their handle. No special configur ATAuth supports per-user access rules: - **Allow/deny by DID**: Target specific AT Protocol identities -- **Allow/deny by handle pattern**: `*.arcnode.xyz` matches all handles on a PDS domain +- **Allow/deny by handle pattern**: `*.your-domain.com` 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 diff --git a/docs/RCA-2026-02-22-redirect-uri.md b/docs/RCA-2026-02-22-redirect-uri.md index 7da519c..b71cc12 100644 --- a/docs/RCA-2026-02-22-redirect-uri.md +++ b/docs/RCA-2026-02-22-redirect-uri.md @@ -11,7 +11,7 @@ Users see `500 Internal Server Error` when trying to log in. ## Symptoms -- `md.bkb.cx` returns 500 Internal Server Error on login +- `app.example.com` returns 500 Internal Server Error on login - ATAuth pod logs: `Unhandled error: TypeError: Invalid redirect_uri` at `NodeOAuthClient.authorize` - No AT Protocol OAuth requests reach the PDS -- error is thrown client-side before any network call @@ -24,18 +24,18 @@ Two related bugs in how `redirect_uri` was handled: The `@atproto/oauth-client` library's `NodeOAuthClient.authorize()` validates that the `redirect_uri` is in its own `clientMetadata.redirect_uris` array before sending anything to the PDS (early validation, ~line 147 of `oauth-client.js`). The `/client-metadata.json` endpoint (served to PDSes for discovery) correctly listed all redirect URIs including the OIDC callback. However, the `NodeOAuthClient` instance inside `OAuthService` was initialized with only 2 URIs: -- `https://apricot.workingtitle.zip/auth/callback` (primary) -- `https://apricot.workingtitle.zip/proxy/callback` (proxy) +- `https://auth.example.com/auth/callback` (primary) +- `https://auth.example.com/proxy/callback` (proxy) Missing: -- `https://apricot.workingtitle.zip/oauth/callback` (OIDC) -- `https://apricot.workingtitle.zip/auth/proxy/callback` (forward-auth) +- `https://auth.example.com/oauth/callback` (OIDC) +- `https://auth.example.com/auth/proxy/callback` (forward-auth) **Location**: `gateway/src/services/oauth.ts` line 47, `gateway/src/index.ts` line 116 ### Bug 2: Legacy auth flow passed downstream app callback as OAuth redirect_uri -The `/auth/init` route passed the downstream app's callback URL (e.g., `https://md.bkb.cx/api/auth/callback`) as the `redirect_uri` to `NodeOAuthClient.authorize()`. This URL: +The `/auth/init` route passed the downstream app's callback URL (e.g., `https://app.example.com/api/auth/callback`) as the `redirect_uri` to `NodeOAuthClient.authorize()`. This URL: 1. Was not registered in `clientMetadata.redirect_uris` (fails local validation) 2. Would not be accepted by the PDS (doesn't match client_id's registered callbacks) diff --git a/gateway/SAFE-DEPLOY.md b/gateway/SAFE-DEPLOY.md deleted file mode 100644 index 35147a3..0000000 --- a/gateway/SAFE-DEPLOY.md +++ /dev/null @@ -1,152 +0,0 @@ -# Safe Deployment Runbook for ATAuth Gateway - -## Current Environment - -| Setting | Value | -|---------|-------| -| Cluster | DigitalOcean Managed Kubernetes (`storm-dr-cluster`, nyc1) | -| Namespace | `atauth` | -| Registry | `registry.digitalocean.com/ghostmesh-registry` | -| Image | `registry.digitalocean.com/ghostmesh-registry/atauth` | -| URL | `https://auth-staging.workingtitle.zip` | -| Apricot | `https://apricot.workingtitle.zip` | -| Storage | SQLite on `do-block-storage` PVC | -| Strategy | Recreate (RWO PVC -- cannot use RollingUpdate) | - -## Pre-Deployment Checklist - -- [ ] All tests pass locally (`cd gateway && npx vitest run`) -- [ ] TypeScript compiles clean (`npx tsc --noEmit`) -- [ ] ESLint passes (`npx eslint src/`) -- [ ] Database schema changes are backward compatible -- [ ] New environment variables documented - ---- - -## Automated Deployment (Gitea Actions) - -Push to `main` triggers the CI pipeline: - -1. **Test** -- typecheck + lint + vitest -2. **Build** -- Docker image with `--platform linux/amd64` -3. **Push** -- to DO registry with commit SHA + `latest` tags -4. **Deploy** -- `kubectl set image` + `kubectl rollout status` - -Workflow file: `.gitea/workflows/deploy.yml` - -**Known issue**: `needs` job scheduling can stall after test jobs complete. -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'" -``` - ---- - -## Manual Deployment (Fallback) - -### 1. Pre-deploy backup - -```bash -kubectl exec -n atauth deploy/atauth -- cp /app/data/gateway.db /app/data/gateway.db.backup -``` - -### 2. Build and push - -```bash -cd /path/to/atauth - -# Use unique tag (commit SHA or descriptive name) -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 -``` - -### 3. Deploy - -```bash -# Set the specific image tag (avoids k8s caching issues with :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 -``` - -### 4. Verify - -```bash -# Health check -curl -s https://auth-staging.workingtitle.zip/health | jq . - -# OIDC discovery -curl -s https://auth-staging.workingtitle.zip/.well-known/openid-configuration | jq . - -# Admin dashboard (requires auth) -curl -s -o /dev/null -w "%{http_code}" https://apricot.workingtitle.zip/admin/login - -# Check logs -kubectl -n atauth logs deploy/atauth --tail=20 -``` - ---- - -## Rollback - -### Quick Rollback - -```bash -kubectl -n atauth rollout undo deployment/atauth -kubectl -n atauth rollout status deployment/atauth -``` - -10 revision history is kept by default. - -### Rollback with Database Restore - -```bash -# Scale down -kubectl -n atauth scale deployment/atauth --replicas=0 - -# Restore from pre-deploy backup -kubectl -n atauth exec -it -- \ - cp /app/data/gateway.db.backup /app/data/gateway.db - -# Rollback image and scale up -kubectl -n atauth rollout undo deployment/atauth -kubectl -n atauth scale deployment/atauth --replicas=1 -``` - -### Restore from B2 Backup - -Automated backups run every 2 hours (CronJob in `backups` namespace, age-encrypted to Backblaze B2). - ---- - -## Monitoring During Deployment - -```bash -# Watch pod status -watch kubectl -n atauth get pods - -# Stream logs -kubectl -n atauth logs -f deployment/atauth - -# Check events -kubectl -n atauth get events --sort-by='.lastTimestamp' - -# Monitor health endpoint -watch -n 5 'curl -s https://auth-staging.workingtitle.zip/health | jq' -``` - -## Important Notes - -- **Image tag caching**: k8s nodes cache `:latest` with `imagePullPolicy: IfNotPresent`. Always use a unique tag (commit SHA or descriptive name) to ensure the new image is pulled. -- **RWO PVC**: Strategy must be `Recreate` since the SQLite PVC is ReadWriteOnce. RollingUpdate will deadlock when the new pod lands on a different node. -- **Domain**: Must use `workingtitle.zip`, NOT `arcnode.xyz` (same-site header issue with PDS). diff --git a/gateway/src/routes/admin-dashboard.test.ts b/gateway/src/routes/admin-dashboard.test.ts index ae10e74..3844508 100644 --- a/gateway/src/routes/admin-dashboard.test.ts +++ b/gateway/src/routes/admin-dashboard.test.ts @@ -40,7 +40,7 @@ describe('Dashboard Overview', () => { }); it('should render overview page with stats', async () => { - db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); const res = await request(app) .get('/admin/dashboard') @@ -87,7 +87,7 @@ describe('Dashboard Origins', () => { }); it('should list existing origins', async () => { - db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); const res = await request(app) .get('/admin/dashboard/origins') @@ -95,7 +95,7 @@ describe('Dashboard Origins', () => { expect(res.status).toBe(200); expect(res.text).toContain('SearXNG'); - expect(res.text).toContain('search.arcnode.xyz'); + expect(res.text).toContain('search.example.com'); }); it('should add an origin via form POST', async () => { @@ -135,7 +135,7 @@ describe('Dashboard Origins', () => { }); it('should delete an origin via form POST', async () => { - const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + const origin = db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); const page = await request(app) .get('/admin/dashboard/origins') @@ -193,14 +193,14 @@ describe('Dashboard Access Rules', () => { origin_id: '', rule_type: 'allow', subject_type: 'handle_pattern', - subject_value: '*.arcnode.xyz', + subject_value: '*.example.com', description: 'PDS users', }); expect(res.status).toBe(302); const rules = db.listProxyAccessRules(); expect(rules).toHaveLength(1); - expect(rules[0].subject_value).toBe('*.arcnode.xyz'); + expect(rules[0].subject_value).toBe('*.example.com'); }); it('should display existing rules', async () => { @@ -208,7 +208,7 @@ describe('Dashboard Access Rules', () => { origin_id: null, rule_type: 'allow', subject_type: 'handle_pattern', - subject_value: '*.arcnode.xyz', + subject_value: '*.example.com', description: 'PDS users', }); @@ -216,7 +216,7 @@ describe('Dashboard Access Rules', () => { .get('/admin/dashboard/access') .set('Cookie', adminCookie()); - expect(res.text).toContain('*.arcnode.xyz'); + expect(res.text).toContain('*.example.com'); expect(res.text).toContain('PDS users'); expect(res.text).toContain('allow'); }); @@ -313,12 +313,12 @@ describe('Dashboard Access Check', () => { }); it('should show access check result', async () => { - const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + const origin = db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); db.createProxyAccessRule({ origin_id: null, rule_type: 'allow', subject_type: 'handle_pattern', - subject_value: '*.arcnode.xyz', + subject_value: '*.example.com', description: null, }); @@ -335,7 +335,7 @@ describe('Dashboard Access Check', () => { .send({ _csrf: csrf, did: 'did:plc:test123', - handle: 'bkb.arcnode.xyz', + handle: 'alice.example.com', origin_id: origin.id.toString(), }); @@ -344,12 +344,12 @@ describe('Dashboard Access Check', () => { }); it('should show denied result', async () => { - const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + const origin = db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); db.createProxyAccessRule({ origin_id: null, rule_type: 'allow', subject_type: 'handle_pattern', - subject_value: '*.arcnode.xyz', + subject_value: '*.example.com', description: null, }); @@ -895,7 +895,7 @@ describe('Dashboard Proxy Wizard', () => { .send({ _csrf: csrf, name: 'SearXNG', - origin: 'https://search.arcnode.xyz', + origin: 'https://search.example.com', }); expect(res.status).toBe(302); @@ -909,7 +909,7 @@ describe('Dashboard Proxy Wizard', () => { 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') + .get('/admin/dashboard/proxy-wizard/result?origin=https://search.example.com&name=SearXNG&origin_id=1') .set('Cookie', adminCookie()); expect(res.status).toBe(200); diff --git a/gateway/src/routes/admin.ts b/gateway/src/routes/admin.ts index bca1dd7..b36717e 100644 --- a/gateway/src/routes/admin.ts +++ b/gateway/src/routes/admin.ts @@ -635,7 +635,7 @@ export function createAdminRoutes( * Add an allowed origin for forward-auth * * Body: - * - origin: Full origin URL (e.g. "https://search.arcnode.xyz") + * - origin: Full origin URL (e.g. "https://search.example.com") * - name: Display name (e.g. "SearXNG") */ router.post('/proxy/origins', requireAdmin, async (req: Request, res: Response) => { @@ -725,7 +725,7 @@ export function createAdminRoutes( * - origin_id: number | null (null = global rule) * - rule_type: "allow" | "deny" * - subject_type: "did" | "handle_pattern" - * - subject_value: string (DID or pattern like "*.arcnode.xyz") + * - subject_value: string (DID or pattern like "*.example.com") * - description: string (optional label) */ router.post('/proxy/access', requireAdmin, async (req: Request, res: Response) => { diff --git a/gateway/src/routes/proxy-auth.test.ts b/gateway/src/routes/proxy-auth.test.ts index 91105e2..ed9f425 100644 --- a/gateway/src/routes/proxy-auth.test.ts +++ b/gateway/src/routes/proxy-auth.test.ts @@ -118,14 +118,14 @@ describe('GET /auth/verify', () => { const session = createTestSession(db); const ticket = createAuthTicket( session.id, session.did, session.handle, - 'https://search.arcnode.xyz', TEST_SECRET, + 'https://search.example.com', TEST_SECRET, ); const res = await request(app) .get('/auth/verify') - .set('X-Original-URL', `https://search.arcnode.xyz/path?_atauth_ticket=${ticket}`) + .set('X-Original-URL', `https://search.example.com/path?_atauth_ticket=${ticket}`) .set('X-Forwarded-Proto', 'https') - .set('X-Forwarded-Host', 'search.arcnode.xyz'); + .set('X-Forwarded-Host', 'search.example.com'); expect(res.status).toBe(200); expect(res.headers['x-auth-did']).toBe(session.did); @@ -165,7 +165,7 @@ describe('GET /auth/proxy/login', () => { it('should redirect with ticket for existing session (silent SSO)', async () => { // Add allowed origin - db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); // Create session and cookie const session = createTestSession(db); @@ -173,23 +173,23 @@ describe('GET /auth/proxy/login', () => { const res = await request(app) .get('/auth/proxy/login') - .query({ rd: 'https://search.arcnode.xyz/path' }) + .query({ rd: 'https://search.example.com/path' }) .set('Cookie', `${SESSION_COOKIE_NAME}=${sessionCookie}`); expect(res.status).toBe(302); - expect(res.headers.location).toContain('search.arcnode.xyz'); + expect(res.headers.location).toContain('search.example.com'); expect(res.headers.location).toContain('_atauth_ticket='); }); it('should deny silent SSO when access rules block user', async () => { - db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); - // Only allow arcnode.xyz handles + // Only allow example.com handles db.createProxyAccessRule({ origin_id: null, rule_type: 'allow', subject_type: 'handle_pattern', - subject_value: '*.arcnode.xyz', + subject_value: '*.example.com', description: null, }); @@ -198,7 +198,7 @@ describe('GET /auth/proxy/login', () => { const res = await request(app) .get('/auth/proxy/login') - .query({ rd: 'https://search.arcnode.xyz/path' }) + .query({ rd: 'https://search.example.com/path' }) .set('Cookie', `${SESSION_COOKIE_NAME}=${sessionCookie}`); expect(res.status).toBe(403); @@ -206,14 +206,14 @@ describe('GET /auth/proxy/login', () => { }); it('should allow silent SSO when no access rules exist (open mode)', async () => { - db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); const session = createTestSession(db); const sessionCookie = createSessionCookie(session.id, TEST_SECRET, 604800); const res = await request(app) .get('/auth/proxy/login') - .query({ rd: 'https://search.arcnode.xyz/path' }) + .query({ rd: 'https://search.example.com/path' }) .set('Cookie', `${SESSION_COOKIE_NAME}=${sessionCookie}`); expect(res.status).toBe(302); @@ -221,11 +221,11 @@ describe('GET /auth/proxy/login', () => { }); it('should render login page when no session exists', async () => { - db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); const res = await request(app) .get('/auth/proxy/login') - .query({ rd: 'https://search.arcnode.xyz/path' }); + .query({ rd: 'https://search.example.com/path' }); expect(res.status).toBe(200); expect(res.text).toContain('Sign in to continue'); @@ -318,7 +318,7 @@ describe('POST /auth/proxy/login', () => { const now = Math.floor(Date.now() / 1000); db.saveProxyAuthRequest({ id: 'expired-req', - redirect_uri: 'https://search.arcnode.xyz/', + redirect_uri: 'https://search.example.com/', created_at: now - 700, expires_at: now - 100, }); @@ -335,7 +335,7 @@ describe('POST /auth/proxy/login', () => { const now = Math.floor(Date.now() / 1000); db.saveProxyAuthRequest({ id: 'valid-req', - redirect_uri: 'https://search.arcnode.xyz/', + redirect_uri: 'https://search.example.com/', created_at: now, expires_at: now + 600, }); @@ -363,7 +363,7 @@ describe('POST /auth/proxy/login', () => { const now = Math.floor(Date.now() / 1000); db.saveProxyAuthRequest({ id: 'req1', - redirect_uri: 'https://search.arcnode.xyz/', + redirect_uri: 'https://search.example.com/', created_at: now, expires_at: now + 600, }); @@ -388,7 +388,7 @@ describe('POST /auth/proxy/login', () => { const now = Math.floor(Date.now() / 1000); db.saveProxyAuthRequest({ id: 'req2', - redirect_uri: 'https://search.arcnode.xyz/', + redirect_uri: 'https://search.example.com/', created_at: now, expires_at: now + 600, }); @@ -413,7 +413,7 @@ describe('POST /auth/proxy/login', () => { const now = Math.floor(Date.now() / 1000); db.saveProxyAuthRequest({ id: 'req3', - redirect_uri: 'https://search.arcnode.xyz/', + redirect_uri: 'https://search.example.com/', created_at: now, expires_at: now + 600, }); @@ -432,7 +432,7 @@ describe('POST /auth/proxy/login', () => { const now = Math.floor(Date.now() / 1000); db.saveProxyAuthRequest({ id: 'req4', - redirect_uri: 'https://search.arcnode.xyz/', + redirect_uri: 'https://search.example.com/', created_at: now, expires_at: now + 600, }); @@ -493,12 +493,12 @@ describe('GET /auth/proxy/callback', () => { const now = Math.floor(Date.now() / 1000); // Register the allowed origin (required for access check) - db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); // Set up proxy auth request db.saveProxyAuthRequest({ id: 'auth-req-1', - redirect_uri: 'https://search.arcnode.xyz/path?q=test', + redirect_uri: 'https://search.example.com/path?q=test', created_at: now, expires_at: now + 600, }); @@ -508,7 +508,7 @@ describe('GET /auth/proxy/callback', () => { state: 'oauth-state-1', code_verifier: 'auth-req-1', app_id: 'proxy-auth', - redirect_uri: 'https://search.arcnode.xyz/path?q=test', + redirect_uri: 'https://search.example.com/path?q=test', created_at: now, }); @@ -523,7 +523,7 @@ describe('GET /auth/proxy/callback', () => { // Should redirect back to original URL with ticket expect(res.status).toBe(302); - expect(res.headers.location).toContain('search.arcnode.xyz'); + expect(res.headers.location).toContain('search.example.com'); expect(res.headers.location).toContain('_atauth_ticket='); // Should set session cookie on ATAuth domain @@ -556,7 +556,7 @@ describe('GET /auth/proxy/callback', () => { state: 'orphan-state', code_verifier: 'missing-auth-req', app_id: 'proxy-auth', - redirect_uri: 'https://search.arcnode.xyz/', + redirect_uri: 'https://search.example.com/', created_at: now, }); @@ -578,7 +578,7 @@ describe('GET /auth/proxy/callback', () => { db.saveProxyAuthRequest({ id: 'auth-req-err', - redirect_uri: 'https://search.arcnode.xyz/', + redirect_uri: 'https://search.example.com/', created_at: now, expires_at: now + 600, }); @@ -587,7 +587,7 @@ describe('GET /auth/proxy/callback', () => { state: 'error-state', code_verifier: 'auth-req-err', app_id: 'proxy-auth', - redirect_uri: 'https://search.arcnode.xyz/', + redirect_uri: 'https://search.example.com/', created_at: now, }); diff --git a/gateway/src/services/database.proxy.test.ts b/gateway/src/services/database.proxy.test.ts index e8426cc..aed64ac 100644 --- a/gateway/src/services/database.proxy.test.ts +++ b/gateway/src/services/database.proxy.test.ts @@ -135,8 +135,8 @@ describe('Proxy Allowed Origins', () => { }); it('should add and list allowed origins', () => { - db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); - db.addProxyAllowedOrigin('https://element.arcnode.xyz', 'Element'); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); + db.addProxyAllowedOrigin('https://chat.example.com', 'Element'); const origins = db.listProxyAllowedOrigins(); expect(origins).toHaveLength(2); @@ -154,22 +154,22 @@ describe('Proxy Allowed Origins', () => { }); it('should reject duplicate origins', () => { - db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); expect(() => { - db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG Copy'); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG Copy'); }).toThrow(/UNIQUE/); }); it('should remove an allowed origin', () => { - const created = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + const created = db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); db.removeProxyAllowedOrigin(created.id); expect(db.listProxyAllowedOrigins()).toHaveLength(0); }); it('should check if origin is allowed', () => { - db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); - expect(db.isProxyOriginAllowed('https://search.arcnode.xyz')).toBe(true); + expect(db.isProxyOriginAllowed('https://search.example.com')).toBe(true); expect(db.isProxyOriginAllowed('https://evil.example.com')).toBe(false); }); }); @@ -189,7 +189,7 @@ describe('Proxy Auth Requests', () => { const now = Math.floor(Date.now() / 1000); const req = { id: 'auth-req-123', - redirect_uri: 'https://search.arcnode.xyz/path', + redirect_uri: 'https://search.example.com/path', created_at: now, expires_at: now + 600, }; @@ -197,7 +197,7 @@ describe('Proxy Auth Requests', () => { const retrieved = db.getProxyAuthRequest('auth-req-123'); expect(retrieved).not.toBeNull(); - expect(retrieved!.redirect_uri).toBe('https://search.arcnode.xyz/path'); + expect(retrieved!.redirect_uri).toBe('https://search.example.com/path'); expect(retrieved!.expires_at).toBe(now + 600); }); @@ -256,13 +256,13 @@ describe('Proxy Access Rules', () => { origin_id: null, rule_type: 'allow', subject_type: 'handle_pattern', - subject_value: '*.arcnode.xyz', + subject_value: '*.example.com', description: 'PDS users', }); expect(rule.id).toBeGreaterThan(0); expect(rule.rule_type).toBe('allow'); - expect(rule.subject_value).toBe('*.arcnode.xyz'); + expect(rule.subject_value).toBe('*.example.com'); const rules = db.listProxyAccessRules(); expect(rules).toHaveLength(1); @@ -283,7 +283,7 @@ describe('Proxy Access Rules', () => { }); it('should filter rules by origin_id', () => { - const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + const origin = db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); db.createProxyAccessRule({ origin_id: origin.id, @@ -297,7 +297,7 @@ describe('Proxy Access Rules', () => { origin_id: null, rule_type: 'allow', subject_type: 'handle_pattern', - subject_value: '*.arcnode.xyz', + subject_value: '*.example.com', description: 'Global rule', }); @@ -311,13 +311,13 @@ describe('Proxy Access Rules', () => { }); it('should partition rules for access check', () => { - const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + const origin = db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); db.createProxyAccessRule({ origin_id: origin.id, rule_type: 'allow', subject_type: 'handle_pattern', - subject_value: '*.arcnode.xyz', + subject_value: '*.example.com', description: null, }); db.createProxyAccessRule({ @@ -339,13 +339,13 @@ describe('Proxy Access Rules', () => { expect(result.denyRules).toHaveLength(1); expect(result.denyRules[0].subject_value).toBe('did:plc:banned'); expect(result.originAllowRules).toHaveLength(1); - expect(result.originAllowRules[0].subject_value).toBe('*.arcnode.xyz'); + expect(result.originAllowRules[0].subject_value).toBe('*.example.com'); expect(result.globalAllowRules).toHaveLength(1); expect(result.globalAllowRules[0].subject_value).toBe('*'); }); it('should cascade delete rules when origin is removed', () => { - const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + const origin = db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); db.createProxyAccessRule({ origin_id: origin.id, @@ -371,8 +371,8 @@ describe('Proxy Access Rules', () => { }); it('should look up origin ID by origin URL', () => { - const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); - expect(db.getOriginIdByOrigin('https://search.arcnode.xyz')).toBe(origin.id); + const origin = db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); + expect(db.getOriginIdByOrigin('https://search.example.com')).toBe(origin.id); expect(db.getOriginIdByOrigin('https://nonexistent.example.com')).toBeNull(); }); }); diff --git a/gateway/src/types/proxy.ts b/gateway/src/types/proxy.ts index a9d3196..bf75cf5 100644 --- a/gateway/src/types/proxy.ts +++ b/gateway/src/types/proxy.ts @@ -20,7 +20,7 @@ export interface ProxySession { /** An origin allowed to use forward-auth */ export interface ProxyAllowedOrigin { id: number; - origin: string; // e.g. "https://search.arcnode.xyz" + origin: string; // e.g. "https://search.example.com" name: string; // e.g. "SearXNG" created_at: number; } diff --git a/gateway/src/utils/access-check.test.ts b/gateway/src/utils/access-check.test.ts index e9055c7..ca39efa 100644 --- a/gateway/src/utils/access-check.test.ts +++ b/gateway/src/utils/access-check.test.ts @@ -24,21 +24,21 @@ describe('matchHandlePattern', () => { }); it('should match suffix pattern "*.domain"', () => { - expect(matchHandlePattern('*.arcnode.xyz', 'bkb.arcnode.xyz')).toBe(true); - expect(matchHandlePattern('*.arcnode.xyz', 'other.arcnode.xyz')).toBe(true); + expect(matchHandlePattern('*.example.com', 'alice.example.com')).toBe(true); + expect(matchHandlePattern('*.example.com', 'other.example.com')).toBe(true); }); it('should not match suffix pattern against different domain', () => { - expect(matchHandlePattern('*.arcnode.xyz', 'user.bsky.social')).toBe(false); - expect(matchHandlePattern('*.arcnode.xyz', 'arcnode.xyz')).toBe(false); + expect(matchHandlePattern('*.example.com', 'user.bsky.social')).toBe(false); + expect(matchHandlePattern('*.example.com', 'example.com')).toBe(false); }); it('should match exact handle', () => { - expect(matchHandlePattern('bkb.arcnode.xyz', 'bkb.arcnode.xyz')).toBe(true); + expect(matchHandlePattern('alice.example.com', 'alice.example.com')).toBe(true); }); it('should not match different exact handle', () => { - expect(matchHandlePattern('bkb.arcnode.xyz', 'other.arcnode.xyz')).toBe(false); + expect(matchHandlePattern('alice.example.com', 'other.example.com')).toBe(false); }); it('should be case-sensitive', () => { -- knotmirror 0.0.1