fix: support auth.options config in sessions (#24460)

* fix: support auth.options config in sessions

* chore: fix lint

* feat(users-permissions): add jwt algorithm configuration

* refactor: remove deprecated admin.auth.options warnings

* test(token): add TypeScript error suppression for mock config in token tests

* feat(bootstrap): re-add warning for deprecated expiresIn option in session settings

---------

Co-authored-by: Jamie Howard <jhoward1994@gmail.com>
Co-authored-by: Jamie Howard <48524071+jhoward1994@users.noreply.github.com>
This commit is contained in:
Ben Irvin 2025-09-29 15:53:29 +02:00 committed by Bassel Kanso
parent ff1eac610b
commit 723a2f0c62
11 changed files with 1235 additions and 51 deletions

View File

@ -63,12 +63,20 @@ Optional request fields on login/register:
Configuration:
- `admin.auth.secret` — required JWT secret used by admin origin
- `admin.auth.secret` — JWT secret for symmetric algorithms (HS256, HS384, HS512)
- `admin.auth.sessions.options.algorithm` — JWT algorithm (default: 'HS256')
- `admin.auth.sessions.options.privateKey` — Private key for asymmetric algorithms (RS256, RS512, ES256, etc.)
- `admin.auth.sessions.options.publicKey` — Public key for asymmetric algorithms (RS256, RS512, ES256, etc.)
- `admin.auth.sessions.options.*` — Any other JWT options (issuer, audience, subject, etc.)
- `admin.auth.sessions.accessTokenLifespan` (default 1800)
- `admin.auth.sessions.maxRefreshTokenLifespan` (default 30 days)
- `admin.auth.sessions.idleRefreshTokenLifespan` (default 7 days)
- `admin.auth.sessions.maxSessionLifespan` (default 7 days)
- `admin.auth.sessions.idleSessionLifespan` (default 1 hour)
- `admin.auth.sessions.idleRefreshTokenLifespan` (default 14 days)
- `admin.auth.sessions.maxSessionLifespan` (default 1 day)
- `admin.auth.sessions.idleSessionLifespan` (default 2 hours)
**Deprecated:**
- `admin.auth.options.*` — Use `admin.auth.sessions.options.*` instead
- Cookie options (applied to `strapi_admin_refresh`):
- `admin.auth.cookie.domain` (or `admin.auth.domain`)
- `admin.auth.cookie.path` (default `/admin`)

View File

@ -2,6 +2,18 @@ module.exports = ({ env }) => ({
// autoOpen: false,
auth: {
secret: env('ADMIN_JWT_SECRET', 'example-token'),
sessions: {
options: {
//algorithm: env('ADMIN_JWT_ALGORITHM', 'HS256'),
// Any other JWT options (issuer, audience, subject, etc.) can be added here
},
// Token and session lifespan configuration
accessTokenLifespan: 30 * 60, // 30 minutes
maxRefreshTokenLifespan: 30 * 24 * 60 * 60, // 30 days
idleRefreshTokenLifespan: 14 * 24 * 60 * 60, // 14 days
maxSessionLifespan: 24 * 60 * 60, // 1 day
idleSessionLifespan: 2 * 60 * 60, // 2 hours
},
},
apiToken: {
salt: env('API_TOKEN_SALT', 'example-salt'),

View File

@ -103,8 +103,7 @@ const createDefaultAPITokensIfNeeded = async () => {
};
export default async ({ strapi }: { strapi: Core.Strapi }) => {
// Fallback for backward compatibility: if the new maxRefreshTokenLifespan is not set,
// reuse the legacy admin.auth.options.expiresIn value (previously the sole JWT lifespan)
// Get the merged token options (includes defaults merged with user config)
const { options } = getTokenOptions();
const legacyMaxRefreshFallback =
expiresInToSeconds(options?.expiresIn) ?? DEFAULT_MAX_REFRESH_TOKEN_LIFESPAN;
@ -141,6 +140,9 @@ export default async ({ strapi }: { strapi: Core.Strapi }) => {
'admin.auth.sessions.idleSessionLifespan',
DEFAULT_IDLE_SESSION_LIFESPAN
),
algorithm: options?.algorithm,
// Pass through all JWT options (includes privateKey, publicKey, and any other options)
jwtOptions: options,
});
await registerAdminConditions();

View File

@ -38,73 +38,242 @@ describe('expiresInToSeconds', () => {
describe('Token', () => {
describe('token options', () => {
test('Has defaults', () => {
const getFn = jest.fn(() => ({}));
beforeEach(() => {
// Reset global.strapi before each test to avoid state pollution
global.strapi = {
config: {
get: getFn,
get: jest.fn(() => ({})),
},
log: {
warn: jest.fn(),
},
plugins: {},
apis: {},
admin: {
services: {},
},
} as any;
// Clear all mocks
jest.clearAllMocks();
});
afterEach(() => {
// Reset global.strapi after each test to ensure complete isolation
global.strapi = {
config: {
get: jest.fn(() => ({})),
},
log: {
warn: jest.fn(),
},
plugins: {},
apis: {},
admin: {
services: {},
},
} as any;
});
test('Has defaults when no configuration is provided', () => {
const getFn = jest.fn(() => ({})) as any;
global.strapi.config.get = getFn;
const res = getTokenOptions();
expect(getFn).toHaveBeenCalledWith('admin.auth', {});
expect(res).toEqual({ options: { expiresIn: '30d' } });
expect(getFn).toHaveBeenCalledWith('admin.auth.sessions.options', {});
expect(res).toEqual({
secret: undefined,
options: { expiresIn: '30d' },
});
});
test('Merges defaults with configuration', () => {
test('Merges defaults with legacy admin.auth.options configuration', () => {
const config = {
options: {
algorithm: 'HS256',
expiresIn: '1d',
},
secret: '123',
};
const getFn = jest.fn((key) => {
if (key === 'admin.auth') return config;
if (key === 'admin.auth.sessions.options') return {};
return {};
});
// @ts-expect-error mock
global.strapi.config.get = getFn;
const res = getTokenOptions();
expect(getFn).toHaveBeenCalledWith('admin.auth', {});
expect(getFn).toHaveBeenCalledWith('admin.auth.sessions.options', {});
expect(res).toEqual({
secret: config.secret,
options: {
algorithm: 'HS256',
expiresIn: '1d',
},
});
});
test('Uses new admin.auth.sessions.options configuration', () => {
const config = {
options: {},
secret: '123',
};
const getFn = jest.fn(() => config);
global.strapi = {
config: {
get: getFn,
},
} as any;
const sessionsOptions = {
algorithm: 'HS256',
expiresIn: '2d',
issuer: 'test-issuer',
};
const getFn = jest.fn((key) => {
if (key === 'admin.auth') return config;
if (key === 'admin.auth.sessions.options') return sessionsOptions;
return {};
});
// @ts-expect-error mock
global.strapi.config.get = getFn;
const res = getTokenOptions();
expect(getFn).toHaveBeenCalledWith('admin.auth', {});
expect(getFn).toHaveBeenCalledWith('admin.auth.sessions.options', {});
expect(res).toEqual({
options: {
expiresIn: '30d',
algorithm: 'HS256',
expiresIn: '2d',
issuer: 'test-issuer',
},
secret: config.secret,
});
expect(global.strapi.log.warn).not.toHaveBeenCalled();
});
test('sessions.options takes priority over legacy auth.options', () => {
const config = {
options: {
algorithm: 'HS256',
expiresIn: '1d',
issuer: 'legacy-issuer',
},
secret: '123',
};
const sessionsOptions = {
algorithm: 'RS256',
expiresIn: '2d',
issuer: 'new-issuer',
audience: 'test-audience',
};
const getFn = jest.fn((key) => {
if (key === 'admin.auth') return config;
if (key === 'admin.auth.sessions.options') return sessionsOptions;
return {};
});
// @ts-expect-error mock
global.strapi.config.get = getFn;
const res = getTokenOptions();
expect(res).toEqual({
options: {
algorithm: 'RS256', // sessions.options takes priority
expiresIn: '2d', // sessions.options takes priority
issuer: 'new-issuer', // sessions.options takes priority
audience: 'test-audience', // from sessions.options
},
secret: config.secret,
});
});
test('Overwrite defaults with configuration options', () => {
test('Supports asymmetric algorithm configuration', () => {
const config = {
options: {
expiresIn: '1d',
},
options: {},
secret: '123',
};
const getFn = jest.fn(() => config);
global.strapi = {
config: {
get: getFn,
},
} as any;
const sessionsOptions = {
algorithm: 'RS256',
privateKey: '-----BEGIN PRIVATE KEY-----\ntest-private-key\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN PUBLIC KEY-----\ntest-public-key\n-----END PUBLIC KEY-----',
issuer: 'test-issuer',
audience: 'test-audience',
};
const getFn = jest.fn((key) => {
if (key === 'admin.auth') return config;
if (key === 'admin.auth.sessions.options') return sessionsOptions;
return {};
});
// @ts-expect-error mock
global.strapi.config.get = getFn;
const res = getTokenOptions();
expect(getFn).toHaveBeenCalledWith('admin.auth', {});
expect(res).toEqual({
options: {
expiresIn: '1d',
},
secret: config.secret,
options: {
algorithm: 'RS256',
expiresIn: '30d', // From defaultJwtOptions
privateKey: '-----BEGIN PRIVATE KEY-----\ntest-private-key\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN PUBLIC KEY-----\ntest-public-key\n-----END PUBLIC KEY-----',
issuer: 'test-issuer',
audience: 'test-audience',
},
});
});
test('Supports symmetric algorithm configuration', () => {
const config = {
options: {},
secret: 'symmetric-secret-key',
};
const sessionsOptions = {
algorithm: 'HS512',
issuer: 'test-issuer',
audience: 'test-audience',
privateKey: '-----BEGIN PRIVATE KEY-----\ntest-private-key\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN PUBLIC KEY-----\ntest-public-key\n-----END PUBLIC KEY-----',
};
const getFn = jest.fn((key) => {
if (key === 'admin.auth') return config;
if (key === 'admin.auth.sessions.options') return sessionsOptions;
return {};
});
// @ts-expect-error mock
global.strapi.config.get = getFn;
const res = getTokenOptions();
expect(res).toEqual({
secret: config.secret,
options: {
algorithm: 'HS512',
expiresIn: '30d', // From defaultJwtOptions
issuer: 'test-issuer',
audience: 'test-audience',
privateKey: '-----BEGIN PRIVATE KEY-----\ntest-private-key\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN PUBLIC KEY-----\ntest-public-key\n-----END PUBLIC KEY-----',
},
});
});
});
describe('createToken', () => {
test('Create a random token of length 128', () => {
test('Create a random token of length 40', () => {
const token = createToken();
expect(token).toBeDefined();

View File

@ -8,6 +8,8 @@ const defaultJwtOptions = { expiresIn: '30d' };
export type TokenOptions = {
expiresIn?: string;
algorithm?: Algorithm;
privateKey?: string;
publicKey?: string;
[key: string]: unknown;
};
@ -26,9 +28,15 @@ const getTokenOptions = () => {
{} as AdminAuthConfig
);
// Check for new sessions.options configuration
const sessionsOptions = strapi.config.get('admin.auth.sessions.options', {});
// Merge with legacy options for backward compatibility
const mergedOptions = _.merge({}, defaultJwtOptions, options, sessionsOptions);
return {
secret,
options: _.merge(defaultJwtOptions, options),
options: mergedOptions,
};
};

View File

@ -0,0 +1,468 @@
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { createSessionManager, SessionData } from '../session-manager';
// Generate test RSA key pair
const generateRSAKeyPair = () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});
return { publicKey, privateKey };
};
const { publicKey: testPublicKey, privateKey: testPrivateKey } = generateRSAKeyPair();
describe('SessionManager JWT Configuration', () => {
let sessionManager: any;
let mockDb: any;
beforeEach(() => {
const mockQuery = {
create: jest.fn((data: SessionData) => {
return {
...data,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
};
}),
findOne: jest.fn(),
findMany: jest.fn(),
delete: jest.fn(),
deleteMany: jest.fn(),
};
mockDb = {
query: jest.fn().mockReturnValue(mockQuery),
};
// Mock database query to return valid session data
mockQuery.findOne.mockImplementation(() => {
// Return session data for refresh token validation
return Promise.resolve({
userId: 'user123',
sessionId: 'session123',
origin: 'test',
status: 'active',
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
absoluteExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
});
sessionManager = createSessionManager({ db: mockDb });
});
describe('Default Configuration', () => {
test('Uses default HS256 algorithm when no algorithm is specified', async () => {
sessionManager.defineOrigin('test', {
jwtSecret: 'test-secret',
accessTokenLifespan: 3600,
maxRefreshTokenLifespan: 86400,
idleRefreshTokenLifespan: 3600,
maxSessionLifespan: 3600,
idleSessionLifespan: 1800,
});
// First generate a refresh token
const refreshResult = await sessionManager('test').generateRefreshToken(
'user123',
'session123'
);
expect(refreshResult.token).toBeDefined();
// Then generate access token using the refresh token
const result = await sessionManager('test').generateAccessToken(refreshResult.token);
expect(result.token).toBeDefined();
// Decode and verify algorithm
const decoded = jwt.decode(result.token, { complete: true });
expect(decoded?.header.alg).toBe('HS256');
});
});
describe('Symmetric Algorithm Configuration', () => {
test('Uses configured symmetric algorithm (HS512)', async () => {
sessionManager.defineOrigin('test', {
jwtSecret: 'test-secret',
accessTokenLifespan: 3600,
maxRefreshTokenLifespan: 86400,
idleRefreshTokenLifespan: 3600,
maxSessionLifespan: 3600,
idleSessionLifespan: 1800,
algorithm: 'HS512',
jwtOptions: {
issuer: 'test-issuer',
audience: 'test-audience',
},
});
// First generate a refresh token
const refreshResult = await sessionManager('test').generateRefreshToken(
'user123',
'session123'
);
expect(refreshResult.token).toBeDefined();
// Then generate access token using the refresh token
const result = await sessionManager('test').generateAccessToken(refreshResult.token);
expect(result.token).toBeDefined();
// Decode and verify algorithm and options
const decoded = jwt.decode(result.token, { complete: true });
expect(decoded?.header.alg).toBe('HS512');
expect((decoded?.payload as any)?.iss).toBe('test-issuer');
expect((decoded?.payload as any)?.aud).toBe('test-audience');
});
test('Uses configured symmetric algorithm (HS384)', async () => {
sessionManager.defineOrigin('test', {
jwtSecret: 'test-secret',
accessTokenLifespan: 3600,
maxRefreshTokenLifespan: 86400,
idleRefreshTokenLifespan: 3600,
maxSessionLifespan: 3600,
idleSessionLifespan: 1800,
algorithm: 'HS384',
jwtOptions: {
subject: 'test-subject',
},
});
// First generate a refresh token
const refreshResult = await sessionManager('test').generateRefreshToken(
'user123',
'session123'
);
expect(refreshResult.token).toBeDefined();
// Then generate access token using the refresh token
const result = await sessionManager('test').generateAccessToken(refreshResult.token);
expect(result.token).toBeDefined();
// Decode and verify algorithm and options
const decoded = jwt.decode(result.token, { complete: true });
expect(decoded?.header.alg).toBe('HS384');
expect((decoded?.payload as any)?.sub).toBe('test-subject');
});
});
describe('Asymmetric Algorithm Configuration', () => {
test('Uses configured asymmetric algorithm (RS256) with proper keys', async () => {
sessionManager.defineOrigin('test', {
jwtSecret: 'test-secret', // Fallback for backward compatibility
accessTokenLifespan: 3600,
maxRefreshTokenLifespan: 86400,
idleRefreshTokenLifespan: 3600,
maxSessionLifespan: 3600,
idleSessionLifespan: 1800,
algorithm: 'RS256',
jwtOptions: {
privateKey: testPrivateKey,
publicKey: testPublicKey,
issuer: 'rsa-test-issuer',
audience: 'rsa-test-audience',
},
});
// First generate a refresh token
const refreshResult = await sessionManager('test').generateRefreshToken(
'user123',
'session123'
);
expect(refreshResult.token).toBeDefined();
// Then generate access token using the refresh token
const result = await sessionManager('test').generateAccessToken(refreshResult.token);
expect(result.token).toBeDefined();
// Decode and verify algorithm and options
const decoded = jwt.decode(result.token, { complete: true });
expect(decoded?.header.alg).toBe('RS256');
expect((decoded?.payload as any)?.iss).toBe('rsa-test-issuer');
expect((decoded?.payload as any)?.aud).toBe('rsa-test-audience');
// Verify the token can be verified with the public key
const verified = jwt.verify(result.token, testPublicKey, {
algorithms: ['RS256'],
issuer: 'rsa-test-issuer',
audience: 'rsa-test-audience',
});
expect(verified).toBeDefined();
expect((verified as any).type).toBe('access');
});
test('Uses configured asymmetric algorithm (ES256) with proper keys', async () => {
// Generate ES256 key pair
const { publicKey: esPublicKey, privateKey: esPrivateKey } = crypto.generateKeyPairSync(
'ec',
{
namedCurve: 'prime256v1',
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
}
);
sessionManager.defineOrigin('test', {
jwtSecret: 'test-secret',
accessTokenLifespan: 3600,
maxRefreshTokenLifespan: 86400,
idleRefreshTokenLifespan: 3600,
maxSessionLifespan: 3600,
idleSessionLifespan: 1800,
algorithm: 'ES256',
jwtOptions: {
privateKey: esPrivateKey,
publicKey: esPublicKey,
issuer: 'es256-test-issuer',
},
});
// First generate a refresh token
const refreshResult = await sessionManager('test').generateRefreshToken(
'user123',
'session123'
);
expect(refreshResult.token).toBeDefined();
// Then generate access token using the refresh token
const result = await sessionManager('test').generateAccessToken(refreshResult.token);
expect(result.token).toBeDefined();
// Decode and verify algorithm
const decoded = jwt.decode(result.token, { complete: true });
expect(decoded?.header.alg).toBe('ES256');
expect((decoded?.payload as any)?.iss).toBe('es256-test-issuer');
});
});
describe('JWT Options Integration', () => {
test('Passes through all JWT options to jwt.sign', async () => {
sessionManager.defineOrigin('test', {
jwtSecret: 'test-secret',
accessTokenLifespan: 3600,
maxRefreshTokenLifespan: 86400,
idleRefreshTokenLifespan: 3600,
maxSessionLifespan: 3600,
idleSessionLifespan: 1800,
algorithm: 'HS256',
jwtOptions: {
issuer: 'test-issuer',
audience: 'test-audience',
subject: 'test-subject',
expiresIn: '1h',
notBefore: '0',
jwtid: 'test-jwt-id',
},
});
// First generate a refresh token
const refreshResult = await sessionManager('test').generateRefreshToken(
'user123',
'session123'
);
expect(refreshResult.token).toBeDefined();
// Then generate access token using the refresh token
const result = await sessionManager('test').generateAccessToken(refreshResult.token);
expect(result.token).toBeDefined();
// Decode and verify all options
const decoded = jwt.decode(result.token, { complete: true });
expect((decoded?.payload as any)?.iss).toBe('test-issuer');
expect((decoded?.payload as any)?.aud).toBe('test-audience');
expect((decoded?.payload as any)?.sub).toBe('test-subject');
expect((decoded?.payload as any)?.jti).toBe('test-jwt-id');
});
test('JWT options work with refresh tokens', async () => {
sessionManager.defineOrigin('test', {
jwtSecret: 'test-secret',
accessTokenLifespan: 3600,
maxRefreshTokenLifespan: 86400,
idleRefreshTokenLifespan: 3600,
maxSessionLifespan: 3600,
idleSessionLifespan: 1800,
algorithm: 'HS256',
jwtOptions: {
issuer: 'refresh-test-issuer',
audience: 'refresh-test-audience',
},
});
const result = await sessionManager('test').generateRefreshToken('user123', 'device123');
expect(result.token).toBeDefined();
// Decode and verify options
const decoded = jwt.decode(result.token, { complete: true });
expect((decoded?.payload as any)?.iss).toBe('refresh-test-issuer');
expect((decoded?.payload as any)?.aud).toBe('refresh-test-audience');
expect((decoded?.payload as any)?.type).toBe('refresh');
});
});
describe('Error Handling', () => {
test('Throws error when asymmetric algorithm is used without proper keys', async () => {
sessionManager.defineOrigin('test', {
jwtSecret: 'test-secret',
accessTokenLifespan: 3600,
maxRefreshTokenLifespan: 86400,
idleRefreshTokenLifespan: 3600,
maxSessionLifespan: 3600,
idleSessionLifespan: 1800,
algorithm: 'RS256',
jwtOptions: {
// Missing privateKey and publicKey
},
});
// Should throw error immediately when trying to generate refresh token
await expect(
sessionManager('test').generateRefreshToken('user123', 'session123')
).rejects.toThrow('Private key is required for asymmetric algorithm RS256');
});
test('Works correctly with symmetric algorithm', async () => {
sessionManager.defineOrigin('test', {
jwtSecret: 'test-secret',
accessTokenLifespan: 3600,
maxRefreshTokenLifespan: 86400,
idleRefreshTokenLifespan: 3600,
maxSessionLifespan: 3600,
idleSessionLifespan: 1800,
algorithm: 'HS256',
jwtOptions: {
issuer: 'test-issuer',
},
});
// First generate a refresh token
const refreshResult = await sessionManager('test').generateRefreshToken(
'user123',
'session123'
);
expect(refreshResult.token).toBeDefined();
// Then generate access token using the refresh token
const result = await sessionManager('test').generateAccessToken(refreshResult.token);
expect(result.token).toBeDefined();
// Verify the token works
const decoded = jwt.decode(result.token, { complete: true });
expect(decoded?.header.alg).toBe('HS256');
expect((decoded?.payload as any)?.iss).toBe('test-issuer');
});
test('Throws error when symmetric algorithm is used without secret', async () => {
sessionManager.defineOrigin('test', {
// Missing jwtSecret
accessTokenLifespan: 3600,
maxRefreshTokenLifespan: 86400,
idleRefreshTokenLifespan: 3600,
maxSessionLifespan: 3600,
idleSessionLifespan: 1800,
algorithm: 'HS256',
});
await expect(
sessionManager('test').generateAccessToken('user123', 'session123')
).rejects.toThrow('Secret key is required for symmetric algorithm HS256');
});
});
describe('Token Validation', () => {
test('Validates tokens with correct algorithm and options', async () => {
sessionManager.defineOrigin('test', {
jwtSecret: 'test-secret',
accessTokenLifespan: 3600,
maxRefreshTokenLifespan: 86400,
idleRefreshTokenLifespan: 3600,
maxSessionLifespan: 3600,
idleSessionLifespan: 1800,
algorithm: 'HS256',
jwtOptions: {
issuer: 'validation-test-issuer',
audience: 'validation-test-audience',
},
});
// Generate token
// First generate a refresh token
const refreshResult = await sessionManager('test').generateRefreshToken(
'user123',
'session123'
);
expect(refreshResult.token).toBeDefined();
// Then generate access token using the refresh token
const result = await sessionManager('test').generateAccessToken(refreshResult.token);
// Validate token
const validation = await sessionManager('test').validateAccessToken(result.token);
expect(validation.isValid).toBe(true);
expect(validation.payload).toBeDefined();
expect(validation.payload?.userId).toBe('user123');
expect(validation.payload?.sessionId).toBeDefined();
expect(validation.payload?.sessionId).toBeTruthy();
});
test('Validates asymmetric tokens correctly', async () => {
sessionManager.defineOrigin('test', {
jwtSecret: 'test-secret',
accessTokenLifespan: 3600,
maxRefreshTokenLifespan: 86400,
idleRefreshTokenLifespan: 3600,
maxSessionLifespan: 3600,
idleSessionLifespan: 1800,
algorithm: 'RS256',
jwtOptions: {
privateKey: testPrivateKey,
publicKey: testPublicKey,
issuer: 'rsa-validation-issuer',
},
});
// Generate token
// First generate a refresh token
const refreshResult = await sessionManager('test').generateRefreshToken(
'user123',
'session123'
);
expect(refreshResult.token).toBeDefined();
// Then generate access token using the refresh token
const result = await sessionManager('test').generateAccessToken(refreshResult.token);
// Validate token
const validation = await sessionManager('test').validateAccessToken(result.token);
expect(validation.isValid).toBe(true);
expect(validation.payload).toBeDefined();
expect(validation.payload?.userId).toBe('user123');
expect(validation.payload?.sessionId).toBeDefined();
expect(validation.payload?.sessionId).toBeTruthy();
});
});
});

View File

@ -1,6 +1,6 @@
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import type { VerifyOptions } from 'jsonwebtoken';
import type { VerifyOptions, Algorithm } from 'jsonwebtoken';
import type { Database } from '@strapi/database';
import { DEFAULT_ALGORITHM } from '../constants';
@ -111,12 +111,14 @@ class DatabaseSessionProvider implements SessionProvider {
}
export interface SessionManagerConfig {
jwtSecret: string;
jwtSecret?: string;
accessTokenLifespan: number;
maxRefreshTokenLifespan: number;
idleRefreshTokenLifespan: number;
maxSessionLifespan: number;
idleSessionLifespan: number;
algorithm?: Algorithm;
jwtOptions?: Record<string, unknown>;
}
class OriginSessionManager {
@ -214,6 +216,46 @@ class SessionManager {
);
}
/**
* Get the appropriate JWT key based on the algorithm
*/
private getJwtKey(
config: SessionManagerConfig,
algorithm: Algorithm,
operation: 'sign' | 'verify'
): string {
const isAsymmetric =
algorithm.startsWith('RS') || algorithm.startsWith('ES') || algorithm.startsWith('PS');
if (isAsymmetric) {
// For asymmetric algorithms, check if user has provided proper key configuration
if (operation === 'sign') {
const privateKey = config.jwtOptions?.privateKey as string;
if (privateKey) {
return privateKey;
}
throw new Error(
`SessionManager: Private key is required for asymmetric algorithm ${algorithm}. Please configure admin.auth.options.privateKey.`
);
} else {
const publicKey = config.jwtOptions?.publicKey as string;
if (publicKey) {
return publicKey;
}
throw new Error(
`SessionManager: Public key is required for asymmetric algorithm ${algorithm}. Please configure admin.auth.options.publicKey.`
);
}
} else {
if (!config.jwtSecret) {
throw new Error(
`SessionManager: Secret key is required for symmetric algorithm ${algorithm}`
);
}
return config.jwtSecret;
}
}
generateSessionId(): string {
return crypto.randomBytes(16).toString('hex');
}
@ -249,6 +291,8 @@ class SessionManager {
await this.maybeCleanupExpired();
const config = this.getConfigForOrigin(origin);
const algorithm = config.algorithm || DEFAULT_ALGORITHM;
const jwtKey = this.getJwtKey(config, algorithm, 'sign');
const sessionId = this.generateSessionId();
const tokenType = options?.type ?? 'refresh';
const isRefresh = tokenType === 'refresh';
@ -285,9 +329,14 @@ class SessionManager {
exp: expiresAtSeconds,
};
const token = jwt.sign(payload, config.jwtSecret, {
algorithm: DEFAULT_ALGORITHM,
// Filter out conflicting options that are already handled by the payload or used for key selection
const jwtOptions = config.jwtOptions || {};
const { expiresIn, privateKey, publicKey, ...jwtSignOptions } = jwtOptions;
const token = jwt.sign(payload, jwtKey, {
algorithm,
noTimestamp: true,
...jwtSignOptions,
});
return {
@ -309,8 +358,11 @@ class SessionManager {
try {
const config = this.getConfigForOrigin(origin);
const payload = jwt.verify(token, config.jwtSecret, {
algorithms: [DEFAULT_ALGORITHM],
const algorithm = config.algorithm || DEFAULT_ALGORITHM;
const jwtKey = this.getJwtKey(config, algorithm, 'verify');
const payload = jwt.verify(token, jwtKey, {
algorithms: [algorithm],
...config.jwtOptions,
}) as TokenPayload;
// Ensure this is an access token
@ -333,11 +385,14 @@ class SessionManager {
try {
const config = this.getConfigForOrigin(origin);
const algorithm = config.algorithm || DEFAULT_ALGORITHM;
const jwtKey = this.getJwtKey(config, algorithm, 'verify');
const verifyOptions: VerifyOptions = {
algorithms: [DEFAULT_ALGORITHM],
algorithms: [algorithm],
...config.jwtOptions,
};
const payload = jwt.verify(token, config.jwtSecret, verifyOptions) as RefreshTokenPayload;
const payload = jwt.verify(token, jwtKey, verifyOptions) as RefreshTokenPayload;
if (payload.type !== 'refresh') {
return { isValid: false };
@ -408,9 +463,16 @@ class SessionManager {
};
const config = this.getConfigForOrigin(origin);
const token = jwt.sign(payload, config.jwtSecret, {
algorithm: DEFAULT_ALGORITHM,
const algorithm = config.algorithm || DEFAULT_ALGORITHM;
const jwtKey = this.getJwtKey(config, algorithm, 'sign');
// Filter out conflicting options that are already handled by the payload or used for key selection
const jwtOptions = config.jwtOptions || {};
const { expiresIn, privateKey, publicKey, ...jwtSignOptions } = jwtOptions;
const token = jwt.sign(payload, jwtKey, {
algorithm,
expiresIn: config.accessTokenLifespan,
...jwtSignOptions,
});
return { token };
@ -436,8 +498,11 @@ class SessionManager {
try {
const config = this.getConfigForOrigin(origin);
const payload = jwt.verify(refreshToken, config.jwtSecret, {
algorithms: [DEFAULT_ALGORITHM],
const algorithm = config.algorithm || DEFAULT_ALGORITHM;
const jwtKey = this.getJwtKey(config, algorithm, 'verify');
const payload = jwt.verify(refreshToken, jwtKey, {
algorithms: [algorithm],
...config.jwtOptions,
}) as RefreshTokenPayload;
if (!payload || payload.type !== 'refresh') {
@ -465,9 +530,13 @@ class SessionManager {
exp: childExp,
};
const childToken = jwt.sign(childPayload, config.jwtSecret, {
algorithm: DEFAULT_ALGORITHM,
// Filter out conflicting options that are already handled by the payload
const { expiresIn, ...jwtSignOptions } = config.jwtOptions || {};
const childToken = jwt.sign(childPayload, jwtKey, {
algorithm,
noTimestamp: true,
...jwtSignOptions,
});
let absoluteExpiresAt;
@ -532,9 +601,13 @@ class SessionManager {
iat: childIat,
exp: childExp,
};
const childToken = jwt.sign(payloadOut, config.jwtSecret, {
algorithm: DEFAULT_ALGORITHM,
// Filter out conflicting options that are already handled by the payload
const { expiresIn, ...jwtSignOptions } = config.jwtOptions || {};
const childToken = jwt.sign(payloadOut, jwtKey, {
algorithm,
noTimestamp: true,
...jwtSignOptions,
});
await this.provider.updateBySessionId(current.sessionId, {

View File

@ -139,6 +139,8 @@ module.exports = async ({ strapi }) => {
upConfig.sessions?.idleRefreshTokenLifespan || DEFAULT_IDLE_REFRESH_TOKEN_LIFESPAN,
maxSessionLifespan: upConfig.sessions?.maxSessionLifespan || DEFAULT_MAX_SESSION_LIFESPAN,
idleSessionLifespan: upConfig.sessions?.idleSessionLifespan || DEFAULT_IDLE_SESSION_LIFESPAN,
algorithm: upConfig.jwt?.algorithm,
jwtOptions: upConfig.jwt || {},
});
}

View File

@ -47,8 +47,9 @@ const createStrapiInstance = async ({
const originalBootstrap = modules.bootstrap;
// decorate modules bootstrap
modules.bootstrap = async () => {
await originalBootstrap();
await bootstrap({ strapi: instance });
await originalBootstrap();
};
}

View File

@ -0,0 +1,304 @@
'use strict';
import { createAuthRequest } from 'api-tests/request';
import { createStrapiInstance, superAdmin } from 'api-tests/strapi';
import { createUtils } from 'api-tests/utils';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
// Generate test RSA key pair
const generateRSAKeyPair = () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});
return { publicKey, privateKey };
};
const { publicKey: testPublicKey, privateKey: testPrivateKey } = generateRSAKeyPair();
describe('Admin JWT Configuration API Tests', () => {
let rq: any;
let strapi: any;
let utils: any;
afterEach(async () => {
if (strapi) {
await strapi.destroy();
strapi = null;
rq = null;
utils = null;
}
});
describe('Default JWT Configuration', () => {
beforeEach(async () => {
strapi = await createStrapiInstance({
async bootstrap({ strapi: s }) {
s.config.set('admin.rateLimit.enabled', false);
},
});
rq = await createAuthRequest({ strapi });
utils = createUtils(strapi);
});
test('Uses default HS256 algorithm when no configuration is provided', async () => {
const res = await rq({
url: '/admin/login',
method: 'POST',
body: superAdmin.loginInfo,
});
expect(res.statusCode).toBe(200);
expect(res.body.data.token).toBeDefined();
// Decode the JWT to verify algorithm
const token = res.body.data.token;
const decoded = jwt.decode(token, { complete: true });
expect(decoded?.header.alg).toBe('HS256');
expect(decoded?.header.typ).toBe('JWT');
});
});
describe('Legacy admin.auth.options Configuration', () => {
beforeEach(async () => {
strapi = await createStrapiInstance({
async bootstrap({ strapi: s }) {
s.config.set('admin.rateLimit.enabled', false);
s.config.set('admin.auth.options', {
algorithm: 'HS512',
issuer: 'legacy-test-issuer',
audience: 'legacy-test-audience',
});
},
});
rq = await createAuthRequest({ strapi });
utils = createUtils(strapi);
});
test('Uses legacy admin.auth.options configuration with deprecation warning', async () => {
const res = await rq({
url: '/admin/login',
method: 'POST',
body: superAdmin.loginInfo,
});
expect(res.statusCode).toBe(200);
expect(res.body.data.token).toBeDefined();
// Decode the JWT to verify configuration
const token = res.body.data.token;
const decoded = jwt.decode(token, { complete: true });
expect(decoded?.header.alg).toBe('HS512');
expect(decoded?.payload.iss).toBe('legacy-test-issuer');
expect(decoded?.payload.aud).toBe('legacy-test-audience');
});
});
describe('New admin.auth.sessions.options Configuration', () => {
beforeEach(async () => {
strapi = await createStrapiInstance({
async bootstrap({ strapi: s }) {
s.config.set('admin.rateLimit.enabled', false);
s.config.set('admin.auth.sessions.options', {
algorithm: 'HS384',
issuer: 'sessions-test-issuer',
audience: 'sessions-test-audience',
subject: 'sessions-test-subject',
});
},
});
rq = await createAuthRequest({ strapi });
utils = createUtils(strapi);
});
test('Uses new admin.auth.sessions.options configuration', async () => {
const res = await rq({
url: '/admin/login',
method: 'POST',
body: superAdmin.loginInfo,
});
expect(res.statusCode).toBe(200);
expect(res.body.data.token).toBeDefined();
// Decode the JWT to verify configuration
const token = res.body.data.token;
const decoded = jwt.decode(token, { complete: true });
expect(decoded?.header.alg).toBe('HS384');
expect(decoded?.payload.iss).toBe('sessions-test-issuer');
expect(decoded?.payload.aud).toBe('sessions-test-audience');
expect(decoded?.payload.sub).toBe('sessions-test-subject');
});
});
describe('Configuration Priority: sessions.options over auth.options', () => {
beforeEach(async () => {
strapi = await createStrapiInstance({
async bootstrap({ strapi: s }) {
s.config.set('admin.rateLimit.enabled', false);
// Set legacy options
s.config.set('admin.auth.options', {
algorithm: 'HS256',
issuer: 'legacy-issuer',
audience: 'legacy-audience',
});
// Set new options (should take priority)
s.config.set('admin.auth.sessions.options', {
algorithm: 'HS512',
issuer: 'sessions-issuer',
audience: 'sessions-audience',
subject: 'sessions-subject',
});
},
});
rq = await createAuthRequest({ strapi });
utils = createUtils(strapi);
});
test('sessions.options takes priority over legacy auth.options', async () => {
const res = await rq({
url: '/admin/login',
method: 'POST',
body: superAdmin.loginInfo,
});
expect(res.statusCode).toBe(200);
expect(res.body.data.token).toBeDefined();
// Decode the JWT to verify sessions.options takes priority
const token = res.body.data.token;
const decoded = jwt.decode(token, { complete: true });
expect(decoded?.header.alg).toBe('HS512'); // From sessions.options
expect(decoded?.payload.iss).toBe('sessions-issuer'); // From sessions.options
expect(decoded?.payload.aud).toBe('sessions-audience'); // From sessions.options
expect(decoded?.payload.sub).toBe('sessions-subject'); // From sessions.options
});
});
describe('Asymmetric Algorithm Configuration (RS256)', () => {
beforeEach(async () => {
strapi = await createStrapiInstance({
async bootstrap({ strapi: s }) {
s.config.set('admin.rateLimit.enabled', false);
s.config.set('admin.auth.sessions.options', {
algorithm: 'RS256',
privateKey: testPrivateKey,
publicKey: testPublicKey,
issuer: 'rsa-test-issuer',
audience: 'rsa-test-audience',
});
},
});
rq = await createAuthRequest({ strapi });
utils = createUtils(strapi);
});
test('Uses RS256 algorithm with RSA key pair', async () => {
const res = await rq({
url: '/admin/login',
method: 'POST',
body: superAdmin.loginInfo,
});
expect(res.statusCode).toBe(200);
expect(res.body.data.token).toBeDefined();
// Decode the JWT to verify configuration
const token = res.body.data.token;
const decoded = jwt.decode(token, { complete: true });
expect(decoded?.header.alg).toBe('RS256');
expect(decoded?.payload.iss).toBe('rsa-test-issuer');
expect(decoded?.payload.aud).toBe('rsa-test-audience');
// Verify the token can be verified with the public key
const verified = jwt.verify(token, testPublicKey, {
algorithms: ['RS256'],
issuer: 'rsa-test-issuer',
audience: 'rsa-test-audience',
});
expect(verified).toBeDefined();
expect(verified.type).toBe('access');
});
test('Access token can be used for authenticated requests', async () => {
// First login to get token
const loginRes = await rq({
url: '/admin/login',
method: 'POST',
body: superAdmin.loginInfo,
});
expect(loginRes.statusCode).toBe(200);
const token = loginRes.body.data.token;
// Use token for authenticated request
const authRes = await rq({
url: '/admin/users/me',
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(authRes.statusCode).toBe(200);
expect(authRes.body.data).toBeDefined();
});
});
describe('Mixed Configuration Scenarios', () => {
beforeEach(async () => {
strapi = await createStrapiInstance({
async bootstrap({ strapi: s }) {
s.config.set('admin.rateLimit.enabled', false);
// Legacy configuration
s.config.set('admin.auth.options', {
algorithm: 'HS256',
issuer: 'legacy-issuer',
});
// New configuration (should take priority)
s.config.set('admin.auth.sessions.options', {
algorithm: 'HS384',
audience: 'new-audience',
subject: 'new-subject',
});
},
});
rq = await createAuthRequest({ strapi });
utils = createUtils(strapi);
});
test('Works with both legacy and new configuration (sessions.options priority)', async () => {
const res = await rq({
url: '/admin/login',
method: 'POST',
body: superAdmin.loginInfo,
});
expect(res.statusCode).toBe(200);
expect(res.body.data.token).toBeDefined();
// Decode the JWT to verify mixed configuration
const token = res.body.data.token;
const decoded = jwt.decode(token, { complete: true });
expect(decoded?.header.alg).toBe('HS384'); // From sessions.options
expect(decoded?.payload.iss).toBe('legacy-issuer'); // From legacy auth.options
expect(decoded?.payload.aud).toBe('new-audience'); // From sessions.options
expect(decoded?.payload.sub).toBe('new-subject'); // From sessions.options
});
});
});

View File

@ -0,0 +1,137 @@
'use strict';
/* eslint-env jest */
/* eslint-disable import/no-extraneous-dependencies */
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const { createStrapiInstance } = require('api-tests/strapi');
const { createRequest } = require('api-tests/request');
const { createAuthenticatedUser } = require('../utils');
let strapi;
const makeRSAKeys = () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
return { publicKey, privateKey };
};
describe('Content API JWT Algorithm Configuration (refresh mode)', () => {
const user = {
username: 'algo-user',
email: 'algo-user@strapi.io',
password: 'Test1234',
confirmed: true,
provider: 'local',
};
afterEach(async () => {
if (strapi) {
await strapi.db.query('plugin::users-permissions.user').deleteMany();
await strapi.destroy();
strapi = null;
}
});
test('Defaults to HS256 when no algorithm configured', async () => {
strapi = await createStrapiInstance({
bypassAuth: false,
async bootstrap({ strapi: s }) {
s.config.set('plugin::users-permissions.jwtManagement', 'refresh');
// Ensure a secret exists for HS algorithms
s.config.set('plugin::users-permissions.jwtSecret', 'test-secret');
},
});
await createAuthenticatedUser({ strapi, userInfo: user });
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
const res = await rqAuth({
method: 'POST',
url: '/local',
body: { identifier: user.email, password: user.password },
});
expect(res.statusCode).toBe(200);
const token = res.body.jwt;
expect(typeof token).toBe('string');
const decoded = jwt.decode(token, { complete: true });
expect(decoded && decoded.header && decoded.header.alg).toBe('HS256');
});
test('Uses configured symmetric algorithm (HS384)', async () => {
strapi = await createStrapiInstance({
bypassAuth: false,
async bootstrap({ strapi: s }) {
s.config.set('plugin::users-permissions.jwtManagement', 'refresh');
s.config.set('plugin::users-permissions.jwtSecret', 'test-secret');
s.config.set('plugin::users-permissions.jwt', {
algorithm: 'HS384',
issuer: 'up-hs-issuer',
audience: 'up-hs-aud',
});
},
});
await createAuthenticatedUser({ strapi, userInfo: user });
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
const res = await rqAuth({
method: 'POST',
url: '/local',
body: { identifier: user.email, password: user.password },
});
expect(res.statusCode).toBe(200);
const token = res.body.jwt;
const decoded = jwt.decode(token, { complete: true });
expect(decoded && decoded.header && decoded.header.alg).toBe('HS384');
expect(decoded && decoded.payload && decoded.payload.iss).toBe('up-hs-issuer');
expect(decoded && decoded.payload && decoded.payload.aud).toBe('up-hs-aud');
});
test('Uses configured asymmetric algorithm (RS256)', async () => {
const { publicKey, privateKey } = makeRSAKeys();
strapi = await createStrapiInstance({
bypassAuth: false,
async bootstrap({ strapi: s }) {
s.config.set('plugin::users-permissions.jwtManagement', 'refresh');
s.config.set('plugin::users-permissions.jwt', {
algorithm: 'RS256',
privateKey,
publicKey,
issuer: 'up-rs-issuer',
audience: 'up-rs-aud',
});
},
});
await createAuthenticatedUser({ strapi, userInfo: user });
const rqAuth = createRequest({ strapi }).setURLPrefix('/api/auth');
const res = await rqAuth({
method: 'POST',
url: '/local',
body: { identifier: user.email, password: user.password },
});
expect(res.statusCode).toBe(200);
const token = res.body.jwt;
const decoded = jwt.decode(token, { complete: true });
expect(decoded && decoded.header && decoded.header.alg).toBe('RS256');
expect(decoded && decoded.payload && decoded.payload.iss).toBe('up-rs-issuer');
expect(decoded && decoded.payload && decoded.payload.aud).toBe('up-rs-aud');
const verified = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'up-rs-issuer',
audience: 'up-rs-aud',
});
expect(verified && verified.type).toBe('access');
});
});