Skip to content

Commit 78d3f82

Browse files
committed
refactor: split encryption logic and cookie
1 parent 5391dc8 commit 78d3f82

File tree

5 files changed

+131
-114
lines changed

5 files changed

+131
-114
lines changed

src/lib/auth/auth.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,37 +14,24 @@ import {
1414
TOKEN_SEVEN_DAYS_SECONDS,
1515
TRUSTED_ORIGINS,
1616
} from "./constants";
17+
import { saveTokenCookie } from "./cookie";
1718
import type {
1819
OidcDiscovery,
1920
OidcDiscoveryResponse,
2021
OidcTokenData,
2122
TokenResponse,
2223
} from "./types";
23-
import { decrypt, encrypt, saveAccountToken } from "./utils";
24+
import { decrypt, saveAccountToken } from "./utils";
25+
26+
// Re-export saveTokenCookie for backwards compatibility
27+
export { saveTokenCookie } from "./cookie";
2428

2529
/**
2630
* Cached token endpoint to avoid repeated discovery calls.
2731
*/
2832
let cachedTokenEndpoint: string | null = null;
2933
let cachedEndSessionEndpoint: string | null = null;
3034

31-
/**
32-
* Saves encrypted token data in HTTP-only cookie.
33-
* Exported for use by saveAccountToken in utils.
34-
*/
35-
export async function saveTokenCookie(tokenData: OidcTokenData): Promise<void> {
36-
const encrypted = await encrypt(tokenData, BETTER_AUTH_SECRET);
37-
const cookieStore = await cookies();
38-
39-
cookieStore.set(OIDC_TOKEN_COOKIE_NAME, encrypted, {
40-
httpOnly: true,
41-
secure: IS_PRODUCTION,
42-
sameSite: "lax",
43-
maxAge: TOKEN_SEVEN_DAYS_SECONDS,
44-
path: "/",
45-
});
46-
}
47-
4835
/**
4936
* Discovers and caches the token and end_session endpoints from OIDC provider.
5037
* Exported for use in server actions and token refresh logic.
@@ -179,7 +166,7 @@ export async function refreshAccessToken({
179166
}
180167

181168
export const auth: Auth<BetterAuthOptions> = betterAuth({
182-
debug: true,
169+
debug: !IS_PRODUCTION,
183170
secret: BETTER_AUTH_SECRET,
184171
baseURL: BASE_URL,
185172
account: {

src/lib/auth/cookie.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Cookie utilities for OIDC token storage.
3+
* Separated to avoid circular dependencies with auth modules.
4+
*/
5+
6+
import { cookies } from "next/headers";
7+
import {
8+
BETTER_AUTH_SECRET,
9+
IS_PRODUCTION,
10+
OIDC_TOKEN_COOKIE_NAME,
11+
TOKEN_SEVEN_DAYS_SECONDS,
12+
} from "./constants";
13+
import { encrypt } from "./crypto";
14+
import type { OidcTokenData } from "./types";
15+
16+
/**
17+
* Saves encrypted token data in HTTP-only cookie.
18+
* Used by saveAccountToken in utils and refreshAccessToken in auth.
19+
*/
20+
export async function saveTokenCookie(tokenData: OidcTokenData): Promise<void> {
21+
const encrypted = await encrypt(tokenData, BETTER_AUTH_SECRET);
22+
const cookieStore = await cookies();
23+
24+
cookieStore.set(OIDC_TOKEN_COOKIE_NAME, encrypted, {
25+
httpOnly: true,
26+
secure: IS_PRODUCTION,
27+
sameSite: "lax",
28+
maxAge: TOKEN_SEVEN_DAYS_SECONDS,
29+
path: "/",
30+
});
31+
}

