ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto

add loopback client metadata for oauth in local dev!!!!!

authored by byarielm.fyi and committed by byarielm.fyi d3eacace eade55a5

verified
+37 -23
netlify/functions/batch-follow-users.ts
··· 1 import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { NodeOAuthClient } from '@atproto/oauth-client-node'; 3 import { JoseKey } from '@atproto/jwk-jose'; 4 import { stateStore, sessionStore, userSessions } from './oauth-stores-db'; 5 import { getOAuthConfig } from './oauth-config'; ··· 67 }; 68 } 69 70 - // Initialize OAuth client 71 const config = getOAuthConfig(); 72 - const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!); 73 - const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 74 75 - const client = new NodeOAuthClient({ 76 - clientMetadata: { 77 - client_id: config.clientId, 78 - client_name: 'ATlast', 79 - client_uri: config.clientId.replace('/client-metadata.json', ''), 80 - redirect_uris: [config.redirectUri], 81 - scope: 'atproto transition:generic', 82 - grant_types: ['authorization_code', 'refresh_token'], 83 - response_types: ['code'], 84 - application_type: 'web', 85 - token_endpoint_auth_method: 'private_key_jwt', 86 - token_endpoint_auth_signing_alg: 'ES256', 87 - dpop_bound_access_tokens: true, 88 - jwks_uri: config.jwksUri, 89 - }, 90 - keyset: [privateKey], 91 - stateStore: stateStore as any, 92 - sessionStore: sessionStore as any, 93 - }); 94 95 // Restore OAuth session 96 const oauthSession = await client.restore(userSession.did);
··· 1 import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 + import { NodeOAuthClient, atprotoLoopbackClientMetadata } from '@atproto/oauth-client-node'; 3 import { JoseKey } from '@atproto/jwk-jose'; 4 import { stateStore, sessionStore, userSessions } from './oauth-stores-db'; 5 import { getOAuthConfig } from './oauth-config'; ··· 67 }; 68 } 69 70 const config = getOAuthConfig(); 71 + const isDev = config.clientType === 'loopback'; 72 73 + let client: NodeOAuthClient; 74 + 75 + if (isDev) { 76 + // Loopback 77 + const clientMetadata = atprotoLoopbackClientMetadata(config.clientId); 78 + client = new NodeOAuthClient({ 79 + clientMetadata: clientMetadata, 80 + stateStore: stateStore as any, 81 + sessionStore: sessionStore as any, 82 + }); 83 + } else { 84 + // Production with private key 85 + const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!); 86 + const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 87 + 88 + client = new NodeOAuthClient({ 89 + clientMetadata: { 90 + client_id: config.clientId, 91 + client_name: 'ATlast', 92 + client_uri: config.clientId.replace('/client-metadata.json', ''), 93 + redirect_uris: [config.redirectUri], 94 + scope: 'atproto transition:generic', 95 + grant_types: ['authorization_code', 'refresh_token'], 96 + response_types: ['code'], 97 + application_type: 'web', 98 + token_endpoint_auth_method: 'private_key_jwt', 99 + token_endpoint_auth_signing_alg: 'ES256', 100 + dpop_bound_access_tokens: true, 101 + jwks_uri: config.jwksUri, 102 + }, 103 + keyset: [privateKey], 104 + stateStore: stateStore as any, 105 + sessionStore: sessionStore as any, 106 + }); 107 + } 108 109 // Restore OAuth session 110 const oauthSession = await client.restore(userSession.did);
+37 -23
netlify/functions/batch-search-actors.ts
··· 1 import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { NodeOAuthClient } from '@atproto/oauth-client-node'; 3 import { JoseKey } from '@atproto/jwk-jose'; 4 import { stateStore, sessionStore, userSessions } from './oauth-stores-db'; 5 import { getOAuthConfig } from './oauth-config'; ··· 58 }; 59 } 60 61 - // Initialize OAuth client 62 const config = getOAuthConfig(); 63 - const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!); 64 - const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 65 66 - const client = new NodeOAuthClient({ 67 - clientMetadata: { 68 - client_id: config.clientId, 69 - client_name: 'ATlast', 70 - client_uri: config.clientId.replace('/client-metadata.json', ''), 71 - redirect_uris: [config.redirectUri], 72 - scope: 'atproto transition:generic', 73 - grant_types: ['authorization_code', 'refresh_token'], 74 - response_types: ['code'], 75 - application_type: 'web', 76 - token_endpoint_auth_method: 'private_key_jwt', 77 - token_endpoint_auth_signing_alg: 'ES256', 78 - dpop_bound_access_tokens: true, 79 - jwks_uri: config.jwksUri, 80 - }, 81 - keyset: [privateKey], 82 - stateStore: stateStore as any, 83 - sessionStore: sessionStore as any, 84 - }); 85 86 // Restore OAuth session 87 const oauthSession = await client.restore(userSession.did);
··· 1 import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 + import { NodeOAuthClient, atprotoLoopbackClientMetadata } from '@atproto/oauth-client-node'; 3 import { JoseKey } from '@atproto/jwk-jose'; 4 import { stateStore, sessionStore, userSessions } from './oauth-stores-db'; 5 import { getOAuthConfig } from './oauth-config'; ··· 58 }; 59 } 60 61 const config = getOAuthConfig(); 62 + const isDev = config.clientType === 'loopback'; 63 64 + let client: NodeOAuthClient; 65 + 66 + if (isDev) { 67 + // Loopback 68 + const clientMetadata = atprotoLoopbackClientMetadata(config.clientId); 69 + client = new NodeOAuthClient({ 70 + clientMetadata: clientMetadata, 71 + stateStore: stateStore as any, 72 + sessionStore: sessionStore as any, 73 + }); 74 + } else { 75 + // Production with private key 76 + const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!); 77 + const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 78 + 79 + client = new NodeOAuthClient({ 80 + clientMetadata: { 81 + client_id: config.clientId, 82 + client_name: 'ATlast', 83 + client_uri: config.clientId.replace('/client-metadata.json', ''), 84 + redirect_uris: [config.redirectUri], 85 + scope: 'atproto transition:generic', 86 + grant_types: ['authorization_code', 'refresh_token'], 87 + response_types: ['code'], 88 + application_type: 'web', 89 + token_endpoint_auth_method: 'private_key_jwt', 90 + token_endpoint_auth_signing_alg: 'ES256', 91 + dpop_bound_access_tokens: true, 92 + jwks_uri: config.jwksUri, 93 + }, 94 + keyset: [privateKey], 95 + stateStore: stateStore as any, 96 + sessionStore: sessionStore as any, 97 + }); 98 + } 99 100 // Restore OAuth session 101 const oauthSession = await client.restore(userSession.did);
+36 -12
netlify/functions/client-metadata.ts
··· 1 import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 3 export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 4 try { 5 // Get the host that's requesting the metadata 6 // This will be different for production vs preview deploys vs dev --live ··· 16 }; 17 } 18 19 - // Build the redirect URI based on the requesting host 20 const redirectUri = `https://${requestHost}/.netlify/functions/oauth-callback`; 21 const appUrl = `https://${requestHost}`; 22 const jwksUri = `https://${requestHost}/.netlify/functions/jwks`; 23 const clientId = `https://${requestHost}/.netlify/functions/client-metadata`; 24 - 25 - console.log('Client metadata generated for host:', { 26 - requestHost, 27 - redirectUri, 28 - appUrl, 29 - clientId, 30 - jwksUri, 31 - }); 32 33 const metadata = { 34 client_id: clientId, ··· 50 headers: { 51 'Content-Type': 'application/json', 52 'Access-Control-Allow-Origin': '*', 53 - 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', 54 - 'Pragma': 'no-cache', 55 - 'Expires': '0', 56 }, 57 body: JSON.stringify(metadata), 58 };
··· 1 import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 3 export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 4 + 5 try { 6 // Get the host that's requesting the metadata 7 // This will be different for production vs preview deploys vs dev --live ··· 17 }; 18 } 19 20 + // Check if this is a loopback/development request 21 + const isLoopback = requestHost.startsWith('127.0.0.1') || 22 + requestHost.startsWith('[::1]') || 23 + requestHost === 'localhost'; 24 + 25 + if (isLoopback) { 26 + // For loopback clients, return minimal metadata 27 + // NOTE: In practice, the OAuth server won't fetch this because 28 + // loopback clients use hardcoded metadata on the server side 29 + const appUrl = `http://${requestHost}`; 30 + const redirectUri = `${appUrl}/.netlify/functions/oauth-callback`; 31 + 32 + return { 33 + statusCode: 200, 34 + headers: { 35 + 'Content-Type': 'application/json', 36 + 'Access-Control-Allow-Origin': '*', 37 + }, 38 + body: JSON.stringify({ 39 + client_id: appUrl, // Just the origin for loopback 40 + client_name: 'ATlast (Local Dev)', 41 + client_uri: appUrl, 42 + redirect_uris: [redirectUri], 43 + scope: 'atproto transition:generic', 44 + grant_types: ['authorization_code', 'refresh_token'], 45 + response_types: ['code'], 46 + application_type: 'web', 47 + token_endpoint_auth_method: 'none', // No auth for loopback 48 + dpop_bound_access_tokens: true, 49 + }), 50 + }; 51 + } 52 + 53 + // Production: Confidential client metadata 54 const redirectUri = `https://${requestHost}/.netlify/functions/oauth-callback`; 55 const appUrl = `https://${requestHost}`; 56 const jwksUri = `https://${requestHost}/.netlify/functions/jwks`; 57 const clientId = `https://${requestHost}/.netlify/functions/client-metadata`; 58 59 const metadata = { 60 client_id: clientId, ··· 76 headers: { 77 'Content-Type': 'application/json', 78 'Access-Control-Allow-Origin': '*', 79 + 'Cache-Control': 'no-store' 80 }, 81 body: JSON.stringify(metadata), 82 };
+70 -58
netlify/functions/oauth-callback.ts
··· 1 import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { NodeOAuthClient } from '@atproto/oauth-client-node'; 3 import { JoseKey } from '@atproto/jwk-jose'; 4 import { stateStore, sessionStore, userSessions } from './oauth-stores-db'; 5 import { getOAuthConfig } from './oauth-config'; ··· 13 } 14 15 export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 16 - let currentHost = process.env.DEPLOY_URL 17 - ? new URL(process.env.DEPLOY_URL).host 18 - : (event.headers['x-forwarded-host'] || event.headers.host); 19 - let currentUrl = currentHost ? `https://${currentHost}` : process.env.URL || process.env.DEPLOY_PRIME_URL || 'https://atlast.byarielm.fyi'; 20 - const fallbackUrl = currentUrl; 21 22 try { 23 const params = new URLSearchParams(event.rawUrl.split('?')[1] || ''); 24 const code = params.get('code'); 25 const state = params.get('state'); 26 27 - console.log('OAuth callback - Host:', currentHost); 28 - console.log('OAuth callback - currentUrl resolved to:', currentUrl); 29 30 if (!code || !state) { 31 return { ··· 37 }; 38 } 39 40 - if (!process.env.OAUTH_PRIVATE_KEY) { 41 - console.error('OAUTH_PRIVATE_KEY not set'); 42 - return { 43 - statusCode: 302, 44 - headers: { 45 - 'Location': `${currentUrl}/?error=Server configuration error` 46 - }, 47 - body: '' 48 - }; 49 - } 50 - 51 - const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY); 52 - const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 53 - 54 - // All URIs must now be based on the current deploy URL/host 55 - const redirectUri = `${currentUrl}/.netlify/functions/oauth-callback`; 56 - const jwksUri = `${currentUrl}/.netlify/functions/jwks`; // NOW DYNAMIC URL 57 - const clientId = `${currentUrl}/.netlify/functions/client-metadata`; // NOW DYNAMIC URL 58 59 - console.log('OAuth callback URLs:', { 60 - redirectUri, 61 - jwksUri, 62 - clientId, 63 - currentUrl 64 - }); 65 66 - // Build metadata dynamically based on the current environment 67 - const clientMetadata = { 68 - client_id: clientId, // NOW DYNAMIC URL 69 - client_name: 'ATlast', 70 - client_uri: currentUrl, 71 - redirect_uris: [redirectUri], 72 - scope: 'atproto transition:generic', 73 - grant_types: ['authorization_code', 'refresh_token'], 74 - response_types: ['code'], 75 - application_type: 'web', 76 - token_endpoint_auth_method: 'private_key_jwt', 77 - token_endpoint_auth_signing_alg: 'ES256', 78 - dpop_bound_access_tokens: true, 79 - jwks_uri: jwksUri, // NOW DYNAMIC URL 80 - }; 81 82 - // Initialize OAuth client with dynamic metadata 83 - const client = new NodeOAuthClient({ 84 - clientMetadata: clientMetadata as any, 85 - keyset: [privateKey], 86 - stateStore: stateStore as any, 87 - sessionStore: sessionStore as any, 88 - }); 89 90 const result = await client.callback(params); 91 92 - // Store a simple session mapping: sessionId -> DID 93 const sessionId = crypto.randomUUID(); 94 const did = result.session.did; 95 - 96 await userSessions.set(sessionId, { did }); 97 98 - const cookieFlags = 'HttpOnly; SameSite=Lax; Max-Age=1209600; Path=/; Secure'; 99 100 return { 101 statusCode: 302, ··· 108 109 } catch (error) { 110 console.error('OAuth callback error:', error); 111 - 112 return { 113 statusCode: 302, 114 headers: {
··· 1 import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 + import { NodeOAuthClient, atprotoLoopbackClientMetadata } from '@atproto/oauth-client-node'; 3 import { JoseKey } from '@atproto/jwk-jose'; 4 import { stateStore, sessionStore, userSessions } from './oauth-stores-db'; 5 import { getOAuthConfig } from './oauth-config'; ··· 13 } 14 15 export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 16 + const config = getOAuthConfig(); 17 + const isDev = config.clientType === 'loopback'; 18 + 19 + let currentUrl = isDev 20 + ? 'http://127.0.0.1:8888' 21 + : (process.env.DEPLOY_URL 22 + ? `https://${new URL(process.env.DEPLOY_URL).host}` 23 + : process.env.URL || process.env.DEPLOY_PRIME_URL || 'https://atlast.byarielm.fyi'); 24 25 try { 26 const params = new URLSearchParams(event.rawUrl.split('?')[1] || ''); 27 const code = params.get('code'); 28 const state = params.get('state'); 29 30 + console.log('OAuth callback - Mode:', isDev ? 'loopback' : 'production'); 31 + console.log('OAuth callback - URL:', currentUrl); 32 33 if (!code || !state) { 34 return { ··· 40 }; 41 } 42 43 + let client: NodeOAuthClient; 44 45 + if (isDev) { 46 + // LOOPBACK MODE: Use atprotoLoopbackClientMetadata and NO keyset 47 + console.log('🔧 Loopback callback'); 48 + 49 + const clientMetadata = atprotoLoopbackClientMetadata(config.clientId); 50 + 51 + client = new NodeOAuthClient({ 52 + clientMetadata: clientMetadata, 53 + // No keyset for loopback! 54 + stateStore: stateStore as any, 55 + sessionStore: sessionStore as any, 56 + }); 57 + } else { 58 + // PRODUCTION MODE 59 + if (!process.env.OAUTH_PRIVATE_KEY) { 60 + console.error('OAUTH_PRIVATE_KEY not set'); 61 + return { 62 + statusCode: 302, 63 + headers: { 'Location': `${currentUrl}/?error=Server configuration error` }, 64 + body: '' 65 + }; 66 + } 67 68 + const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY); 69 + const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 70 + 71 + const currentHost = process.env.DEPLOY_URL 72 + ? new URL(process.env.DEPLOY_URL).host 73 + : (event.headers['x-forwarded-host'] || event.headers.host); 74 + 75 + currentUrl = `https://${currentHost}`; 76 + const redirectUri = `${currentUrl}/.netlify/functions/oauth-callback`; 77 + const jwksUri = `${currentUrl}/.netlify/functions/jwks`; 78 + const clientId = `${currentUrl}/.netlify/functions/client-metadata`; 79 80 + client = new NodeOAuthClient({ 81 + clientMetadata: { 82 + client_id: clientId, 83 + client_name: 'ATlast', 84 + client_uri: currentUrl, 85 + redirect_uris: [redirectUri], 86 + scope: 'atproto transition:generic', 87 + grant_types: ['authorization_code', 'refresh_token'], 88 + response_types: ['code'], 89 + application_type: 'web', 90 + token_endpoint_auth_method: 'private_key_jwt', 91 + token_endpoint_auth_signing_alg: 'ES256', 92 + dpop_bound_access_tokens: true, 93 + jwks_uri: jwksUri, 94 + } as any, 95 + keyset: [privateKey], 96 + stateStore: stateStore as any, 97 + sessionStore: sessionStore as any, 98 + }); 99 + } 100 101 const result = await client.callback(params); 102 103 + // Store session 104 const sessionId = crypto.randomUUID(); 105 const did = result.session.did; 106 await userSessions.set(sessionId, { did }); 107 108 + // Cookie flags - no Secure flag for loopback 109 + const cookieFlags = isDev 110 + ? 'HttpOnly; SameSite=Lax; Max-Age=1209600; Path=/' 111 + : 'HttpOnly; SameSite=Lax; Max-Age=1209600; Path=/; Secure'; 112 113 return { 114 statusCode: 302, ··· 121 122 } catch (error) { 123 console.error('OAuth callback error:', error); 124 return { 125 statusCode: 302, 126 headers: {
+23 -5
netlify/functions/oauth-config.ts
··· 1 export function getOAuthConfig() { 2 - // In Netlify, process.env.URL is automatically set to the public URL. 3 const baseUrl = process.env.URL || process.env.DEPLOY_PRIME_URL; 4 5 if (process.env.NETLIFY && !process.env.URL) { 6 - // This is a safety check for a critical configuration issue on Netlify 7 throw new Error('process.env.URL is required in Netlify environment'); 8 } 9 ··· 13 CONTEXT: process.env.CONTEXT, 14 using: baseUrl 15 }); 16 - 17 - const redirectUri = `${baseUrl}/.netlify/functions/oauth-callback`; 18 19 return { 20 clientId: `${baseUrl}/.netlify/functions/client-metadata`, // discoverable client URL 21 - redirectUri, 22 jwksUri: `${baseUrl}/.netlify/functions/jwks`, 23 clientType: 'discoverable' as const, 24 }; 25 }
··· 1 export function getOAuthConfig() { 2 + // Development: loopback client for local dev 3 + const isDev = process.env.NODE_ENV === 'development' || process.env.NETLIFY_DEV === 'true'; 4 + 5 + if (isDev) { 6 + const port = process.env.PORT || '8888'; 7 + 8 + // Special loopback client_id format with query params 9 + const clientId = `http://localhost?${new URLSearchParams([ 10 + ['redirect_uri', `http://127.0.0.1:${port}/.netlify/functions/oauth-callback`], 11 + ['scope', 'atproto transition:generic'], 12 + ])}`; 13 + 14 + return { 15 + clientId: clientId, 16 + redirectUri: `http://127.0.0.1:${port}/.netlify/functions/oauth-callback`, 17 + jwksUri: undefined, 18 + clientType: 'loopback' as const, 19 + }; 20 + } 21 + 22 + // Production: discoverable client logic 23 const baseUrl = process.env.URL || process.env.DEPLOY_PRIME_URL; 24 25 if (process.env.NETLIFY && !process.env.URL) { 26 throw new Error('process.env.URL is required in Netlify environment'); 27 } 28 ··· 32 CONTEXT: process.env.CONTEXT, 33 using: baseUrl 34 }); 35 36 return { 37 clientId: `${baseUrl}/.netlify/functions/client-metadata`, // discoverable client URL 38 + redirectUri: `${baseUrl}/.netlify/functions/oauth-callback`, 39 jwksUri: `${baseUrl}/.netlify/functions/jwks`, 40 clientType: 'discoverable' as const, 41 + usePrivateKey: true, 42 }; 43 }
+61 -77
netlify/functions/oauth-start.ts
··· 1 import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { NodeOAuthClient } from '@atproto/oauth-client-node'; 3 import { JoseKey } from '@atproto/jwk-jose'; 4 import { stateStore, sessionStore } from './oauth-stores-db'; 5 import { getOAuthConfig } from './oauth-config'; 6 - import { initDB } from './db'; // initDB is only kept for manual setup/migrations 7 8 interface OAuthStartRequestBody { 9 login_hint?: string; 10 - origin?: string; // The actual origin the frontend is running on 11 } 12 13 function normalizePrivateKey(key: string): string { ··· 19 20 export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 21 try { 22 - // await initDB(); 23 - 24 - // Parse request body 25 let loginHint: string | undefined = undefined; 26 - let requestOrigin: string | undefined = undefined; 27 28 if (event.body) { 29 const parsed: OAuthStartRequestBody = JSON.parse(event.body); 30 loginHint = parsed.login_hint; 31 - requestOrigin = parsed.origin; 32 } 33 34 - // Validate login hint is provided 35 if (!loginHint) { 36 return { 37 statusCode: 400, ··· 39 body: JSON.stringify({ error: 'login_hint (handle or DID) is required' }), 40 }; 41 } 42 - 43 - console.log('OAuth Start - Request origin:', requestOrigin); 44 45 - // Validate private key 46 - if (!process.env.OAUTH_PRIVATE_KEY) { 47 - console.error('OAUTH_PRIVATE_KEY not set'); 48 - return { 49 - statusCode: 500, 50 - headers: { 'Content-Type': 'application/json' }, 51 - body: JSON.stringify({ error: 'Server configuration error' }), 52 - }; 53 - } 54 55 - // Initialize OAuth client 56 - const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY); 57 - const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 58 59 - // Use the dynamic config that Netlify sets for the build 60 - const currentHost = process.env.DEPLOY_URL 61 - ? new URL(process.env.DEPLOY_URL).host 62 - : (event.headers['x-forwarded-host'] || event.headers.host); 63 - 64 - if (!currentHost) { 65 - console.error('Missing host header in function request'); 66 - return { 67 - statusCode: 500, 68 - headers: { 'Content-Type': 'application/json' }, 69 - body: JSON.stringify({ error: 'Server could not determine current host for redirect' }), 70 - }; 71 - } 72 - 73 - const currentUrl = `https://${currentHost}`; 74 75 - // Now, dynamically define the URIs using the CURRENT HOST 76 - const redirectUri = `${currentUrl}/.netlify/functions/oauth-callback`; 77 - const appUrl = currentUrl; 78 - const jwksUri = `${currentUrl}/.netlify/functions/jwks`; 79 - const clientId = `${currentUrl}/.netlify/functions/client-metadata`; 80 - 81 - console.log('OAuth URLs:', { 82 - redirectUri, 83 - appUrl, 84 - jwksUri, 85 - clientId, 86 - requestOrigin 87 - }); 88 89 - // Build metadata dynamically from environment 90 - const clientMetadata = { 91 - client_id: clientId, 92 - client_name: 'ATlast', 93 - client_uri: appUrl, 94 - redirect_uris: [redirectUri], 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: 'private_key_jwt', 100 - token_endpoint_auth_signing_alg: 'ES256', 101 - dpop_bound_access_tokens: true, 102 - jwks_uri: jwksUri, 103 - }; 104 105 - console.log('Client metadata:', clientMetadata); 106 107 - // Initialize NodeOAuthClient with typed stores 108 - const client = new NodeOAuthClient({ 109 - clientMetadata: clientMetadata as any, 110 - keyset: [privateKey], 111 - stateStore: stateStore as any, 112 - sessionStore: sessionStore as any, 113 - }); 114 115 - console.log('OAuth client initialized with redirect_uri:', redirectUri); 116 117 - // Generate authorization URL 118 const authUrl = await client.authorize(loginHint, { 119 scope: 'atproto transition:generic', 120 }); ··· 129 return { 130 statusCode: 500, 131 headers: { 'Content-Type': 'application/json' }, 132 - body: JSON.stringify({ error: 'Failed to start OAuth flow' }), 133 }; 134 } 135 };
··· 1 import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 + import { NodeOAuthClient, atprotoLoopbackClientMetadata } from '@atproto/oauth-client-node'; 3 import { JoseKey } from '@atproto/jwk-jose'; 4 import { stateStore, sessionStore } from './oauth-stores-db'; 5 import { getOAuthConfig } from './oauth-config'; 6 7 interface OAuthStartRequestBody { 8 login_hint?: string; 9 + origin?: string; 10 } 11 12 function normalizePrivateKey(key: string): string { ··· 18 19 export const handler: Handler = async (event: HandlerEvent): Promise<HandlerResponse> => { 20 try { 21 let loginHint: string | undefined = undefined; 22 23 if (event.body) { 24 const parsed: OAuthStartRequestBody = JSON.parse(event.body); 25 loginHint = parsed.login_hint; 26 } 27 28 if (!loginHint) { 29 return { 30 statusCode: 400, ··· 32 body: JSON.stringify({ error: 'login_hint (handle or DID) is required' }), 33 }; 34 } 35 36 + const config = getOAuthConfig(); 37 + const isDev = config.clientType === 'loopback'; 38 39 + let client: NodeOAuthClient; 40 41 + if (isDev) { 42 + // LOOPBACK MODE: Use atprotoLoopbackClientMetadata and NO keyset 43 + console.log('🔧 Using loopback OAuth client for development'); 44 + console.log('Client ID:', config.clientId); 45 + 46 + const clientMetadata = atprotoLoopbackClientMetadata(config.clientId); 47 + 48 + client = new NodeOAuthClient({ 49 + clientMetadata: clientMetadata, 50 + stateStore: stateStore as any, 51 + sessionStore: sessionStore as any, 52 + }); 53 + } else { 54 + // PRODUCTION MODE: Full confidential client with keyset 55 + console.log('🔐 Using confidential OAuth client for production'); 56 + 57 + if (!process.env.OAUTH_PRIVATE_KEY) { 58 + throw new Error('OAUTH_PRIVATE_KEY required for production'); 59 + } 60 61 + const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY); 62 + const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 63 64 + const currentHost = process.env.DEPLOY_URL 65 + ? new URL(process.env.DEPLOY_URL).host 66 + : (event.headers['x-forwarded-host'] || event.headers.host); 67 68 + if (!currentHost) { 69 + throw new Error('Missing host header'); 70 + } 71 72 + const currentUrl = `https://${currentHost}`; 73 + const redirectUri = `${currentUrl}/.netlify/functions/oauth-callback`; 74 + const jwksUri = `${currentUrl}/.netlify/functions/jwks`; 75 + const clientId = `${currentUrl}/.netlify/functions/client-metadata`; 76 77 + client = new NodeOAuthClient({ 78 + clientMetadata: { 79 + client_id: clientId, 80 + client_name: 'ATlast', 81 + client_uri: currentUrl, 82 + redirect_uris: [redirectUri], 83 + scope: 'atproto transition:generic', 84 + grant_types: ['authorization_code', 'refresh_token'], 85 + response_types: ['code'], 86 + application_type: 'web', 87 + token_endpoint_auth_method: 'private_key_jwt', 88 + token_endpoint_auth_signing_alg: 'ES256', 89 + dpop_bound_access_tokens: true, 90 + jwks_uri: jwksUri, 91 + } as any, 92 + keyset: [privateKey], 93 + stateStore: stateStore as any, 94 + sessionStore: sessionStore as any, 95 + }); 96 + } 97 98 const authUrl = await client.authorize(loginHint, { 99 scope: 'atproto transition:generic', 100 }); ··· 109 return { 110 statusCode: 500, 111 headers: { 'Content-Type': 'application/json' }, 112 + body: JSON.stringify({ 113 + error: 'Failed to start OAuth flow', 114 + details: error instanceof Error ? error.message : 'Unknown error', 115 + stack: error instanceof Error ? error.stack : undefined 116 + }), 117 }; 118 } 119 };
+37 -22
netlify/functions/session.ts
··· 1 import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 - import { NodeOAuthClient } from '@atproto/oauth-client-node'; 3 import { JoseKey } from '@atproto/jwk-jose'; 4 import { stateStore, sessionStore, userSessions } from './oauth-stores-db'; 5 import { getOAuthConfig } from './oauth-config'; ··· 106 // Cache miss - fetch full profile 107 try { 108 const config = getOAuthConfig(); 109 - const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!); 110 - const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 111 112 - const client = new NodeOAuthClient({ 113 - clientMetadata: { 114 - client_id: config.clientId, 115 - client_name: 'ATlast', 116 - client_uri: config.clientId.replace('/client-metadata.json', ''), 117 - redirect_uris: [config.redirectUri], 118 - scope: 'atproto transition:generic', 119 - grant_types: ['authorization_code', 'refresh_token'], 120 - response_types: ['code'], 121 - application_type: 'web', 122 - token_endpoint_auth_method: 'private_key_jwt', 123 - token_endpoint_auth_signing_alg: 'ES256', 124 - dpop_bound_access_tokens: true, 125 - jwks_uri: config.jwksUri, 126 - }, 127 - keyset: [privateKey], 128 - stateStore: stateStore as any, 129 - sessionStore: sessionStore as any, 130 - }); 131 132 // Restore OAuth session 133 const oauthSession = await client.restore(did);
··· 1 import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions'; 2 + import { NodeOAuthClient, atprotoLoopbackClientMetadata } from '@atproto/oauth-client-node'; 3 import { JoseKey } from '@atproto/jwk-jose'; 4 import { stateStore, sessionStore, userSessions } from './oauth-stores-db'; 5 import { getOAuthConfig } from './oauth-config'; ··· 106 // Cache miss - fetch full profile 107 try { 108 const config = getOAuthConfig(); 109 + const isDev = config.clientType === 'loopback'; 110 + 111 + let client: NodeOAuthClient; 112 + 113 + if (isDev) { 114 + // Loopback 115 + const clientMetadata = atprotoLoopbackClientMetadata(config.clientId); 116 + client = new NodeOAuthClient({ 117 + clientMetadata: clientMetadata, 118 + stateStore: stateStore as any, 119 + sessionStore: sessionStore as any, 120 + }); 121 + } else { 122 + // Production with private key 123 + const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY!); 124 + const privateKey = await JoseKey.fromImportable(normalizedKey, 'main-key'); 125 126 + client = new NodeOAuthClient({ 127 + clientMetadata: { 128 + client_id: config.clientId, 129 + client_name: 'ATlast', 130 + client_uri: config.clientId.replace('/client-metadata.json', ''), 131 + redirect_uris: [config.redirectUri], 132 + scope: 'atproto transition:generic', 133 + grant_types: ['authorization_code', 'refresh_token'], 134 + response_types: ['code'], 135 + application_type: 'web', 136 + token_endpoint_auth_method: 'private_key_jwt', 137 + token_endpoint_auth_signing_alg: 'ES256', 138 + dpop_bound_access_tokens: true, 139 + jwks_uri: config.jwksUri, 140 + }, 141 + keyset: [privateKey], 142 + stateStore: stateStore as any, 143 + sessionStore: sessionStore as any, 144 + }); 145 + } 146 147 // Restore OAuth session 148 const oauthSession = await client.restore(did);