mirror of
https://github.com/strapi/strapi.git
synced 2025-11-03 03:17:11 +00:00
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:
parent
ff1eac610b
commit
723a2f0c62
@ -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`)
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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, {
|
||||
|
||||
@ -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 || {},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
304
tests/api/core/admin/admin-jwt-configuration.test.api.ts
Normal file
304
tests/api/core/admin/admin-jwt-configuration.test.api.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user