+11
-19
netlify.toml
+11
-19
netlify.toml
···
20
20
Cache-Control = "public, max-age=3600"
21
21
22
22
[[headers]]
23
-
for = "/.well-known/*"
24
-
[headers.values]
23
+
for = "/.well-known/*"
24
+
[headers.values]
25
25
Access-Control-Allow-Origin = "*"
26
26
27
-
[[headers]]
28
-
for = "/*"
29
-
[headers.values]
30
-
Content-Security-Policy = """
31
-
default-src 'self';
32
-
script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com;
33
-
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
34
-
font-src 'self' https://fonts.gstatic.com;
35
-
img-src 'self' data: https:;
36
-
connect-src 'self' https://bsky.social https://*.bsky.network;
37
-
frame-ancestors 'none';
38
-
base-uri 'self';
39
-
form-action 'self';
40
-
"""
41
-
X-Frame-Options = "DENY"
42
-
X-Content-Type-Options = "nosniff"
43
-
Referrer-Policy = "strict-origin-when-cross-origin"
27
+
[[headers]]
28
+
for = "/*"
29
+
[headers.values]
30
+
X-Frame-Options = "DENY"
31
+
X-Content-Type-Options = "nosniff"
32
+
X-XSS-Protection = "1; mode=block"
33
+
Referrer-Policy = "strict-origin-when-cross-origin"
34
+
Permissions-Policy = "geolocation=(), microphone=(), camera=()"
35
+
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://*.bsky.app https://*.bsky.network https://public.api.bsky.app; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;"
+1
-1
netlify/functions/core/config/constants.ts
+1
-1
netlify/functions/core/config/constants.ts
···
4
4
STATE_EXPIRY: 10 * 60 * 1000, // 10 minutes
5
5
COOKIE_MAX_AGE: 2592000, // 30 days in seconds,
6
6
OAUTH_KEY_ID: "main-key", // jwks kid
7
-
OAUTH_SCOPES: "atproto transition:generic", // future?: atproto repo:app.bsky.graph.follow?action=create repo:so.sprk.graph.follow?action=create repo:sh.tangled.graph.follow?action=create
7
+
OAUTH_SCOPES: "atproto transition:generic", // future?: atproto rpc:app.bsky.graph.getFollows?aud=* rpc:app.bsky.actor.getProfile?aud=* repo:app.bsky.graph.follow?action=create repo:so.sprk.graph.follow?action=create repo:sh.tangled.graph.follow?action=create
8
8
} as const;
+117
netlify/functions/core/middleware/session-security.middleware.ts
+117
netlify/functions/core/middleware/session-security.middleware.ts
···
1
+
import { HandlerEvent } from "@netlify/functions";
2
+
3
+
interface SessionFingerprint {
4
+
userAgent: string;
5
+
ipAddress: string;
6
+
createdAt: number;
7
+
}
8
+
9
+
/**
10
+
* Session Security Service
11
+
* Provides additional session replay protection and fingerprinting
12
+
*/
13
+
export class SessionSecurityService {
14
+
/**
15
+
* Generate a session fingerprint from request headers
16
+
*/
17
+
static generateFingerprint(event: HandlerEvent): SessionFingerprint {
18
+
const userAgent = event.headers["user-agent"] || "unknown";
19
+
const ipAddress =
20
+
event.headers["x-forwarded-for"]?.split(",")[0].trim() ||
21
+
event.headers["client-ip"] ||
22
+
"unknown";
23
+
24
+
return {
25
+
userAgent,
26
+
ipAddress,
27
+
createdAt: Date.now(),
28
+
};
29
+
}
30
+
31
+
/**
32
+
* Verify session fingerprint matches current request
33
+
* Helps detect session hijacking
34
+
*/
35
+
static verifyFingerprint(
36
+
stored: SessionFingerprint,
37
+
current: SessionFingerprint,
38
+
): boolean {
39
+
// User agent must match exactly
40
+
if (stored.userAgent !== current.userAgent) {
41
+
console.warn("Session fingerprint mismatch: User-Agent changed");
42
+
return false;
43
+
}
44
+
45
+
// IP can change (mobile networks, VPN) but log if it does
46
+
if (stored.ipAddress !== current.ipAddress) {
47
+
console.info(
48
+
`Session IP changed: ${stored.ipAddress} -> ${current.ipAddress}`,
49
+
);
50
+
// Don't fail - just log for monitoring
51
+
}
52
+
53
+
return true;
54
+
}
55
+
56
+
/**
57
+
* Check if session is being used suspiciously fast
58
+
* (potential replay attack)
59
+
*/
60
+
static detectSuspiciousActivity(
61
+
lastUsed: number,
62
+
minIntervalMs: number = 100,
63
+
): boolean {
64
+
const timeSinceLastUse = Date.now() - lastUsed;
65
+
66
+
// If requests are less than 100ms apart, suspicious
67
+
if (timeSinceLastUse < minIntervalMs) {
68
+
console.warn(
69
+
`Suspicious activity: Request ${timeSinceLastUse}ms after last use`,
70
+
);
71
+
return true;
72
+
}
73
+
74
+
return false;
75
+
}
76
+
}
77
+
78
+
/**
79
+
* Enhanced session validation middleware
80
+
* Adds fingerprinting to detect session hijacking
81
+
*/
82
+
export async function validateSessionSecurity(
83
+
event: HandlerEvent,
84
+
sessionId: string,
85
+
): Promise<void> {
86
+
const currentFingerprint = SessionSecurityService.generateFingerprint(event);
87
+
88
+
// Get stored fingerprint (would need to extend UserSessionStore)
89
+
// For now, just log current fingerprint for monitoring
90
+
console.log("Session fingerprint:", {
91
+
sessionId: sessionId.substring(0, 8) + "...",
92
+
userAgent: currentFingerprint.userAgent.substring(0, 50),
93
+
ip: currentFingerprint.ipAddress,
94
+
});
95
+
96
+
// Future: Store and compare fingerprints
97
+
// const session = await userSessions.get(sessionId);
98
+
// if (session.fingerprint) {
99
+
// if (!SessionSecurityService.verifyFingerprint(session.fingerprint, currentFingerprint)) {
100
+
// throw new AuthenticationError("Session security check failed");
101
+
// }
102
+
// }
103
+
}
104
+
105
+
/**
106
+
* Add session fingerprint to new sessions
107
+
* Call this in oauth-callback.ts when creating session
108
+
*/
109
+
export function createSecureSessionData(
110
+
event: HandlerEvent,
111
+
did: string,
112
+
): { did: string; fingerprint: SessionFingerprint } {
113
+
return {
114
+
did,
115
+
fingerprint: SessionSecurityService.generateFingerprint(event),
116
+
};
117
+
}
+3
netlify/functions/core/types/api.types.ts
+3
netlify/functions/core/types/api.types.ts
+5
-1
netlify/functions/core/types/database.types.ts
+5
-1
netlify/functions/core/types/database.types.ts
···
12
12
export interface OAuthSessionRow {
13
13
key: string;
14
14
data: {
15
-
dpopKey: any;
15
+
dpopJwk?: any;
16
+
dpopKey?: any;
16
17
tokenSet: any;
18
+
authMethod?: string;
19
+
encrypted?: boolean;
17
20
};
18
21
created_at: Date;
19
22
expires_at: Date;
···
22
25
export interface UserSessionRow {
23
26
session_id: string;
24
27
did: string;
28
+
fingerprint?: any;
25
29
created_at: Date;
26
30
expires_at: Date;
27
31
}
+1
netlify/functions/infrastructure/database/DatabaseService.ts
+1
netlify/functions/infrastructure/database/DatabaseService.ts
+55
-3
netlify/functions/infrastructure/oauth/stores/SessionStore.ts
+55
-3
netlify/functions/infrastructure/oauth/stores/SessionStore.ts
···
1
1
import { getDbClient } from "../../database";
2
2
import { SessionData, OAuthSessionRow } from "../../../core/types";
3
3
import { CONFIG } from "../../../core/config/constants";
4
+
import {
5
+
encryptToken,
6
+
decryptToken,
7
+
isEncryptionConfigured,
8
+
} from "../../../utils/encryption.utils";
4
9
5
10
export class PostgresSessionStore {
6
11
private sql = getDbClient();
12
+
private encryptionEnabled = isEncryptionConfigured();
7
13
8
14
async get(key: string): Promise<SessionData | undefined> {
9
15
const result = await this.sql`
···
11
17
WHERE key = ${key} AND expires_at > NOW()
12
18
`;
13
19
const rows = result as OAuthSessionRow[];
14
-
return rows[0]?.data as SessionData | undefined;
20
+
21
+
if (!rows[0]) return undefined;
22
+
23
+
const stored = rows[0].data;
24
+
25
+
// Handle encrypted format
26
+
if (
27
+
this.encryptionEnabled &&
28
+
typeof stored === "object" &&
29
+
stored.encrypted
30
+
) {
31
+
try {
32
+
// Decrypt tokenSet and reconstruct with dpopJwk
33
+
const decryptedTokenSet = decryptToken(stored.tokenSet);
34
+
35
+
return {
36
+
dpopJwk: stored.dpopJwk, // Use dpopJwk (not dpopKey!)
37
+
tokenSet: decryptedTokenSet,
38
+
authMethod: stored.authMethod,
39
+
} as SessionData;
40
+
} catch (error) {
41
+
console.error(
42
+
"[SessionStore] Failed to decrypt session token set:",
43
+
error,
44
+
);
45
+
return undefined;
46
+
}
47
+
}
48
+
49
+
// Fallback for unencrypted format
50
+
return stored as SessionData;
15
51
}
16
52
17
53
async set(key: string, value: SessionData): Promise<void> {
18
54
const expiresAt = new Date(Date.now() + CONFIG.SESSION_EXPIRY);
55
+
56
+
let dataToStore: any;
57
+
58
+
if (this.encryptionEnabled) {
59
+
// Encrypt only tokenSet, keep dpopJwk and authMethod as-is
60
+
dataToStore = {
61
+
encrypted: true,
62
+
dpopJwk: (value as any).dpopJwk,
63
+
authMethod: (value as any).authMethod,
64
+
tokenSet: encryptToken(value.tokenSet),
65
+
};
66
+
} else {
67
+
// Store as-is if encryption disabled
68
+
dataToStore = value;
69
+
}
70
+
19
71
await this.sql`
20
72
INSERT INTO oauth_sessions (key, data, expires_at)
21
-
VALUES (${key}, ${JSON.stringify(value)}, ${expiresAt})
73
+
VALUES (${key}, ${JSON.stringify(dataToStore)}, ${expiresAt})
22
74
ON CONFLICT (key) DO UPDATE SET
23
-
data = ${JSON.stringify(value)},
75
+
data = ${JSON.stringify(dataToStore)},
24
76
expires_at = ${expiresAt}
25
77
`;
26
78
}
+13
-3
netlify/functions/infrastructure/oauth/stores/StateStore.ts
+13
-3
netlify/functions/infrastructure/oauth/stores/StateStore.ts
···
11
11
WHERE key = ${key} AND expires_at > NOW()
12
12
`;
13
13
const rows = result as OAuthStateRow[];
14
-
return rows[0]?.data as StateData | undefined;
14
+
15
+
if (!rows[0]) return undefined;
16
+
17
+
// State data contains dpopKey which must remain as JWK object
18
+
// We don't encrypt state data - it's ephemeral (10 min expiry)
19
+
return rows[0].data as StateData;
15
20
}
16
21
17
22
async set(key: string, value: StateData): Promise<void> {
18
23
const expiresAt = new Date(Date.now() + CONFIG.STATE_EXPIRY);
24
+
25
+
// Store as-is - no encryption for state data
26
+
// State is ephemeral and dpopKey needs to be valid JWK
27
+
const dataToStore = JSON.stringify(value);
28
+
19
29
await this.sql`
20
30
INSERT INTO oauth_states (key, data, expires_at)
21
-
VALUES (${key}, ${JSON.stringify(value)}, ${expiresAt.toISOString()})
31
+
VALUES (${key}, ${dataToStore}, ${expiresAt.toISOString()})
22
32
ON CONFLICT (key) DO UPDATE SET
23
-
data = ${JSON.stringify(value)},
33
+
data = ${dataToStore},
24
34
expires_at = ${expiresAt.toISOString()}
25
35
`;
26
36
}
+7
-4
netlify/functions/infrastructure/oauth/stores/UserSessionStore.ts
+7
-4
netlify/functions/infrastructure/oauth/stores/UserSessionStore.ts
···
7
7
8
8
async get(sessionId: string): Promise<UserSessionData | undefined> {
9
9
const result = await this.sql`
10
-
SELECT did FROM user_sessions
10
+
SELECT did, fingerprint FROM user_sessions
11
11
WHERE session_id = ${sessionId} AND expires_at > NOW()
12
12
`;
13
13
const rows = result as UserSessionRow[];
14
-
return rows[0] ? { did: rows[0].did } : undefined;
14
+
return rows[0]
15
+
? { did: rows[0].did, fingerprint: rows[0].fingerprint }
16
+
: undefined;
15
17
}
16
18
17
19
async set(sessionId: string, data: UserSessionData): Promise<void> {
18
20
const expiresAt = new Date(Date.now() + CONFIG.SESSION_EXPIRY);
19
21
await this.sql`
20
-
INSERT INTO user_sessions (session_id, did, expires_at)
21
-
VALUES (${sessionId}, ${data.did}, ${expiresAt})
22
+
INSERT INTO user_sessions (session_id, did, fingerprint, expires_at)
23
+
VALUES (${sessionId}, ${data.did}, ${JSON.stringify(data.fingerprint)}, ${expiresAt})
22
24
ON CONFLICT (session_id) DO UPDATE SET
23
25
did = ${data.did},
26
+
fingerprint = ${JSON.stringify(data.fingerprint)},
24
27
expires_at = ${expiresAt}
25
28
`;
26
29
}
+7
-2
netlify/functions/oauth-callback.ts
+7
-2
netlify/functions/oauth-callback.ts
···
1
1
import { SimpleHandler } from "./core/types/api.types";
2
2
import { createOAuthClient, getOAuthConfig } from "./infrastructure/oauth";
3
+
import { createSecureSessionData } from "./core/middleware/session-security.middleware";
3
4
import { userSessions } from "./infrastructure/oauth/stores";
4
5
import { redirectResponse } from "./utils";
5
6
import { withErrorHandling } from "./core/middleware";
···
38
39
);
39
40
40
41
const sessionId = crypto.randomUUID();
41
-
const did = result.session.did;
42
-
await userSessions.set(sessionId, { did });
42
+
const secureData = createSecureSessionData(event, result.session.did);
43
+
44
+
await userSessions.set(sessionId, {
45
+
did: secureData.did,
46
+
fingerprint: secureData.fingerprint,
47
+
});
43
48
44
49
console.log("[oauth-callback] Created user session:", sessionId);
45
50
+24
-1
netlify/functions/services/SessionService.ts
+24
-1
netlify/functions/services/SessionService.ts
···
1
1
import { Agent } from "@atproto/api";
2
2
import type { NodeOAuthClient } from "@atproto/oauth-client-node";
3
+
import { SessionSecurityService } from "../core/middleware/session-security.middleware";
3
4
import type { HandlerEvent } from "@netlify/functions";
4
5
import { AuthenticationError, ERROR_MESSAGES } from "../core/errors";
5
6
import { createOAuthClient } from "../infrastructure/oauth";
6
7
import { userSessions } from "../infrastructure/oauth/stores";
7
8
import { configCache } from "../infrastructure/cache/CacheService";
9
+
import { sessionStore } from "../infrastructure/oauth/stores";
8
10
9
11
export class SessionService {
10
12
static async getAgentForSession(
11
13
sessionId: string,
12
-
event?: HandlerEvent,
14
+
event: HandlerEvent,
13
15
): Promise<{
14
16
agent: Agent;
15
17
did: string;
···
22
24
throw new AuthenticationError(ERROR_MESSAGES.INVALID_SESSION);
23
25
}
24
26
27
+
const currentFingerprint =
28
+
SessionSecurityService.generateFingerprint(event);
29
+
if (
30
+
userSession.fingerprint &&
31
+
!SessionSecurityService.verifyFingerprint(
32
+
userSession.fingerprint,
33
+
currentFingerprint,
34
+
)
35
+
) {
36
+
throw new AuthenticationError("Session hijacking detected");
37
+
}
38
+
25
39
const did = userSession.did;
26
40
console.log("[SessionService] Found user session for DID:", did);
27
41
···
39
53
40
54
const oauthSession = await client.restore(did);
41
55
console.log("[SessionService] Restored OAuth session for DID:", did);
56
+
57
+
// Log token rotation for monitoring
58
+
// The restore() call automatically refreshes if needed
59
+
const sessionData = await sessionStore.get(did);
60
+
if (sessionData) {
61
+
// Token refresh happens transparently in restore()
62
+
// Just log for monitoring purposes
63
+
console.log("[SessionService] OAuth session restored/refreshed");
64
+
}
42
65
43
66
const agent = new Agent(oauthSession);
44
67
+139
netlify/functions/utils/encryption.utils.ts
+139
netlify/functions/utils/encryption.utils.ts
···
1
+
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
2
+
import { ApiError } from "../core/errors";
3
+
4
+
/**
5
+
* Token Encryption Service
6
+
* Encrypts sensitive OAuth tokens at rest using AES-256-GCM
7
+
*/
8
+
9
+
function getEncryptionKey(): Buffer {
10
+
const key = process.env.TOKEN_ENCRYPTION_KEY;
11
+
12
+
if (!key) {
13
+
throw new ApiError(
14
+
"Encryption key not configured",
15
+
500,
16
+
"TOKEN_ENCRYPTION_KEY environment variable is required",
17
+
);
18
+
}
19
+
20
+
// Expect 64-char hex string (32 bytes)
21
+
if (key.length !== 64) {
22
+
throw new ApiError(
23
+
"Invalid encryption key",
24
+
500,
25
+
"TOKEN_ENCRYPTION_KEY must be 64 hex characters (32 bytes)",
26
+
);
27
+
}
28
+
29
+
return Buffer.from(key, "hex");
30
+
}
31
+
32
+
interface EncryptedPayload {
33
+
iv: string;
34
+
data: string;
35
+
tag: string;
36
+
}
37
+
38
+
/**
39
+
* Encrypt sensitive data using AES-256-GCM
40
+
* @param data - Data to encrypt (will be JSON stringified)
41
+
* @returns Encrypted payload as JSON string
42
+
*/
43
+
export function encryptToken(data: any): string {
44
+
try {
45
+
const key = getEncryptionKey();
46
+
const iv = randomBytes(16);
47
+
48
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
49
+
50
+
const jsonData = JSON.stringify(data);
51
+
const encrypted = Buffer.concat([
52
+
cipher.update(jsonData, "utf8"),
53
+
cipher.final(),
54
+
]);
55
+
56
+
const authTag = cipher.getAuthTag();
57
+
58
+
const payload: EncryptedPayload = {
59
+
iv: iv.toString("hex"),
60
+
data: encrypted.toString("hex"),
61
+
tag: authTag.toString("hex"),
62
+
};
63
+
64
+
return JSON.stringify(payload);
65
+
} catch (error) {
66
+
console.error("Token encryption failed:", error);
67
+
throw new ApiError(
68
+
"Failed to encrypt token",
69
+
500,
70
+
error instanceof Error ? error.message : "Unknown encryption error",
71
+
);
72
+
}
73
+
}
74
+
75
+
/**
76
+
* Decrypt sensitive data
77
+
* @param encrypted - Encrypted payload as JSON string
78
+
* @returns Decrypted data
79
+
*/
80
+
export function decryptToken(encrypted: string): any {
81
+
try {
82
+
const key = getEncryptionKey();
83
+
const payload: EncryptedPayload = JSON.parse(encrypted);
84
+
85
+
const decipher = createDecipheriv(
86
+
"aes-256-gcm",
87
+
key,
88
+
Buffer.from(payload.iv, "hex"),
89
+
);
90
+
91
+
decipher.setAuthTag(Buffer.from(payload.tag, "hex"));
92
+
93
+
const decrypted = Buffer.concat([
94
+
decipher.update(Buffer.from(payload.data, "hex")),
95
+
decipher.final(),
96
+
]);
97
+
98
+
return JSON.parse(decrypted.toString("utf8"));
99
+
} catch (error) {
100
+
console.error("Token decryption failed:", error);
101
+
throw new ApiError(
102
+
"Failed to decrypt token",
103
+
500,
104
+
error instanceof Error ? error.message : "Unknown decryption error",
105
+
);
106
+
}
107
+
}
108
+
109
+
/**
110
+
* Generate a new encryption key (for initial setup)
111
+
* Run this once and store in environment variables
112
+
*/
113
+
export function generateEncryptionKey(): string {
114
+
return randomBytes(32).toString("hex");
115
+
}
116
+
117
+
/**
118
+
* Check if encryption is properly configured
119
+
* Returns false in development if key is missing (with warning)
120
+
*/
121
+
export function isEncryptionConfigured(): boolean {
122
+
const key = process.env.TOKEN_ENCRYPTION_KEY;
123
+
124
+
if (!key) {
125
+
if (process.env.NODE_ENV === "production") {
126
+
throw new ApiError(
127
+
"Encryption key not configured in production",
128
+
500,
129
+
"TOKEN_ENCRYPTION_KEY is required in production",
130
+
);
131
+
}
132
+
console.warn(
133
+
"⚠️ TOKEN_ENCRYPTION_KEY not set - tokens will NOT be encrypted",
134
+
);
135
+
return false;
136
+
}
137
+
138
+
return true;
139
+
}
+1
netlify/functions/utils/index.ts
+1
netlify/functions/utils/index.ts
+2
-1
package.json
+2
-1
package.json
···
9
9
"dev:mock": "vite --mode mock",
10
10
"dev:full": "netlify dev",
11
11
"build": "vite build",
12
-
"init-db": "tsx scripts/init-local-db.ts"
12
+
"init-db": "tsx scripts/init-local-db.ts",
13
+
"generate-key": "tsx scripts/generate-encryption-key.ts"
13
14
},
14
15
"dependencies": {
15
16
"@atcute/identity": "^1.1.0",
+21
scripts/generate-encryption-key.ts
+21
scripts/generate-encryption-key.ts
···
1
+
import { randomBytes } from "crypto";
2
+
3
+
/**
4
+
* Generate encryption key for token storage
5
+
* Run once: npx tsx scripts/generate-encryption-key.ts
6
+
*/
7
+
8
+
const key = randomBytes(32).toString("hex");
9
+
10
+
console.log("\n🔐 TOKEN ENCRYPTION KEY GENERATED\n");
11
+
console.log("Add this to your .env file and Netlify environment variables:\n");
12
+
console.log(`TOKEN_ENCRYPTION_KEY=${key}\n`);
13
+
console.log("⚠️ IMPORTANT:");
14
+
console.log("1. Keep this key secret and secure");
15
+
console.log("2. Never commit this to git");
16
+
console.log(
17
+
"3. Use the same key across all environments to decrypt existing tokens",
18
+
);
19
+
console.log(
20
+
"4. If you lose this key, all encrypted tokens will be unrecoverable\n",
21
+
);
+37
scripts/keygen.js
+37
scripts/keygen.js
···
1
+
import { generateKeyPair, exportJWK, exportPKCS8 } from "jose";
2
+
import { writeFileSync } from "fs";
3
+
4
+
async function generateKeys() {
5
+
// Generate ES256 key pair (recommended by atproto)
6
+
const { publicKey, privateKey } = await generateKeyPair("ES256", {
7
+
extractable: true,
8
+
});
9
+
10
+
// Export public key as JWK (for client-metadata.json)
11
+
const publicJWK = await exportJWK(publicKey);
12
+
publicJWK.kid = "main-key"; // Key ID
13
+
publicJWK.use = "sig"; // Signature use
14
+
publicJWK.alg = "ES256";
15
+
16
+
// Export private key as PKCS8 (for environment variable)
17
+
const privateKeyPem = await exportPKCS8(privateKey);
18
+
19
+
console.log("\n=== PUBLIC KEY (JWK) ===");
20
+
console.log("Add this to your client-metadata.json jwks.keys array:");
21
+
console.log(JSON.stringify(publicJWK, null, 2));
22
+
23
+
console.log("\n=== PRIVATE KEY (PEM) ===");
24
+
console.log(
25
+
"Add this to Netlify environment variables as OAUTH_PRIVATE_KEY:",
26
+
);
27
+
console.log(privateKeyPem);
28
+
29
+
// Save to files for reference
30
+
writeFileSync("public-jwk.json", JSON.stringify(publicJWK, null, 2));
31
+
writeFileSync("private-key.pem", privateKeyPem);
32
+
33
+
console.log("\n✅ Keys saved to public-jwk.json and private-key.pem");
34
+
console.log("⚠️ Keep private-key.pem SECRET! Add it to .gitignore");
35
+
}
36
+
37
+
generateKeys().catch(console.error);