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