this repo has no description

Implement production-ready ATProto OAuth

- Use SQLite for session and state persistence (survives restarts)
- Use ES256 private key for confidential client authentication
- Expose /client-metadata.json and /jwks.json at root level
- Configure for standard HTTPS URL (no custom ports - required by Bluesky)
- Add proper error handling and error messages on login page
- Update documentation with deployment instructions

Co-authored-by: Shelley <shelley@exe.dev>

+268 -80
+41 -10
CLAUDE.md
··· 8 8 src/ 9 9 server.ts - Main Hono server entry point 10 10 lib/ 11 - oauth.ts - ATProto OAuth client setup 11 + oauth.ts - ATProto OAuth client setup with SQLite persistence 12 12 session.ts - Session management 13 13 routes/ 14 14 auth.ts - Authentication routes (login, callback, logout) ··· 20 20 main.ts - Main layout template 21 21 public/ 22 22 styles.css - Application styles 23 + data/ 24 + oauth.db - SQLite database for OAuth state and sessions 25 + private-key.json - ES256 private key for confidential client auth 23 26 ``` 24 27 25 28 ## Running ··· 35 38 ## Environment Variables 36 39 37 40 - `PORT` - Server port (default: 8000) 38 - - `PUBLIC_URL` - Public URL for OAuth callbacks (e.g., https://stdeditor.exe.xyz:8000) 41 + - `PUBLIC_URL` - Public URL for OAuth callbacks (MUST be HTTPS without custom port for production) 42 + - `DATA_DIR` - Directory for persistent data (default: ./data) 43 + 44 + ## Production Deployment 45 + 46 + **IMPORTANT**: Bluesky's OAuth server does not allow custom HTTPS ports. You must: 47 + 48 + 1. Configure exe.dev proxy to forward port 8000: 49 + ```bash 50 + ssh exe.dev share port stdeditor 8000 51 + ssh exe.dev share set-public stdeditor 52 + ``` 53 + 54 + 2. Set PUBLIC_URL to the standard HTTPS URL (no port): 55 + ``` 56 + PUBLIC_URL=https://stdeditor.exe.xyz 57 + ``` 58 + 59 + ## ATProto OAuth Implementation 60 + 61 + This app is a **confidential client** using: 62 + - ES256 key for client authentication (stored in data/private-key.json) 63 + - SQLite for session and state persistence 64 + - DPoP bound access tokens 65 + - PAR (Pushed Authorization Requests) 66 + 67 + ### OAuth Endpoints 68 + 69 + - `GET /client-metadata.json` - OAuth client metadata 70 + - `GET /jwks.json` - JSON Web Key Set for client authentication 71 + - `GET /auth/login` - Login form 72 + - `POST /auth/login` - Initiate OAuth flow 73 + - `GET /auth/callback` - OAuth callback handler 74 + - `GET /auth/logout` - Logout and revoke session 39 75 40 76 ## ATProto Collections 41 77 ··· 45 81 ## Key Dependencies 46 82 47 83 - `hono` - Web framework 48 - - `@atproto/oauth-client-node` - ATProto OAuth 84 + - `@atproto/oauth-client-node` - ATProto OAuth (production-ready) 49 85 - `@atproto/api` - ATProto API client 50 - - `@atproto/jwk-jose` - DPOP key generation 51 - 52 - ## Notes 53 - 54 - - Sessions are stored in-memory (not persisted across restarts) 55 - - OAuth uses loopback client for development 56 - - Documents use `draft` tag for unpublished state 86 + - `@atproto/jwk-jose` - ES256 key generation and management 87 + - `bun:sqlite` - SQLite for session persistence
+17
public/styles.css
··· 450 450 text-align: center; 451 451 padding: 2rem; 452 452 } 453 + 454 + .error-message { 455 + background: #fef2f2; 456 + border: 1px solid #fecaca; 457 + color: #dc2626; 458 + padding: 1rem; 459 + border-radius: 4px; 460 + margin-bottom: 1.5rem; 461 + } 462 + 463 + @media (prefers-color-scheme: dark) { 464 + .error-message { 465 + background: #450a0a; 466 + border-color: #7f1d1d; 467 + color: #fca5a5; 468 + } 469 + }
+111 -41
src/lib/oauth.ts
··· 1 - import { NodeOAuthClient, NodeSavedSessionStore, NodeSavedStateStore } from '@atproto/oauth-client-node'; 1 + import { NodeOAuthClient } from '@atproto/oauth-client-node'; 2 + import type { NodeSavedSession, NodeSavedState } from '@atproto/oauth-client-node'; 3 + import { JoseKey } from '@atproto/jwk-jose'; 2 4 import { Agent } from '@atproto/api'; 3 - import { JoseKey } from '@atproto/jwk-jose'; 5 + import { Database } from 'bun:sqlite'; 6 + import * as fs from 'fs'; 7 + import * as path from 'path'; 4 8 5 - // In-memory stores - in production you'd want to persist these 6 - const sessionStore = new Map<string, any>(); 7 - const stateStore = new Map<string, any>(); 9 + // Constants 10 + const PUBLIC_URL = process.env.PUBLIC_URL || 'http://localhost:8000'; 11 + const DATA_DIR = process.env.DATA_DIR || './data'; 12 + const DB_PATH = path.join(DATA_DIR, 'oauth.db'); 13 + const KEYS_PATH = path.join(DATA_DIR, 'private-key.json'); 8 14 9 - const savedSessionStore: NodeSavedSessionStore = { 10 - async get(key: string) { 11 - return sessionStore.get(key); 15 + // Ensure data directory exists 16 + if (!fs.existsSync(DATA_DIR)) { 17 + fs.mkdirSync(DATA_DIR, { recursive: true }); 18 + } 19 + 20 + // Initialize SQLite database 21 + const db = new Database(DB_PATH); 22 + 23 + // Create tables for OAuth state and sessions 24 + db.run(` 25 + CREATE TABLE IF NOT EXISTS oauth_states ( 26 + key TEXT PRIMARY KEY, 27 + state TEXT NOT NULL, 28 + created_at INTEGER DEFAULT (strftime('%s', 'now')) 29 + ) 30 + `); 31 + 32 + db.run(` 33 + CREATE TABLE IF NOT EXISTS oauth_sessions ( 34 + did TEXT PRIMARY KEY, 35 + session TEXT NOT NULL, 36 + updated_at INTEGER DEFAULT (strftime('%s', 'now')) 37 + ) 38 + `); 39 + 40 + // Clean up old states (older than 1 hour) 41 + db.run(`DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600`); 42 + 43 + // State store implementation 44 + const stateStore = { 45 + async set(key: string, state: NodeSavedState): Promise<void> { 46 + const stateJson = JSON.stringify(state); 47 + db.run( 48 + `INSERT OR REPLACE INTO oauth_states (key, state, created_at) VALUES (?, ?, strftime('%s', 'now'))`, 49 + [key, stateJson] 50 + ); 12 51 }, 13 - async set(key: string, value: any) { 14 - sessionStore.set(key, value); 52 + async get(key: string): Promise<NodeSavedState | undefined> { 53 + const row = db.query(`SELECT state FROM oauth_states WHERE key = ?`).get(key) as { state: string } | null; 54 + if (!row) return undefined; 55 + return JSON.parse(row.state); 15 56 }, 16 - async del(key: string) { 17 - sessionStore.delete(key); 57 + async del(key: string): Promise<void> { 58 + db.run(`DELETE FROM oauth_states WHERE key = ?`, [key]); 18 59 }, 19 60 }; 20 61 21 - const savedStateStore: NodeSavedStateStore = { 22 - async get(key: string) { 23 - return stateStore.get(key); 62 + // Session store implementation 63 + const sessionStore = { 64 + async set(did: string, session: NodeSavedSession): Promise<void> { 65 + const sessionJson = JSON.stringify(session); 66 + db.run( 67 + `INSERT OR REPLACE INTO oauth_sessions (did, session, updated_at) VALUES (?, ?, strftime('%s', 'now'))`, 68 + [did, sessionJson] 69 + ); 24 70 }, 25 - async set(key: string, value: any) { 26 - stateStore.set(key, value); 71 + async get(did: string): Promise<NodeSavedSession | undefined> { 72 + const row = db.query(`SELECT session FROM oauth_sessions WHERE did = ?`).get(did) as { session: string } | null; 73 + if (!row) return undefined; 74 + return JSON.parse(row.session); 27 75 }, 28 - async del(key: string) { 29 - stateStore.delete(key); 76 + async del(did: string): Promise<void> { 77 + db.run(`DELETE FROM oauth_sessions WHERE did = ?`, [did]); 30 78 }, 31 79 }; 32 80 33 - const clientId = process.env.PUBLIC_URL 34 - ? `${process.env.PUBLIC_URL}/client-metadata.json` 35 - : 'http://localhost'; 36 - 37 - const baseUrl = process.env.PUBLIC_URL || 'http://localhost:8000'; 81 + // Generate or load private key for confidential client 82 + async function getOrCreatePrivateKey(): Promise<JoseKey> { 83 + if (fs.existsSync(KEYS_PATH)) { 84 + const keyData = JSON.parse(fs.readFileSync(KEYS_PATH, 'utf-8')); 85 + return JoseKey.fromJWK(keyData, keyData.kid); 86 + } 87 + 88 + // Generate a new ES256 key 89 + const key = await JoseKey.generate(['ES256'], crypto.randomUUID()); 90 + const jwk = key.privateJwk; 91 + 92 + // Save to disk 93 + fs.writeFileSync(KEYS_PATH, JSON.stringify(jwk, null, 2)); 94 + 95 + return key; 96 + } 38 97 39 98 let oauthClientInstance: NodeOAuthClient | null = null; 40 99 let initPromise: Promise<NodeOAuthClient> | null = null; ··· 44 103 if (initPromise) return initPromise; 45 104 46 105 initPromise = (async () => { 47 - // Generate a keypair for DPOP 48 - const keyset = await Promise.all([ 49 - JoseKey.generate(['ES256']) 50 - ]); 51 - 106 + const privateKey = await getOrCreatePrivateKey(); 107 + 52 108 oauthClientInstance = new NodeOAuthClient({ 53 109 clientMetadata: { 54 - client_id: clientId.startsWith('http://localhost') ? clientId : `${baseUrl}/client-metadata.json`, 110 + client_id: `${PUBLIC_URL}/client-metadata.json`, 55 111 client_name: 'stdeditor', 56 - client_uri: baseUrl, 57 - redirect_uris: [`${baseUrl}/auth/callback`], 112 + client_uri: PUBLIC_URL, 113 + redirect_uris: [`${PUBLIC_URL}/auth/callback`], 58 114 scope: 'atproto transition:generic', 59 115 grant_types: ['authorization_code', 'refresh_token'], 60 116 response_types: ['code'], 61 117 application_type: 'web', 62 - token_endpoint_auth_method: 'none', 118 + token_endpoint_auth_method: 'private_key_jwt', 119 + token_endpoint_auth_signing_alg: 'ES256', 63 120 dpop_bound_access_tokens: true, 121 + jwks_uri: `${PUBLIC_URL}/jwks.json`, 64 122 }, 65 - stateStore: savedStateStore, 66 - sessionStore: savedSessionStore, 67 - keyset, 123 + keyset: [privateKey], 124 + stateStore, 125 + sessionStore, 68 126 }); 69 127 70 128 return oauthClientInstance; ··· 77 135 return initOAuthClient(); 78 136 } 79 137 80 - export { oauthClientInstance as oauthClient }; 138 + export async function getClientMetadata() { 139 + const client = await getOAuthClient(); 140 + return client.clientMetadata; 141 + } 81 142 82 - export async function getAgentForSession(sessionId: string): Promise<{ agent: Agent; did: string; handle: string }> { 143 + export async function getJwks() { 83 144 const client = await getOAuthClient(); 84 - const oauthSession = await client.restore(sessionId); 145 + return client.jwks; 146 + } 147 + 148 + export async function getAgentForSession(did: string): Promise<{ agent: Agent; did: string; handle: string }> { 149 + const client = await getOAuthClient(); 150 + const oauthSession = await client.restore(did); 85 151 86 152 if (!oauthSession) { 87 153 throw new Error('Session not found'); ··· 90 156 const agent = new Agent(oauthSession); 91 157 92 158 // Fetch profile to get handle 93 - const profile = await agent.getProfile({ actor: oauthSession.did }); 159 + const profile = await agent.getProfile({ actor: did }); 94 160 95 161 return { 96 162 agent, 97 - did: oauthSession.did, 163 + did, 98 164 handle: profile.data.handle, 99 165 }; 100 166 } 167 + 168 + export async function deleteSession(did: string): Promise<void> { 169 + await sessionStore.del(did); 170 + }
+5 -4
src/lib/session.ts
··· 1 1 import type { Context } from 'hono'; 2 2 import { getCookie } from 'hono/cookie'; 3 - import { oauthClient, getAgentForSession } from './oauth'; 3 + import { getAgentForSession } from './oauth'; 4 4 import type { Agent } from '@atproto/api'; 5 5 6 6 export interface Session { ··· 10 10 } 11 11 12 12 export async function getSession(c: Context): Promise<Session> { 13 - const sessionId = getCookie(c, 'session'); 13 + const did = getCookie(c, 'session'); 14 14 15 - if (!sessionId) { 15 + if (!did) { 16 16 return { did: null, handle: null, agent: null }; 17 17 } 18 18 19 19 try { 20 - const { agent, did, handle } = await getAgentForSession(sessionId); 20 + const { agent, handle } = await getAgentForSession(did); 21 21 return { did, handle, agent }; 22 22 } catch (error) { 23 + // Session might be invalid or expired 23 24 console.error('Session error:', error); 24 25 return { did: null, handle: null, agent: null }; 25 26 }
+70 -24
src/routes/auth.ts
··· 1 1 import { Hono } from 'hono'; 2 2 import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; 3 3 import { html } from 'hono/html'; 4 - import { getOAuthClient } from '../lib/oauth'; 4 + import { getOAuthClient, getClientMetadata, getJwks, deleteSession } from '../lib/oauth'; 5 5 import { layout } from '../views/layouts/main'; 6 6 7 7 export const authRoutes = new Hono(); 8 + 9 + // Client metadata endpoint (required for OAuth) 10 + authRoutes.get('/client-metadata.json', async (c) => { 11 + try { 12 + const metadata = await getClientMetadata(); 13 + return c.json(metadata); 14 + } catch (error) { 15 + console.error('Error getting client metadata:', error); 16 + return c.json({ error: 'Failed to get client metadata' }, 500); 17 + } 18 + }); 19 + 20 + // JWKS endpoint (required for confidential clients) 21 + authRoutes.get('/jwks.json', async (c) => { 22 + try { 23 + const jwks = await getJwks(); 24 + return c.json(jwks); 25 + } catch (error) { 26 + console.error('Error getting JWKS:', error); 27 + return c.json({ error: 'Failed to get JWKS' }, 500); 28 + } 29 + }); 8 30 9 31 // Login page 10 32 authRoutes.get('/login', async (c) => { 33 + const error = c.req.query('error'); 34 + 11 35 const content = html` 12 36 <div class="auth-form"> 13 37 <h1>Login with Bluesky</h1> 38 + 39 + ${error ? html` 40 + <div class="error-message"> 41 + ${error === 'handle_required' ? 'Please enter your handle or DID.' : 42 + error === 'authorization_failed' ? 'Authorization failed. Please try again.' : 43 + error === 'callback_failed' ? 'Login failed. Please try again.' : 44 + 'An error occurred. Please try again.'} 45 + </div> 46 + ` : ''} 47 + 14 48 <form action="/auth/login" method="POST"> 15 49 <div class="form-group"> 16 50 <label for="handle">Handle or DID</label> ··· 20 54 name="handle" 21 55 placeholder="yourname.bsky.social" 22 56 required 57 + autocomplete="username" 58 + autocapitalize="none" 23 59 /> 60 + <small>Enter your Bluesky handle (e.g., yourname.bsky.social) or DID</small> 24 61 </div> 25 62 <button type="submit" class="btn btn-primary">Login</button> 26 63 </form> ··· 33 70 // Handle login form submission 34 71 authRoutes.post('/login', async (c) => { 35 72 const body = await c.req.parseBody(); 36 - const handle = body.handle as string; 73 + let handle = body.handle as string; 37 74 38 75 if (!handle) { 39 76 return c.redirect('/auth/login?error=handle_required'); 40 77 } 41 78 79 + // Trim and normalize handle 80 + handle = handle.trim().toLowerCase(); 81 + 82 + // Remove @ prefix if present 83 + if (handle.startsWith('@')) { 84 + handle = handle.slice(1); 85 + } 86 + 42 87 try { 43 88 const client = await getOAuthClient(); 44 89 const url = await client.authorize(handle, { ··· 54 99 55 100 // OAuth callback 56 101 authRoutes.get('/callback', async (c) => { 57 - const params = new URL(c.req.url).searchParams; 102 + const url = new URL(c.req.url); 103 + const params = url.searchParams; 104 + 105 + // Check for error from authorization server 106 + const error = params.get('error'); 107 + if (error) { 108 + console.error('OAuth error:', error, params.get('error_description')); 109 + return c.redirect('/auth/login?error=callback_failed'); 110 + } 58 111 59 112 try { 60 113 const client = await getOAuthClient(); 61 114 const { session } = await client.callback(params); 62 115 63 - // Store session ID in cookie 116 + // Store the DID in a cookie for session management 117 + // The actual OAuth session is stored in the database by the OAuth client 64 118 setCookie(c, 'session', session.did, { 65 119 httpOnly: true, 66 - secure: process.env.NODE_ENV === 'production', 120 + secure: process.env.NODE_ENV === 'production' || process.env.PUBLIC_URL?.startsWith('https'), 67 121 sameSite: 'Lax', 68 122 maxAge: 60 * 60 * 24 * 7, // 7 days 69 123 path: '/', ··· 78 132 79 133 // Logout 80 134 authRoutes.get('/logout', async (c) => { 135 + const did = getCookie(c, 'session'); 136 + 137 + if (did) { 138 + try { 139 + // Delete the OAuth session from the database 140 + await deleteSession(did); 141 + } catch (error) { 142 + console.error('Error deleting session:', error); 143 + } 144 + } 145 + 81 146 deleteCookie(c, 'session', { path: '/' }); 82 147 return c.redirect('/'); 83 148 }); 84 - 85 - // Client metadata endpoint for OAuth 86 - authRoutes.get('/client-metadata.json', async (c) => { 87 - const baseUrl = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 8000}`; 88 - 89 - return c.json({ 90 - client_id: `${baseUrl}/auth/client-metadata.json`, 91 - client_name: 'stdeditor', 92 - client_uri: baseUrl, 93 - logo_uri: `${baseUrl}/public/logo.png`, 94 - redirect_uris: [`${baseUrl}/auth/callback`], 95 - scope: 'atproto transition:generic', 96 - grant_types: ['authorization_code', 'refresh_token'], 97 - response_types: ['code'], 98 - application_type: 'web', 99 - token_endpoint_auth_method: 'none', 100 - dpop_bound_access_tokens: true, 101 - }); 102 - });
+23
src/server.ts
··· 7 7 import { layout } from './views/layouts/main'; 8 8 import { homePage } from './views/home'; 9 9 import { getSession } from './lib/session'; 10 + import { getClientMetadata, getJwks } from './lib/oauth'; 10 11 11 12 export const app = new Hono(); 12 13 13 14 // Static files 14 15 app.use('/public/*', serveStatic({ root: './' })); 15 16 17 + // OAuth metadata endpoints at root level 18 + app.get('/client-metadata.json', async (c) => { 19 + try { 20 + const metadata = await getClientMetadata(); 21 + return c.json(metadata); 22 + } catch (error) { 23 + console.error('Error getting client metadata:', error); 24 + return c.json({ error: 'Failed to get client metadata' }, 500); 25 + } 26 + }); 27 + 28 + app.get('/jwks.json', async (c) => { 29 + try { 30 + const jwks = await getJwks(); 31 + return c.json(jwks); 32 + } catch (error) { 33 + console.error('Error getting JWKS:', error); 34 + return c.json({ error: 'Failed to get JWKS' }, 500); 35 + } 36 + }); 37 + 16 38 // Session middleware - adds session to context 17 39 app.use('*', async (c, next) => { 18 40 const session = await getSession(c); ··· 33 55 34 56 const port = parseInt(process.env.PORT || '8000'); 35 57 console.log(`Starting server on http://localhost:${port}`); 58 + console.log(`Public URL: ${process.env.PUBLIC_URL || 'http://localhost:' + port}`); 36 59 37 60 export default { 38 61 port,
+1 -1
stdeditor.service
··· 7 7 User=exedev 8 8 WorkingDirectory=/home/exedev/stdeditor 9 9 Environment=PATH=/home/exedev/.bun/bin:/usr/local/bin:/usr/bin:/bin 10 - Environment=PUBLIC_URL=https://stdeditor.exe.xyz:8000 10 + Environment=PUBLIC_URL=https://stdeditor.exe.xyz 11 11 Environment=PORT=8000 12 12 ExecStart=/home/exedev/.bun/bin/bun run src/server.ts 13 13 Restart=on-failure