From 1b7ba505dfcc72c11b214565422262b6973006b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:22:21 +0000 Subject: [PATCH 1/4] Initial plan From b2781f24df931a5529ecec93450a22d546661cbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:29:28 +0000 Subject: [PATCH 2/4] Add token encryption to auth package Co-authored-by: rob-at-cortex <50111225+rob-at-cortex@users.noreply.github.com> --- packages/auth/src/crypto.ts | 77 +++++++++++++++++++ packages/auth/src/index.ts | 3 +- .../auth/src/payload-jwt/configuration.ts | 13 +++- .../auth/src/payload-jwt/getAccessToken.ts | 25 ++++-- 4 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 packages/auth/src/crypto.ts diff --git a/packages/auth/src/crypto.ts b/packages/auth/src/crypto.ts new file mode 100644 index 0000000..7d1f5c7 --- /dev/null +++ b/packages/auth/src/crypto.ts @@ -0,0 +1,77 @@ +import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; + +/** + * Encrypts a token using AES-256-GCM encryption + * @param token - The token string to encrypt + * @param secret - The encryption key (PAYLOAD_SECRET) + * @returns The encrypted token in format: iv:authTag:encryptedData (hex encoded) + */ +export function encryptToken(token: string | null | undefined, secret: string): string | null { + if (!token) return null; + + try { + // Generate a 32-byte key from the secret using SHA-256 + const key = createHash('sha256').update(secret).digest(); + + // Generate a random 12-byte initialization vector + const iv = randomBytes(12); + + // Create cipher + const cipher = createCipheriv('aes-256-gcm', key, iv); + + // Encrypt the token + let encrypted = cipher.update(token, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + // Get the auth tag for GCM mode + const authTag = cipher.getAuthTag(); + + // Return format: iv:authTag:encryptedData + return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; + } catch (error) { + console.error('Error encrypting token:', error); + return null; + } +} + +/** + * Decrypts a token that was encrypted with encryptToken + * @param encryptedToken - The encrypted token string in format: iv:authTag:encryptedData + * @param secret - The encryption key (PAYLOAD_SECRET) + * @returns The decrypted token string + */ +export function decryptToken(encryptedToken: string | null | undefined, secret: string): string | null { + if (!encryptedToken) return null; + + try { + // Parse the encrypted token format + const parts = encryptedToken.split(':'); + if (parts.length !== 3) { + // Token might be unencrypted (backward compatibility during migration) + console.warn('Token format is invalid or unencrypted'); + return encryptedToken; + } + + const [ivHex, authTagHex, encryptedData] = parts; + + // Generate the same key from the secret + const key = createHash('sha256').update(secret).digest(); + + // Convert hex strings back to buffers + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + + // Create decipher + const decipher = createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(authTag); + + // Decrypt the token + let decrypted = decipher.update(encryptedData, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } catch (error) { + console.error('Error decrypting token:', error); + return null; + } +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 3d5f9e3..ba985f4 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,3 +1,4 @@ export * from './payload-jwt' export * from './payload-access' -export * from './types' \ No newline at end of file +export * from './types' +export * from './crypto' \ No newline at end of file diff --git a/packages/auth/src/payload-jwt/configuration.ts b/packages/auth/src/payload-jwt/configuration.ts index 4dc10d7..fb640d3 100644 --- a/packages/auth/src/payload-jwt/configuration.ts +++ b/packages/auth/src/payload-jwt/configuration.ts @@ -2,6 +2,7 @@ import type { User } from '@/types' import type { SanitizedConfig } from 'payload' import { decodeJwt } from 'jose' import { getPayload } from 'payload' +import { encryptToken } from '../crypto' type AccountType = NonNullable[number] @@ -13,15 +14,21 @@ function upsertAccount(existing: AccountType[] = [], account: AccountType) { (a: AccountType) => a.provider === provider && a.providerAccountId === providerAccountId ) + // Get the PAYLOAD_SECRET for encryption + const secret = process.env.PAYLOAD_SECRET; + if (!secret) { + throw new Error('PAYLOAD_SECRET environment variable is required for token encryption'); + } + const nextRow = { ...(idx >= 0 ? existing[idx] : {}), provider, providerAccountId, type: account.type, - // token fields (must match your Users.accounts[] schema) - access_token: account.access_token ?? null, - refresh_token: account.refresh_token ?? null, + // Encrypt tokens before storing (must match your Users.accounts[] schema) + access_token: encryptToken(account.access_token, secret), + refresh_token: encryptToken(account.refresh_token, secret), expires_at: account.expires_at ?? null, id_token: account.id_token ?? null, token_type: account.token_type ?? null, diff --git a/packages/auth/src/payload-jwt/getAccessToken.ts b/packages/auth/src/payload-jwt/getAccessToken.ts index 96c7ef6..f906b67 100644 --- a/packages/auth/src/payload-jwt/getAccessToken.ts +++ b/packages/auth/src/payload-jwt/getAccessToken.ts @@ -1,5 +1,6 @@ import type { User } from '@/types' import type { Payload } from 'payload' +import { decryptToken, encryptToken } from '../crypto' type AccountRow = NonNullable[number] @@ -7,14 +8,24 @@ export async function getAccessToken(payload: Payload, userId: string) { const user = await payload.findByID({ collection: "users", id: userId, depth: 0 }); const kc = (user as Partial)?.accounts?.find((a: AccountRow) => a.provider === "keycloak") as AccountRow | undefined; - if (!kc?.access_token) return null; + + // Get the PAYLOAD_SECRET for decryption + const secret = process.env.PAYLOAD_SECRET; + if (!secret) { + throw new Error('PAYLOAD_SECRET environment variable is required for token decryption'); + } + + // Decrypt the access token + const decryptedAccessToken = decryptToken(kc?.access_token, secret); + if (!decryptedAccessToken || !kc) return null; const expiresAt = kc.expires_at ?? 0; const stillValid = expiresAt === 0 || Date.now() < expiresAt * 1000 - 30_000; // 30s skew - if (stillValid) return kc.access_token; + if (stillValid) return decryptedAccessToken; // Refresh if needed - if (!kc.refresh_token) return null; + const decryptedRefreshToken = decryptToken(kc.refresh_token, secret); + if (!decryptedRefreshToken) return null; const resp = await fetch(`${process.env.OAUTH_ISSUER}/protocol/openid-connect/token`, { method: "POST", @@ -23,7 +34,7 @@ export async function getAccessToken(payload: Payload, userId: string) { client_id: process.env.OAUTH_CLIENT_ID!, client_secret: process.env.OAUTH_CLIENT_SECRET!, grant_type: "refresh_token", - refresh_token: kc.refresh_token, + refresh_token: decryptedRefreshToken, }), }); @@ -33,14 +44,14 @@ export async function getAccessToken(payload: Payload, userId: string) { const newExpiresAt = Math.floor(Date.now() / 1000 + json.expires_in); - // Persist back into users.accounts[] (update your matching row logic as needed) + // Encrypt new tokens before persisting back into users.accounts[] const accounts = ((user as Partial).accounts ?? []).map((a: AccountRow | undefined) => a?.provider === "keycloak" ? { ...a, - access_token: json.access_token, + access_token: encryptToken(json.access_token, secret), expires_at: newExpiresAt, - refresh_token: json.refresh_token ?? a.refresh_token, + refresh_token: encryptToken(json.refresh_token, secret) ?? a.refresh_token, } : a ); From 31deebde250bfe028c517e0276375269e1cd64a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:30:55 +0000 Subject: [PATCH 3/4] Address code review feedback - improve error handling and fix logic issues Co-authored-by: rob-at-cortex <50111225+rob-at-cortex@users.noreply.github.com> --- packages/auth/src/crypto.ts | 7 ++++--- packages/auth/src/payload-jwt/getAccessToken.ts | 10 +++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/auth/src/crypto.ts b/packages/auth/src/crypto.ts index 7d1f5c7..4b6b715 100644 --- a/packages/auth/src/crypto.ts +++ b/packages/auth/src/crypto.ts @@ -29,7 +29,7 @@ export function encryptToken(token: string | null | undefined, secret: string): // Return format: iv:authTag:encryptedData return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; } catch (error) { - console.error('Error encrypting token:', error); + console.error('Error encrypting token'); return null; } } @@ -48,7 +48,8 @@ export function decryptToken(encryptedToken: string | null | undefined, secret: const parts = encryptedToken.split(':'); if (parts.length !== 3) { // Token might be unencrypted (backward compatibility during migration) - console.warn('Token format is invalid or unencrypted'); + // Log for audit purposes + console.warn('Encountered potentially unencrypted token in database - migration may be needed'); return encryptedToken; } @@ -71,7 +72,7 @@ export function decryptToken(encryptedToken: string | null | undefined, secret: return decrypted; } catch (error) { - console.error('Error decrypting token:', error); + console.error('Error decrypting token'); return null; } } diff --git a/packages/auth/src/payload-jwt/getAccessToken.ts b/packages/auth/src/payload-jwt/getAccessToken.ts index f906b67..30265d9 100644 --- a/packages/auth/src/payload-jwt/getAccessToken.ts +++ b/packages/auth/src/payload-jwt/getAccessToken.ts @@ -9,6 +9,9 @@ export async function getAccessToken(payload: Payload, userId: string) { const kc = (user as Partial)?.accounts?.find((a: AccountRow) => a.provider === "keycloak") as AccountRow | undefined; + // Check if keycloak account exists + if (!kc) return null; + // Get the PAYLOAD_SECRET for decryption const secret = process.env.PAYLOAD_SECRET; if (!secret) { @@ -16,8 +19,8 @@ export async function getAccessToken(payload: Payload, userId: string) { } // Decrypt the access token - const decryptedAccessToken = decryptToken(kc?.access_token, secret); - if (!decryptedAccessToken || !kc) return null; + const decryptedAccessToken = decryptToken(kc.access_token, secret); + if (!decryptedAccessToken) return null; const expiresAt = kc.expires_at ?? 0; const stillValid = expiresAt === 0 || Date.now() < expiresAt * 1000 - 30_000; // 30s skew @@ -51,7 +54,8 @@ export async function getAccessToken(payload: Payload, userId: string) { ...a, access_token: encryptToken(json.access_token, secret), expires_at: newExpiresAt, - refresh_token: encryptToken(json.refresh_token, secret) ?? a.refresh_token, + // Use new refresh token if provided, otherwise keep the existing encrypted one + refresh_token: json.refresh_token ? encryptToken(json.refresh_token, secret) : a.refresh_token, } : a ); From 4835febe7011c34589efb68285e910bd49b94872 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:32:38 +0000 Subject: [PATCH 4/4] Add token encryption documentation Co-authored-by: rob-at-cortex <50111225+rob-at-cortex@users.noreply.github.com> --- packages/auth/docs/token-encryption.md | 77 ++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 packages/auth/docs/token-encryption.md diff --git a/packages/auth/docs/token-encryption.md b/packages/auth/docs/token-encryption.md new file mode 100644 index 0000000..b746f49 --- /dev/null +++ b/packages/auth/docs/token-encryption.md @@ -0,0 +1,77 @@ +# Token Encryption + +The auth package now encrypts OAuth access tokens and refresh tokens before storing them in the Payload database. + +## Overview + +All `access_token` and `refresh_token` values in the Users collection accounts array are now encrypted using AES-256-GCM encryption before being stored in the database. This provides an additional layer of security for sensitive OAuth tokens. + +## Encryption Method + +- **Algorithm**: AES-256-GCM (Authenticated Encryption with Associated Data) +- **Key Derivation**: SHA-256 hash of PAYLOAD_SECRET environment variable +- **IV**: Random 12-byte initialization vector generated for each encryption +- **Format**: `iv:authTag:encryptedData` (all hex-encoded) + +## Security Features + +1. **Reversible Encryption**: Tokens can be decrypted when needed for API calls +2. **Unique IVs**: Each encryption uses a unique random initialization vector +3. **Authentication**: GCM mode provides authentication tags to prevent tampering +4. **Key Management**: Uses existing PAYLOAD_SECRET environment variable + +## Usage + +The encryption and decryption are handled automatically by the auth package: + +### Storing Tokens (Automatic) + +When tokens are stored via `persistTokens()`: +```typescript +await payloadAuthConfig.persistTokens(user.id, account, payloadConfig) +``` + +### Retrieving Tokens (Automatic) + +When tokens are retrieved via `getAccessToken()`: +```typescript +const token = await getAccessToken(payload, session.user.id); +``` + +## Migration + +The decryption function includes backward compatibility for unencrypted tokens: +- If a token doesn't match the encrypted format, it's returned as-is +- A warning is logged for audit purposes +- This allows for gradual migration of existing tokens + +## Environment Variables + +Requires `PAYLOAD_SECRET` environment variable to be set. This is used as the encryption key. + +## Security Considerations + +1. **PAYLOAD_SECRET must be kept secure** - anyone with access to this secret can decrypt stored tokens +2. **Rotate PAYLOAD_SECRET carefully** - changing it will make existing encrypted tokens unreadable +3. **Backup strategy** - ensure proper backup procedures are in place before rotating keys +4. **Audit logs** - warnings are logged when unencrypted tokens are encountered + +## Functions + +### `encryptToken(token, secret)` +Encrypts a token string using AES-256-GCM. + +**Parameters:** +- `token` (string | null | undefined): The token to encrypt +- `secret` (string): The encryption key (typically PAYLOAD_SECRET) + +**Returns:** Encrypted token string or null + +### `decryptToken(encryptedToken, secret)` +Decrypts a token that was encrypted with encryptToken. + +**Parameters:** +- `encryptedToken` (string | null | undefined): The encrypted token +- `secret` (string): The encryption key (typically PAYLOAD_SECRET) + +**Returns:** Decrypted token string or null