From 723a2f0c6270d4fe819050ffdcdaba5be79f8f05 Mon Sep 17 00:00:00 2001 From: Ben Irvin Date: Mon, 29 Sep 2025 15:53:29 +0200 Subject: [PATCH] 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 Co-authored-by: Jamie Howard <48524071+jhoward1994@users.noreply.github.com> --- .../authentication/00-sessions-and-jwt.md | 16 +- examples/getstarted/config/admin.js | 12 + packages/core/admin/server/src/bootstrap.ts | 6 +- .../src/services/__tests__/token.test.ts | 223 ++++++++- .../core/admin/server/src/services/token.ts | 10 +- .../session-manager-jwt-config.test.ts | 468 ++++++++++++++++++ .../core/core/src/services/session-manager.ts | 105 +++- .../server/bootstrap/index.js | 2 + packages/utils/api-tests/strapi.js | 3 +- .../admin/admin-jwt-configuration.test.api.ts | 304 ++++++++++++ .../auth-jwt-algorithm.test.api.js | 137 +++++ 11 files changed, 1235 insertions(+), 51 deletions(-) create mode 100644 packages/core/core/src/services/__tests__/session-manager-jwt-config.test.ts create mode 100644 tests/api/core/admin/admin-jwt-configuration.test.api.ts create mode 100644 tests/api/plugins/users-permissions/content-api/auth-jwt-algorithm.test.api.js diff --git a/docs/docs/docs/01-core/authentication/00-sessions-and-jwt.md b/docs/docs/docs/01-core/authentication/00-sessions-and-jwt.md index 81f9774c64..9529e27866 100644 --- a/docs/docs/docs/01-core/authentication/00-sessions-and-jwt.md +++ b/docs/docs/docs/01-core/authentication/00-sessions-and-jwt.md @@ -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`) diff --git a/examples/getstarted/config/admin.js b/examples/getstarted/config/admin.js index f94415b0be..ac49879b08 100644 --- a/examples/getstarted/config/admin.js +++ b/examples/getstarted/config/admin.js @@ -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'), diff --git a/packages/core/admin/server/src/bootstrap.ts b/packages/core/admin/server/src/bootstrap.ts index 9f299735c5..9ad32499fe 100644 --- a/packages/core/admin/server/src/bootstrap.ts +++ b/packages/core/admin/server/src/bootstrap.ts @@ -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(); diff --git a/packages/core/admin/server/src/services/__tests__/token.test.ts b/packages/core/admin/server/src/services/__tests__/token.test.ts index 41d191a4f4..3c4c1439f6 100644 --- a/packages/core/admin/server/src/services/__tests__/token.test.ts +++ b/packages/core/admin/server/src/services/__tests__/token.test.ts @@ -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(); diff --git a/packages/core/admin/server/src/services/token.ts b/packages/core/admin/server/src/services/token.ts index f991b79f18..d9cb8bb3c6 100644 --- a/packages/core/admin/server/src/services/token.ts +++ b/packages/core/admin/server/src/services/token.ts @@ -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, }; }; diff --git a/packages/core/core/src/services/__tests__/session-manager-jwt-config.test.ts b/packages/core/core/src/services/__tests__/session-manager-jwt-config.test.ts new file mode 100644 index 0000000000..616d08c1c4 --- /dev/null +++ b/packages/core/core/src/services/__tests__/session-manager-jwt-config.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/core/core/src/services/session-manager.ts b/packages/core/core/src/services/session-manager.ts index 2359d3945a..7547de73bd 100644 --- a/packages/core/core/src/services/session-manager.ts +++ b/packages/core/core/src/services/session-manager.ts @@ -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; } 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, { diff --git a/packages/plugins/users-permissions/server/bootstrap/index.js b/packages/plugins/users-permissions/server/bootstrap/index.js index 18b2417a42..d4d7644e40 100644 --- a/packages/plugins/users-permissions/server/bootstrap/index.js +++ b/packages/plugins/users-permissions/server/bootstrap/index.js @@ -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 || {}, }); } diff --git a/packages/utils/api-tests/strapi.js b/packages/utils/api-tests/strapi.js index 675956f528..799c65575e 100644 --- a/packages/utils/api-tests/strapi.js +++ b/packages/utils/api-tests/strapi.js @@ -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(); }; } diff --git a/tests/api/core/admin/admin-jwt-configuration.test.api.ts b/tests/api/core/admin/admin-jwt-configuration.test.api.ts new file mode 100644 index 0000000000..137ec790d9 --- /dev/null +++ b/tests/api/core/admin/admin-jwt-configuration.test.api.ts @@ -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 + }); + }); +}); diff --git a/tests/api/plugins/users-permissions/content-api/auth-jwt-algorithm.test.api.js b/tests/api/plugins/users-permissions/content-api/auth-jwt-algorithm.test.api.js new file mode 100644 index 0000000000..19ab9df2ce --- /dev/null +++ b/tests/api/plugins/users-permissions/content-api/auth-jwt-algorithm.test.api.js @@ -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'); + }); +});