diff --git a/docs/DO-HANDOFF.md b/docs/DO-HANDOFF.md new file mode 100644 index 0000000..6302c3c --- /dev/null +++ b/docs/DO-HANDOFF.md @@ -0,0 +1,242 @@ +# ATAuth - DigitalOcean Kubernetes Handoff + +This document provides everything you need to connect to and manage the ATAuth deployment on DigitalOcean managed Kubernetes. + +## Cluster Overview + +| Item | Value | +|------|-------| +| Provider | DigitalOcean Managed Kubernetes | +| Cluster | `do-nyc1-storm-dr-cluster` (nyc1 region) | +| Kubernetes | v1.34.1 | +| Namespace | `atauth` | +| Nodes | 3x worker (4 vCPU, 8GB RAM each) | +| Container registry | `registry.digitalocean.com/ghostmesh-registry` | + +## Connecting with kubectl + +You will receive a kubeconfig file (`atauth-team-kubeconfig.yaml`) separately. This kubeconfig is scoped to the `atauth` namespace only -- you cannot access other namespaces on the cluster. + +### Setup + +```bash +# Option 1: Set KUBECONFIG env var +export KUBECONFIG=/path/to/atauth-team-kubeconfig.yaml + +# Option 2: Merge into your default config +cp ~/.kube/config ~/.kube/config.backup +KUBECONFIG=~/.kube/config:/path/to/atauth-team-kubeconfig.yaml kubectl config view --merge --flatten > /tmp/merged && mv /tmp/merged ~/.kube/config +kubectl config use-context atauth-team@do-nyc1-storm-dr-cluster +``` + +### Verify + +```bash +kubectl get pods -n atauth +# Should show the atauth pod running + +kubectl get deployments -n atauth +# NAME READY UP-TO-DATE AVAILABLE +# atauth 1/1 1 1 +``` + +## What You Have Access To + +Your ServiceAccount (`atauth-team`) has full CRUD access within the `atauth` namespace: + +- Pods (including logs, exec, port-forward) +- Deployments and ReplicaSets +- Services +- Ingresses +- ConfigMaps +- Secrets +- PersistentVolumeClaims +- Events + +You do **not** have access to cluster-scoped resources or other namespaces. + +## Current Deployment Architecture + +``` + ┌──────────────────────┐ +Internet ──> nginx │ Ingress (nginx) │ + ingress ──┤ apricot.workingtitle │ + │ .zip │ + └──────────┬───────────┘ + │ + ┌──────────▼───────────┐ + │ Service (ClusterIP) │ + │ atauth:3100 │ + └──────────┬───────────┘ + │ + ┌──────────▼───────────┐ + │ Deployment │ + │ 1 replica, Recreate │ + │ strategy │ + └──────────┬───────────┘ + │ + ┌──────────▼───────────┐ + │ PVC (1Gi, RWO) │ + │ do-block-storage │ + │ SQLite DB at │ + │ /app/data/gateway.db │ + └──────────────────────┘ +``` + +### Key Resources + +| Resource | Name | Notes | +|----------|------|-------| +| Deployment | `atauth` | 1 replica, `Recreate` strategy (required -- RWO PVC) | +| Service | `atauth` | ClusterIP, port 3100 | +| Ingress | `atauth` | Host: `apricot.workingtitle.zip`, TLS via `atauth-tls` secret | +| PVC | `atauth-data` | 1Gi, `do-block-storage`, holds SQLite DB | +| ConfigMap | `atauth-config` | Non-sensitive env vars (CORS, OIDC issuer, WebAuthn, etc.) | +| Secret | `atauth-secrets` | `ATAUTH_ADMIN_TOKEN`, `ATAUTH_OIDC_KEY_SECRET`, `ATAUTH_FORWARD_AUTH_SESSION_SECRET` | +| Secret | `atauth-tls` | TLS cert/key for the ingress | +| Secret | `registry-ghostmesh-registry` | Docker registry pull credentials | + +### Container Image + +- Registry: `registry.digitalocean.com/ghostmesh-registry/atauth` +- Tags: short git SHA (e.g., `43d2471`) + `latest` +- Platform: `linux/amd64` (required -- DO nodes are amd64) +- Dockerfile: `gateway/Dockerfile` + +### Environment Variables + +Sourced from `atauth-config` ConfigMap: + +| Env Var | ConfigMap Key | Description | +|---------|---------------|-------------| +| `OAUTH_CLIENT_ID` | `ATAUTH_OAUTH_CLIENT_ID` | AT Protocol OAuth client metadata URL | +| `OAUTH_REDIRECT_URI` | `ATAUTH_OAUTH_REDIRECT_URI` | OAuth callback URL | +| `CORS_ORIGINS` | `ATAUTH_CORS_ORIGINS` | Comma-separated allowed origins | +| `OIDC_ENABLED` | `ATAUTH_OIDC_ENABLED` | Enable OIDC provider | +| `OIDC_ISSUER` | `ATAUTH_OIDC_ISSUER` | OIDC issuer URL | +| `FORWARD_AUTH_ENABLED` | `ATAUTH_FORWARD_AUTH_ENABLED` | Enable nginx forward-auth proxy | +| `FORWARD_AUTH_SESSION_TTL` | `ATAUTH_FORWARD_AUTH_SESSION_TTL` | Session TTL in seconds | +| `FORWARD_AUTH_PROXY_COOKIE_TTL` | `ATAUTH_FORWARD_AUTH_PROXY_COOKIE_TTL` | Proxy cookie TTL in seconds | +| `MFA_ENABLED` | `ATAUTH_MFA_ENABLED` | Enable passkey/WebAuthn MFA | +| `WEBAUTHN_RP_ID` | `ATAUTH_WEBAUTHN_RP_ID` | WebAuthn relying party ID | +| `WEBAUTHN_ORIGIN` | `ATAUTH_WEBAUTHN_ORIGIN` | WebAuthn origin URL | +| `WEBAUTHN_RP_NAME` | `ATAUTH_WEBAUTHN_RP_NAME` | WebAuthn relying party display name | + +Sourced from `atauth-secrets` Secret: + +| Env Var | Secret Key | Description | +|---------|------------|-------------| +| `ADMIN_TOKEN` | `ATAUTH_ADMIN_TOKEN` | Admin API bearer token | +| `OIDC_KEY_SECRET` | `ATAUTH_OIDC_KEY_SECRET` | OIDC signing key secret | +| `FORWARD_AUTH_SESSION_SECRET` | `ATAUTH_FORWARD_AUTH_SESSION_SECRET` | Session encryption secret | + +Hardcoded in deployment spec: + +| Env Var | Value | +|---------|-------| +| `PORT` | `3100` | +| `HOST` | `0.0.0.0` | +| `NODE_ENV` | `production` | +| `DB_PATH` | `/app/data/gateway.db` | + +## Common Operations + +### View logs + +```bash +kubectl logs -n atauth deployment/atauth -f +``` + +### Restart the deployment + +```bash +kubectl rollout restart deployment/atauth -n atauth +kubectl rollout status deployment/atauth -n atauth +``` + +### Deploy a new image + +```bash +kubectl set image deployment/atauth atauth=registry.digitalocean.com/ghostmesh-registry/atauth: -n atauth +kubectl rollout status deployment/atauth -n atauth --timeout=120s +``` + +### Update config + +```bash +# Edit a ConfigMap value +kubectl patch configmap atauth-config -n atauth --type merge \ + -p '{"data":{"ATAUTH_CORS_ORIGINS":"https://apricot.workingtitle.zip,https://new-app.example.com"}}' + +# Restart to pick up changes (env vars are read at startup) +kubectl rollout restart deployment/atauth -n atauth +``` + +### Update secrets + +```bash +# Update a secret value (must be base64 encoded) +kubectl patch secret atauth-secrets -n atauth --type merge \ + -p '{"data":{"ATAUTH_ADMIN_TOKEN":"'$(echo -n "new-token-value" | base64)'"}}' + +kubectl rollout restart deployment/atauth -n atauth +``` + +### Port-forward for local access + +```bash +kubectl port-forward -n atauth svc/atauth 3100:3100 +# ATAuth is now accessible at http://localhost:3100 +``` + +### Shell into the container + +```bash +kubectl exec -it -n atauth deployment/atauth -- /bin/sh +``` + +### Check health + +```bash +# Via port-forward +curl http://localhost:3100/health + +# Or check readiness probe status +kubectl describe pod -n atauth -l app=atauth | grep -A5 "Conditions:" +``` + +## CI/CD Pipeline + +The existing Gitea Actions workflow (`.gitea/workflows/deploy.yml`) runs on push to `main`: + +1. **Test job** (`ubuntu-latest` runner): `npm ci`, typecheck, lint, vitest +2. **Build and deploy job** (`host` runner): Docker build, push to DO registry, `kubectl set image`, rollout status + +The pipeline uses these Gitea org-level secrets: +- `DO_REGISTRY_TOKEN` -- DO container registry API token +- `DO_KUBECONFIG` -- base64-encoded kubeconfig for the `ci-deployer` ServiceAccount + +## Important Gotchas + +1. **Recreate strategy is required.** The PVC is RWO (ReadWriteOnce). RollingUpdate will deadlock if the new pod lands on a different node than the old one. + +2. **Domain must be `workingtitle.zip`, not `arcnode.xyz`.** The `arcnode.xyz` domain hosts a PDS (AT Protocol Personal Data Server). Using it for ATAuth causes same-site cookie/header conflicts. + +3. **Docker images must target `linux/amd64`.** The DO worker nodes run amd64. ARM images will crash with exec format errors. + +4. **HMAC token encoding.** ATAuth signs HMAC tokens with `createHmac('sha256', secretString)` using the UTF-8 encoding of the hex secret. Downstream services verifying tokens must match this encoding. + +5. **`req.accepts('json')` matches `*/*`.** Use `req.is('json')` for Content-Type checks in Express routes. + +6. **Registry pull secret must exist in namespace.** The `registry-ghostmesh-registry` secret is already present. If you recreate the namespace, you must re-add it or image pulls will fail. + +7. **SQLite WAL mode.** The DB runs in WAL mode on DO block storage. Single-writer only -- do not scale beyond 1 replica. + +## Live URLs + +| URL | Description | +|-----|-------------| +| `https://apricot.workingtitle.zip` | ATAuth gateway (public) | +| `https://apricot.workingtitle.zip/.well-known/openid-configuration` | OIDC discovery | +| `https://apricot.workingtitle.zip/admin/login` | Admin dashboard | +| `https://apricot.workingtitle.zip/health` | Health check endpoint | -- knotmirror 0.0.1 From cf9ed149b3e7b27939e6ee9327c9d8aaf269373a Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Sun, 1 Mar 2026 11:53:05 -0600 Subject: [PATCH 2/7] feat: add /version endpoint and deployment docs Add GET /version returning service name, package version, and build commit SHA. Pass BUILD_COMMIT as Docker build arg through CI pipeline. Include deployment inventory and RCA docs from infrastructure consolidation (single K8s atauth instance). --- .gitea/workflows/deploy.yml | 1 + docs/DEPLOYMENT-INVENTORY.md | 111 +++++++++++++++++ docs/RCA-2026-03-01-dual-atauth-instances.md | 121 +++++++++++++++++++ gateway/Dockerfile | 4 + gateway/src/index.ts | 11 ++ 5 files changed, 248 insertions(+) create mode 100644 docs/DEPLOYMENT-INVENTORY.md create mode 100644 docs/RCA-2026-03-01-dual-atauth-instances.md diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8952c6d..aff1771 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -88,6 +88,7 @@ jobs: run: | docker build \ --platform linux/amd64 \ + --build-arg BUILD_COMMIT=${{ steps.sha.outputs.short }} \ -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short }} \ -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ gateway/ diff --git a/docs/DEPLOYMENT-INVENTORY.md b/docs/DEPLOYMENT-INVENTORY.md new file mode 100644 index 0000000..b3d9fe3 --- /dev/null +++ b/docs/DEPLOYMENT-INVENTORY.md @@ -0,0 +1,111 @@ +# ATAuth Deployment Inventory + +**Last updated**: 2026-03-01 + +## Single Instance (Consolidated) + +### K8s Instance (DigitalOcean) + +| Field | Value | +|-------|-------| +| **Public URL** | `https://apricot.workingtitle.zip` | +| **Internal** | `atauth.atauth.svc.cluster.local:3100` | +| **Version** | v2.2.0 | +| **Routing** | Cloudflare Tunnel → K8s service | +| **Image** | `registry.digitalocean.com/ghostmesh-registry/atauth` | +| **Data** | K8s PersistentVolume (SQLite) at `/app/data/gateway.db` | +| **CI/CD** | Gitea Actions → DO registry → kubectl (currently broken as of 2026-02-25) | +| **Kubectl** | `KUBECONFIG=~/.kube/atauth-kubeconfig.yaml kubectl --insecure-skip-tls-verify -n atauth` | +| **Admin Token** | In K8s Secret `atauth-secrets` key `ATAUTH_ADMIN_TOKEN` | + +**All apps use this instance.** + +### Decommissioned: PDS Server Instance (Hetzner) + +Stopped 2026-03-01 after consolidation. Data preserved at Docker volume `atauth_atauth-data` on pds-hetzner. + +| Field | Value | +|-------|-------| +| **Former URL** | `https://atauth.arcnode.xyz` (tunnel route still exists, returns 502) | +| **Data backup** | `/var/lib/docker/volumes/atauth_atauth-data/_data/gateway.db` | +| **Config** | `/opt/atauth/.env`, `/opt/atauth/docker-compose.yml` | + +## Registered Apps + +### Proxy Origins (forward-auth) + +| Origin | Name | Notes | +|--------|------|-------| +| `search.arcnode.xyz` | SearXNG | nginx auth_request → K8s atauth | +| `blog.arcnode.xyz` | Arcnode Blog | Redirects to proxy login, validates ticket server-side | + +### OIDC Clients + +| ID | Name | Redirect URI | +|----|------|-------------| +| `bits-console` | BITS Backup Console | `bits-console.cloudforest-basilisk.ts.net/auth/callback` | +| `gitea` | Gitea/Forgejo | `gitea.cloudforest-basilisk.ts.net/user/oauth2/atauth/callback` | +| `audiobookshelf` | Audiobookshelf | `audiobookshelf.cloudforest-basilisk.ts.net/auth/openid/callback` | +| `mydrawings` | MyDrawings | `rock.workingtitle.zip/api/auth/atproto/callback` | + +### Legacy HMAC Apps + +| ID | Name | Callback URL | +|----|------|-------------| +| `mspdesk` | MSP-Desk | `tickets.brooksitservices.com/auth/callback` | +| `mdeditor` | Markdown Editor | `md.bkb.cx/api/auth/callback` | + +## Critical Rules + +1. **Never reroute `apricot.workingtitle.zip`** — All apps depend on this single atauth instance. Rerouting the tunnel will break everything. + +2. **PDS nginx must set Sec-Fetch headers for `/oauth/authorize`** — Cloudflare Tunnel strips browser security headers. Config at `/pds/nginx/nginx.conf` on pds-hetzner. Required headers: + ```nginx + proxy_set_header Sec-Fetch-Site "cross-site"; + proxy_set_header Sec-Fetch-Mode "navigate"; + proxy_set_header Sec-Fetch-Dest "document"; + ``` + +3. **HMAC secrets are per-app** — Each registered app has its own HMAC secret. Token verification uses `createHmac('sha256', secretString)` with UTF-8 encoding of the hex secret. + +4. **SQLite single-writer** — Do NOT scale beyond 1 replica. PVC is RWO. + +## Cloudflare Tunnel Routes + +Tunnel: `k3s-pds-tunnel` (`600f069c-4c53-4a59-b1fd-57c5ad09c044`) + +| Hostname | Destination | Notes | +|----------|-------------|-------| +| `apricot.workingtitle.zip` | K8s service `atauth:3100` | Primary — DO NOT CHANGE | + +`atauth.arcnode.xyz` route and DNS CNAME removed 2026-03-01. + +## Kubectl Access + +```bash +# Save kubeconfig (shared separately, do not commit) +export KUBECONFIG=~/.kube/atauth-kubeconfig.yaml + +# Note: TLS verify fails with DO CA cert, use --insecure-skip-tls-verify +kubectl --insecure-skip-tls-verify -n atauth get pods +kubectl --insecure-skip-tls-verify -n atauth logs deployment/atauth -f +kubectl --insecure-skip-tls-verify -n atauth rollout restart deployment/atauth + +# Get admin token +kubectl --insecure-skip-tls-verify -n atauth get secret atauth-secrets \ + -o jsonpath='{.data.ATAUTH_ADMIN_TOKEN}' | base64 -d +``` + +See `docs/DO-HANDOFF.md` for full operations guide. + +## Cleanup TODO + +- [ ] Fix Gitea CI pipeline (broken since 2026-02-25) +- [x] Remove stale `atauth.arcnode.xyz` Cloudflare tunnel route + DNS (done 2026-03-01) +- [ ] Remove `/opt/atauth/` directory and Docker volumes from pds-hetzner +- [ ] Add `/version` endpoint to atauth for quick identification +- [ ] Add uptime monitoring +- [x] Consolidate to single K8s atauth instance (done 2026-03-01) +- [x] Decommission PDS atauth sidecar (done 2026-03-01) +- [x] Fix PDS nginx Sec-Fetch headers (done 2026-03-01) +- [x] Remove stale app registrations from PDS instance (done 2026-03-01) diff --git a/docs/RCA-2026-03-01-dual-atauth-instances.md b/docs/RCA-2026-03-01-dual-atauth-instances.md new file mode 100644 index 0000000..fa70d2b --- /dev/null +++ b/docs/RCA-2026-03-01-dual-atauth-instances.md @@ -0,0 +1,121 @@ +# Root Cause Analysis: Dual ATAuth Instances & PDS Server Version Drift (2026-03-01) + +## Incident + +Blog authentication flow (`blog.arcnode.xyz`) failed with "OAuth state not found or expired" after PDS OAuth completed successfully. Investigation revealed a second undocumented atauth instance on the PDS Hetzner server running v1.0.0 (3+ months behind production v2.2.0). Attempted fix temporarily broke authentication for all apps using the production K8s atauth. + +**Duration**: ~3 hours active debugging +**Impact**: +- Blog login broken (original issue) +- ~10 minute outage for all apps using K8s atauth (mspdesk, search, etc.) due to incorrect tunnel reroute +- PDS server running atauth v1.0.0 without passkey, proxy auth, OIDC, or admin dashboard features +**Severity**: High (P2) — partial auth outage, undocumented infrastructure drift + +## Timeline + +1. Blog login flow completed PDS OAuth but atauth returned "OAuth state not found" +2. Traced root cause: blog server-side called local atauth (`172.17.0.1:3100` on PDS server) for `/auth/init`, creating state locally. But PDS redirected browser to `apricot.workingtitle.zip/auth/callback` (K8s atauth) which had no knowledge of that state. +3. **Mistake**: Rerouted `apricot.workingtitle.zip` tunnel from K8s atauth to PDS server atauth — immediately broke all apps (mspdesk, search, etc.) that use K8s atauth with different HMAC secrets and app registrations. +4. Reverted tunnel within ~10 minutes. +5. **Fix**: Created `atauth.arcnode.xyz` as a separate public URL for the PDS server's atauth instance, keeping `apricot.workingtitle.zip` pointed at K8s. +6. Discovered PDS server was running atauth v1.0.0 (Dec 2025 image) while K8s was running v2.2.0 (Feb 2026). Built v2.2.0 from source and deployed. + +## Root Causes + +### 1. Undocumented second atauth instance + +The PDS Hetzner server (`46.224.33.19`) had atauth deployed at `/opt/atauth/` using Docker image `ghcr.io/cache8063/atauth-gateway:latest` (v1.0.0, built Dec 11 2025). This instance was: +- Not documented in any infrastructure docs +- Not tracked in the Cloudflare tunnel config +- Running a version 3+ months behind production +- Had its own separate SQLite database with its own app registrations and HMAC secrets +- Accessible only via Docker bridge IP (`172.17.0.1:3100`) — no public URL + +### 2. Mixed internal/external URL configuration + +The atauth instance on the PDS server had: +- `OAUTH_CLIENT_ID=https://apricot.workingtitle.zip/client-metadata.json` +- `OAUTH_REDIRECT_URI=https://apricot.workingtitle.zip/auth/callback` + +This meant it told the PDS "I am `apricot.workingtitle.zip`" but it was NOT `apricot.workingtitle.zip`. The PDS would redirect the browser to the real `apricot.workingtitle.zip` (K8s atauth) which had no knowledge of the state created by the PDS server's atauth. + +### 3. Stale container image — no CI/CD pipeline to PDS server + +The Gitea CI workflow builds images and pushes to `gitea.cloudforest-basilisk.ts.net/arcnode.xyz/atauth-gateway`. But: +- The PDS server was pulling from `ghcr.io/cache8063/atauth-gateway:latest` (different registry) +- No Watchtower or auto-update mechanism for atauth on the PDS server +- The Gitea CI has been failing since Feb 25 (runner issues), so even the Gitea registry image was stale (Dec 2025) +- Had to build v2.2.0 from source directly on the server + +### 4. Shared tunnel namespace without isolation + +Both atauth instances shared the same `apricot.workingtitle.zip` identity. Changing the tunnel route for one immediately broke the other because: +- Different app registrations (different HMAC secrets) +- Different session stores (SQLite on each) +- Different feature sets (v1.0.0 vs v2.2.0) + +## What Data Was At Risk + +Checked and confirmed all data survived: +- All 5 registered apps intact (mspdesk, apricot, md, mdeditor, arcnode-blog) +- All HMAC secrets preserved (64 bytes each) +- All sessions preserved (4 active) +- K8s atauth completely untouched +- Docker volume `atauth_atauth-data` used by both old and new containers (same project prefix) + +## Fix Applied + +1. Created `atauth.arcnode.xyz` DNS + tunnel route → PDS server atauth (`46.224.33.19:3100`) +2. Updated PDS server atauth env: + - `OAUTH_CLIENT_ID=https://atauth.arcnode.xyz/client-metadata.json` + - `OAUTH_REDIRECT_URI=https://atauth.arcnode.xyz/auth/callback` +3. Built and deployed atauth v2.2.0 from source on PDS server +4. Enabled passkey (RP ID: `atauth.arcnode.xyz`), forward-auth proxy +5. Updated blog to use atauth proxy login flow (no more client-side callback delay) +6. K8s atauth (`apricot.workingtitle.zip`) left completely untouched +7. Fixed PDS nginx `Sec-Fetch-Mode` header — PDS started enforcing this header on `/oauth/authorize`, and Cloudflare Tunnel strips it. Added `Sec-Fetch-Mode: navigate` and `Sec-Fetch-Dest: document` to the nginx proxy config alongside the existing `Sec-Fetch-Site: cross-site`. This was breaking both search.arcnode.xyz (K8s atauth) and blog.arcnode.xyz (PDS atauth) since both OAuth flows go through the same PDS. +8. Cleaned up stale PDS atauth app registrations — removed duplicate `mdeditor` (same product as `md`), stale `mspdesk` and `apricot` entries that only belong on K8s instance + +## Current State (Post-Fix) + +| Instance | URL | Version | Apps | +|----------|-----|---------|------| +| K8s (DO) | `apricot.workingtitle.zip` | v2.2.0 | search, mspdesk, others | +| PDS (Hetzner) | `atauth.arcnode.xyz` | v2.2.0 | arcnode-blog, md | + +**Note**: Consolidation target is the K8s instance (`apricot.workingtitle.zip`). Once kubeconfig access is available, migrate PDS instance apps to K8s and decommission the PDS sidecar. + +## Prevention + +### Immediate + +1. **Document all atauth instances** — Update infrastructure overview with both instances, their URLs, versions, and which apps use which +2. **Each instance MUST have its own unique public URL** — Never configure `OAUTH_CLIENT_ID`/`OAUTH_REDIRECT_URI` pointing to a different instance +3. **Add version endpoint** — `GET /version` returning build commit and version to quickly identify running versions + +### Short-term + +4. **Fix Gitea CI pipeline** — Runner has been broken since Feb 25. Fix it so images auto-build on push +5. **Add Watchtower or auto-pull** for atauth on PDS server to track Gitea registry +6. **Consolidate to single atauth instance** — Both instances have diverged data. Pick one as source of truth and migrate (see cleanup task below) + +### Long-term + +7. **Migrate atauth to dedicated instance** — Currently running as a sidecar on the PDS server. Should be its own container/VM with proper CI/CD, monitoring, and backup +8. **Infrastructure-as-code** — Tunnel routes, DNS, and service configs should be in version control, not manually configured +9. **Add health monitoring** — Alert if any atauth instance returns non-200 or version mismatch + +## Cleanup Task + +Migrate atauth to a dedicated instance off the PDS server. This was identified as necessary during this incident. Track in project TODO. + +## Files Changed + +- `/opt/atauth/.env` (PDS server) — Updated OAUTH_CLIENT_ID/REDIRECT_URI to atauth.arcnode.xyz +- `/opt/atauth/docker-compose.yml` (PDS server) — Switched from ghcr.io to locally built v2.2.0 image +- `/pds/nginx/nginx.conf` (PDS server) — Added `Sec-Fetch-Mode` and `Sec-Fetch-Dest` headers to `/oauth/authorize` location +- Cloudflare tunnel — Added `atauth.arcnode.xyz` ingress rule +- `arcnode-blog/src/lib/auth.ts` — Rewrote to use proxy auth flow +- `arcnode-blog/src/pages/write.astro` — Redirect to atauth proxy login instead of inline form +- `arcnode-blog/src/pages/api/publish.ts` — Use session cookies instead of HMAC token +- Deleted: `arcnode-blog/src/pages/api/login.ts`, `api/session.ts`, `auth/callback.astro` diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 4cd712a..3a43301 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -46,6 +46,10 @@ RUN mkdir -p /app/data && chown atauth:atauth /app/data # Switch to non-root user USER atauth +# Build metadata +ARG BUILD_COMMIT=unknown +ENV BUILD_COMMIT=${BUILD_COMMIT} + # Environment defaults ENV NODE_ENV=production \ PORT=3100 \ diff --git a/gateway/src/index.ts b/gateway/src/index.ts index a872137..a14f6aa 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -250,6 +250,17 @@ async function main(): Promise { }); }); + // Version endpoint + const pkgPath = path.join(process.cwd(), 'package.json'); + const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version; + app.get('/version', (_req, res) => { + res.json({ + service: 'atauth-gateway', + version: pkgVersion, + commit: process.env.BUILD_COMMIT || 'unknown', + }); + }); + // Forward-auth proxy routes MUST be mounted before the general /auth rate limiter. // /auth/verify is called by nginx auth_request on every subrequest from a single // pod IP, so per-IP rate limiting would block legitimate traffic. -- knotmirror 0.0.1 From 111b837929e0cbe3a3d49a22ef69a11f5a809b0f Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Sun, 1 Mar 2026 11:56:31 -0600 Subject: [PATCH 3/7] docs: mark CI pipeline and /version endpoint as done --- docs/DEPLOYMENT-INVENTORY.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/DEPLOYMENT-INVENTORY.md b/docs/DEPLOYMENT-INVENTORY.md index b3d9fe3..ffabaee 100644 --- a/docs/DEPLOYMENT-INVENTORY.md +++ b/docs/DEPLOYMENT-INVENTORY.md @@ -14,7 +14,7 @@ | **Routing** | Cloudflare Tunnel → K8s service | | **Image** | `registry.digitalocean.com/ghostmesh-registry/atauth` | | **Data** | K8s PersistentVolume (SQLite) at `/app/data/gateway.db` | -| **CI/CD** | Gitea Actions → DO registry → kubectl (currently broken as of 2026-02-25) | +| **CI/CD** | Gitea Actions → DO registry → kubectl (working, last deploy 2026-03-01) | | **Kubectl** | `KUBECONFIG=~/.kube/atauth-kubeconfig.yaml kubectl --insecure-skip-tls-verify -n atauth` | | **Admin Token** | In K8s Secret `atauth-secrets` key `ATAUTH_ADMIN_TOKEN` | @@ -100,10 +100,10 @@ See `docs/DO-HANDOFF.md` for full operations guide. ## Cleanup TODO -- [ ] Fix Gitea CI pipeline (broken since 2026-02-25) +- [x] Fix Gitea CI pipeline — was not actually broken, stale note (verified 2026-03-01) - [x] Remove stale `atauth.arcnode.xyz` Cloudflare tunnel route + DNS (done 2026-03-01) - [ ] Remove `/opt/atauth/` directory and Docker volumes from pds-hetzner -- [ ] Add `/version` endpoint to atauth for quick identification +- [x] Add `/version` endpoint to atauth for quick identification (done 2026-03-01, commit 28fd4cc) - [ ] Add uptime monitoring - [x] Consolidate to single K8s atauth instance (done 2026-03-01) - [x] Decommission PDS atauth sidecar (done 2026-03-01) -- knotmirror 0.0.1 From 957730e50952cbe932389f73fff233f63ccabf1c Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Mon, 9 Mar 2026 11:45:16 -0500 Subject: [PATCH 4/7] feat: passkey login for forward-auth proxy + security hardening Add passkey/WebAuthn support to the forward-auth proxy login flow, previously only available in the OIDC authorize flow: - New POST /auth/proxy/passkey endpoint - Passkey button + WebAuthn JS on proxy login page - 9 new tests for proxy passkey flow Security fixes from audit: - MFA verify endpoints require authentication (prevent unauthenticated brute-force) - Session expiry enforced in database query - OIDC logout redirect uses origin comparison (prevent open redirect) - PKCE plain method rejected, S256 uses constant-time comparison - Empty DID guard in token endpoint - Handle format validation in OIDC authorize - trust proxy configured for correct client IP - user_id NaN validation - Removed uuid package (use crypto.randomUUID()) --- gateway/package-lock.json | 24 +-- gateway/package.json | 4 +- gateway/src/index.ts | 5 +- gateway/src/routes/auth.ts | 15 +- gateway/src/routes/mfa.ts | 28 ++- gateway/src/routes/oidc/authorize.ts | 5 + gateway/src/routes/oidc/logout.ts | 12 +- gateway/src/routes/oidc/token.ts | 9 + gateway/src/routes/proxy-auth.test.ts | 247 ++++++++++++++++++++++++- gateway/src/routes/proxy-auth.ts | 236 ++++++++++++++++++++++- gateway/src/services/database.ts | 2 +- gateway/src/services/oidc/pkce.test.ts | 9 +- gateway/src/services/oidc/pkce.ts | 13 +- 13 files changed, 551 insertions(+), 58 deletions(-) diff --git a/gateway/package-lock.json b/gateway/package-lock.json index fea926f..b4d46a1 100644 --- a/gateway/package-lock.json +++ b/gateway/package-lock.json @@ -17,8 +17,7 @@ "helmet": "^8.0.0", "nodemailer": "^7.0.13", "otpauth": "^9.2.1", - "qrcode": "^1.5.3", - "uuid": "^9.0.0" + "qrcode": "^1.5.3" }, "devDependencies": { "@types/better-sqlite3": "^7.6.8", @@ -28,7 +27,6 @@ "@types/nodemailer": "^7.0.9", "@types/qrcode": "^1.5.6", "@types/supertest": "^6.0.3", - "@types/uuid": "^9.0.7", "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.0.0", "globals": "^15.0.0", @@ -1825,13 +1823,6 @@ "@types/superagent": "^8.1.0" } }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -5356,19 +5347,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/gateway/package.json b/gateway/package.json index 7695639..b5b8b59 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -39,8 +39,7 @@ "helmet": "^8.0.0", "nodemailer": "^7.0.13", "otpauth": "^9.2.1", - "qrcode": "^1.5.3", - "uuid": "^9.0.0" + "qrcode": "^1.5.3" }, "devDependencies": { "@types/better-sqlite3": "^7.6.8", @@ -50,7 +49,6 @@ "@types/nodemailer": "^7.0.9", "@types/qrcode": "^1.5.6", "@types/supertest": "^6.0.3", - "@types/uuid": "^9.0.7", "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.0.0", "globals": "^15.0.0", diff --git a/gateway/src/index.ts b/gateway/src/index.ts index a14f6aa..0864479 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -206,6 +206,9 @@ async function main(): Promise { // Create Express app const app = express(); + // Trust the first proxy (k8s ingress / cloudflared) for correct client IP in rate limiting + app.set('trust proxy', 1); + // Middleware // Generate a per-request nonce for inline scripts (CSP script-src) app.use((_req, res, next) => { @@ -265,7 +268,7 @@ async function main(): Promise { // /auth/verify is called by nginx auth_request on every subrequest from a single // pod IP, so per-IP rate limiting would block legitimate traffic. if (config.forwardAuth.enabled) { - const proxyRouter = createProxyAuthRoutes(db, oauth, { ...config.forwardAuth, sessionSecret: config.forwardAuth.sessionSecret! }, config.oidc.issuer); + const proxyRouter = createProxyAuthRoutes(db, oauth, { ...config.forwardAuth, sessionSecret: config.forwardAuth.sessionSecret! }, config.oidc.issuer, passkeyService); // Mount entire proxy router at /auth -- no rate limit on /auth/verify app.use('/auth', proxyRouter); console.log('Forward-auth proxy enabled'); diff --git a/gateway/src/routes/auth.ts b/gateway/src/routes/auth.ts index af966f1..0f0d483 100644 --- a/gateway/src/routes/auth.ts +++ b/gateway/src/routes/auth.ts @@ -6,7 +6,7 @@ */ import { Router, Request, Response } from 'express'; -import { v4 as uuidv4 } from 'uuid'; +import crypto from 'crypto'; import { OAuthService } from '../services/oauth.js'; import { DatabaseService } from '../services/database.js'; import { createGatewayToken } from '../utils/hmac.js'; @@ -170,7 +170,7 @@ export function createAuthRoutes( app.token_ttl_seconds ); - const sessionId = uuidv4(); + const sessionId = crypto.randomUUID(); const expiresAt = new Date(Date.now() + app.token_ttl_seconds * 1000); db.createSession({ @@ -241,10 +241,15 @@ export function createAuthRoutes( throw httpError.notFound('app_not_found', 'Application not found'); } + const parsedUserId = parseInt(user_id, 10); + if (!Number.isFinite(parsedUserId)) { + throw httpError.badRequest('invalid_user_id', 'user_id must be a valid integer'); + } + db.setUserMapping({ did: session.did, app_id, - user_id: parseInt(user_id, 10), + user_id: parsedUserId, handle: session.handle, }); @@ -252,7 +257,7 @@ export function createAuthRoutes( { did: session.did, handle: session.handle, - user_id: parseInt(user_id, 10), + user_id: parsedUserId, app_id, }, app.hmac_secret, @@ -264,7 +269,7 @@ export function createAuthRoutes( token, did: session.did, handle: session.handle, - user_id: parseInt(user_id, 10), + user_id: parsedUserId, }); }); diff --git a/gateway/src/routes/mfa.ts b/gateway/src/routes/mfa.ts index 6f7d2b3..89ded40 100644 --- a/gateway/src/routes/mfa.ts +++ b/gateway/src/routes/mfa.ts @@ -87,18 +87,24 @@ export function createMFARouter( /** * POST /auth/mfa/totp/verify * Verify TOTP code during login + * Requires authentication -- DID comes from the authenticated session, not the request body */ router.post('/totp/verify', async (req: Request, res: Response) => { try { - const { did, code, client_id, scope } = req.body as { - did: string; + const { did: authedDid } = await authenticateRequest(req, db, oidcService); + const { code, client_id, scope } = req.body as { code: string; client_id?: string; scope?: string; }; - if (!did || !code) { - throw new HttpError(400, 'invalid_request', 'Missing did or code'); + const did = authedDid; + if (!did) { + throw new HttpError(401, 'unauthorized', 'Authentication required'); + } + + if (!code) { + throw new HttpError(400, 'invalid_request', 'Missing code'); } const success = mfaService.verifyTOTP(did, code); @@ -217,18 +223,24 @@ export function createMFARouter( /** * POST /auth/mfa/backup-codes/verify * Verify a backup code during login + * Requires authentication -- DID comes from the authenticated session, not the request body */ router.post('/backup-codes/verify', async (req: Request, res: Response) => { try { - const { did, code, client_id, scope } = req.body as { - did: string; + const { did: authedDid } = await authenticateRequest(req, db, oidcService); + const { code, client_id, scope } = req.body as { code: string; client_id?: string; scope?: string; }; - if (!did || !code) { - throw new HttpError(400, 'invalid_request', 'Missing did or code'); + const did = authedDid; + if (!did) { + throw new HttpError(401, 'unauthorized', 'Authentication required'); + } + + if (!code) { + throw new HttpError(400, 'invalid_request', 'Missing code'); } const success = mfaService.verifyBackupCode(did, code); diff --git a/gateway/src/routes/oidc/authorize.ts b/gateway/src/routes/oidc/authorize.ts index 9ca7f38..14265e2 100644 --- a/gateway/src/routes/oidc/authorize.ts +++ b/gateway/src/routes/oidc/authorize.ts @@ -452,6 +452,11 @@ export function createAuthorizeRouter( sanitizedHandle = sanitizedHandle + '.bsky.social'; } + // Validate handle format + if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test(sanitizedHandle)) { + return res.status(400).json({ error: 'invalid_handle', message: 'Invalid handle format' }); + } + // Get the pending authorization const authData = db.getAuthorizationCode(auth_code); if (!authData) { diff --git a/gateway/src/routes/oidc/logout.ts b/gateway/src/routes/oidc/logout.ts index 348606a..5dd87e4 100644 --- a/gateway/src/routes/oidc/logout.ts +++ b/gateway/src/routes/oidc/logout.ts @@ -54,9 +54,15 @@ export function createLogoutRouter(db: DatabaseService, oidcService: OIDCService }); } - // Check if redirect URI is registered - // For logout, we could have a separate list, but for simplicity use redirect_uris - if (!client.redirect_uris.some((uri) => post_logout_redirect_uri.startsWith(uri.split('?')[0]))) { + // Check if redirect URI origin matches a registered redirect_uri origin + let redirectAllowed = false; + try { + const redirectOrigin = new URL(post_logout_redirect_uri).origin; + redirectAllowed = client.redirect_uris.some((uri) => { + try { return new URL(uri).origin === redirectOrigin; } catch { return false; } + }); + } catch { /* invalid URL */ } + if (!redirectAllowed) { return res.status(400).json({ error: 'invalid_request', error_description: 'Invalid post_logout_redirect_uri', diff --git a/gateway/src/routes/oidc/token.ts b/gateway/src/routes/oidc/token.ts index 959a5c8..ea8fc19 100644 --- a/gateway/src/routes/oidc/token.ts +++ b/gateway/src/routes/oidc/token.ts @@ -223,6 +223,15 @@ async function handleAuthorizationCodeGrant( } } + // Ensure the auth code has been populated with a user identity + if (!authCode.did) { + res.status(400).json({ + error: 'invalid_grant', + error_description: 'Authorization code has not been authenticated', + }); + return; + } + // Mark code as used db.markAuthorizationCodeUsed(code); diff --git a/gateway/src/routes/proxy-auth.test.ts b/gateway/src/routes/proxy-auth.test.ts index ed9f425..13b3b64 100644 --- a/gateway/src/routes/proxy-auth.test.ts +++ b/gateway/src/routes/proxy-auth.test.ts @@ -26,7 +26,7 @@ const forwardAuthConfig: ForwardAuthConfig = { proxyCookieTtl: 86400, }; -function createTestApp(db: DatabaseService) { +function createTestApp(db: DatabaseService, mockPasskey?: any) { const app = express(); app.use((_req, res, next) => { res.locals.cspNonce = crypto.randomBytes(16).toString('base64'); @@ -41,7 +41,7 @@ function createTestApp(db: DatabaseService) { handleCallback: vi.fn(), } as any; - const router = createProxyAuthRoutes(db, mockOAuth, forwardAuthConfig, TEST_ISSUER); + const router = createProxyAuthRoutes(db, mockOAuth, forwardAuthConfig, TEST_ISSUER, mockPasskey || null); app.use('/auth', router); return { app, mockOAuth }; } @@ -601,3 +601,246 @@ describe('GET /auth/proxy/callback', () => { expect(res.text).toContain('unexpected error'); }); }); + +const mockCredential = { + id: 'credential-id-123', + rawId: 'credential-id-123', + response: { + clientDataJSON: 'mock-client-data', + authenticatorData: 'mock-auth-data', + signature: 'mock-signature', + }, + type: 'public-key', +}; + +describe('POST /auth/proxy/passkey', () => { + let db: DatabaseService; + let app: express.Application; + let mockPasskey: { verifyAuthentication: ReturnType }; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + mockPasskey = { + verifyAuthentication: vi.fn(), + }; + ({ app } = createTestApp(db, mockPasskey)); + }); + + afterEach(() => { + db.close(); + }); + + it('should complete proxy flow via passkey authentication', async () => { + const now = Math.floor(Date.now() / 1000); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); + db.saveProxyAuthRequest({ + id: 'passkey-req-1', + redirect_uri: 'https://search.example.com/path', + created_at: now, + expires_at: now + 600, + }); + + mockPasskey.verifyAuthentication.mockResolvedValue({ + success: true, + did: 'did:plc:test123', + handle: 'user.bsky.social', + }); + + const res = await request(app) + .post('/auth/proxy/passkey') + .send({ + auth_request_id: 'passkey-req-1', + credential: mockCredential, + challenge: 'test-challenge', + }); + + expect(res.status).toBe(200); + expect(res.body.redirect_url).toContain('search.example.com'); + expect(res.body.redirect_url).toContain('_atauth_ticket='); + + // Session cookie should be set + expect(res.headers['set-cookie']).toBeDefined(); + expect(res.headers['set-cookie'][0]).toContain(SESSION_COOKIE_NAME); + + // Auth request should be cleaned up + expect(db.getProxyAuthRequest('passkey-req-1')).toBeNull(); + + // Proxy session should exist + const sessions = db.getAllProxySessions('did:plc:test123'); + expect(sessions).toHaveLength(1); + expect(sessions[0].handle).toBe('user.bsky.social'); + }); + + it('should return 400 for missing parameters', async () => { + const res = await request(app) + .post('/auth/proxy/passkey') + .send({ auth_request_id: 'test' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_request'); + }); + + it('should return 400 for invalid auth request', async () => { + const res = await request(app) + .post('/auth/proxy/passkey') + .send({ + auth_request_id: 'nonexistent', + credential: mockCredential, + challenge: 'test-challenge', + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_request'); + expect(res.body.error_description).toContain('expired or invalid'); + }); + + it('should return 400 for expired auth request', async () => { + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'expired-passkey-req', + redirect_uri: 'https://search.example.com/', + created_at: now - 700, + expires_at: now - 100, + }); + + const res = await request(app) + .post('/auth/proxy/passkey') + .send({ + auth_request_id: 'expired-passkey-req', + credential: mockCredential, + challenge: 'test-challenge', + }); + + expect(res.status).toBe(400); + expect(res.body.error_description).toContain('expired'); + }); + + it('should return 401 for failed passkey verification', async () => { + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'fail-passkey-req', + redirect_uri: 'https://search.example.com/', + created_at: now, + expires_at: now + 600, + }); + + mockPasskey.verifyAuthentication.mockResolvedValue({ + success: false, + error: 'Unknown credential', + }); + + const res = await request(app) + .post('/auth/proxy/passkey') + .send({ + auth_request_id: 'fail-passkey-req', + credential: mockCredential, + challenge: 'bad-challenge', + }); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('authentication_failed'); + expect(res.body.error_description).toContain('Unknown credential'); + }); + + it('should return 403 when access rules deny the user', async () => { + const now = Math.floor(Date.now() / 1000); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); + db.saveProxyAuthRequest({ + id: 'denied-passkey-req', + redirect_uri: 'https://search.example.com/', + created_at: now, + expires_at: now + 600, + }); + + // Only allow *.example.com handles + db.createProxyAccessRule({ + origin_id: null, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.example.com', + description: null, + }); + + mockPasskey.verifyAuthentication.mockResolvedValue({ + success: true, + did: 'did:plc:outsider', + handle: 'outsider.bsky.social', + }); + + const res = await request(app) + .post('/auth/proxy/passkey') + .send({ + auth_request_id: 'denied-passkey-req', + credential: mockCredential, + challenge: 'test-challenge', + }); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('access_denied'); + }); + + it('should return 404 when passkey service is not enabled', async () => { + const { app: appNoPasskey } = createTestApp(db); + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'no-passkey-req', + redirect_uri: 'https://search.example.com/', + created_at: now, + expires_at: now + 600, + }); + + const res = await request(appNoPasskey) + .post('/auth/proxy/passkey') + .send({ + auth_request_id: 'no-passkey-req', + credential: mockCredential, + challenge: 'test-challenge', + }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('not_found'); + }); +}); + +describe('Proxy login page passkey rendering', () => { + let db: DatabaseService; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + db.addProxyAllowedOrigin('https://search.example.com', 'SearXNG'); + }); + + afterEach(() => { + db.close(); + }); + + it('should render passkey button when passkey service is enabled', async () => { + const mockPasskey = { verifyAuthentication: vi.fn() }; + const { app } = createTestApp(db, mockPasskey); + + const res = await request(app) + .get('/auth/proxy/login') + .query({ rd: 'https://search.example.com/page' }); + + expect(res.status).toBe(200); + expect(res.text).toContain('passkeyBtn'); + expect(res.text).toContain('Sign in with passkey'); + expect(res.text).toContain('b64urlToBuffer'); + expect(res.text).toContain('/auth/proxy/passkey'); + }); + + it('should not render passkey button when passkey service is disabled', async () => { + const { app } = createTestApp(db); + + const res = await request(app) + .get('/auth/proxy/login') + .query({ rd: 'https://search.example.com/page' }); + + expect(res.status).toBe(200); + expect(res.text).not.toContain('passkeyBtn'); + expect(res.text).not.toContain('Sign in with passkey'); + // Handle form should still be present + expect(res.text).toContain('loginForm'); + expect(res.text).toContain('you.bsky.social'); + }); +}); diff --git a/gateway/src/routes/proxy-auth.ts b/gateway/src/routes/proxy-auth.ts index 3909056..a10744f 100644 --- a/gateway/src/routes/proxy-auth.ts +++ b/gateway/src/routes/proxy-auth.ts @@ -18,6 +18,7 @@ import { Router, Request, Response } from 'express'; import crypto from 'crypto'; import type { DatabaseService } from '../services/database.js'; import type { OAuthService } from '../services/oauth.js'; +import type { PasskeyService } from '../services/passkey.js'; import type { ForwardAuthConfig, AccessCheckResult } from '../types/proxy.js'; import { checkAccess } from '../utils/access-check.js'; import { @@ -39,6 +40,7 @@ export function createProxyAuthRoutes( oauthService: OAuthService, forwardAuthConfig: ForwardAuthConfig, oidcIssuer: string, + passkeyService: PasskeyService | null = null, ): Router { const router = Router(); const secret = forwardAuthConfig.sessionSecret; @@ -188,7 +190,7 @@ export function createProxyAuthRoutes( expires_at: now + 600, // 10 minutes }); - res.type('html').send(renderProxyLoginPage(authRequestId, res.locals.cspNonce)); + res.type('html').send(renderProxyLoginPage(authRequestId, res.locals.cspNonce, undefined, !!passkeyService)); }); // ===== POST /auth/proxy/login ===== @@ -281,11 +283,117 @@ export function createProxyAuthRoutes( if (isJsonRequest) { res.status(400).json({ error: userMessage }); } else { - res.status(400).type('html').send(renderProxyLoginPage(auth_request_id, res.locals.cspNonce, userMessage)); + res.status(400).type('html').send(renderProxyLoginPage(auth_request_id, res.locals.cspNonce, userMessage, !!passkeyService)); } } }); + // ===== POST /auth/proxy/passkey ===== + // Authenticate via passkey for the forward-auth flow. + router.post('/proxy/passkey', async (req: Request, res: Response) => { + try { + if (!passkeyService) { + return res.status(404).json({ + error: 'not_found', + error_description: 'Passkey authentication is not enabled', + }); + } + + const { auth_request_id, credential, challenge } = req.body; + + if (!auth_request_id || !credential || !challenge) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing required parameters: auth_request_id, credential, challenge', + }); + } + + // Validate the pending auth request + const authRequest = db.getProxyAuthRequest(auth_request_id); + if (!authRequest) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Login request expired or invalid', + }); + } + + if (authRequest.expires_at < Math.floor(Date.now() / 1000)) { + db.deleteProxyAuthRequest(auth_request_id); + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Login request expired', + }); + } + + // Verify the passkey + const result = await passkeyService.verifyAuthentication(credential, challenge); + + if (!result.success || !result.did || !result.handle) { + return res.status(401).json({ + error: 'authentication_failed', + error_description: result.error || 'Passkey authentication failed', + }); + } + + console.log(`[Proxy Passkey] Authenticated user: ${result.handle} (${result.did})`); + + // Check access rules before creating session + const accessResult = enforceAccess(result.did, result.handle, authRequest.redirect_uri); + if (!accessResult.allowed) { + db.deleteProxyAuthRequest(auth_request_id); + return res.status(403).json({ + error: 'access_denied', + error_description: 'You do not have access to this service', + }); + } + + // Create a proxy session + const now = Math.floor(Date.now() / 1000); + const sessionId = crypto.randomBytes(32).toString('base64url'); + db.createProxySession({ + id: sessionId, + did: result.did, + handle: result.handle, + created_at: now, + expires_at: now + forwardAuthConfig.sessionTtl, + last_activity: now, + user_agent: req.headers['user-agent'], + ip_address: req.headers['x-forwarded-for'] as string || req.ip, + }); + + // Set the ATAuth session cookie + const sessionCookieValue = createSessionCookie(sessionId, secret, forwardAuthConfig.sessionTtl); + res.setHeader('Set-Cookie', + `${SESSION_COOKIE_NAME}=${sessionCookieValue}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${forwardAuthConfig.sessionTtl}`, + ); + + // Generate auth ticket for the redirect target + const targetOrigin = extractOrigin(authRequest.redirect_uri); + if (!targetOrigin) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Invalid redirect URI', + }); + } + + const ticket = createAuthTicket(sessionId, result.did, result.handle, targetOrigin, secret); + + // Clean up + db.deleteProxyAuthRequest(auth_request_id); + + // Redirect back to the original URL with ticket + const redirectUrl = new URL(authRequest.redirect_uri); + redirectUrl.searchParams.set('_atauth_ticket', ticket); + res.json({ redirect_url: redirectUrl.toString() }); + } catch (error) { + console.error('[Proxy Passkey] Error:', error); + res.status(500).json({ + error: 'server_error', + error_description: 'Internal server error', + }); + } + }); + // ===== GET /auth/proxy/callback ===== // AT Protocol OAuth callback for the forward-auth flow. router.get('/proxy/callback', async (req: Request, res: Response) => { @@ -443,7 +551,7 @@ function escapeHtml(str: string): string { .replace(/'/g, '''); } -function renderProxyLoginPage(authRequestId: string, nonce?: string, errorMessage?: string): string { +function renderProxyLoginPage(authRequestId: string, nonce?: string, errorMessage?: string, passkeyEnabled = false): string { const errorHtml = errorMessage ? `
${escapeHtml(errorMessage)}
` : '
'; @@ -468,7 +576,12 @@ function renderProxyLoginPage(authRequestId: string, nonce?: string, errorMessag

Enter your Bluesky handle or custom domain

- + ${passkeyEnabled ? ` +
or
+ ` : ''}

Bluesky will ask to authorize broad access -- this is a limitation of the AT Protocol OAuth standard. This gateway only reads your identity (handle). It will never post, follow, or access your data.

@@ -482,7 +595,97 @@ function renderProxyLoginPage(authRequestId: string, nonce?: string, errorMessag submitBtn.disabled = true; submitBtn.textContent = 'Redirecting...'; if (errorDiv) errorDiv.style.display = 'none'; - }); + });${passkeyEnabled ? ` + function b64urlToBuffer(s) { + var b = s.replace(/-/g, '+').replace(/_/g, '/'); + var pad = b.length % 4 === 0 ? '' : '='.repeat(4 - (b.length % 4)); + var bin = atob(b + pad); + var arr = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr.buffer; + } + function bufferToB64url(buf) { + var bytes = new Uint8Array(buf); + var bin = ''; + for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, ''); + } + function showError(msg) { + var e = document.getElementById('error'); + if (e) { e.textContent = msg; e.style.display = 'block'; } + } + var passkeyBtn = document.getElementById('passkeyBtn'); + if (passkeyBtn && window.PublicKeyCredential) { + passkeyBtn.style.display = 'flex'; + passkeyBtn.addEventListener('click', function() { + passkeyBtn.disabled = true; + passkeyBtn.textContent = 'Authenticating...'; + if (errorDiv) errorDiv.style.display = 'none'; + fetch('/auth/passkey/authenticate/options', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + var opts = data.options || data; + var pubKeyOpts = { + challenge: b64urlToBuffer(opts.challenge), + timeout: opts.timeout, + rpId: opts.rpId, + userVerification: opts.userVerification + }; + if (opts.allowCredentials && opts.allowCredentials.length > 0) { + pubKeyOpts.allowCredentials = opts.allowCredentials.map(function(c) { + return { id: b64urlToBuffer(c.id), type: c.type, transports: c.transports }; + }); + } + return navigator.credentials.get({ publicKey: pubKeyOpts }).then(function(cred) { + return { cred: cred, challenge: opts.challenge }; + }); + }) + .then(function(res) { + var cred = res.cred; + return fetch('/auth/proxy/passkey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + auth_request_id: document.querySelector('input[name="auth_request_id"]').value, + challenge: res.challenge, + credential: { + id: cred.id, + rawId: bufferToB64url(cred.rawId), + response: { + clientDataJSON: bufferToB64url(cred.response.clientDataJSON), + authenticatorData: bufferToB64url(cred.response.authenticatorData), + signature: bufferToB64url(cred.response.signature), + userHandle: cred.response.userHandle ? bufferToB64url(cred.response.userHandle) : undefined + }, + type: cred.type, + authenticatorAttachment: cred.authenticatorAttachment + } + }) + }); + }) + .then(function(r) { return r.json(); }) + .then(function(result) { + if (result.redirect_url) { + window.location.href = result.redirect_url; + } else { + showError(result.error_description || 'Passkey authentication failed'); + passkeyBtn.disabled = false; + passkeyBtn.textContent = 'Sign in with passkey'; + } + }) + .catch(function(err) { + if (err.name !== 'NotAllowedError') { + showError('Passkey authentication failed. Try signing in with your handle.'); + } + passkeyBtn.disabled = false; + passkeyBtn.textContent = 'Sign in with passkey'; + }); + }); + }` : ''} `; @@ -644,6 +847,29 @@ function sharedStyles(): string { font-size: 14px; display: none; } + .divider { display: flex; align-items: center; margin: 24px 0; gap: 12px; } + .divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: #334155; } + .divider span { color: #64748b; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; } + .passkey-btn { + width: 100%; + padding: 14px; + background: transparent; + color: #e2e8f0; + border: 1px solid #334155; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: border-color 0.2s, transform 0.15s, box-shadow 0.15s, background 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 0; + } + .passkey-btn:hover { border-color: #3b82f6; background: rgba(59, 130, 246, 0.06); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1); } + .passkey-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; } + .passkey-btn svg { width: 18px; height: 18px; } .privacy { font-size: 12px; color: #64748b; margin-top: 24px; line-height: 1.6; text-align: center; } .privacy strong { color: #94a3b8; } `; diff --git a/gateway/src/services/database.ts b/gateway/src/services/database.ts index 17e7e8b..c211a2e 100644 --- a/gateway/src/services/database.ts +++ b/gateway/src/services/database.ts @@ -415,7 +415,7 @@ export class DatabaseService { } getSession(sessionId: string): AppSession | null { - const stmt = this.db.prepare('SELECT * FROM sessions WHERE id = ?'); + const stmt = this.db.prepare('SELECT * FROM sessions WHERE id = ? AND expires_at > datetime(\'now\')'); const row = stmt.get(sessionId) as (Omit & { created_at: string; expires_at: string; diff --git a/gateway/src/services/oidc/pkce.test.ts b/gateway/src/services/oidc/pkce.test.ts index f2f7efc..1c5c97f 100644 --- a/gateway/src/services/oidc/pkce.test.ts +++ b/gateway/src/services/oidc/pkce.test.ts @@ -65,11 +65,11 @@ describe('PKCE', () => { expect(verifyCodeChallenge(wrongVerifier, challenge, 'S256')).toBe(false); }); - it('should verify plain challenge correctly', () => { + it('should reject plain method as insecure', () => { const verifier = generateCodeVerifier(); const challenge = generateCodeChallenge(verifier, 'plain'); - expect(verifyCodeChallenge(verifier, challenge, 'plain')).toBe(true); + expect(verifyCodeChallenge(verifier, challenge, 'plain')).toBe(false); }); it('should reject invalid plain verifier', () => { @@ -123,7 +123,10 @@ describe('PKCE', () => { describe('isValidCodeChallengeMethod', () => { it('should accept valid methods', () => { expect(isValidCodeChallengeMethod('S256')).toBe(true); - expect(isValidCodeChallengeMethod('plain')).toBe(true); + }); + + it('should reject plain method', () => { + expect(isValidCodeChallengeMethod('plain')).toBe(false); }); it('should reject invalid methods', () => { diff --git a/gateway/src/services/oidc/pkce.ts b/gateway/src/services/oidc/pkce.ts index 8deb9b9..3c4e608 100644 --- a/gateway/src/services/oidc/pkce.ts +++ b/gateway/src/services/oidc/pkce.ts @@ -14,15 +14,20 @@ export function verifyCodeChallenge( codeChallenge: string, method: 'S256' | 'plain' = 'S256' ): boolean { + // Reject plain method -- only S256 is secure if (method === 'plain') { - return codeVerifier === codeChallenge; + return false; } // S256: SHA256(code_verifier) base64url encoded const hash = crypto.createHash('sha256').update(codeVerifier).digest(); const computed = hash.toString('base64url'); - return computed === codeChallenge; + // Constant-time comparison to prevent timing attacks + const a = Buffer.from(computed); + const b = Buffer.from(codeChallenge); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); } /** @@ -59,6 +64,6 @@ export function isValidCodeVerifier(codeVerifier: string): boolean { /** * Validate code challenge method */ -export function isValidCodeChallengeMethod(method: string): method is 'S256' | 'plain' { - return method === 'S256' || method === 'plain'; +export function isValidCodeChallengeMethod(method: string): method is 'S256' { + return method === 'S256'; } -- knotmirror 0.0.1 From e70b1959996a1d1636385500c7ceec44f857ffed Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Mon, 9 Mar 2026 11:45:44 -0500 Subject: [PATCH 5/7] chore: sanitize docs for FOSS release, add GitHub CI - Remove DO-HANDOFF.md (deployment-specific ops document) - Update HOMELAB.md clone URL to GitHub - Update CHANGELOG.md comparison links to GitHub - Add v2.3.0 changelog entry - Add GitHub Actions CI workflow (test on push/PR) --- .github/workflows/ci.yml | 38 ++++++ CHANGELOG.md | 44 +++++-- docs/DO-HANDOFF.md | 242 --------------------------------------- docs/HOMELAB.md | 2 +- 4 files changed, 74 insertions(+), 252 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 docs/DO-HANDOFF.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..625440a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + paths-ignore: + - '*.md' + - 'docs/**' + pull_request: + branches: [main] + +jobs: + test: + name: Test + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e924e30..f263457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ 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.3.0] - 2026-03-09 + +### Added + +- Passkey login support for forward-auth proxy flow (previously only available in OIDC flow) +- New endpoint `POST /auth/proxy/passkey` for WebAuthn authentication in proxy-auth +- Passkey button and WebAuthn JavaScript on proxy login page (feature-detected) +- GitHub Actions CI workflow for the FOSS repository + +### Security + +- MFA verify endpoints (`/auth/mfa/totp/verify`, `/auth/mfa/backup-codes/verify`) now require authentication; DID comes from session, not request body +- Session expiry enforced at the database query level (`getSession()` checks `expires_at`) +- OIDC logout redirect validation uses origin comparison instead of prefix matching (prevents open redirect) +- PKCE `plain` method rejected; only `S256` accepted with constant-time comparison +- Empty DID guard in OIDC token endpoint prevents minting tokens for unauthenticated authorization codes +- Handle format validation in OIDC authorize endpoint +- `trust proxy` configured for correct client IP behind reverse proxies +- `user_id` NaN validation in auth link endpoint + +### Changed + +- Removed `uuid` package dependency (replaced with `crypto.randomUUID()`) +- Test suite expanded to 404 tests + ## [2.2.0] - 2026-02-24 ### Added @@ -208,12 +233,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Input validation and sanitization - Rate limiting on all endpoints -[2.2.0]: https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/compare/v2.1.0...v2.2.0 -[2.1.0]: https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/compare/v2.0.3...v2.1.0 -[2.0.3]: https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/compare/v2.0.2...v2.0.3 -[2.0.2]: https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/compare/v2.0.1...v2.0.2 -[2.0.1]: https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/compare/v2.0.0...v2.0.1 -[2.0.0]: https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/compare/v1.3.0...v2.0.0 -[1.3.0]: https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/compare/v1.2.0...v1.3.0 -[1.2.0]: https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/compare/v1.0.0...v1.2.0 -[1.0.0]: https://gitea.cloudforest-basilisk.ts.net/Arcnode.xyz/atauth/releases/tag/v1.0.0 +[2.3.0]: https://github.com/Cache8063/atauth/compare/v2.2.0...v2.3.0 +[2.2.0]: https://github.com/Cache8063/atauth/compare/v2.1.0...v2.2.0 +[2.1.0]: https://github.com/Cache8063/atauth/compare/v2.0.3...v2.1.0 +[2.0.3]: https://github.com/Cache8063/atauth/compare/v2.0.2...v2.0.3 +[2.0.2]: https://github.com/Cache8063/atauth/compare/v2.0.1...v2.0.2 +[2.0.1]: https://github.com/Cache8063/atauth/compare/v2.0.0...v2.0.1 +[2.0.0]: https://github.com/Cache8063/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/docs/DO-HANDOFF.md b/docs/DO-HANDOFF.md deleted file mode 100644 index 6302c3c..0000000 --- a/docs/DO-HANDOFF.md +++ /dev/null @@ -1,242 +0,0 @@ -# ATAuth - DigitalOcean Kubernetes Handoff - -This document provides everything you need to connect to and manage the ATAuth deployment on DigitalOcean managed Kubernetes. - -## Cluster Overview - -| Item | Value | -|------|-------| -| Provider | DigitalOcean Managed Kubernetes | -| Cluster | `do-nyc1-storm-dr-cluster` (nyc1 region) | -| Kubernetes | v1.34.1 | -| Namespace | `atauth` | -| Nodes | 3x worker (4 vCPU, 8GB RAM each) | -| Container registry | `registry.digitalocean.com/ghostmesh-registry` | - -## Connecting with kubectl - -You will receive a kubeconfig file (`atauth-team-kubeconfig.yaml`) separately. This kubeconfig is scoped to the `atauth` namespace only -- you cannot access other namespaces on the cluster. - -### Setup - -```bash -# Option 1: Set KUBECONFIG env var -export KUBECONFIG=/path/to/atauth-team-kubeconfig.yaml - -# Option 2: Merge into your default config -cp ~/.kube/config ~/.kube/config.backup -KUBECONFIG=~/.kube/config:/path/to/atauth-team-kubeconfig.yaml kubectl config view --merge --flatten > /tmp/merged && mv /tmp/merged ~/.kube/config -kubectl config use-context atauth-team@do-nyc1-storm-dr-cluster -``` - -### Verify - -```bash -kubectl get pods -n atauth -# Should show the atauth pod running - -kubectl get deployments -n atauth -# NAME READY UP-TO-DATE AVAILABLE -# atauth 1/1 1 1 -``` - -## What You Have Access To - -Your ServiceAccount (`atauth-team`) has full CRUD access within the `atauth` namespace: - -- Pods (including logs, exec, port-forward) -- Deployments and ReplicaSets -- Services -- Ingresses -- ConfigMaps -- Secrets -- PersistentVolumeClaims -- Events - -You do **not** have access to cluster-scoped resources or other namespaces. - -## Current Deployment Architecture - -``` - ┌──────────────────────┐ -Internet ──> nginx │ Ingress (nginx) │ - ingress ──┤ apricot.workingtitle │ - │ .zip │ - └──────────┬───────────┘ - │ - ┌──────────▼───────────┐ - │ Service (ClusterIP) │ - │ atauth:3100 │ - └──────────┬───────────┘ - │ - ┌──────────▼───────────┐ - │ Deployment │ - │ 1 replica, Recreate │ - │ strategy │ - └──────────┬───────────┘ - │ - ┌──────────▼───────────┐ - │ PVC (1Gi, RWO) │ - │ do-block-storage │ - │ SQLite DB at │ - │ /app/data/gateway.db │ - └──────────────────────┘ -``` - -### Key Resources - -| Resource | Name | Notes | -|----------|------|-------| -| Deployment | `atauth` | 1 replica, `Recreate` strategy (required -- RWO PVC) | -| Service | `atauth` | ClusterIP, port 3100 | -| Ingress | `atauth` | Host: `apricot.workingtitle.zip`, TLS via `atauth-tls` secret | -| PVC | `atauth-data` | 1Gi, `do-block-storage`, holds SQLite DB | -| ConfigMap | `atauth-config` | Non-sensitive env vars (CORS, OIDC issuer, WebAuthn, etc.) | -| Secret | `atauth-secrets` | `ATAUTH_ADMIN_TOKEN`, `ATAUTH_OIDC_KEY_SECRET`, `ATAUTH_FORWARD_AUTH_SESSION_SECRET` | -| Secret | `atauth-tls` | TLS cert/key for the ingress | -| Secret | `registry-ghostmesh-registry` | Docker registry pull credentials | - -### Container Image - -- Registry: `registry.digitalocean.com/ghostmesh-registry/atauth` -- Tags: short git SHA (e.g., `43d2471`) + `latest` -- Platform: `linux/amd64` (required -- DO nodes are amd64) -- Dockerfile: `gateway/Dockerfile` - -### Environment Variables - -Sourced from `atauth-config` ConfigMap: - -| Env Var | ConfigMap Key | Description | -|---------|---------------|-------------| -| `OAUTH_CLIENT_ID` | `ATAUTH_OAUTH_CLIENT_ID` | AT Protocol OAuth client metadata URL | -| `OAUTH_REDIRECT_URI` | `ATAUTH_OAUTH_REDIRECT_URI` | OAuth callback URL | -| `CORS_ORIGINS` | `ATAUTH_CORS_ORIGINS` | Comma-separated allowed origins | -| `OIDC_ENABLED` | `ATAUTH_OIDC_ENABLED` | Enable OIDC provider | -| `OIDC_ISSUER` | `ATAUTH_OIDC_ISSUER` | OIDC issuer URL | -| `FORWARD_AUTH_ENABLED` | `ATAUTH_FORWARD_AUTH_ENABLED` | Enable nginx forward-auth proxy | -| `FORWARD_AUTH_SESSION_TTL` | `ATAUTH_FORWARD_AUTH_SESSION_TTL` | Session TTL in seconds | -| `FORWARD_AUTH_PROXY_COOKIE_TTL` | `ATAUTH_FORWARD_AUTH_PROXY_COOKIE_TTL` | Proxy cookie TTL in seconds | -| `MFA_ENABLED` | `ATAUTH_MFA_ENABLED` | Enable passkey/WebAuthn MFA | -| `WEBAUTHN_RP_ID` | `ATAUTH_WEBAUTHN_RP_ID` | WebAuthn relying party ID | -| `WEBAUTHN_ORIGIN` | `ATAUTH_WEBAUTHN_ORIGIN` | WebAuthn origin URL | -| `WEBAUTHN_RP_NAME` | `ATAUTH_WEBAUTHN_RP_NAME` | WebAuthn relying party display name | - -Sourced from `atauth-secrets` Secret: - -| Env Var | Secret Key | Description | -|---------|------------|-------------| -| `ADMIN_TOKEN` | `ATAUTH_ADMIN_TOKEN` | Admin API bearer token | -| `OIDC_KEY_SECRET` | `ATAUTH_OIDC_KEY_SECRET` | OIDC signing key secret | -| `FORWARD_AUTH_SESSION_SECRET` | `ATAUTH_FORWARD_AUTH_SESSION_SECRET` | Session encryption secret | - -Hardcoded in deployment spec: - -| Env Var | Value | -|---------|-------| -| `PORT` | `3100` | -| `HOST` | `0.0.0.0` | -| `NODE_ENV` | `production` | -| `DB_PATH` | `/app/data/gateway.db` | - -## Common Operations - -### View logs - -```bash -kubectl logs -n atauth deployment/atauth -f -``` - -### Restart the deployment - -```bash -kubectl rollout restart deployment/atauth -n atauth -kubectl rollout status deployment/atauth -n atauth -``` - -### Deploy a new image - -```bash -kubectl set image deployment/atauth atauth=registry.digitalocean.com/ghostmesh-registry/atauth: -n atauth -kubectl rollout status deployment/atauth -n atauth --timeout=120s -``` - -### Update config - -```bash -# Edit a ConfigMap value -kubectl patch configmap atauth-config -n atauth --type merge \ - -p '{"data":{"ATAUTH_CORS_ORIGINS":"https://apricot.workingtitle.zip,https://new-app.example.com"}}' - -# Restart to pick up changes (env vars are read at startup) -kubectl rollout restart deployment/atauth -n atauth -``` - -### Update secrets - -```bash -# Update a secret value (must be base64 encoded) -kubectl patch secret atauth-secrets -n atauth --type merge \ - -p '{"data":{"ATAUTH_ADMIN_TOKEN":"'$(echo -n "new-token-value" | base64)'"}}' - -kubectl rollout restart deployment/atauth -n atauth -``` - -### Port-forward for local access - -```bash -kubectl port-forward -n atauth svc/atauth 3100:3100 -# ATAuth is now accessible at http://localhost:3100 -``` - -### Shell into the container - -```bash -kubectl exec -it -n atauth deployment/atauth -- /bin/sh -``` - -### Check health - -```bash -# Via port-forward -curl http://localhost:3100/health - -# Or check readiness probe status -kubectl describe pod -n atauth -l app=atauth | grep -A5 "Conditions:" -``` - -## CI/CD Pipeline - -The existing Gitea Actions workflow (`.gitea/workflows/deploy.yml`) runs on push to `main`: - -1. **Test job** (`ubuntu-latest` runner): `npm ci`, typecheck, lint, vitest -2. **Build and deploy job** (`host` runner): Docker build, push to DO registry, `kubectl set image`, rollout status - -The pipeline uses these Gitea org-level secrets: -- `DO_REGISTRY_TOKEN` -- DO container registry API token -- `DO_KUBECONFIG` -- base64-encoded kubeconfig for the `ci-deployer` ServiceAccount - -## Important Gotchas - -1. **Recreate strategy is required.** The PVC is RWO (ReadWriteOnce). RollingUpdate will deadlock if the new pod lands on a different node than the old one. - -2. **Domain must be `workingtitle.zip`, not `arcnode.xyz`.** The `arcnode.xyz` domain hosts a PDS (AT Protocol Personal Data Server). Using it for ATAuth causes same-site cookie/header conflicts. - -3. **Docker images must target `linux/amd64`.** The DO worker nodes run amd64. ARM images will crash with exec format errors. - -4. **HMAC token encoding.** ATAuth signs HMAC tokens with `createHmac('sha256', secretString)` using the UTF-8 encoding of the hex secret. Downstream services verifying tokens must match this encoding. - -5. **`req.accepts('json')` matches `*/*`.** Use `req.is('json')` for Content-Type checks in Express routes. - -6. **Registry pull secret must exist in namespace.** The `registry-ghostmesh-registry` secret is already present. If you recreate the namespace, you must re-add it or image pulls will fail. - -7. **SQLite WAL mode.** The DB runs in WAL mode on DO block storage. Single-writer only -- do not scale beyond 1 replica. - -## Live URLs - -| URL | Description | -|-----|-------------| -| `https://apricot.workingtitle.zip` | ATAuth gateway (public) | -| `https://apricot.workingtitle.zip/.well-known/openid-configuration` | OIDC discovery | -| `https://apricot.workingtitle.zip/admin/login` | Admin dashboard | -| `https://apricot.workingtitle.zip/health` | Health check endpoint | diff --git a/docs/HOMELAB.md b/docs/HOMELAB.md index 96d7444..ba0c76d 100644 --- a/docs/HOMELAB.md +++ b/docs/HOMELAB.md @@ -12,7 +12,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 cp gateway/.env.example .env ``` -- knotmirror 0.0.1 From 32912c2891d0916cb62e9e286300fb23abc7b28e Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Mon, 9 Mar 2026 11:52:01 -0500 Subject: [PATCH 6/7] ci: add GitHub Actions release workflow for GHCR Builds multi-arch Docker image (amd64 + arm64) and pushes to ghcr.io/cache8063/atauth-gateway on version tag push. --- .github/workflows/release.yml | 70 +++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5c41ffc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/atauth-gateway + +permissions: + contents: read + packages: write + +jobs: + test: + name: Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: gateway + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npm run test:run + + build-and-push: + name: Build and Push Docker Image + runs-on: ubuntu-latest + needs: [test] + steps: + - uses: actions/checkout@v4 + + - name: Extract version from tag + id: meta + run: | + VERSION=${GITHUB_REF_NAME#v} + SHORT_SHA=$(echo "$GITHUB_SHA" | cut -c1-7) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./gateway + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + BUILD_COMMIT=${{ steps.meta.outputs.short_sha }} + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max -- knotmirror 0.0.1 From 5fc366913528b648f496bdab8f82204f1a78ed2f Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Mon, 9 Mar 2026 11:53:47 -0500 Subject: [PATCH 7/7] fix(ci): lowercase Docker image name for GHCR github.repository_owner preserves case but Docker tags must be lowercase. Use tr to normalize the image name. --- .github/workflows/release.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c41ffc..7083295 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,6 @@ on: env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository_owner }}/atauth-gateway permissions: contents: read @@ -42,8 +41,10 @@ jobs: run: | VERSION=${GITHUB_REF_NAME#v} SHORT_SHA=$(echo "$GITHUB_SHA" | cut -c1-7) + IMAGE_NAME=$(echo "${{ github.repository_owner }}/atauth-gateway" | tr '[:upper:]' '[:lower:]') echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + echo "image_name=$IMAGE_NAME" >> "$GITHUB_OUTPUT" - name: Log in to GHCR uses: docker/login-action@v3 @@ -64,7 +65,7 @@ jobs: build-args: | BUILD_COMMIT=${{ steps.meta.outputs.short_sha }} tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ steps.meta.outputs.image_name }}:${{ steps.meta.outputs.version }} + ${{ env.REGISTRY }}/${{ steps.meta.outputs.image_name }}:latest cache-from: type=gha cache-to: type=gha,mode=max