src/lib/auth/crypto.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Cryptographic utilities for token encryption and decryption.
3+
* Separated to avoid circular dependencies with auth modules.
4+
*/
5+
6+
import { createHash } from "node:crypto";
7+
import * as jose from "jose";
8+
import type { OidcTokenData } from "./types";
9+
10+
/**
11+
* Derives encryption key from secret.
12+
* Uses SHA-256 to derive exactly 32 bytes (256 bits) from the provided secret,
13+
* ensuring compatibility with AES-256-GCM regardless of secret length.
14+
*/
15+
function getSecret(secret: string): Uint8Array {
16+
// Hash the secret to get exactly 32 bytes for AES-256-GCM
17+
return new Uint8Array(createHash("sha256").update(secret).digest());
18+
}
19+
20+
/**
21+
* Encrypts token data using JWE (JSON Web Encryption).
22+
* Uses AES-256-GCM with direct key agreement (alg: 'dir').
23+
* Exported for testing purposes.
24+
*/
25+
export async function encrypt(
26+
data: OidcTokenData,
27+
secret: string,
28+
): Promise<string> {
29+
const key = getSecret(secret);
30+
const plaintext = new TextEncoder().encode(JSON.stringify(data));
31+
return await new jose.CompactEncrypt(plaintext)
32+
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
33+
.encrypt(key);
34+
}
35+
36+
/**
37+
* Decrypts JWE token and returns parsed token data.
38+
* Validates data structure after decryption.
39+
* Exported for testing purposes.
40+
*/
41+
export async function decrypt(
42+
jwe: string,
43+
secret: string,
44+
): Promise<OidcTokenData> {
45+
try {
46+
const key = getSecret(secret);
47+
const { plaintext } = await jose.compactDecrypt(jwe, key);
48+
const data = JSON.parse(new TextDecoder().decode(plaintext));
49+
50+
if (!isOidcTokenData(data)) {
51+
throw new Error("Invalid token data structure");
52+
}
53+
54+
return data;
55+
} catch (error) {
56+
if (error instanceof jose.errors.JWEDecryptionFailed) {
57+
throw new Error("Token decryption failed - possible tampering");
58+
}
59+
if (error instanceof jose.errors.JWEInvalid) {
60+
throw new Error("Invalid JWE format");
61+
}
62+
// Wrap unexpected errors to avoid exposing internal details
63+
const message = error instanceof Error ? error.message : "Unknown error";
64+
throw new Error(`Token decryption error: ${message}`);
65+
}
66+
}
67+
68+
/**
69+
* Type guard to validate OidcTokenData structure at runtime.
70+
* Used after decrypting token data from cookie to ensure data integrity.
71+
* Note: idToken is not validated here as it's optional and not critical for token validation.
72+
*/
73+
export function isOidcTokenData(data: unknown): data is OidcTokenData {
74+
if (typeof data !== "object" || data === null) {
75+
return false;
76+
}
77+
78+
const obj = data as Record<string, unknown>;
79+
80+
return (
81+
typeof obj.accessToken === "string" &&
82+
typeof obj.accessTokenExpiresAt === "number" &&
83+
typeof obj.userId === "string" &&
84+
(obj.refreshToken === undefined || typeof obj.refreshToken === "string") &&
85+
(obj.refreshTokenExpiresAt === undefined ||
86+
typeof obj.refreshTokenExpiresAt === "number")
87+
);
88+
}

