diff --git a/README.md b/README.md index 40accfe..e7c00df 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,40 @@ # ATAuth - AT Protocol Authentication Library -A generic, plug-and-play authentication library for AT Protocol (Bluesky) OAuth integration. +A complete, plug-and-play authentication system for AT Protocol (Bluesky) OAuth integration. + +## Components + +| Component | Description | +|-----------|-------------| +| **[gateway/](gateway/)** | Node.js OAuth gateway server | +| **[src/](src/)** | Rust token verification library | +| **[ts/](ts/)** | TypeScript/React frontend utilities | ## Features -- **Token Verification**: Secure HMAC-SHA256 token verification with constant-time comparison -- **Session Management**: Trait-based session storage with SQLite and PostgreSQL backends +- **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)│ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + │ token │ HMAC secret + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Your Backend │────▶│ Token Verify │ +│ (Rust/Node) │ │ (atauth lib) │ +└─────────────────┘ └─────────────────┘ +``` + ## Installation ### Rust diff --git a/gateway/.env.example b/gateway/.env.example new file mode 100644 index 0000000..b1d3b2e --- /dev/null +++ b/gateway/.env.example @@ -0,0 +1,19 @@ +# Gateway Server Configuration +PORT=3100 +HOST=0.0.0.0 + +# OAuth Client Configuration +# Update these to match your deployment domain +OAUTH_CLIENT_ID=https://auth.yourdomain.com/client-metadata.json +OAUTH_REDIRECT_URI=https://auth.yourdomain.com/auth/callback + +# Admin API Token (generate a secure random string) +# Required to register new applications via /admin/games +ADMIN_TOKEN=your-secure-admin-token-here + +# Database Path (SQLite) +DB_PATH=./data/gateway.db + +# CORS Origins (comma-separated) +# Add your application domains +CORS_ORIGINS=http://localhost:3000,https://app.yourdomain.com diff --git a/gateway/README.md b/gateway/README.md new file mode 100644 index 0000000..844c64d --- /dev/null +++ b/gateway/README.md @@ -0,0 +1,265 @@ +# ATAuth Gateway + +AT Protocol OAuth gateway for application authentication. This gateway handles the OAuth flow with Bluesky/AT Protocol and issues HMAC-signed tokens that your backend servers can verify. + +## Quick Start + +### 1. Install Dependencies + +```bash +cd gateway +npm install +``` + +### 2. Configure Environment + +```bash +cp .env.example .env +``` + +Edit `.env`: +```bash +# Your gateway's public URL +OAUTH_CLIENT_ID=https://auth.yourdomain.com/client-metadata.json +OAUTH_REDIRECT_URI=https://auth.yourdomain.com/auth/callback + +# Admin API token (generate a secure random string) +ADMIN_TOKEN=your-secure-admin-token + +# Your application domains +CORS_ORIGINS=https://app.yourdomain.com,http://localhost:3000 +``` + +### 3. Start the Gateway + +```bash +npm run dev # Development +npm start # Production +``` + +### 4. Register Your Application + +```bash +curl -X POST http://localhost:3100/admin/apps \ + -H "Authorization: Bearer your-admin-token" \ + -H "Content-Type: application/json" \ + -d '{ + "id": "myapp", + "name": "My Application", + "callback_url": "https://myapp.com/auth/callback" + }' +``` + +Save the returned `hmac_secret` - you'll need it for your backend! + +## API Endpoints + +### Authentication Flow + +#### `POST /auth/init` +Start OAuth flow. + +```json +{ + "app_id": "myapp", + "handle": "user.bsky.social", + "redirect_uri": "https://myapp.com/auth/callback" +} +``` + +Returns: +```json +{ + "auth_url": "https://bsky.social/oauth/authorize?...", + "state": "...", + "app_id": "myapp" +} +``` + +#### `GET /auth/callback` +OAuth callback (redirects with token). + +Query params from AT Protocol OAuth flow are processed, then redirects to your `redirect_uri` with: +- `token` - HMAC-signed token for your backend +- `session_id` - Gateway session ID +- `needs_linking` - `true` if user not yet linked to app account + +#### `POST /auth/link` +Link AT Protocol identity to your app's user account. + +```json +{ + "session_id": "...", + "user_id": 123, + "app_id": "myapp" +} +``` + +#### `POST /auth/refresh` +Get a fresh token for an existing session. + +#### `POST /auth/logout` +Invalidate a session. + +### Token Verification + +#### `POST /token/verify` +Verify a token (for your backend server). + +```json +{ + "token": "...", + "app_id": "myapp" +} +``` + +Returns: +```json +{ + "valid": true, + "payload": { + "did": "did:plc:...", + "handle": "user.bsky.social", + "user_id": 123, + "app_id": "myapp", + "exp": 1699903600 + } +} +``` + +### Session Management + +#### `POST /session/check-conflict` +Check for existing sessions (multi-device support). + +#### `POST /session/resolve-conflict` +Resolve session conflict (`transfer`, `cancel`, `close_others`). + +#### `POST /session/update-state` +Update connection state (for WebSocket apps). + +### Admin + +#### `POST /admin/apps` +Register a new application (returns HMAC secret). + +#### `PUT /admin/apps/:id` +Update app config (can rotate secret). + +## Integration Examples + +### Frontend (React) + +```tsx +import { redirectToAuth, handleCallback } from '@arcnode/atauth'; + +// Start login +const startLogin = async (handle: string) => { + const response = await fetch('https://auth.yourdomain.com/auth/init', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + app_id: 'myapp', + handle, + redirect_uri: window.location.origin + '/auth/callback' + }) + }); + const { auth_url } = await response.json(); + window.location.href = auth_url; +}; + +// Handle callback page +const CallbackPage = () => { + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const token = params.get('token'); + if (token) { + // Store token and redirect to app + localStorage.setItem('auth_token', token); + window.location.href = '/dashboard'; + } + }, []); +}; +``` + +### Backend (Node.js) + +```typescript +import { TokenVerifier } from 'atauth'; + +const verifier = new TokenVerifier(process.env.HMAC_SECRET); + +app.use('/api', (req, res, next) => { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) { + return res.status(401).json({ error: 'No token' }); + } + + try { + const payload = verifier.verify(token); + req.user = payload; + next(); + } catch (e) { + res.status(401).json({ error: 'Invalid token' }); + } +}); +``` + +### Backend (Rust) + +```rust +use atauth::TokenVerifier; + +let verifier = TokenVerifier::new(hmac_secret.as_bytes()); + +match verifier.verify(&token) { + Ok(payload) => { + println!("User: {} ({})", payload.handle, payload.did); + // payload.user_id is your app's user ID if linked + } + Err(e) => { + eprintln!("Auth failed: {}", e); + } +} +``` + +## Deployment + +### Docker + +```dockerfile +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production +COPY dist ./dist +COPY data ./data +EXPOSE 3100 +CMD ["node", "dist/index.js"] +``` + +### systemd + +```ini +[Unit] +Description=ATAuth Gateway +After=network.target + +[Service] +Type=simple +User=atauth +WorkingDirectory=/opt/atauth-gateway +ExecStart=/usr/bin/node dist/index.js +Restart=on-failure +Environment=NODE_ENV=production + +[Install] +WantedBy=multi-user.target +``` + +## Security Notes + +1. **HTTPS Required**: Always run behind HTTPS in production +2. **Secure Admin Token**: Use a long random string for `ADMIN_TOKEN` +3. **Store HMAC Secrets Safely**: Never commit secrets to git +4. **Restrict CORS**: Only allow your application domains diff --git a/gateway/package.json b/gateway/package.json new file mode 100644 index 0000000..9a3c94c --- /dev/null +++ b/gateway/package.json @@ -0,0 +1,49 @@ +{ + "name": "@arcnode/atauth-gateway", + "version": "0.1.0", + "description": "AT Protocol OAuth gateway for application authentication", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts", + "lint": "eslint src/", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "atproto", + "bluesky", + "authentication", + "oauth", + "gateway" + ], + "author": "Bryan Brooks ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://your-gitea-instance.example.com/Arcnode.xyz/atauth" + }, + "dependencies": { + "@atproto/oauth-client-node": "^0.1.0", + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "express": "^4.18.2", + "helmet": "^7.1.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.8", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "@types/uuid": "^9.0.7", + "eslint": "^8.56.0", + "tsx": "^4.7.0", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/gateway/src/index.ts b/gateway/src/index.ts new file mode 100644 index 0000000..118d9c7 --- /dev/null +++ b/gateway/src/index.ts @@ -0,0 +1,156 @@ +/** + * ATAuth Gateway + * + * AT Protocol OAuth gateway for application authentication. + * Issues HMAC-signed tokens for backend servers to verify user identity. + */ + +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import path from 'path'; +import fs from 'fs'; + +import { DatabaseService } from './services/database.js'; +import { OAuthService } from './services/oauth.js'; +import { createAuthRoutes } from './routes/auth.js'; +import { createTokenRoutes } from './routes/token.js'; +import { createAdminRoutes } from './routes/admin.js'; +import { createSessionRoutes } from './routes/session.js'; + +// Configuration from environment +const config = { + port: parseInt(process.env.PORT || '3100', 10), + host: process.env.HOST || '0.0.0.0', + + // OAuth client configuration + clientId: process.env.OAUTH_CLIENT_ID || 'https://auth.example.com/client-metadata.json', + redirectUri: process.env.OAUTH_REDIRECT_URI || 'https://auth.example.com/auth/callback', + + // Admin token for app registration + adminToken: process.env.ADMIN_TOKEN, + + // Database path + dbPath: process.env.DB_PATH || path.join(process.cwd(), 'data', 'gateway.db'), + + // CORS origins + corsOrigins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'], +}; + +async function main(): Promise { + console.log('Starting ATAuth Gateway...'); + + // Ensure data directory exists + const dataDir = path.dirname(config.dbPath); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + + // Initialize services + const db = new DatabaseService(config.dbPath); + const oauth = new OAuthService(db, config.clientId, config.redirectUri); + + try { + await oauth.initialize(); + console.log('OAuth client initialized'); + } catch (error) { + console.error('Failed to initialize OAuth client:', error); + } + + // Create Express app + const app = express(); + + // Middleware + app.use(helmet({ + crossOriginResourcePolicy: { policy: 'cross-origin' }, + })); + app.use(cors({ + origin: config.corsOrigins, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], + })); + app.use(express.json()); + + // Request logging + app.use((req, _res, next) => { + console.log(`${new Date().toISOString()} ${req.method} ${req.path}`); + next(); + }); + + // Health check + app.get('/health', (_req, res) => { + res.json({ + status: 'ok', + service: 'atauth-gateway', + timestamp: new Date().toISOString(), + }); + }); + + // Routes + app.use('/auth', createAuthRoutes(db, oauth)); + app.use('/token', createTokenRoutes(db)); + app.use('/admin', createAdminRoutes(db, config.adminToken)); + app.use('/session', createSessionRoutes(db)); + + // OAuth client metadata (for AT Protocol discovery) + app.get('/client-metadata.json', (_req, res) => { + res.json({ + client_id: config.clientId, + client_name: 'ATAuth Gateway', + client_uri: config.clientId.replace('/client-metadata.json', ''), + redirect_uris: [config.redirectUri], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: 'atproto transition:generic', + application_type: 'web', + token_endpoint_auth_method: 'none', + dpop_bound_access_tokens: true, + }); + }); + + // Error handler + app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error('Unhandled error:', err); + res.status(500).json({ + error: 'internal_error', + message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error', + }); + }); + + // Graceful shutdown + const shutdown = () => { + console.log('Shutting down...'); + db.close(); + process.exit(0); + }; + + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + + // Periodic cleanup (every hour) + setInterval(() => { + const statesDeleted = db.cleanupOldOAuthStates(); + const sessionsDeleted = db.cleanupExpiredSessions(); + if (statesDeleted > 0 || sessionsDeleted > 0) { + console.log(`Cleanup: ${statesDeleted} OAuth states, ${sessionsDeleted} sessions`); + } + }, 60 * 60 * 1000); + + // Start server + app.listen(config.port, config.host, () => { + console.log(`ATAuth Gateway listening on ${config.host}:${config.port}`); + console.log(`OAuth Client ID: ${config.clientId}`); + console.log(`OAuth Redirect URI: ${config.redirectUri}`); + if (config.adminToken) { + console.log('Admin endpoints enabled'); + } else { + console.log('Admin endpoints disabled (set ADMIN_TOKEN to enable)'); + } + }); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/gateway/src/routes/admin.ts b/gateway/src/routes/admin.ts new file mode 100644 index 0000000..469c298 --- /dev/null +++ b/gateway/src/routes/admin.ts @@ -0,0 +1,197 @@ +/** + * Admin Routes + * + * Application registration and management endpoints + */ + +import { Router, Request, Response } from 'express'; +import { DatabaseService } from '../services/database.js'; +import { generateHmacSecret } from '../utils/hmac.js'; + +export function createAdminRoutes(db: DatabaseService, adminToken?: string): Router { + const router = Router(); + + const requireAdmin = (req: Request, res: Response, next: () => void) => { + if (!adminToken) { + return res.status(403).json({ + error: 'admin_disabled', + message: 'Admin endpoints are disabled (set ADMIN_TOKEN)', + }); + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + error: 'missing_auth', + message: 'Authorization header required', + }); + } + + const token = authHeader.substring(7); + if (token !== adminToken) { + return res.status(403).json({ + error: 'invalid_token', + message: 'Invalid admin token', + }); + } + + next(); + }; + + /** + * POST /admin/apps + * Register a new application + * + * Body: + * - id: Unique app identifier + * - name: Display name + * - token_ttl_seconds: Token lifetime (default 3600) + * - callback_url: OAuth callback URL + */ + router.post('/apps', requireAdmin, async (req: Request, res: Response) => { + try { + const { id, name, token_ttl_seconds, callback_url } = req.body; + + if (!id || !name) { + return res.status(400).json({ + error: 'missing_params', + message: 'id and name are required', + }); + } + + const existing = db.getApp(id); + if (existing) { + return res.status(409).json({ + error: 'app_exists', + message: `Application '${id}' already exists`, + }); + } + + const hmac_secret = generateHmacSecret(); + + db.upsertApp({ + id, + name, + hmac_secret, + token_ttl_seconds: token_ttl_seconds || 3600, + callback_url, + }); + + res.status(201).json({ + id, + name, + hmac_secret, + token_ttl_seconds: token_ttl_seconds || 3600, + callback_url, + message: 'Application registered. Store the hmac_secret securely!', + }); + } catch (error) { + console.error('App registration error:', error); + res.status(500).json({ + error: 'registration_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * GET /admin/apps/:id + * Get application configuration (without secret) + */ + router.get('/apps/:id', requireAdmin, async (req: Request, res: Response) => { + try { + const app = db.getApp(req.params.id); + if (!app) { + return res.status(404).json({ + error: 'app_not_found', + message: `Application '${req.params.id}' not found`, + }); + } + + res.json({ + id: app.id, + name: app.name, + token_ttl_seconds: app.token_ttl_seconds, + callback_url: app.callback_url, + }); + } catch (error) { + console.error('Get app error:', error); + res.status(500).json({ + error: 'get_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * PUT /admin/apps/:id + * Update application configuration + */ + router.put('/apps/:id', requireAdmin, async (req: Request, res: Response) => { + try { + const existing = db.getApp(req.params.id); + if (!existing) { + return res.status(404).json({ + error: 'app_not_found', + message: `Application '${req.params.id}' not found`, + }); + } + + const { name, token_ttl_seconds, callback_url, rotate_secret } = req.body; + + const updated = { + id: req.params.id, + name: name || existing.name, + hmac_secret: rotate_secret ? generateHmacSecret() : existing.hmac_secret, + token_ttl_seconds: token_ttl_seconds || existing.token_ttl_seconds, + callback_url: callback_url !== undefined ? callback_url : existing.callback_url, + }; + + db.upsertApp(updated); + + const response: Record = { + id: updated.id, + name: updated.name, + token_ttl_seconds: updated.token_ttl_seconds, + callback_url: updated.callback_url, + }; + + if (rotate_secret) { + response.hmac_secret = updated.hmac_secret; + response.message = 'Secret rotated. Update your backend configuration!'; + } + + res.json(response); + } catch (error) { + console.error('Update app error:', error); + res.status(500).json({ + error: 'update_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * POST /admin/cleanup + * Clean up expired sessions and OAuth states + */ + router.post('/cleanup', requireAdmin, async (_req: Request, res: Response) => { + try { + const statesDeleted = db.cleanupOldOAuthStates(); + const sessionsDeleted = db.cleanupExpiredSessions(); + + res.json({ + oauth_states_deleted: statesDeleted, + sessions_deleted: sessionsDeleted, + }); + } catch (error) { + console.error('Cleanup error:', error); + res.status(500).json({ + error: 'cleanup_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +} diff --git a/gateway/src/routes/auth.ts b/gateway/src/routes/auth.ts new file mode 100644 index 0000000..9344f08 --- /dev/null +++ b/gateway/src/routes/auth.ts @@ -0,0 +1,322 @@ +/** + * Auth Routes + * + * OAuth flow endpoints for AT Protocol authentication + */ + +import { Router, Request, Response } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { OAuthService } from '../services/oauth.js'; +import { DatabaseService } from '../services/database.js'; +import { createGatewayToken } from '../utils/hmac.js'; + +export function createAuthRoutes( + db: DatabaseService, + oauth: OAuthService +): Router { + const router = Router(); + + /** + * POST /auth/init + * Start OAuth flow for an application + * + * Body: + * - app_id: The application identifier (required) + * - handle: The user's AT Protocol handle (required) + * - redirect_uri: Where to redirect after auth (optional) + */ + router.post('/init', async (req: Request, res: Response) => { + try { + const { app_id, handle, redirect_uri } = req.body; + + if (!app_id || typeof app_id !== 'string') { + return res.status(400).json({ + error: 'missing_app_id', + message: 'app_id is required', + }); + } + + if (!handle || typeof handle !== 'string') { + return res.status(400).json({ + error: 'missing_handle', + message: 'handle is required (e.g., yourname.bsky.social)', + }); + } + + const app = db.getApp(app_id); + if (!app) { + return res.status(404).json({ + error: 'app_not_found', + message: `Application '${app_id}' is not registered`, + }); + } + + const { url, state } = await oauth.generateAuthUrl( + app_id, + handle, + typeof redirect_uri === 'string' ? redirect_uri : undefined + ); + + res.json({ + auth_url: url, + state, + app_id, + }); + } catch (error) { + console.error('Auth init error:', error); + res.status(500).json({ + error: 'auth_init_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * GET /auth/callback + * OAuth callback handler + */ + router.get('/callback', async (req: Request, res: Response) => { + try { + const params = new URLSearchParams(req.url.split('?')[1] || ''); + + const state = params.get('state'); + if (!state) { + return res.status(400).json({ + error: 'missing_state', + message: 'OAuth state parameter is missing', + }); + } + + const savedState = db.getOAuthState(state); + if (!savedState) { + return res.status(400).json({ + error: 'invalid_state', + message: 'OAuth state not found or expired', + }); + } + + db.deleteOAuthState(state); + + const app = db.getApp(savedState.app_id); + if (!app) { + return res.status(404).json({ + error: 'app_not_found', + message: 'Application configuration not found', + }); + } + + const result = await oauth.handleCallback(params); + + const existingMapping = db.getUserMapping(result.did, savedState.app_id); + const userId = existingMapping?.user_id ?? null; + + const token = createGatewayToken( + { + did: result.did, + handle: result.handle, + user_id: userId, + app_id: savedState.app_id, + }, + app.hmac_secret, + app.token_ttl_seconds + ); + + const sessionId = uuidv4(); + const expiresAt = new Date(Date.now() + app.token_ttl_seconds * 1000); + + db.createSession({ + id: sessionId, + did: result.did, + handle: result.handle, + user_id: userId, + app_id: savedState.app_id, + expires_at: expiresAt, + }); + + if (savedState.redirect_uri) { + const redirectUrl = new URL(savedState.redirect_uri); + redirectUrl.searchParams.set('token', token); + redirectUrl.searchParams.set('session_id', sessionId); + if (userId === null) { + redirectUrl.searchParams.set('needs_linking', 'true'); + } + return res.redirect(redirectUrl.toString()); + } + + res.json({ + token, + session_id: sessionId, + did: result.did, + handle: result.handle, + user_id: userId, + needs_linking: userId === null, + expires_at: expiresAt.toISOString(), + }); + } catch (error) { + console.error('Auth callback error:', error); + res.status(500).json({ + error: 'auth_callback_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * POST /auth/link + * Link an AT Protocol identity to an application user account + */ + router.post('/link', async (req: Request, res: Response) => { + try { + const { session_id, user_id, app_id } = req.body; + + if (!session_id || !user_id || !app_id) { + return res.status(400).json({ + error: 'missing_params', + message: 'session_id, user_id, and app_id are required', + }); + } + + const session = db.getSession(session_id); + if (!session) { + return res.status(404).json({ + error: 'session_not_found', + message: 'Session not found or expired', + }); + } + + if (session.app_id !== app_id) { + return res.status(400).json({ + error: 'app_mismatch', + message: 'Session app_id does not match', + }); + } + + const app = db.getApp(app_id); + if (!app) { + return res.status(404).json({ + error: 'app_not_found', + message: 'Application not found', + }); + } + + db.setUserMapping({ + did: session.did, + app_id, + user_id: parseInt(user_id, 10), + handle: session.handle, + }); + + const token = createGatewayToken( + { + did: session.did, + handle: session.handle, + user_id: parseInt(user_id, 10), + app_id, + }, + app.hmac_secret, + app.token_ttl_seconds + ); + + res.json({ + success: true, + token, + did: session.did, + handle: session.handle, + user_id: parseInt(user_id, 10), + }); + } catch (error) { + console.error('Link error:', error); + res.status(500).json({ + error: 'link_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * POST /auth/refresh + * Refresh an expired gateway token + */ + router.post('/refresh', async (req: Request, res: Response) => { + try { + const { session_id, app_id } = req.body; + + if (!session_id || !app_id) { + return res.status(400).json({ + error: 'missing_params', + message: 'session_id and app_id are required', + }); + } + + const session = db.getSession(session_id); + if (!session) { + return res.status(404).json({ + error: 'session_not_found', + message: 'Session not found or expired', + }); + } + + const app = db.getApp(app_id); + if (!app) { + return res.status(404).json({ + error: 'app_not_found', + message: 'Application not found', + }); + } + + const mapping = db.getUserMapping(session.did, app_id); + const userId = mapping?.user_id ?? session.user_id; + + const token = createGatewayToken( + { + did: session.did, + handle: session.handle, + user_id: userId, + app_id, + }, + app.hmac_secret, + app.token_ttl_seconds + ); + + res.json({ + token, + expires_in: app.token_ttl_seconds, + }); + } catch (error) { + console.error('Refresh error:', error); + res.status(500).json({ + error: 'refresh_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * POST /auth/logout + * Invalidate a session + */ + router.post('/logout', async (req: Request, res: Response) => { + try { + const { session_id } = req.body; + + if (!session_id) { + return res.status(400).json({ + error: 'missing_session_id', + message: 'session_id is required', + }); + } + + db.deleteSession(session_id); + + res.json({ success: true }); + } catch (error) { + console.error('Logout error:', error); + res.status(500).json({ + error: 'logout_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +} diff --git a/gateway/src/routes/session.ts b/gateway/src/routes/session.ts new file mode 100644 index 0000000..98e614c --- /dev/null +++ b/gateway/src/routes/session.ts @@ -0,0 +1,293 @@ +/** + * Session Routes + * + * Endpoints for session conflict detection and resolution + */ + +import { Router, Request, Response } from 'express'; +import { DatabaseService } from '../services/database.js'; +import { createGatewayToken } from '../utils/hmac.js'; +import type { SessionResolution, SessionConflict } from '../types/index.js'; + +export function createSessionRoutes(db: DatabaseService): Router { + const router = Router(); + + /** + * POST /session/check-conflict + * Check for existing active sessions + */ + router.post('/check-conflict', async (req: Request, res: Response) => { + try { + const { session_id, app_id } = req.body; + + if (!session_id || typeof session_id !== 'string') { + return res.status(400).json({ + error: 'missing_session_id', + message: 'session_id is required', + }); + } + + if (!app_id || typeof app_id !== 'string') { + return res.status(400).json({ + error: 'missing_app_id', + message: 'app_id is required', + }); + } + + const pendingSession = db.getSession(session_id); + if (!pendingSession) { + return res.status(404).json({ + error: 'session_not_found', + message: 'Pending session not found or expired', + }); + } + + const activeSessions = db.getActiveSessionsByDid(pendingSession.did, app_id); + const otherSessions = activeSessions.filter((s) => s.id !== session_id); + + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + const conflictingSessions = otherSessions.filter( + (s) => s.connection_state === 'connected' || s.last_activity > fiveMinutesAgo + ); + + const response: SessionConflict = { + has_conflict: conflictingSessions.length > 0, + existing_sessions: conflictingSessions.map((s) => ({ + session_id: s.id, + created_at: s.created_at.toISOString(), + last_activity: s.last_activity.toISOString(), + connection_state: s.connection_state, + client_info: s.client_info, + })), + pending_session_id: session_id, + }; + + res.json(response); + } catch (error) { + console.error('Check conflict error:', error); + res.status(500).json({ + error: 'check_conflict_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * POST /session/resolve-conflict + * Resolve a session conflict + */ + router.post('/resolve-conflict', async (req: Request, res: Response) => { + try { + const { session_id, app_id, resolution } = req.body; + + if (!session_id || !app_id) { + return res.status(400).json({ + error: 'missing_params', + message: 'session_id and app_id are required', + }); + } + + const validResolutions: SessionResolution[] = ['transfer', 'cancel', 'close_others']; + if (!resolution || !validResolutions.includes(resolution)) { + return res.status(400).json({ + error: 'invalid_resolution', + message: "resolution must be: 'transfer', 'cancel', or 'close_others'", + }); + } + + const session = db.getSession(session_id); + if (!session) { + return res.status(404).json({ + error: 'session_not_found', + message: 'Session not found or expired', + }); + } + + const app = db.getApp(app_id); + if (!app) { + return res.status(404).json({ + error: 'app_not_found', + message: 'Application not found', + }); + } + + switch (resolution as SessionResolution) { + case 'cancel': + db.deleteSession(session_id); + return res.json({ + success: true, + action: 'cancelled', + message: 'Login cancelled', + }); + + case 'close_others': + case 'transfer': { + const closedCount = db.deleteOtherSessions(session_id, session.did, app_id); + db.updateSessionConnectionState(session_id, 'pending'); + + const mapping = db.getUserMapping(session.did, app_id); + const userId = mapping?.user_id ?? session.user_id; + + const token = createGatewayToken( + { + did: session.did, + handle: session.handle, + user_id: userId, + app_id, + }, + app.hmac_secret, + app.token_ttl_seconds + ); + + return res.json({ + success: true, + action: resolution === 'transfer' ? 'transferred' : 'closed_others', + closed_count: closedCount, + token, + session_id, + did: session.did, + handle: session.handle, + user_id: userId, + }); + } + } + } catch (error) { + console.error('Resolve conflict error:', error); + res.status(500).json({ + error: 'resolve_conflict_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * POST /session/update-state + * Update session connection state + */ + router.post('/update-state', async (req: Request, res: Response) => { + try { + const { session_id, state, client_info } = req.body; + + if (!session_id) { + return res.status(400).json({ + error: 'missing_session_id', + message: 'session_id is required', + }); + } + + const validStates = ['connected', 'disconnected', 'pending']; + if (!state || !validStates.includes(state)) { + return res.status(400).json({ + error: 'invalid_state', + message: "state must be: 'connected', 'disconnected', or 'pending'", + }); + } + + const session = db.getSession(session_id); + if (!session) { + return res.status(404).json({ + error: 'session_not_found', + message: 'Session not found', + }); + } + + db.updateSessionConnectionState(session_id, state, client_info); + + res.json({ success: true, session_id, state }); + } catch (error) { + console.error('Update state error:', error); + res.status(500).json({ + error: 'update_state_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * POST /session/heartbeat + * Update session last activity + */ + router.post('/heartbeat', async (req: Request, res: Response) => { + try { + const { session_id } = req.body; + + if (!session_id) { + return res.status(400).json({ + error: 'missing_session_id', + message: 'session_id is required', + }); + } + + const session = db.getSession(session_id); + if (!session) { + return res.status(404).json({ + error: 'session_not_found', + message: 'Session not found or expired', + }); + } + + db.updateSessionActivity(session_id); + + res.json({ success: true, session_id }); + } catch (error) { + console.error('Heartbeat error:', error); + res.status(500).json({ + error: 'heartbeat_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * GET /session/active + * List all active sessions for the user + */ + router.get('/active', async (req: Request, res: Response) => { + try { + const { session_id, app_id } = req.query; + + if (!session_id || typeof session_id !== 'string') { + return res.status(400).json({ + error: 'missing_session_id', + message: 'session_id is required', + }); + } + + if (!app_id || typeof app_id !== 'string') { + return res.status(400).json({ + error: 'missing_app_id', + message: 'app_id is required', + }); + } + + const session = db.getSession(session_id); + if (!session) { + return res.status(404).json({ + error: 'session_not_found', + message: 'Session not found or expired', + }); + } + + const activeSessions = db.getActiveSessionsByDid(session.did, app_id); + + res.json({ + sessions: activeSessions.map((s) => ({ + session_id: s.id, + is_current: s.id === session_id, + created_at: s.created_at.toISOString(), + last_activity: s.last_activity.toISOString(), + connection_state: s.connection_state, + client_info: s.client_info, + })), + }); + } catch (error) { + console.error('List active sessions error:', error); + res.status(500).json({ + error: 'list_sessions_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +} diff --git a/gateway/src/routes/token.ts b/gateway/src/routes/token.ts new file mode 100644 index 0000000..5e166e1 --- /dev/null +++ b/gateway/src/routes/token.ts @@ -0,0 +1,133 @@ +/** + * Token Routes + * + * Token verification and management endpoints + */ + +import { Router, Request, Response } from 'express'; +import { DatabaseService } from '../services/database.js'; +import { verifyGatewayToken } from '../utils/hmac.js'; + +export function createTokenRoutes(db: DatabaseService): Router { + const router = Router(); + + /** + * POST /token/verify + * Verify a gateway token (for backend servers) + * + * Body: + * - token: The gateway token to verify + * - app_id: The application identifier + */ + router.post('/verify', async (req: Request, res: Response) => { + try { + const { token, app_id } = req.body; + + if (!token || !app_id) { + return res.status(400).json({ + valid: false, + error: 'missing_params', + message: 'token and app_id are required', + }); + } + + const app = db.getApp(app_id); + if (!app) { + return res.status(404).json({ + valid: false, + error: 'app_not_found', + message: `Application '${app_id}' is not registered`, + }); + } + + const payload = verifyGatewayToken(token, app.hmac_secret); + if (!payload) { + return res.status(401).json({ + valid: false, + error: 'invalid_token', + message: 'Token is invalid or expired', + }); + } + + if (payload.app_id !== app_id) { + return res.status(401).json({ + valid: false, + error: 'app_mismatch', + message: 'Token was issued for a different application', + }); + } + + res.json({ + valid: true, + payload: { + did: payload.did, + handle: payload.handle, + user_id: payload.user_id, + app_id: payload.app_id, + exp: payload.exp, + }, + }); + } catch (error) { + console.error('Token verify error:', error); + res.status(500).json({ + valid: false, + error: 'verify_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * GET /token/info + * Get information about a token + */ + router.get('/info', async (req: Request, res: Response) => { + try { + const { token, app_id } = req.query; + + if (!token || typeof token !== 'string' || !app_id || typeof app_id !== 'string') { + return res.status(400).json({ + error: 'missing_params', + message: 'token and app_id query parameters are required', + }); + } + + const app = db.getApp(app_id); + if (!app) { + return res.status(404).json({ + error: 'app_not_found', + message: `Application '${app_id}' is not registered`, + }); + } + + const payload = verifyGatewayToken(token, app.hmac_secret); + if (!payload) { + return res.status(401).json({ + error: 'invalid_token', + message: 'Token is invalid or expired', + }); + } + + const now = Math.floor(Date.now() / 1000); + const remainingSeconds = payload.exp - now; + + res.json({ + did: payload.did, + handle: payload.handle, + user_id: payload.user_id, + app_id: payload.app_id, + issued_at: new Date(payload.iat * 1000).toISOString(), + expires_at: new Date(payload.exp * 1000).toISOString(), + remaining_seconds: remainingSeconds, + }); + } catch (error) { + console.error('Token info error:', error); + res.status(500).json({ + error: 'info_failed', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +} diff --git a/gateway/src/services/database.ts b/gateway/src/services/database.ts new file mode 100644 index 0000000..9448d01 --- /dev/null +++ b/gateway/src/services/database.ts @@ -0,0 +1,260 @@ +/** + * Database Service + * + * SQLite database for OAuth state, app sessions, and user mappings + */ + +import Database from 'better-sqlite3'; +import path from 'path'; +import type { + AppConfig, + AppSession, + OAuthState, + UserMapping, + SessionConnectionState, + ActiveSession, +} from '../types/index.js'; + +export class DatabaseService { + private db: Database.Database; + + constructor(dbPath?: string) { + const defaultPath = path.join(process.cwd(), 'data', 'gateway.db'); + this.db = new Database(dbPath || defaultPath); + this.initialize(); + } + + private initialize(): void { + // Enable WAL mode for better concurrency + this.db.pragma('journal_mode = WAL'); + + // Create tables + this.db.exec(` + -- Application configurations (HMAC secrets per app) + CREATE TABLE IF NOT EXISTS apps ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + hmac_secret TEXT NOT NULL, + token_ttl_seconds INTEGER DEFAULT 3600, + callback_url TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + -- OAuth state for PKCE flow + CREATE TABLE IF NOT EXISTS oauth_states ( + state TEXT PRIMARY KEY, + code_verifier TEXT NOT NULL, + app_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (app_id) REFERENCES apps(id) + ); + + -- User mappings (DID -> app user_id) + CREATE TABLE IF NOT EXISTS user_mappings ( + did TEXT NOT NULL, + app_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + handle TEXT, + linked_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (did, app_id), + FOREIGN KEY (app_id) REFERENCES apps(id) + ); + + -- Active sessions + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + did TEXT NOT NULL, + handle TEXT NOT NULL, + user_id INTEGER, + app_id TEXT NOT NULL, + refresh_token TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + connection_state TEXT DEFAULT 'pending', + last_activity DATETIME DEFAULT CURRENT_TIMESTAMP, + client_info TEXT, + FOREIGN KEY (app_id) REFERENCES apps(id) + ); + + -- Indexes + CREATE INDEX IF NOT EXISTS idx_oauth_states_created ON oauth_states(created_at); + CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); + CREATE INDEX IF NOT EXISTS idx_sessions_did_app ON sessions(did, app_id); + `); + } + + // App configuration methods + getApp(appId: string): AppConfig | null { + const stmt = this.db.prepare('SELECT * FROM apps WHERE id = ?'); + const row = stmt.get(appId) as AppConfig | undefined; + return row || null; + } + + upsertApp(app: AppConfig): void { + const stmt = this.db.prepare(` + INSERT INTO apps (id, name, hmac_secret, token_ttl_seconds, callback_url) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + hmac_secret = excluded.hmac_secret, + token_ttl_seconds = excluded.token_ttl_seconds, + callback_url = excluded.callback_url + `); + stmt.run(app.id, app.name, app.hmac_secret, app.token_ttl_seconds, app.callback_url); + } + + // OAuth state methods + saveOAuthState(state: OAuthState): void { + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO oauth_states (state, code_verifier, app_id, redirect_uri, created_at) + VALUES (?, ?, ?, ?, ?) + `); + stmt.run(state.state, state.code_verifier, state.app_id, state.redirect_uri, state.created_at); + } + + getOAuthState(state: string): OAuthState | null { + const stmt = this.db.prepare('SELECT * FROM oauth_states WHERE state = ?'); + const row = stmt.get(state) as OAuthState | undefined; + return row || null; + } + + deleteOAuthState(state: string): void { + const stmt = this.db.prepare('DELETE FROM oauth_states WHERE state = ?'); + stmt.run(state); + } + + cleanupOldOAuthStates(): number { + const tenMinutesAgo = Math.floor(Date.now() / 1000) - 600; + const stmt = this.db.prepare('DELETE FROM oauth_states WHERE created_at < ?'); + const result = stmt.run(tenMinutesAgo); + return result.changes; + } + + // User mapping methods + getUserMapping(did: string, appId: string): UserMapping | null { + const stmt = this.db.prepare( + 'SELECT * FROM user_mappings WHERE did = ? AND app_id = ?' + ); + const row = stmt.get(did, appId) as UserMapping | undefined; + return row || null; + } + + setUserMapping(mapping: Omit): void { + const stmt = this.db.prepare(` + INSERT INTO user_mappings (did, app_id, user_id, handle) + VALUES (?, ?, ?, ?) + ON CONFLICT(did, app_id) DO UPDATE SET + user_id = excluded.user_id, + handle = excluded.handle + `); + stmt.run(mapping.did, mapping.app_id, mapping.user_id, mapping.handle); + } + + // Session methods + createSession(session: Omit): void { + const stmt = this.db.prepare(` + INSERT INTO sessions (id, did, handle, user_id, app_id, refresh_token, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + stmt.run( + session.id, + session.did, + session.handle, + session.user_id, + session.app_id, + session.refresh_token, + session.expires_at.toISOString() + ); + } + + getSession(sessionId: string): AppSession | null { + const stmt = this.db.prepare('SELECT * FROM sessions WHERE id = ?'); + const row = stmt.get(sessionId) as (Omit & { + created_at: string; + expires_at: string; + }) | undefined; + + if (!row) return null; + + return { + ...row, + created_at: new Date(row.created_at), + expires_at: new Date(row.expires_at), + }; + } + + deleteSession(sessionId: string): void { + const stmt = this.db.prepare('DELETE FROM sessions WHERE id = ?'); + stmt.run(sessionId); + } + + cleanupExpiredSessions(): number { + const stmt = this.db.prepare('DELETE FROM sessions WHERE expires_at < datetime("now")'); + const result = stmt.run(); + return result.changes; + } + + // Session conflict detection methods + getActiveSessionsByDid(did: string, appId: string): ActiveSession[] { + const stmt = this.db.prepare(` + SELECT * FROM sessions + WHERE did = ? AND app_id = ? AND expires_at > datetime('now') + ORDER BY created_at DESC + `); + const rows = stmt.all(did, appId) as Array<{ + id: string; + did: string; + handle: string; + user_id: number | null; + app_id: string; + refresh_token?: string; + created_at: string; + expires_at: string; + connection_state: SessionConnectionState; + last_activity: string; + client_info?: string; + }>; + + return rows.map((row) => ({ + ...row, + created_at: new Date(row.created_at), + expires_at: new Date(row.expires_at), + last_activity: new Date(row.last_activity), + connection_state: row.connection_state || 'pending', + })); + } + + updateSessionConnectionState( + sessionId: string, + state: SessionConnectionState, + clientInfo?: string + ): void { + const stmt = this.db.prepare(` + UPDATE sessions + SET connection_state = ?, last_activity = datetime('now'), client_info = COALESCE(?, client_info) + WHERE id = ? + `); + stmt.run(state, clientInfo, sessionId); + } + + updateSessionActivity(sessionId: string): void { + const stmt = this.db.prepare(` + UPDATE sessions SET last_activity = datetime('now') WHERE id = ? + `); + stmt.run(sessionId); + } + + deleteOtherSessions(keepSessionId: string, did: string, appId: string): number { + const stmt = this.db.prepare(` + DELETE FROM sessions + WHERE did = ? AND app_id = ? AND id != ? + `); + const result = stmt.run(did, appId, keepSessionId); + return result.changes; + } + + close(): void { + this.db.close(); + } +} diff --git a/gateway/src/services/oauth.ts b/gateway/src/services/oauth.ts new file mode 100644 index 0000000..ed84a2a --- /dev/null +++ b/gateway/src/services/oauth.ts @@ -0,0 +1,171 @@ +/** + * OAuth Service + * + * Handles AT Protocol OAuth flow using @atproto/oauth-client-node + */ + +import { NodeOAuthClient, NodeSavedState, NodeSavedSession } from '@atproto/oauth-client-node'; +import { DatabaseService } from './database.js'; +import type { OAuthState } from '../types/index.js'; + +// In-memory storage for OAuth client sessions +const sessionStore = new Map(); +const stateStore = new Map(); + +// Capture the most recently set state key +let pendingStateKey: string | null = null; + +export interface OAuthResult { + did: string; + handle: string; + accessJwt?: string; + refreshJwt?: string; +} + +export class OAuthService { + private client: NodeOAuthClient | null = null; + private db: DatabaseService; + private clientId: string; + private redirectUri: string; + + constructor(db: DatabaseService, clientId: string, redirectUri: string) { + this.db = db; + this.clientId = clientId; + this.redirectUri = redirectUri; + } + + async initialize(): Promise { + this.client = new NodeOAuthClient({ + clientMetadata: { + client_id: this.clientId, + client_name: 'ATAuth Gateway', + client_uri: this.clientId, + redirect_uris: [this.redirectUri], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: 'atproto transition:generic', + application_type: 'web', + token_endpoint_auth_method: 'none', + dpop_bound_access_tokens: true, + }, + stateStore: { + async get(key: string): Promise { + return stateStore.get(key); + }, + async set(key: string, state: NodeSavedState): Promise { + stateStore.set(key, state); + pendingStateKey = key; + }, + async del(key: string): Promise { + stateStore.delete(key); + }, + }, + sessionStore: { + async get(key: string): Promise { + return sessionStore.get(key); + }, + async set(key: string, session: NodeSavedSession): Promise { + sessionStore.set(key, session); + }, + async del(key: string): Promise { + sessionStore.delete(key); + }, + }, + }); + } + + /** + * Generate OAuth authorization URL + * + * @param appId - The application identifier + * @param handle - The user's AT Protocol handle + * @param customRedirect - Optional custom redirect URI + */ + async generateAuthUrl(appId: string, handle: string, customRedirect?: string): Promise<{ + url: string; + state: string; + }> { + if (!this.client) { + throw new Error('OAuth client not initialized'); + } + + if (!handle) { + throw new Error('Handle is required for OAuth authorization'); + } + + pendingStateKey = null; + + const url = await this.client.authorize(handle, { + scope: 'atproto transition:generic', + }); + + const state = pendingStateKey; + if (!state) { + throw new Error('OAuth library did not store state'); + } + + const oauthState: OAuthState = { + state, + code_verifier: '', + app_id: appId, + redirect_uri: customRedirect || this.redirectUri, + created_at: Math.floor(Date.now() / 1000), + }; + this.db.saveOAuthState(oauthState); + + console.log(`OAuth state saved: ${state} for app ${appId}`); + + return { url: url.toString(), state }; + } + + /** + * Handle OAuth callback and exchange code for tokens + */ + async handleCallback(params: URLSearchParams): Promise { + if (!this.client) { + throw new Error('OAuth client not initialized'); + } + + const { session } = await this.client.callback(params); + + const did: string = session.did; + + let handle: string = did; + try { + const resolved = await this.resolveDidToHandle(did); + if (resolved) { + handle = resolved; + } + } catch { + // Fall back to DID + } + + return { did, handle }; + } + + private async resolveDidToHandle(did: string): Promise { + try { + const response = await fetch( + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` + ); + if (!response.ok) return null; + const data = (await response.json()) as { handle: string }; + return data.handle; + } catch { + return null; + } + } + + async resolveHandle(handle: string): Promise { + try { + const response = await fetch( + `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` + ); + if (!response.ok) return null; + const data = (await response.json()) as { did: string }; + return data.did; + } catch { + return null; + } + } +} diff --git a/gateway/src/types/index.ts b/gateway/src/types/index.ts new file mode 100644 index 0000000..3498af9 --- /dev/null +++ b/gateway/src/types/index.ts @@ -0,0 +1,83 @@ +/** + * ATAuth Gateway Types + * + * Defines the token format and interfaces for application authentication + */ + +export interface GatewayTokenPayload { + /** AT Protocol DID (e.g., "did:plc:xyz...") */ + did: string; + /** User's handle (e.g., "alice.bsky.social") */ + handle: string; + /** Application-specific user ID (assigned by your app) */ + user_id: number | null; + /** Application identifier (e.g., "myapp", "game", "dashboard") */ + app_id: string; + /** Issued at (Unix timestamp) */ + iat: number; + /** Expires at (Unix timestamp) */ + exp: number; + /** Random nonce for replay protection */ + nonce: string; +} + +export interface AppSession { + id: string; + did: string; + handle: string; + user_id: number | null; + app_id: string; + created_at: Date; + expires_at: Date; + refresh_token?: string; +} + +export interface AppConfig { + id: string; + name: string; + hmac_secret: string; + token_ttl_seconds: number; + callback_url?: string; +} + +export interface OAuthState { + state: string; + code_verifier: string; + app_id: string; + redirect_uri: string; + created_at: number; +} + +export interface UserMapping { + did: string; + app_id: string; + user_id: number; + handle: string; + linked_at: Date; +} + +/** Session connection state for conflict detection */ +export type SessionConnectionState = 'pending' | 'connected' | 'disconnected'; + +/** Extended session info with connection tracking */ +export interface ActiveSession extends AppSession { + connection_state: SessionConnectionState; + last_activity: Date; + client_info?: string; +} + +/** Session conflict resolution options */ +export type SessionResolution = 'transfer' | 'cancel' | 'close_others'; + +/** Session conflict information returned to client */ +export interface SessionConflict { + has_conflict: boolean; + existing_sessions: Array<{ + session_id: string; + created_at: string; + last_activity: string; + connection_state: SessionConnectionState; + client_info?: string; + }>; + pending_session_id?: string; +} diff --git a/gateway/src/utils/hmac.ts b/gateway/src/utils/hmac.ts new file mode 100644 index 0000000..23721bb --- /dev/null +++ b/gateway/src/utils/hmac.ts @@ -0,0 +1,92 @@ +/** + * HMAC Token Utilities + * + * Creates and verifies HMAC-SHA256 signed tokens for application authentication. + * Token format: base64url(payload).base64url(hmac_sha256(payload, secret)) + */ + +import crypto from 'crypto'; +import type { GatewayTokenPayload } from '../types/index.js'; + +const ALGORITHM = 'sha256'; + +/** + * Create an HMAC-signed gateway token + */ +export function createGatewayToken( + payload: Omit, + secret: string, + ttlSeconds: number = 3600 +): string { + const now = Math.floor(Date.now() / 1000); + + const fullPayload: GatewayTokenPayload = { + ...payload, + iat: now, + exp: now + ttlSeconds, + nonce: crypto.randomBytes(16).toString('hex'), + }; + + const payloadJson = JSON.stringify(fullPayload); + const payloadBase64 = Buffer.from(payloadJson).toString('base64url'); + + const signature = crypto + .createHmac(ALGORITHM, secret) + .update(payloadBase64) + .digest('base64url'); + + return `${payloadBase64}.${signature}`; +} + +/** + * Verify an HMAC-signed gateway token + * Returns the payload if valid, null if invalid or expired + */ +export function verifyGatewayToken( + token: string, + secret: string +): GatewayTokenPayload | null { + const parts = token.split('.'); + if (parts.length !== 2) { + return null; + } + + const [payloadBase64, providedSignature] = parts; + + // Verify signature + const expectedSignature = crypto + .createHmac(ALGORITHM, secret) + .update(payloadBase64) + .digest('base64url'); + + // Constant-time comparison + if (!crypto.timingSafeEqual( + Buffer.from(providedSignature), + Buffer.from(expectedSignature) + )) { + return null; + } + + // Decode payload + try { + const payloadJson = Buffer.from(payloadBase64, 'base64url').toString('utf8'); + const payload = JSON.parse(payloadJson) as GatewayTokenPayload; + + // Check expiry + const now = Math.floor(Date.now() / 1000); + if (payload.exp < now) { + return null; + } + + return payload; + } catch { + return null; + } +} + +/** + * Generate a cryptographically secure HMAC secret + */ +export function generateHmacSecret(): string { + return crypto.randomBytes(32).toString('hex'); +} diff --git a/gateway/tsconfig.json b/gateway/tsconfig.json new file mode 100644 index 0000000..c40de27 --- /dev/null +++ b/gateway/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}