Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions packages/auth/docs/token-encryption.md
Original file line number Diff line number Diff line change
@@ -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
78 changes: 78 additions & 0 deletions packages/auth/src/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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');
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)
// Log for audit purposes
console.warn('Encountered potentially unencrypted token in database - migration may be needed');
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');
return null;
}
}
3 changes: 2 additions & 1 deletion packages/auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './payload-jwt'
export * from './payload-access'
export * from './types'
export * from './types'
export * from './crypto'
13 changes: 10 additions & 3 deletions packages/auth/src/payload-jwt/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<User['accounts']>[number]

Expand All @@ -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,
Expand Down
29 changes: 22 additions & 7 deletions packages/auth/src/payload-jwt/getAccessToken.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import type { User } from '@/types'
import type { Payload } from 'payload'
import { decryptToken, encryptToken } from '../crypto'

type AccountRow = NonNullable<User['accounts']>[number]

export async function getAccessToken(payload: Payload, userId: string) {
const user = await payload.findByID({ collection: "users", id: userId, depth: 0 });

const kc = (user as Partial<User>)?.accounts?.find((a: AccountRow) => a.provider === "keycloak") as AccountRow | undefined;
if (!kc?.access_token) return null;

// Check if keycloak account exists
if (!kc) 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) 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",
Expand All @@ -23,7 +37,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,
}),
});

Expand All @@ -33,14 +47,15 @@ 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<User>).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,
// 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
);
Expand Down