an app to share curated trails sidetrail.app
atproto nextjs react rsc
at main 160 lines 5.2 kB view raw
1import "server-only"; 2import { 3 NodeOAuthClient, 4 OAuthClientMetadataInput, 5 Keyset, 6 JoseKey, 7} from "@atproto/oauth-client-node"; 8import { 9 OAuthAuthorizationServerMetadata, 10 OAuthProtectedResourceMetadata, 11} from "@atproto/oauth-types"; 12import { StateStore, SessionStore, initAuthTables, requestLock } from "./storage"; 13import { SimpleStoreRedis } from "./redis-store"; 14 15const authServerMetadataCache = new SimpleStoreRedis<OAuthAuthorizationServerMetadata>({ 16 keyPrefix: "oauth:as-metadata:", 17 ttlSeconds: 24 * 60 * 60, 18}); 19const protectedResourceMetadataCache = new SimpleStoreRedis<OAuthProtectedResourceMetadata>({ 20 keyPrefix: "oauth:pr-metadata:", 21 ttlSeconds: 24 * 60 * 60, 22}); 23const dpopNonceCache = new SimpleStoreRedis<string>({ 24 keyPrefix: "oauth:dpop-nonce:", 25 ttlSeconds: 24 * 60 * 60, 26}); 27 28// Environment variables 29const PUBLIC_URL = process.env.PUBLIC_URL; // e.g., https://sidetrail.app 30const PRIVATE_KEY_ES256 = process.env.PRIVATE_KEY_ES256; // JWK string for signing 31const COOKIE_SECRET = process.env.COOKIE_SECRET; 32 33// Singleton OAuth client 34let oauthClient: NodeOAuthClient | null = null; 35let initPromise: Promise<NodeOAuthClient> | null = null; 36 37/** 38 * Creates and returns the OAuth client singleton. 39 * Handles both development (loopback) and production (confidential client) modes. 40 */ 41export async function getOAuthClient(): Promise<NodeOAuthClient> { 42 if (oauthClient) return oauthClient; 43 if (initPromise) return initPromise; 44 45 initPromise = createOAuthClient(); 46 oauthClient = await initPromise; 47 return oauthClient; 48} 49 50async function createOAuthClient(): Promise<NodeOAuthClient> { 51 // Initialize database tables 52 await initAuthTables(); 53 54 // Build keyset for confidential client (production only) 55 let keyset: Keyset | undefined; 56 57 if (PUBLIC_URL && PRIVATE_KEY_ES256) { 58 try { 59 const jwk = JSON.parse(PRIVATE_KEY_ES256); 60 const key = await JoseKey.fromJWK(jwk); 61 keyset = new Keyset([key]); 62 } catch (err) { 63 console.error("Failed to parse PRIVATE_KEY_ES256:", err); 64 throw new Error("Invalid PRIVATE_KEY_ES256. Must be a valid ES256 JWK JSON string."); 65 } 66 } 67 68 // For production, we need a keyset 69 if (PUBLIC_URL && !keyset) { 70 throw new Error( 71 "Production mode requires PRIVATE_KEY_ES256 environment variable. " + 72 "Generate one with: npx @atproto/oauth-client-node gen-jwk", 73 ); 74 } 75 76 // Get the signing key if we have a keyset 77 // The keyset was built from a single key, so just check if it exists 78 const hasSigningKey = keyset && keyset.size > 0; 79 80 // Build client metadata 81 const clientMetadata: OAuthClientMetadataInput = PUBLIC_URL 82 ? { 83 // Production: Confidential client 84 client_name: "Sidetrail", 85 client_id: `${PUBLIC_URL}/oauth-client-metadata.json`, 86 client_uri: PUBLIC_URL, 87 redirect_uris: [`${PUBLIC_URL}/oauth/callback`], 88 scope: "atproto transition:generic", 89 grant_types: ["authorization_code", "refresh_token"], 90 response_types: ["code"], 91 application_type: "web", 92 token_endpoint_auth_method: hasSigningKey ? "private_key_jwt" : "none", 93 token_endpoint_auth_signing_alg: hasSigningKey ? "ES256" : undefined, 94 dpop_bound_access_tokens: true, 95 jwks_uri: `${PUBLIC_URL}/oauth/jwks.json`, 96 } 97 : { 98 // Development: Loopback client (no keys needed) 99 // ATProto requires 127.0.0.1 for loopback, not localhost (RFC 8252) 100 client_name: "Sidetrail (Development)", 101 client_id: `http://localhost?redirect_uri=${encodeURIComponent( 102 "http://127.0.0.1:3000/oauth/callback", 103 )}&scope=${encodeURIComponent("atproto transition:generic")}`, 104 redirect_uris: ["http://127.0.0.1:3000/oauth/callback"], 105 scope: "atproto transition:generic", 106 grant_types: ["authorization_code", "refresh_token"], 107 response_types: ["code"], 108 application_type: "web", 109 token_endpoint_auth_method: "none", 110 dpop_bound_access_tokens: true, 111 }; 112 113 return new NodeOAuthClient({ 114 clientMetadata, 115 keyset, 116 stateStore: new StateStore(), 117 sessionStore: new SessionStore(), 118 requestLock, 119 authorizationServerMetadataCache: authServerMetadataCache, 120 protectedResourceMetadataCache, 121 dpopNonceCache, 122 }); 123} 124 125/** 126 * Get the client metadata for the /oauth/client-metadata.json endpoint. 127 */ 128export async function getClientMetadata() { 129 const client = await getOAuthClient(); 130 return client.clientMetadata; 131} 132 133/** 134 * Get the JWKS for the /oauth/jwks.json endpoint. 135 */ 136export async function getJwks() { 137 const client = await getOAuthClient(); 138 return client.jwks; 139} 140 141/** 142 * Cookie secret for iron-session. 143 */ 144export function getCookieSecret(): string { 145 if (!COOKIE_SECRET) { 146 if (process.env.NODE_ENV === "production") { 147 throw new Error("COOKIE_SECRET is required in production"); 148 } 149 // Default for development 150 return "development-secret-at-least-32-characters-long"; 151 } 152 return COOKIE_SECRET; 153} 154 155/** 156 * Check if we're in production mode (has PUBLIC_URL). 157 */ 158export function isProduction(): boolean { 159 return !!PUBLIC_URL; 160}