src/lib/auth/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface OidcTokenData
1818
| "updatedAt"
1919
| "id"
2020
> {
21-
accessTokenExpiresAt?: number;
21+
accessTokenExpiresAt: number;
2222
refreshTokenExpiresAt?: number;
2323
providerId?: string;
2424
accountId?: string;

src/lib/auth/utils.ts

Lines changed: 5 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,20 @@
11
/**
2-
* Utility functions for authentication, token validation, and encryption.
2+
* Utility functions for authentication and token management.
33
*/
44

5-
import { createHash } from "node:crypto";
65
import type { Account } from "better-auth";
7-
import * as jose from "jose";
86
import { cookies } from "next/headers";
9-
import { saveTokenCookie } from "./auth";
107
import {
118
BETTER_AUTH_SECRET,
129
OIDC_TOKEN_COOKIE_NAME,
1310
TOKEN_ONE_HOUR_MS,
1411
} from "./constants";
12+
import { saveTokenCookie } from "./cookie";
13+
import { decrypt } from "./crypto";
1514
import type { OidcTokenData } from "./types";
1615

17-
/**
18-
* Derives encryption key from secret.
19-
* Uses SHA-256 to derive exactly 32 bytes (256 bits) from the provided secret,
20-
* ensuring compatibility with AES-256-GCM regardless of secret length.
21-
*/
22-
function getSecret(secret: string): Uint8Array {
23-
// Hash the secret to get exactly 32 bytes for AES-256-GCM
24-
return new Uint8Array(createHash("sha256").update(secret).digest());
25-
}
26-
27-
/**
28-
* Encrypts token data using JWE (JSON Web Encryption).
29-
* Uses AES-256-GCM with direct key agreement (alg: 'dir').
30-
* Exported for testing purposes.
31-
*/
32-
export async function encrypt(
33-
data: OidcTokenData,
34-
secret: string,
35-
): Promise<string> {
36-
const key = getSecret(secret);
37-
const plaintext = new TextEncoder().encode(JSON.stringify(data));
38-
return await new jose.CompactEncrypt(plaintext)
39-
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
40-
.encrypt(key);
41-
}
42-
43-
/**
44-
* Decrypts JWE token and returns parsed token data.
45-
* Validates data structure after decryption.
46-
* Exported for testing purposes.
47-
*/
48-
export async function decrypt(
49-
jwe: string,
50-
secret: string,
51-
): Promise<OidcTokenData> {
52-
try {
53-
const key = getSecret(secret);
54-
const { plaintext } = await jose.compactDecrypt(jwe, key);
55-
const data = JSON.parse(new TextDecoder().decode(plaintext));
56-
57-
if (!isOidcTokenData(data)) {
58-
throw new Error("Invalid token data structure");
59-
}
60-
61-
return data;
62-
} catch (error) {
63-
if (error instanceof jose.errors.JWEDecryptionFailed) {
64-
throw new Error("Token decryption failed - possible tampering");
65-
}
66-
if (error instanceof jose.errors.JWEInvalid) {
67-
throw new Error("Invalid JWE format");
68-
}
69-
// Wrap unexpected errors to avoid exposing internal details
70-
const message = error instanceof Error ? error.message : "Unknown error";
71-
throw new Error(`Token decryption error: ${message}`);
72-
}
73-
}
74-
75-
/**
76-
* Type guard to validate OidcTokenData structure at runtime.
77-
* Used after decrypting token data from cookie to ensure data integrity.
78-
* Note: idToken is not validated here as it's optional and not critical for token validation.
79-
*/
80-
export function isOidcTokenData(data: unknown): data is OidcTokenData {
81-
if (typeof data !== "object" || data === null) {
82-
return false;
83-
}
84-
85-
const obj = data as Record<string, unknown>;
86-
87-
return (
88-
typeof obj.accessToken === "string" &&
89-
typeof obj.accessTokenExpiresAt === "number" &&
90-
typeof obj.userId === "string" &&
91-
(obj.refreshToken === undefined || typeof obj.refreshToken === "string") &&
92-
(obj.refreshTokenExpiresAt === undefined ||
93-
typeof obj.refreshTokenExpiresAt === "number")
94-
);
95-
}
16+
// Re-export crypto functions for backwards compatibility
17+
export { decrypt, encrypt, isOidcTokenData } from "./crypto";
9618

9719
/**
9820
* Retrieves the OIDC ID token from HTTP-only cookie.
@@ -153,17 +75,6 @@ export async function saveAccountToken(account: Account) {
15375
userId: account.userId,
15476
};
15577

156-
console.log("[account] Token data to save:", JSON.stringify(account));
157-
158-
console.log("[Save Token] Token data to save:", {
159-
hasAccessToken: !!tokenData.accessToken,
160-
hasRefreshToken: !!tokenData.refreshToken,
161-
accessTokenExpiresAt: new Date(accessTokenExpiresAt).toISOString(),
162-
refreshTokenExpiresAt: tokenData.refreshTokenExpiresAt
163-
? new Date(tokenData.refreshTokenExpiresAt).toISOString()
164-
: "none",
165-
});
166-
16778
await saveTokenCookie(tokenData);
16879

16980
console.log("[Save Token] Token cookie saved successfully");

0 commit comments

Comments
 (0)