diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..8f43c61
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,40 @@
+# ATAuth Gateway Configuration
+# Copy this file to .env and customize for your deployment
+
+# =============================================================================
+# REQUIRED SETTINGS
+# =============================================================================
+
+# Admin API token for registering applications
+# Generate with: openssl rand -hex 32
+ADMIN_TOKEN=your-secure-admin-token-here
+
+# Your gateway's public URL (where users will be redirected)
+# This must be accessible from the internet for OAuth to work
+OAUTH_CLIENT_ID=https://auth.yourdomain.com/client-metadata.json
+OAUTH_REDIRECT_URI=https://auth.yourdomain.com/auth/callback
+
+# =============================================================================
+# OPTIONAL SETTINGS
+# =============================================================================
+
+# Allowed CORS origins (comma-separated)
+# Add your app domains here
+CORS_ORIGINS=https://app1.yourdomain.com,https://app2.yourdomain.com
+
+# =============================================================================
+# SELF-HOSTED PDS CONFIGURATION
+# =============================================================================
+# If you run your own PDS, your users authenticate with handles like:
+# @alice.your-pds.com
+# @bob.your-pds.com
+#
+# The gateway automatically discovers the user's PDS from their handle.
+# No additional configuration needed - just ensure your PDS is accessible.
+#
+# Benefits of self-hosted PDS:
+# - Full control over your identity infrastructure
+# - No dependency on Bluesky's servers
+# - Works on air-gapped networks (if PDS is local)
+# - Your data stays on your infrastructure
+# =============================================================================
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..3bc3ae2
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,65 @@
+name: Build and Push Docker Image
+
+on:
+ push:
+ branches: [main]
+ tags: ['v*']
+ pull_request:
+ branches: [main]
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}-gateway
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Container Registry
+ if: github.event_name != 'pull_request'
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=ref,event=branch
+ type=ref,event=pr
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=semver,pattern={{major}}
+ type=raw,value=latest,enable={{is_default_branch}}
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v5
+ with:
+ context: ./gateway
+ file: ./gateway/Dockerfile
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ platforms: linux/amd64,linux/arm64
+
+ - name: Generate SBOM
+ if: github.event_name != 'pull_request'
+ uses: anchore/sbom-action@v0
+ with:
+ image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
diff --git a/README.md b/README.md
index 072294b..4455ecc 100644
--- a/README.md
+++ b/README.md
@@ -1,214 +1,198 @@
-# ATAuth - AT Protocol Authentication Library
+# ATAuth - Decentralized Authentication for AT Protocol
-A complete, plug-and-play authentication system for AT Protocol (Bluesky) OAuth integration.
+A complete, plug-and-play authentication system using AT Protocol (Bluesky) OAuth. Use it with Bluesky or **run your own PDS** for fully self-hosted, decentralized identity.
+
+## Why ATAuth?
+
+**No more password databases.** Users authenticate with their AT Protocol identity (`@user.bsky.social` or `@alice.your-pds.com`). You verify tokens - the identity provider handles the rest.
+
+| Traditional Auth | ATAuth |
+|-----------------|--------|
+| You store passwords | PDS manages identity |
+| You implement MFA | PDS handles MFA |
+| Password resets, account recovery | Not your problem |
+| LDAP, Active Directory complexity | Simple token verification |
+
+**With a self-hosted PDS**, ATAuth becomes a fully independent auth system - no different from running your own Keycloak, but with portable, decentralized identities.
+
+## Quick Start (Docker)
+
+```bash
+# Clone and configure
+git clone https://github.com/Cache8063/atauth.git
+cd atauth
+cp .env.example .env
+echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> .env
+
+# Start the gateway
+docker compose up -d
+
+# Register your first app
+curl -X POST http://localhost:3100/admin/apps \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"id": "myapp", "name": "My Application"}'
+```
+
+See [docs/HOMELAB.md](docs/HOMELAB.md) for complete deployment guide.
## Components
| Component | Description |
|-----------|-------------|
-| **[gateway/](gateway/)** | Node.js OAuth gateway server |
+| **[gateway/](gateway/)** | Node.js OAuth gateway server ([Docker image](https://ghcr.io/cache8063/atauth-gateway)) |
| **[src/](src/)** | Rust token verification library |
| **[ts/](ts/)** | TypeScript/React frontend utilities |
-## Features
-
-- **OAuth Gateway**: Ready-to-deploy AT Protocol OAuth server
-- **Token Verification**: Secure HMAC-SHA256 with constant-time comparison
-- **Session Management**: Trait-based storage with SQLite and PostgreSQL backends
-- **Rate Limiting**: IP-based rate limiting with configurable thresholds
-- **Input Validation**: DID and handle format validation
-- **TypeScript Support**: Full TypeScript/React utilities for frontend integration
-
## Architecture
```text
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
-│ Your Frontend │────▶│ ATAuth Gateway │────▶│ Bluesky PDS │
-│ (React/Next) │ │ (Node.js) │ │ (OAuth Provider)│
+│ Your Apps │────▶│ ATAuth Gateway │────▶│ PDS │
+│ (any stack) │ │ (one place) │ │ (yours or bsky) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
- │ token │ HMAC secret
+ │ HMAC token │ AT Protocol OAuth
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Your Backend │────▶│ Token Verify │
-│ (Rust/Node) │ │ (atauth lib) │
+│ (Rust/Node/?) │ │ (atauth lib) │
└─────────────────┘ └─────────────────┘
```
-## Installation
+**The gateway handles the complex OAuth flow once.** Your apps receive simple HMAC-signed tokens to verify.
-### Rust
+## Self-Hosted PDS = Full Independence
-Add to your `Cargo.toml`:
+When you run your own PDS:
+- Users get handles like `@alice.your-domain.com`
+- No dependency on Bluesky servers
+- Works on air-gapped networks
+- Same security model as enterprise OAuth
+- Your identity, your infrastructure
-```toml
-[dependencies]
-atauth = { git = "https://github.com/Cache8063/atauth" }
+```yaml
+# Add to your docker-compose.yml
+services:
+ pds:
+ image: ghcr.io/bluesky-social/pds:latest
+ # ... configure for your domain
+```
+
+## Installation
+
+### Docker (Recommended)
+
+```bash
+docker pull ghcr.io/cache8063/atauth-gateway:latest
```
-Or with specific features:
+### Rust Library
```toml
[dependencies]
-atauth = { git = "https://github.com/Cache8063/atauth", features = ["session-sqlite", "rate-limit"] }
+atauth = { git = "https://github.com/Cache8063/atauth" }
```
### TypeScript/JavaScript
```bash
npm install atauth
-# or
-pnpm add atauth
-```
-
-## Quick Start
-
-### Rust - Basic Token Verification
-
-```rust
-use atauth::{TokenVerifier, TokenPayload};
-
-// Create a verifier with your HMAC secret (shared with auth gateway)
-let verifier = TokenVerifier::new(b"your-secret-key");
-
-// Verify a token from the auth gateway
-match verifier.verify("token-from-client") {
- Ok(payload) => {
- println!("Authenticated: {} ({})", payload.handle, payload.did);
- // payload.user_id contains app-specific user ID if linked
- }
- Err(e) => {
- eprintln!("Auth failed: {}", e);
- }
-}
```
-### Rust - With Session Store
+## Usage
-```rust
-use atauth::{TokenVerifier, SessionManager};
-use atauth::session::SqliteSessionStore;
-
-// Setup
-let verifier = TokenVerifier::new(b"your-secret-key");
-let session_store = SqliteSessionStore::new("sessions.db")?;
-let sessions = SessionManager::new(session_store);
-
-// On login
-if let Ok(payload) = verifier.verify(token) {
- let session = sessions.create_session(&payload, token.to_string())?;
- // Return session.token to client as session cookie
-}
+### Gateway - Register Apps
-// On subsequent requests
-match sessions.validate(session_token) {
- Ok(session) => {
- // User is authenticated
- println!("User: {}", session.handle);
- }
- Err(_) => {
- // Invalid or expired session
- }
-}
-
-// On logout
-sessions.invalidate(session_token)?;
+```bash
+curl -X POST https://auth.example.com/admin/apps \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "id": "jellyfin",
+ "name": "Jellyfin",
+ "callback_url": "https://jellyfin.example.com/sso/callback"
+ }'
+# Returns: { "hmac_secret": "..." } - save this!
```
-### Rust - With Rate Limiting
+### Rust - Verify Tokens
```rust
-use atauth::rate_limit::{RateLimiter, RateLimiterConfig};
-use std::time::Duration;
+use atauth::TokenVerifier;
-let config = RateLimiterConfig::default()
- .with_max_attempts(5)
- .with_lockout(Duration::from_secs(300));
+let verifier = TokenVerifier::new(b"your-hmac-secret");
-let limiter = RateLimiter::new(config);
-
-// Before authentication attempt
-let client_ip = "192.168.1.1".parse().unwrap();
-if let Err(e) = limiter.check(&client_ip) {
- return Err(e); // Rate limited
+match verifier.verify(token) {
+ Ok(payload) => {
+ println!("Welcome, {}!", payload.handle);
+ // payload.did, payload.user_id, payload.app_id available
+ }
+ Err(e) => eprintln!("Auth failed: {}", e),
}
-
-// After failed attempt
-limiter.record_failure(&client_ip);
-
-// After successful attempt
-limiter.record_success(&client_ip);
```
-### TypeScript - React Integration
+### TypeScript - Frontend Integration
```tsx
-import { initAuthStore, useAuthStore } from 'atauth/react';
-
-// Initialize once at app startup
-initAuthStore({
- gatewayUrl: 'https://auth.example.com',
- appId: 'myapp',
- callbackUrl: 'https://myapp.com/auth/callback',
-});
+import { useAuthStore } from 'atauth/react';
function LoginButton() {
- const { isAuthenticated, user, login, logout, isLoading } = useAuthStore();
-
- if (isLoading) {
- return
Loading...
;
- }
+ const { isAuthenticated, user, login, logout } = useAuthStore();
if (isAuthenticated) {
return (
-
+ <>
Welcome, {user?.handle}!
-
+ >
);
}
- return ;
+ return ;
}
```
-### TypeScript - Manual Token Handling
-
-```typescript
-import {
- decodeToken,
- handleCallback,
- isOAuthCallback,
- redirectToAuth
-} from 'atauth';
-
-// Redirect to auth
-redirectToAuth({
- gatewayUrl: 'https://auth.example.com',
- appId: 'myapp',
- callbackUrl: window.location.origin + '/callback',
-});
-
-// Handle callback page
-if (isOAuthCallback()) {
- const result = handleCallback({ gatewayUrl: 'https://auth.example.com' });
-
- if (result.success) {
- console.log('Logged in as:', result.user?.handle);
- // Redirect to app
- window.location.href = result.returnTo || '/';
- } else {
- console.error('Auth failed:', result.error);
+### Node.js - Verify Tokens
+
+```javascript
+import crypto from 'crypto';
+
+function verifyToken(token, secret) {
+ const [payloadB64, signature] = token.split('.');
+ const expected = crypto
+ .createHmac('sha256', secret)
+ .update(payloadB64)
+ .digest('base64url');
+
+ // Constant-time comparison
+ if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
+ return null;
}
+
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url'));
+ return payload.exp > Date.now() / 1000 ? payload : null;
}
```
+## Features
+
+- **Multi-App Support**: One gateway serves all your apps with isolated secrets
+- **Rate Limiting**: Built-in IP-based rate limiting (configurable)
+- **Session Management**: SQLite or PostgreSQL session stores
+- **Security Hardened**: Constant-time comparison, CSRF protection, secure token transport
+- **Docker Ready**: Multi-arch images (amd64/arm64)
+- **Homelab Friendly**: Works with Traefik, Caddy, nginx
+
## Token Format
-Tokens follow a JWT-like structure: `base64url(payload).base64url(signature)`
+Simple HMAC-signed tokens (not full JWT):
-### Payload Structure
+```
+base64url(payload).base64url(hmac_sha256(payload, secret))
+```
+Payload:
```json
{
"did": "did:plc:abc123...",
@@ -221,65 +205,28 @@ Tokens follow a JWT-like structure: `base64url(payload).base64url(signature)`
}
```
-## API Reference
-
-### Rust
-
-#### `TokenVerifier`
-
-- `new(secret: &[u8])` - Create verifier with HMAC secret
-- `from_hex(hex: &str)` - Create from hex-encoded secret
-- `from_base64(b64: &str)` - Create from base64-encoded secret
-- `verify(token: &str) -> Result` - Verify and decode token
-- `sign(payload: &TokenPayload) -> Result` - Sign a payload (for gateways)
-
-#### `SessionManager`
-
-- `new(store: S)` - Create manager with store
-- `create_session(payload, token)` - Create session from token
-- `validate(token)` - Validate and optionally extend session
-- `invalidate(token)` - Delete session
-- `invalidate_all_for_did(did)` - Delete all sessions for user
-- `cleanup()` - Remove expired sessions
-
-#### `RateLimiter`
-
-- `new(config)` - Create with configuration
-- `check(ip)` - Check if IP can attempt auth
-- `record_failure(ip)` - Record failed attempt
-- `record_success(ip)` - Clear on success
-
-### TypeScript
-
-#### Token Utilities
-
-- `decodeToken(token)` - Decode without verification
-- `isTokenExpired(payload)` - Check expiration
-- `getTokenRemainingSeconds(payload)` - Time until expiry
-- `isValidDid(did)` - Validate DID format
-- `isValidHandle(handle)` - Validate handle format
+## Documentation
-#### OAuth Utilities
+- [Homelab Deployment Guide](docs/HOMELAB.md) - Docker, reverse proxy configs, self-hosted PDS
+- [Security Policy](SECURITY.md) - Reporting vulnerabilities
+- [Contributing](CONTRIBUTING.md) - How to contribute
-- `redirectToAuth(config, state?)` - Start OAuth flow
-- `handleCallback(config)` - Process callback
-- `isOAuthCallback()` - Check if on callback page
-- `buildLogoutUrl(config)` - Get logout URL
+## Use Cases
-#### React Hooks
+- **Homelab SSO**: Single sign-on for Jellyfin, NextCloud, Gitea, etc.
+- **Multi-tenant Apps**: Users bring their own identity
+- **AT Protocol Apps**: Games, social apps, tools for the ATmosphere
+- **Enterprise**: Self-hosted PDS for internal identity
-- `useAuthStore()` - Main auth hook
-- `useUser()` - Get current user
-- `useIsAuthenticated()` - Check auth status
-- `useNeedsRefresh()` - Check if token needs refresh
+## Security
-## Security Considerations
+- HMAC-SHA256 with constant-time verification
+- Rate limiting on all endpoints
+- CSRF protection via cryptographic nonces
+- Tokens in URL fragments (not query params)
+- Sanitized error responses
-- **Server-side verification**: Always verify tokens server-side. Client-side decoding is for display only.
-- **Constant-time comparison**: Signature verification uses constant-time comparison to prevent timing attacks.
-- **Rate limiting**: Enable rate limiting in production to prevent brute force attacks.
-- **HTTPS only**: Always use HTTPS in production.
-- **Secure secrets**: Store HMAC secrets securely, never in client code.
+See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
## License
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..6794e38
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,65 @@
+# ATAuth - Self-Hosted AT Protocol Authentication
+#
+# Quick Start:
+# 1. Copy .env.example to .env and configure
+# 2. docker compose up -d
+# 3. Register your first app via the admin API
+#
+# For use with your own PDS (recommended):
+# - Set OAUTH_CLIENT_ID to your gateway's public URL
+# - Point your apps to authenticate via this gateway
+# - Users sign in with their AT Protocol handle (@user.your-pds.com)
+
+services:
+ atauth:
+ build:
+ context: ./gateway
+ dockerfile: Dockerfile
+ image: ghcr.io/cache8063/atauth-gateway:latest
+ container_name: atauth-gateway
+ restart: unless-stopped
+ ports:
+ - "3100:3100"
+ environment:
+ # Gateway configuration
+ - PORT=3100
+ - HOST=0.0.0.0
+
+ # OAuth client identity (your gateway's public URL)
+ - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID:-https://auth.example.com/client-metadata.json}
+ - OAUTH_REDIRECT_URI=${OAUTH_REDIRECT_URI:-https://auth.example.com/auth/callback}
+
+ # Admin API token (generate with: openssl rand -hex 32)
+ - ADMIN_TOKEN=${ADMIN_TOKEN}
+
+ # CORS origins (comma-separated)
+ - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:5173}
+
+ # Database path
+ - DB_PATH=/app/data/gateway.db
+ volumes:
+ - atauth-data:/app/data
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3100/health"]
+ interval: 30s
+ timeout: 3s
+ retries: 3
+ start_period: 10s
+ labels:
+ # Traefik labels (optional - uncomment if using Traefik)
+ # - "traefik.enable=true"
+ # - "traefik.http.routers.atauth.rule=Host(`auth.example.com`)"
+ # - "traefik.http.routers.atauth.entrypoints=websecure"
+ # - "traefik.http.routers.atauth.tls.certresolver=letsencrypt"
+ # - "traefik.http.services.atauth.loadbalancer.server.port=3100"
+
+ # Homepage dashboard (optional)
+ - "homepage.group=Authentication"
+ - "homepage.name=ATAuth Gateway"
+ - "homepage.icon=lock"
+ - "homepage.href=https://auth.example.com"
+ - "homepage.description=AT Protocol SSO Gateway"
+
+volumes:
+ atauth-data:
+ driver: local
diff --git a/docs/HOMELAB.md b/docs/HOMELAB.md
new file mode 100644
index 0000000..4075f77
--- /dev/null
+++ b/docs/HOMELAB.md
@@ -0,0 +1,269 @@
+# ATAuth Homelab Deployment Guide
+
+ATAuth provides decentralized authentication for your homelab using AT Protocol. Instead of managing passwords, users authenticate with their AT Protocol identity (like `@alice.your-pds.com`).
+
+## Why AT Protocol for Homelab Auth?
+
+| Traditional SSO | ATAuth + AT Protocol |
+|----------------|---------------------|
+| You manage passwords | PDS manages identity |
+| You handle MFA | PDS handles MFA |
+| You store credentials | No credentials stored |
+| Single point of failure | Decentralized identity |
+| Complex setup (LDAP, etc.) | Simple gateway deployment |
+
+**With a self-hosted PDS**, you have:
+- Full control over your identity infrastructure
+- No external dependencies (works offline)
+- Same security model as enterprise OAuth
+- Portable identity across the AT Protocol network
+
+## Quick Start
+
+### 1. Prerequisites
+
+- Docker and Docker Compose
+- A domain name (e.g., `auth.homelab.local`)
+- Reverse proxy with TLS (Caddy, Traefik, or nginx)
+- Optional: Your own PDS for full independence
+
+### 2. Deploy the Gateway
+
+```bash
+# Clone the repository
+git clone https://github.com/Cache8063/atauth.git
+cd atauth
+
+# Configure environment
+cp .env.example .env
+
+# Generate admin token
+echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> .env
+
+# Edit .env with your domain
+nano .env
+
+# Start the gateway
+docker compose up -d
+```
+
+### 3. Configure Your Domain
+
+Update `.env`:
+```env
+OAUTH_CLIENT_ID=https://auth.homelab.local/client-metadata.json
+OAUTH_REDIRECT_URI=https://auth.homelab.local/auth/callback
+CORS_ORIGINS=https://jellyfin.homelab.local,https://nextcloud.homelab.local
+```
+
+### 4. Register Your Apps
+
+```bash
+# Register Jellyfin
+curl -X POST https://auth.homelab.local/admin/apps \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "id": "jellyfin",
+ "name": "Jellyfin Media Server",
+ "callback_url": "https://jellyfin.homelab.local/sso/callback"
+ }'
+
+# Save the returned hmac_secret for your app's backend
+```
+
+## Reverse Proxy Configuration
+
+### Caddy (Recommended)
+
+```caddyfile
+auth.homelab.local {
+ reverse_proxy atauth:3100
+}
+```
+
+### Traefik
+
+```yaml
+# docker-compose.yml labels
+labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.atauth.rule=Host(`auth.homelab.local`)"
+ - "traefik.http.routers.atauth.entrypoints=websecure"
+ - "traefik.http.routers.atauth.tls.certresolver=letsencrypt"
+ - "traefik.http.services.atauth.loadbalancer.server.port=3100"
+```
+
+### nginx
+
+```nginx
+server {
+ listen 443 ssl http2;
+ server_name auth.homelab.local;
+
+ ssl_certificate /etc/ssl/certs/auth.homelab.local.crt;
+ ssl_certificate_key /etc/ssl/private/auth.homelab.local.key;
+
+ location / {
+ proxy_pass http://atauth:3100;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+```
+
+## Self-Hosted PDS Setup
+
+For complete independence from Bluesky, run your own PDS:
+
+```yaml
+# docker-compose.pds.yml
+services:
+ pds:
+ image: ghcr.io/bluesky-social/pds:latest
+ environment:
+ - PDS_HOSTNAME=pds.homelab.local
+ - PDS_JWT_SECRET=your-jwt-secret
+ - PDS_ADMIN_PASSWORD=your-admin-password
+ - PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=your-key
+ - PDS_DATA_DIRECTORY=/pds
+ - PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks
+ - PDS_DID_PLC_URL=https://plc.directory
+ - PDS_BSKY_APP_VIEW_URL=https://api.bsky.app
+ - PDS_BSKY_APP_VIEW_DID=did:web:api.bsky.app
+ - PDS_REPORT_SERVICE_URL=https://mod.bsky.app
+ - PDS_REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac
+ - PDS_CRAWLERS=https://bsky.network
+ volumes:
+ - pds-data:/pds
+ ports:
+ - "3000:3000"
+```
+
+Then your users can create accounts like `@alice.pds.homelab.local` and authenticate through ATAuth.
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Your Homelab Network │
+│ │
+│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
+│ │ Jellyfin │ │ NextCloud │ │ Gitea │ │
+│ │ │ │ │ │ │ │
+│ │ (app_id: │ │ (app_id: │ │ (app_id: │ │
+│ │ jellyfin) │ │ nextcloud) │ │ gitea) │ │
+│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
+│ │ │ │ │
+│ └─────────────────┼─────────────────┘ │
+│ │ │
+│ ┌───────▼───────┐ │
+│ │ ATAuth │ │
+│ │ Gateway │ │
+│ │ (one place) │ │
+│ └───────┬───────┘ │
+│ │ │
+│ ┌───────▼───────┐ │
+│ │ Your PDS or │ │
+│ │ Bluesky │ │
+│ └───────────────┘ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+## Integrating Apps
+
+### Generic OAuth Integration
+
+Most apps with OAuth support can use ATAuth:
+
+1. **Register the app** with ATAuth admin API
+2. **Configure OAuth** in the app:
+ - Authorization URL: `https://auth.homelab.local/auth/init`
+ - Callback URL: Your app's callback
+3. **Verify tokens** in your app's backend using the HMAC secret
+
+### Token Verification (Backend)
+
+For apps you control, verify tokens server-side:
+
+**Node.js:**
+```javascript
+import crypto from 'crypto';
+
+function verifyToken(token, secret) {
+ const [payloadB64, signature] = token.split('.');
+ const expected = crypto
+ .createHmac('sha256', secret)
+ .update(payloadB64)
+ .digest('base64url');
+
+ if (signature !== expected) return null;
+
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url'));
+ if (payload.exp < Date.now() / 1000) return null;
+
+ return payload; // { did, handle, user_id, app_id, exp }
+}
+```
+
+**Rust:**
+```rust
+use atauth::TokenVerifier;
+
+let verifier = TokenVerifier::new(b"your-hmac-secret");
+match verifier.verify(token) {
+ Ok(payload) => println!("Authenticated: {}", payload.handle),
+ Err(e) => println!("Invalid token: {}", e),
+}
+```
+
+## Security Considerations
+
+1. **Always use TLS** - OAuth requires HTTPS
+2. **Protect admin token** - Only use for initial app registration
+3. **HMAC secrets** - Store securely, rotate periodically
+4. **Rate limiting** - Built-in, but consider additional WAF rules
+5. **Network isolation** - Keep ATAuth on a trusted network segment
+
+## Troubleshooting
+
+### OAuth Error: "Client not found"
+
+Your `OAUTH_CLIENT_ID` must be a publicly accessible URL that returns valid client metadata. Verify:
+```bash
+curl https://auth.homelab.local/client-metadata.json
+```
+
+### Users Can't Authenticate
+
+1. Check PDS is reachable from ATAuth container
+2. Verify user's handle resolves correctly
+3. Check ATAuth logs: `docker compose logs atauth`
+
+### Token Verification Fails
+
+1. Ensure HMAC secret matches between gateway and your app
+2. Check token hasn't expired
+3. Verify `app_id` matches the registered app
+
+## Updating
+
+```bash
+docker compose pull
+docker compose up -d
+```
+
+## Backup
+
+The gateway stores data in a SQLite database:
+```bash
+# Backup
+docker compose exec atauth cp /app/data/gateway.db /app/data/gateway.db.backup
+
+# Or backup the volume
+docker run --rm -v atauth_atauth-data:/data -v $(pwd):/backup alpine \
+ tar czf /backup/atauth-backup.tar.gz -C /data .
+```
diff --git a/gateway/.dockerignore b/gateway/.dockerignore
new file mode 100644
index 0000000..248f249
--- /dev/null
+++ b/gateway/.dockerignore
@@ -0,0 +1,31 @@
+# Dependencies
+node_modules/
+
+# Build output (rebuilt in container)
+dist/
+
+# Development files
+*.log
+.env
+.env.*
+!.env.example
+
+# IDE
+.vscode/
+.idea/
+
+# Test files
+*.test.ts
+__tests__/
+coverage/
+
+# Git
+.git/
+.gitignore
+
+# Documentation
+*.md
+!README.md
+
+# Data (mounted as volume)
+data/
diff --git a/gateway/Dockerfile b/gateway/Dockerfile
new file mode 100644
index 0000000..0b86090
--- /dev/null
+++ b/gateway/Dockerfile
@@ -0,0 +1,56 @@
+# ATAuth Gateway
+# AT Protocol OAuth gateway for self-hosted authentication
+#
+# Build: docker build -t atauth-gateway .
+# Run: docker run -p 3100:3100 -v ./data:/app/data atauth-gateway
+
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+# Install dependencies
+COPY package*.json ./
+RUN npm ci
+
+# Build TypeScript
+COPY tsconfig.json ./
+COPY src/ ./src/
+RUN npm run build
+
+# Production image
+FROM node:20-alpine
+
+WORKDIR /app
+
+# Create non-root user
+RUN addgroup -g 1001 atauth && \
+ adduser -u 1001 -G atauth -s /bin/sh -D atauth
+
+# Install production dependencies only
+COPY package*.json ./
+RUN npm ci --omit=dev && npm cache clean --force
+
+# Copy built files
+COPY --from=builder /app/dist ./dist
+
+# Create data directory
+RUN mkdir -p /app/data && chown -R atauth:atauth /app/data
+
+# Switch to non-root user
+USER atauth
+
+# Environment defaults
+ENV NODE_ENV=production \
+ PORT=3100 \
+ HOST=0.0.0.0 \
+ DB_PATH=/app/data/gateway.db
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:3100/health || exit 1
+
+EXPOSE 3100
+
+VOLUME ["/app/data"]
+
+CMD ["node", "dist/index.js"]