an app to share curated trails
sidetrail.app
atproto
nextjs
react
rsc
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}