refactor(core): Bind signed tokens to a declared purpose (#39467)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yuliia Pominchuk 2026-10-02 09:59:50 +00:00 committed by GitHub
parent c4d31db8d6
commit 37ed348665
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 650 additions and 234 deletions

View File

@ -31,6 +31,30 @@ const instanceAiLazyRuntimeImports = [
message: INSTANCE_AI_LAZY_IMPORT_MESSAGE,
}));
// Only JwtService may reach the raw signing API: it derives the `aud` claim from
// the token's purpose, which is what keeps a token for one purpose from being
// presented for another. The error classes and types stay importable.
const jsonwebtokenSigningRestriction = {
name: 'jsonwebtoken',
// An allowlist, not a denylist: the module's whole runtime surface is off
// limits except the error classes, so a member added upstream is restricted
// from the start. `allowTypeImports` keeps `Secret`, `Algorithm` and friends
// importable. A namespace import is restricted too — the linter cannot see
// which members it reaches for.
allowImportNames: ['JsonWebTokenError', 'TokenExpiredError', 'NotBeforeError'],
allowTypeImports: true,
message:
'Sign and verify through JwtService, so the token is bound to a purpose in token-purposes.ts.',
};
// `jsonwebtoken` declares no `exports`, so `jsonwebtoken/sign` and its siblings
// resolve straight to the same functions and would slip past a name-only rule.
const jsonwebtokenSubpathRestriction = {
group: ['jsonwebtoken/*'],
message:
'Sign and verify through JwtService, so the token is bound to a purpose in token-purposes.ts.',
};
const engineV2ModuleOnlyImport = {
name: '@n8n/engine',
allowTypeImports: true,
@ -154,7 +178,14 @@ export default defineConfig(
// wholesale rather than merging them.
'@typescript-eslint/no-restricted-imports': [
'error',
{ paths: [POLICY_INTERNAL_RESTRICTION, engineV2ModuleOnlyImport] },
{
paths: [
POLICY_INTERNAL_RESTRICTION,
engineV2ModuleOnlyImport,
jsonwebtokenSigningRestriction,
],
patterns: [jsonwebtokenSubpathRestriction],
},
],
},
},
@ -171,11 +202,65 @@ export default defineConfig(
POLICY_INTERNAL_RESTRICTION,
...instanceAiLazyRuntimeImports,
engineV2ModuleOnlyImport,
jsonwebtokenSigningRestriction,
],
patterns: [jsonwebtokenSubpathRestriction],
},
],
},
},
{
// engine-v2 owns `@n8n/engine`, so the block above skips it wholesale — which
// would drop the JWT restriction too. Reinstate it here, without the engine
// restriction these files are exempt from.
files: ['./src/modules/engine-v2/**/*.ts'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
paths: [POLICY_INTERNAL_RESTRICTION, jsonwebtokenSigningRestriction],
patterns: [jsonwebtokenSubpathRestriction],
},
],
},
},
{
// The two places that hold the raw signing API. NEVER add to this list.
files: [
// Owns the signing key and derives every audience from a purpose.
'./src/services/jwt.service.ts',
// Verifies subject tokens with a foreign key from the trusted-key store,
// against the audience that key is registered for.
'./src/modules/token-exchange/services/token-exchange.service.ts',
],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{ paths: [POLICY_INTERNAL_RESTRICTION, engineV2ModuleOnlyImport] },
],
},
},
{
// Tests mint tokens as fixtures, including malformed ones a purpose cannot express.
files: ['./src/**/__tests__/**/*.ts'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{ paths: [POLICY_INTERNAL_RESTRICTION, engineV2ModuleOnlyImport] },
],
},
},
{
// engine-v2 tests reach for `@n8n/engine` the same way the module does, and
// the tests block above would reinstate the restriction they are exempt from.
files: ['./src/modules/engine-v2/**/__tests__/**/*.ts'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{ paths: [POLICY_INTERNAL_RESTRICTION] },
],
},
},
{
// Only the PEP may import the clearance minter.
files: ['./src/policy/policy-enforcement.service.ts'],

View File

@ -31,6 +31,30 @@ const instanceAiLazyRuntimeImports = [
message: INSTANCE_AI_LAZY_IMPORT_MESSAGE,
}));
// Only JwtService may reach the raw signing API: it derives the `aud` claim from
// the token's purpose, which is what keeps a token for one purpose from being
// presented for another. The error classes and types stay importable.
const jsonwebtokenSigningRestriction = {
name: 'jsonwebtoken',
// An allowlist, not a denylist: the module's whole runtime surface is off
// limits except the error classes, so a member added upstream is restricted
// from the start. `allowTypeImports` keeps `Secret`, `Algorithm` and friends
// importable. A namespace import is restricted too — the linter cannot see
// which members it reaches for.
allowImportNames: ['JsonWebTokenError', 'TokenExpiredError', 'NotBeforeError'],
allowTypeImports: true,
message:
'Sign and verify through JwtService, so the token is bound to a purpose in token-purposes.ts.',
};
// `jsonwebtoken` declares no `exports`, so `jsonwebtoken/sign` and its siblings
// resolve straight to the same functions and would slip past a name-only rule.
const jsonwebtokenSubpathRestriction = {
group: ['jsonwebtoken/*'],
message:
'Sign and verify through JwtService, so the token is bound to a purpose in token-purposes.ts.',
};
const engineV2ModuleOnlyImport = {
name: '@n8n/engine',
allowTypeImports: true,
@ -148,7 +172,14 @@ export default defineConfig({
// wholesale rather than merging them.
'no-restricted-imports': [
'error',
{ paths: [POLICY_INTERNAL_RESTRICTION, engineV2ModuleOnlyImport] },
{
paths: [
POLICY_INTERNAL_RESTRICTION,
engineV2ModuleOnlyImport,
jsonwebtokenSigningRestriction,
],
patterns: [jsonwebtokenSubpathRestriction],
},
],
},
},
@ -165,7 +196,9 @@ export default defineConfig({
POLICY_INTERNAL_RESTRICTION,
...instanceAiLazyRuntimeImports,
engineV2ModuleOnlyImport,
jsonwebtokenSigningRestriction,
],
patterns: [jsonwebtokenSubpathRestriction],
},
],
},
@ -179,6 +212,55 @@ export default defineConfig({
files: ['./src/modules/agents/runtime/agent-isolate-pool.ts'],
rules: { 'prefer-const': 'off' },
},
{
// engine-v2 owns `@n8n/engine`, so the block above skips it wholesale — which
// would drop the JWT restriction too. Reinstate it here, without the engine
// restriction these files are exempt from.
files: ['./src/modules/engine-v2/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [POLICY_INTERNAL_RESTRICTION, jsonwebtokenSigningRestriction],
patterns: [jsonwebtokenSubpathRestriction],
},
],
},
},
{
// The two places that hold the raw signing API. NEVER add to this list.
files: [
// Owns the signing key and derives every audience from a purpose.
'./src/services/jwt.service.ts',
// Verifies subject tokens with a foreign key from the trusted-key store,
// against the audience that key is registered for.
'./src/modules/token-exchange/services/token-exchange.service.ts',
],
rules: {
'no-restricted-imports': [
'error',
{ paths: [POLICY_INTERNAL_RESTRICTION, engineV2ModuleOnlyImport] },
],
},
},
{
// Tests mint tokens as fixtures, including malformed ones a purpose cannot express.
files: ['./src/**/__tests__/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{ paths: [POLICY_INTERNAL_RESTRICTION, engineV2ModuleOnlyImport] },
],
},
},
{
// engine-v2 tests reach for `@n8n/engine` the same way the module does, and
// the tests block above would reinstate the restriction they are exempt from.
files: ['./src/modules/engine-v2/**/__tests__/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { paths: [POLICY_INTERNAL_RESTRICTION] }],
},
},
{
// Only the PEP may import the clearance minter.
files: ['./src/policy/policy-enforcement.service.ts'],

View File

@ -41,7 +41,7 @@ describe('AuthService', () => {
userManagement: { jwtSecret: 'random-secret' },
endpoints: { rest: 'rest' },
});
const jwtService = new JwtService(mock(), globalConfig);
const jwtService = new JwtService(mock(), globalConfig, mock());
const urlService = mock<UrlService>();
const userRepository = mock<UserRepository>();
const invalidAuthTokenRepository = mock<InvalidAuthTokenRepository>();
@ -63,10 +63,10 @@ describe('AuthService', () => {
vi.useFakeTimers({ now });
const validToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJ1c2VkTWZhIjpmYWxzZSwiaWF0IjoxNzA2NzUwNjI1LCJleHAiOjE3MDczNTU0MjV9.N7JgwETmO41o4FUDVb4pA1HM3Clj4jyjDK-lE8Fa1Zw'; // Generated using `authService.issueJWT(user, false, browserId)`
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJ1c2VkTWZhIjpmYWxzZSwiaWF0IjoxNzA2NzUwNjI1LCJleHAiOjE3MDczNTU0MjUsImF1ZCI6Im44bjpzZXNzaW9uIn0.Non8MLCyq2HdJMu4G2EMKsooOSSZV09f3SJ0vGk3_O8'; // Generated using `authService.issueJWT(user, false, browserId)`
const validTokenWithMfa =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJ1c2VkTWZhIjp0cnVlLCJpYXQiOjE3MDY3NTA2MjUsImV4cCI6MTcwNzM1NTQyNX0.9kTTue-ZdBQ0CblH0IrqW9K-k0WWfxfsWTglyPB10ko'; // Generated using `authService.issueJWT(user, true, browserId)`
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJ1c2VkTWZhIjp0cnVlLCJpYXQiOjE3MDY3NTA2MjUsImV4cCI6MTcwNzM1NTQyNSwiYXVkIjoibjhuOnNlc3Npb24ifQ.54_9gexM1Y39cMkmp7Rr2cVFxSx8R0xtgEdPAbagomE'; // Generated using `authService.issueJWT(user, true, browserId)`
beforeEach(() => {
vi.resetAllMocks();
@ -530,7 +530,7 @@ describe('AuthService', () => {
const next = vi.fn() as NextFunction;
const tokenWithPayload = (payload: object, options: jwt.SignOptions = { expiresIn: '1h' }) =>
jwtService.sign(payload, options);
jwtService.sign('session', payload, options);
it('should 404 if no cookie is set', () => {
const req = mockReq();
@ -792,7 +792,7 @@ describe('AuthService', () => {
const token = authService.issueJWT(user, false, browserId);
expect(authService.jwtExpiration).toBe(defaultInSeconds);
const decodedToken = jwtService.verify(token);
const decodedToken = jwtService.verify('session', token);
if (decodedToken.exp === undefined || decodedToken.iat === undefined) {
expect.fail('Expected exp and iat to be defined');
}
@ -809,7 +809,7 @@ describe('AuthService', () => {
globalConfig.userManagement.jwtSessionDurationHours = testDurationHours;
const token = authService.issueJWT(user, false, browserId);
const decodedToken = jwtService.verify(token);
const decodedToken = jwtService.verify('session', token);
if (decodedToken.exp === undefined || decodedToken.iat === undefined) {
expect.fail('Expected exp and iat to be defined on decodedToken');
}
@ -865,7 +865,7 @@ describe('AuthService', () => {
['the user id is empty', { id: '', hash: 'mJAYx4Wb7k' }],
['the hash is missing', { id: '123' }],
])('should throw when %s', async (_name, payload) => {
const token = jwtService.sign(payload, { expiresIn: '1h' });
const token = jwtService.sign('session', payload, { expiresIn: '1h' });
await expect(authService.resolveJwt(token, req, res)).rejects.toThrow('Unauthorized');
expect(userRepository.findOne).not.toHaveBeenCalled();
@ -874,7 +874,7 @@ describe('AuthService', () => {
it('should throw when the payload is missing the expiry', async () => {
// jwt.verify treats an absent `exp` as a token that never expires.
const token = jwtService.sign({ id: user.id, hash: 'mJAYx4Wb7k' });
const token = jwtService.sign('session', { id: user.id, hash: 'mJAYx4Wb7k' });
await expect(authService.resolveJwt(token, req, res)).rejects.toThrow('Unauthorized');
expect(userRepository.findOne).not.toHaveBeenCalled();
@ -889,6 +889,7 @@ describe('AuthService', () => {
['browserId', { browserId: 0 }],
])('should throw when %s is present but not its declared type', async (_name, claim) => {
const token = jwtService.sign(
'session',
{ id: user.id, hash: 'mJAYx4Wb7k', ...claim },
{ expiresIn: '1h' },
);

View File

@ -218,7 +218,9 @@ export class AuthService {
if (token) {
try {
const payload = this.jwtService.verify<unknown>(token, { algorithms: ['HS256'] });
const payload = this.jwtService.verify<unknown>('session', token, {
algorithms: ['HS256'],
});
if (this.isAuthJwtPayload(payload)) {
next();
return;
@ -281,7 +283,7 @@ export class AuthService {
const token = req.cookies[AUTH_COOKIE_NAME];
if (!token) return;
try {
const { exp } = this.jwtService.decode(token);
const { exp } = this.jwtService.decodeUnverified(token);
if (exp) {
await this.invalidAuthTokenRepository.insert({
token,
@ -326,7 +328,7 @@ export class AuthService {
usedMfa,
...(isEmbed && { isEmbed }),
};
return this.jwtService.sign(payload, {
return this.jwtService.sign('session', payload, {
expiresIn: this.jwtExpiration,
});
}
@ -425,7 +427,7 @@ export class AuthService {
user: User;
jwtPayload: IssuedJWT;
}> {
const jwtPayload = this.jwtService.verify<unknown>(token, {
const jwtPayload = this.jwtService.verify<unknown>('session', token, {
algorithms: ['HS256'],
});
@ -490,7 +492,7 @@ export class AuthService {
newEmail,
hash: this.createJWTHash(user),
};
const token = this.jwtService.sign(payload, { expiresIn: '20m', audience: 'n8n-email-change' });
const token = this.jwtService.sign('emailChange', payload, { expiresIn: '20m' });
const url = new URL(`${this.urlService.getInstanceBaseUrl()}/confirm-email-change`);
url.searchParams.append('token', token);
return url.toString();
@ -501,9 +503,7 @@ export class AuthService {
): Promise<{ user: User; newEmail: string } | undefined> {
let decoded: EmailChangeToken;
try {
decoded = this.jwtService.verify(token, {
audience: 'n8n-email-change',
});
decoded = this.jwtService.verify('emailChange', token);
} catch {
return;
}
@ -518,7 +518,7 @@ export class AuthService {
generatePasswordResetToken(user: User, expiresIn: TimeUnitValue = '20m') {
const payload: PasswordResetToken = { sub: user.id, hash: this.createJWTHash(user) };
return this.jwtService.sign(payload, { expiresIn, audience: 'n8n-password-reset' });
return this.jwtService.sign('passwordReset', payload, { expiresIn });
}
generatePasswordResetUrl(user: User) {
@ -534,9 +534,7 @@ export class AuthService {
async resolvePasswordResetToken(token: string): Promise<User | undefined> {
let decodedToken: PasswordResetToken;
try {
decodedToken = this.jwtService.verify(token, {
audience: 'n8n-password-reset',
});
decodedToken = this.jwtService.verify('passwordReset', token);
} catch (e) {
if (e instanceof TokenExpiredError) {
this.logger.debug('Reset password token expired');

View File

@ -85,6 +85,7 @@ describe('UsersController', () => {
where: { id: inviteeId },
});
expect(jwtService.sign).toHaveBeenCalledWith(
'invite',
{
inviterId,
inviteeId,
@ -143,6 +144,7 @@ describe('UsersController', () => {
await controller.generateInviteLink(request, mock<Response>());
expect(jwtService.sign).toHaveBeenCalledWith(
'invite',
{
inviterId,
inviteeId,

View File

@ -150,6 +150,7 @@ export class UsersController {
}
const token = this.jwtService.sign(
'invite',
{
inviterId,
inviteeId,

View File

@ -14,6 +14,7 @@ const CREDENTIAL_ID = 'cred-1';
const jwtService = new JwtService(
mock<InstanceSettings>({ encryptionKey: 'test-encryption-key' }),
mock<GlobalConfig>({ userManagement: { jwtSecret: 'test-jwt-secret' } }),
mock(),
);
const urlService = mock<UrlService>();
urlService.getWebhookBaseUrl.mockReturnValue('https://n8n.example.com/');
@ -212,6 +213,7 @@ describe('TeamsArmTemplateService', () => {
it('rejects an n8n token minted for anything other than this route', () => {
// The instance signs tokens for other purposes with the same secret.
const token = jwtService.sign(
'session',
{ projectId: PROJECT_ID, agentId: AGENT_ID, credentialId: CREDENTIAL_ID },
{ expiresIn: '15m' },
);

View File

@ -27,6 +27,7 @@ const user = mock<User>({ id: 'user-1' });
const jwtService = new JwtService(
mock<InstanceSettings>({ encryptionKey: 'test-encryption-key' }),
mock<GlobalConfig>({ userManagement: { jwtSecret: 'test-jwt-secret' } }),
mock(),
);
describe('TeamsSetupService', () => {

View File

@ -13,9 +13,6 @@ import { sanitiseAppName } from '../../integration-helpers';
*/
const TOKEN_TTL = '15m';
/** Scopes the token to this route, so no other n8n-signed token is accepted. */
const TOKEN_SUBJECT = 'teams-arm-template';
interface TeamsArmTokenClaims {
projectId: string;
agentId: string;
@ -168,7 +165,7 @@ export class TeamsArmTemplateService {
*/
verifyToken(projectId: string, agentId: string, credentialId: string, token: string): boolean {
try {
const claims = this.jwtService.verify<TeamsArmTokenClaims>(token, { subject: TOKEN_SUBJECT });
const claims = this.jwtService.verify<TeamsArmTokenClaims>('teamsArmTemplate', token);
return (
claims.projectId === projectId &&
claims.agentId === agentId &&
@ -181,8 +178,9 @@ export class TeamsArmTemplateService {
private signToken(projectId: string, agentId: string, credentialId: string): string {
return this.jwtService.sign(
'teamsArmTemplate',
{ projectId, agentId, credentialId },
{ subject: TOKEN_SUBJECT, expiresIn: TOKEN_TTL },
{ expiresIn: TOKEN_TTL },
);
}

View File

@ -21,7 +21,7 @@ function makeScopedJwt(
opts: { actSub?: string; expired?: boolean } = {},
): string {
const now = Math.floor(Date.now() / 1000);
return jwtService.sign({
return jwtService.sign('tokenExchange', {
iss: TOKEN_EXCHANGE_ISSUER,
sub,
...(opts.actSub && { act: { sub: opts.actSub } }),
@ -166,10 +166,9 @@ describe('McpServerApiKeyService.verifyApiKey (integration)', () => {
// the JWT verify step (with the expected audience) must still reject a token signed
// with a different aud claim.
const owner = await createOwner();
const jwt = jwtService.sign({
const jwt = jwtService.sign('publicApiKey', {
sub: owner.id,
iss: 'n8n',
aud: 'public-api',
jti: 'tampered',
});

View File

@ -26,7 +26,7 @@ const mockReqWith = (authHeader: string | undefined, body?: any) => {
};
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());
const jwtService = new JwtService(instanceSettings, mock(), mock());
let mcpServerApiKeyService: Mocked<McpServerApiKeyService>;
let oauthTokenVerifier: Mocked<OAuthTokenVerifierProxy>;
@ -61,11 +61,10 @@ describe('McpServerMiddlewareService', () => {
describe('getUserForToken', () => {
it('should return user for valid OAuth token (meta.isOAuth = true)', async () => {
const user = mock<User>({ id: 'user-123' });
const oauthToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
const oauthToken = jwtService.signForResource(
{ sub: 'user-123', meta: { isOAuth: true } },
'mcp-server-api',
);
oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({
user,
@ -84,10 +83,7 @@ describe('McpServerMiddlewareService', () => {
it('should return user for valid API key (no meta.isOAuth)', async () => {
const user = mock<User>({ id: 'user-123' });
const apiKeyToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
});
const apiKeyToken = jwtService.sign('mcpApiKey', { sub: 'user-123' });
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({
user,
@ -103,9 +99,8 @@ describe('McpServerMiddlewareService', () => {
it('should return user for valid API key (meta.isOAuth = false)', async () => {
const user = mock<User>({ id: 'user-123' });
const apiKeyToken = jwtService.sign({
const apiKeyToken = jwtService.sign('mcpApiKey', {
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: false },
});
@ -132,11 +127,10 @@ describe('McpServerMiddlewareService', () => {
});
it('should return null when OAuth token verification fails', async () => {
const oauthToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
const oauthToken = jwtService.signForResource(
{ sub: 'user-123', meta: { isOAuth: true } },
'mcp-server-api',
);
oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({ user: null });
@ -146,10 +140,7 @@ describe('McpServerMiddlewareService', () => {
});
it('should return null when API key verification fails', async () => {
const apiKeyToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
});
const apiKeyToken = jwtService.sign('mcpApiKey', { sub: 'user-123' });
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });
@ -310,11 +301,10 @@ describe('McpServerMiddlewareService', () => {
it('should authenticate with valid OAuth token and call next', async () => {
const user = mock<User>({ id: 'user-123' });
const oauthToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
const oauthToken = jwtService.signForResource(
{ sub: 'user-123', meta: { isOAuth: true } },
'mcp-server-api',
);
const req = mockReqWith(`Bearer ${oauthToken}`);
const res = mockDeep<Response>();
@ -338,11 +328,10 @@ describe('McpServerMiddlewareService', () => {
it('should attach the OAuth token scopes to the request', async () => {
const user = mock<User>({ id: 'user-123' });
const oauthToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
const oauthToken = jwtService.signForResource(
{ sub: 'user-123', meta: { isOAuth: true } },
'mcp-server-api',
);
const req = mockReqWith(`Bearer ${oauthToken}`);
const res = mockDeep<Response>();
@ -363,11 +352,10 @@ describe('McpServerMiddlewareService', () => {
it('should attach the OAuth client the token was issued to', async () => {
const user = mock<User>({ id: 'user-123' });
const oauthToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
const oauthToken = jwtService.signForResource(
{ sub: 'user-123', meta: { isOAuth: true } },
'mcp-server-api',
);
const req = mockReqWith(`Bearer ${oauthToken}`);
const res = mockDeep<Response>();
@ -389,10 +377,7 @@ describe('McpServerMiddlewareService', () => {
it('should authenticate with valid API key and call next', async () => {
const user = mock<User>({ id: 'user-123' });
const apiKeyToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
});
const apiKeyToken = jwtService.sign('mcpApiKey', { sub: 'user-123' });
const req = mockReqWith(`Bearer ${apiKeyToken}`);
const res = mockDeep<Response>();
@ -423,7 +408,7 @@ describe('McpServerMiddlewareService', () => {
it('should authenticate with a delegated scoped JWT and set req.user to the actor', async () => {
const actor = mock<User>({ id: 'actor-1' });
const scopedJwt = jwtService.sign({
const scopedJwt = jwtService.sign('tokenExchange', {
iss: 'n8n-token-exchange',
sub: 'subject-1',
act: { sub: 'actor-1' },
@ -450,11 +435,10 @@ describe('McpServerMiddlewareService', () => {
});
it('should return 401 with WWW-Authenticate header when token validation fails', async () => {
const invalidToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
meta: { isOAuth: true },
});
const invalidToken = jwtService.signForResource(
{ sub: 'user-123', meta: { isOAuth: true } },
'mcp-server-api',
);
const req = mockReqWith(`Bearer ${invalidToken}`);
const res = mockDeep<Response>();

View File

@ -30,10 +30,9 @@ export class McpServerApiKeyService {
async createMcpServerApiKey(user: User, trx?: EntityManager) {
const manager = trx ?? this.apiKeyRepository.manager;
const apiKey = this.jwtService.sign({
const apiKey = this.jwtService.sign('mcpApiKey', {
sub: user.id,
iss: API_KEY_ISSUER,
aud: API_KEY_AUDIENCE,
jti: randomUUID(),
});

View File

@ -44,7 +44,7 @@ export class McpServerMiddlewareService {
async getUserForToken(token: string): Promise<UserWithContext> {
let decoded: { meta?: { isOAuth?: boolean } };
try {
decoded = this.jwtService.decode<{ meta?: { isOAuth?: boolean } }>(token);
decoded = this.jwtService.decodeUnverified<{ meta?: { isOAuth?: boolean } }>(token);
} catch (error) {
return {
user: null,

View File

@ -21,7 +21,7 @@ let jwtService: JwtService;
let supportedScopes: string[];
const createSessionToken = (payload: OAuthSessionPayload): string => {
return jwtService.sign(payload, { expiresIn: '10m' });
return jwtService.sign('oauthSession', payload, { expiresIn: '10m' });
};
let oauthClientRepository: OAuthClientRepository;

View File

@ -11,7 +11,7 @@ describe('OAuthSessionService', () => {
const globalConfig = mock<GlobalConfig>({
userManagement: { jwtSecret: 'random-secret' },
});
const jwtService = new JwtService(mock(), globalConfig);
const jwtService = new JwtService(mock(), globalConfig, mock());
const oauthSessionService = new OAuthSessionService(jwtService);
const sessionPayload: OAuthSessionPayload = {
@ -23,13 +23,13 @@ describe('OAuthSessionService', () => {
describe('verifySession', () => {
it('should return the payload of an authorization session token', () => {
const token = jwtService.sign(sessionPayload, { expiresIn: '10m' });
const token = jwtService.sign('oauthSession', sessionPayload, { expiresIn: '10m' });
expect(oauthSessionService.verifySession(token)).toMatchObject(sessionPayload);
});
it('should throw when the signature does not match', () => {
const token = jwtService.sign(sessionPayload, { expiresIn: '10m' });
const token = jwtService.sign('oauthSession', sessionPayload, { expiresIn: '10m' });
const [header, payload, signature] = token.split('.');
const tampered = [header, payload, `${signature}x`].join('.');
@ -37,20 +37,31 @@ describe('OAuthSessionService', () => {
});
it('should throw when the token has expired', () => {
const token = jwtService.sign(sessionPayload, { expiresIn: -10 });
const token = jwtService.sign('oauthSession', sessionPayload, { expiresIn: -10 });
expect(() => oauthSessionService.verifySession(token)).toThrow(TokenExpiredError);
});
// The payload is a valid authorization session, so only the audience stands
// between a token minted for another purpose and the consent flow.
it.each(['session', 'invite'] as const)(
'should throw for a session payload signed for %s',
(purpose) => {
const token = jwtService.sign(purpose, sessionPayload, { expiresIn: '10m' });
expect(() => oauthSessionService.verifySession(token)).toThrow(JsonWebTokenError);
},
);
it('should throw when the payload does not describe an authorization session', () => {
const token = jwtService.sign({ sub: 'user-id' }, { expiresIn: '10m' });
const token = jwtService.sign('oauthSession', { sub: 'user-id' }, { expiresIn: '10m' });
expect(() => oauthSessionService.verifySession(token)).toThrow(ZodError);
});
it('should throw when a required field is missing', () => {
const { codeChallenge: _, ...withoutCodeChallenge } = sessionPayload;
const token = jwtService.sign(withoutCodeChallenge, { expiresIn: '10m' });
const token = jwtService.sign('oauthSession', withoutCodeChallenge, { expiresIn: '10m' });
expect(() => oauthSessionService.verifySession(token)).toThrow(ZodError);
});

View File

@ -24,7 +24,7 @@ import type { UrlService } from '@n8n/backend-services';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());
const jwtService = new JwtService(instanceSettings, mock(), mock());
let logger: Mocked<Logger>;
let userRepository: Mocked<UserRepository>;
@ -97,7 +97,7 @@ describe('OAuthTokenService', () => {
expect(accessToken).toMatch(/^[\w-]+\.[\w-]+\.[\w-]+$/); // JWT format
const decoded = jwtService.decode(accessToken);
const decoded = jwtService.decodeUnverified(accessToken);
expect(decoded.iss).toBe(TEST_BASE_URL);
expect(decoded.sub).toBe(userId);
expect(decoded.aud).toBe(TEST_RESOURCE_URL);
@ -123,7 +123,7 @@ describe('OAuthTokenService', () => {
[],
);
const decoded = jwtService.decode(accessToken);
const decoded = jwtService.decodeUnverified(accessToken);
expect(decoded.aud).toBe('https://n8n.example.com/mcp-server/http');
});
@ -133,14 +133,14 @@ describe('OAuthTokenService', () => {
'execution:read',
]);
const decoded = jwtService.decode(accessToken);
const decoded = jwtService.decodeUnverified(accessToken);
expect(decoded.scope).toBe('workflow:read execution:read');
});
it('should mint an empty scope claim for scope-less grants', () => {
const { accessToken } = service.generateTokenPair('user-123', 'client-456', undefined, []);
const decoded = jwtService.decode(accessToken);
const decoded = jwtService.decodeUnverified(accessToken);
expect(decoded.scope).toBe('');
});
@ -263,7 +263,7 @@ describe('OAuthTokenService', () => {
'https://n8n.example.com/mcp-server/http',
);
const decoded = jwtService.decode(result.access_token);
const decoded = jwtService.decodeUnverified(result.access_token);
expect(decoded.aud).toBe('https://n8n.example.com/mcp-server/http');
});
@ -287,7 +287,9 @@ describe('OAuthTokenService', () => {
const result = await service.validateAndRotateRefreshToken(refreshToken, clientId);
expect(result.scope).toBe('workflow:read execution:read');
expect(jwtService.decode(result.access_token).scope).toBe('workflow:read execution:read');
expect(jwtService.decodeUnverified(result.access_token).scope).toBe(
'workflow:read execution:read',
);
expect(refreshTokenRepository.insertToken).toHaveBeenCalledWith(
expect.objectContaining({ scope: ['workflow:read', 'execution:read'] }),
expect.anything(),
@ -384,7 +386,7 @@ describe('OAuthTokenService', () => {
const result = await boundService.validateAndRotateRefreshToken(REFRESH_TOKEN, CLIENT_ID);
// Not the default resource, which is what a resource-less mint would fall back to.
expect(jwtService.decode(result.access_token).aud).toBe(GRANTED_URL);
expect(jwtService.decodeUnverified(result.access_token).aud).toBe(GRANTED_URL);
});
it('accepts a request naming the granted resource', async () => {
@ -394,7 +396,7 @@ describe('OAuthTokenService', () => {
GRANTED_URL,
);
expect(jwtService.decode(result.access_token).aud).toBe(GRANTED_URL);
expect(jwtService.decodeUnverified(result.access_token).aud).toBe(GRANTED_URL);
});
it('accepts an equivalent spelling of the granted resource', async () => {
@ -405,7 +407,7 @@ describe('OAuthTokenService', () => {
);
// Minted from the stored spelling, which the resource also accepts.
expect(jwtService.decode(result.access_token).aud).toBe(GRANTED_URL);
expect(jwtService.decodeUnverified(result.access_token).aud).toBe(GRANTED_URL);
});
it('rejects a request naming a different resource, leaving the token usable', async () => {
@ -469,11 +471,10 @@ describe('OAuthTokenService', () => {
});
it('should throw error for wrong audience', async () => {
const wrongAudienceToken = jwtService.sign({
sub: 'user-123',
aud: 'wrong-audience', // Matches neither legacy literal nor resource URL
client_id: 'client-456',
});
const wrongAudienceToken = jwtService.signForResource(
{ sub: 'user-123', client_id: 'client-456' },
'wrong-audience',
); // Matches neither legacy literal nor resource URL;
await expect(service.verifyAccessToken(wrongAudienceToken)).rejects.toThrow(
'JWT Verification Failed',
@ -482,11 +483,10 @@ describe('OAuthTokenService', () => {
it('should accept tokens with canonical audience when expected audience is provided', async () => {
const audience = 'https://n8n.example.com/mcp-server/http';
const canonicalAudienceToken = jwtService.sign({
sub: 'user-123',
aud: audience,
client_id: 'client-456',
});
const canonicalAudienceToken = jwtService.signForResource(
{ sub: 'user-123', client_id: 'client-456' },
audience,
);
accessTokenRepository.findOne.mockResolvedValue(
mock<AccessToken>({
@ -508,11 +508,10 @@ describe('OAuthTokenService', () => {
it('should accept legacy audience when expected audience is provided', async () => {
const audience = 'https://n8n.example.com/mcp-server/http';
const legacyAudienceToken = jwtService.sign({
sub: 'user-123',
aud: 'mcp-server-api',
client_id: 'client-456',
});
const legacyAudienceToken = jwtService.signForResource(
{ sub: 'user-123', client_id: 'client-456' },
'mcp-server-api',
);
accessTokenRepository.findOne.mockResolvedValue(
mock<AccessToken>({
@ -533,11 +532,10 @@ describe('OAuthTokenService', () => {
it('should accept token whose aud is the legacy literal (backward compat)', async () => {
const userId = 'user-123';
const clientId = 'client-456';
const legacyToken = jwtService.sign({
sub: userId,
aud: 'mcp-server-api',
client_id: clientId,
});
const legacyToken = jwtService.signForResource(
{ sub: userId, client_id: clientId },
'mcp-server-api',
);
accessTokenRepository.findOne.mockResolvedValue(
mock<AccessToken>({ token: legacyToken, clientId, userId }),
);
@ -551,11 +549,10 @@ describe('OAuthTokenService', () => {
it('should accept token whose aud is the resource URL', async () => {
const userId = 'user-123';
const clientId = 'client-456';
const urlToken = jwtService.sign({
sub: userId,
aud: TEST_RESOURCE_URL,
client_id: clientId,
});
const urlToken = jwtService.signForResource(
{ sub: userId, client_id: clientId },
TEST_RESOURCE_URL,
);
accessTokenRepository.findOne.mockResolvedValue(
mock<AccessToken>({ token: urlToken, clientId, userId }),
);
@ -793,11 +790,10 @@ describe('OAuthTokenService', () => {
});
it('should reject a token whose aud belongs to another resource', async () => {
const tokenForResourceA = jwtService.sign({
sub: 'user-123',
aud: RESOURCE_A_URL,
client_id: 'client-456',
});
const tokenForResourceA = jwtService.signForResource(
{ sub: 'user-123', client_id: 'client-456' },
RESOURCE_A_URL,
);
await expect(
multiResourceService.verifyAccessToken(tokenForResourceA, RESOURCE_B_URL),
@ -805,11 +801,10 @@ describe('OAuthTokenService', () => {
});
it('should not accept the legacy audience at a non-default resource', async () => {
const legacyToken = jwtService.sign({
sub: 'user-123',
aud: LEGACY_AUDIENCE,
client_id: 'client-456',
});
const legacyToken = jwtService.signForResource(
{ sub: 'user-123', client_id: 'client-456' },
LEGACY_AUDIENCE,
);
await expect(
multiResourceService.verifyAccessToken(legacyToken, RESOURCE_B_URL),
@ -817,11 +812,10 @@ describe('OAuthTokenService', () => {
});
it('should accept a token at its own resource', async () => {
const tokenForResourceB = jwtService.sign({
sub: 'user-123',
aud: RESOURCE_B_URL,
client_id: 'client-456',
});
const tokenForResourceB = jwtService.signForResource(
{ sub: 'user-123', client_id: 'client-456' },
RESOURCE_B_URL,
);
accessTokenRepository.findOne.mockResolvedValue(
mock<AccessToken>({ token: tokenForResourceB, clientId: 'client-456', userId: 'user-123' }),
);
@ -832,11 +826,10 @@ describe('OAuthTokenService', () => {
});
it('should still accept the legacy audience at the default (instance MCP) resource', async () => {
const legacyToken = jwtService.sign({
sub: 'user-123',
aud: LEGACY_AUDIENCE,
client_id: 'client-456',
});
const legacyToken = jwtService.signForResource(
{ sub: 'user-123', client_id: 'client-456' },
LEGACY_AUDIENCE,
);
accessTokenRepository.findOne.mockResolvedValue(
mock<AccessToken>({ token: legacyToken, clientId: 'client-456', userId: 'user-123' }),
);
@ -900,11 +893,10 @@ describe('OAuthTokenService', () => {
it('treats a token without a scope claim as having no scopes', async () => {
// cannot occur legitimately: migration 1784000000047 deleted every
// access token minted before scoping shipped
const legacyToken = jwtService.sign({
sub: 'user-123',
aud: TEST_RESOURCE_URL,
client_id: 'client-456',
});
const legacyToken = jwtService.signForResource(
{ sub: 'user-123', client_id: 'client-456' },
TEST_RESOURCE_URL,
);
accessTokenRepository.findOne.mockResolvedValue(
mock<AccessToken>({ token: legacyToken, clientId: 'client-456', userId: 'user-123' }),
);
@ -1002,11 +994,10 @@ describe('OAuthTokenService', () => {
['the instance-base-URL-derived resource URL', TEST_RESOURCE_URL],
['the legacy audience', LEGACY_AUDIENCE],
])('should accept a token whose aud is %s', async (_, audience) => {
const token = jwtService.sign({
sub: 'user-123',
aud: audience,
client_id: 'client-456',
});
const token = jwtService.signForResource(
{ sub: 'user-123', client_id: 'client-456' },
audience,
);
accessTokenRepository.findOne.mockResolvedValue(
mock<AccessToken>({ token, clientId: 'client-456', userId: 'user-123' }),
);
@ -1017,11 +1008,10 @@ describe('OAuthTokenService', () => {
});
it('should reject a token whose aud is an unconfigured host', async () => {
const token = jwtService.sign({
sub: 'user-123',
aud: 'https://other.example.com/mcp-server/http',
client_id: 'client-456',
});
const token = jwtService.signForResource(
{ sub: 'user-123', client_id: 'client-456' },
'https://other.example.com/mcp-server/http',
);
await expect(
configuredService.verifyAccessToken(token, CONFIGURED_RESOURCE_URL),

View File

@ -116,7 +116,7 @@ const sessionTokenFor = async (resourceUrl: string) => {
state: 'test-state',
resource: resourceUrl,
};
return jwtService.sign(payload, { expiresIn: '10m' });
return jwtService.sign('oauthSession', payload, { expiresIn: '10m' });
};
beforeAll(async () => {
@ -282,7 +282,7 @@ describe('token endpoint: refresh grants stay on their approved resource', () =>
const response = await refresh(clientId, refreshToken);
expect(response.statusCode).toBe(200);
expect(jwtService.decode(response.body.access_token).aud).toBe(resourceUrl);
expect(jwtService.decodeUnverified(response.body.access_token).aud).toBe(resourceUrl);
});
test('keeps the resource on the rotated refresh token', async () => {
@ -299,6 +299,6 @@ describe('token endpoint: refresh grants stay on their approved resource', () =>
const reused = await refresh(clientId, rotated.body.refresh_token);
expect(reused.statusCode).toBe(200);
expect(jwtService.decode(reused.body.access_token).aud).toBe(resourceA);
expect(jwtService.decodeUnverified(reused.body.access_token).aud).toBe(resourceA);
});
});

View File

@ -32,7 +32,7 @@ export class OAuthSessionService {
* Create OAuth session token and set it as a cookie
*/
createSession(res: Response, payload: OAuthSessionPayload): void {
const sessionToken = this.jwtService.sign(payload, {
const sessionToken = this.jwtService.sign('oauthSession', payload, {
expiresIn: '10m',
});
@ -52,7 +52,7 @@ export class OAuthSessionService {
* here and reject anything that is not an authorization session.
*/
verifySession(sessionToken: string): OAuthSessionPayload {
const payload = this.jwtService.verify<unknown>(sessionToken);
const payload = this.jwtService.verify<unknown>('oauthSession', sessionToken);
return oauthSessionPayloadSchema.parse(payload);
}

View File

@ -78,11 +78,10 @@ export class OAuthTokenService implements OAuthTokenVerifier {
);
}
const accessToken = this.jwtService.sign(
const accessToken = this.jwtService.signForResource(
{
iss: this.urlService.getInstanceBaseUrl(),
sub: userId,
aud: audience,
client_id: clientId,
jti: randomUUID(),
iat: Math.floor(Date.now() / 1000),
@ -95,6 +94,7 @@ export class OAuthTokenService implements OAuthTokenVerifier {
isOAuth: true,
},
},
audience,
{
header: {
typ: 'at+jwt',
@ -478,14 +478,12 @@ export class OAuthTokenService implements OAuthTokenVerifier {
// tokens minted before n8n v2.19 have aged out (refresh-token lifespan).
private verifyJwtWithAllowedAudiences(token: string, audiences: string[]): unknown {
try {
return this.jwtService.verify(token, {
audience: audiences as [string, ...string[]],
});
return this.jwtService.verifyForResource(token, audiences as [string, ...string[]]);
} catch (error) {
// Some jsonwebtoken builds reject the array form for tokens signed with a single-string aud.
for (const audience of audiences) {
try {
return this.jwtService.verify(token, { audience });
return this.jwtService.verifyForResource(token, audience);
} catch {
continue;
}

View File

@ -395,8 +395,8 @@ describe('OidcService', () => {
describe('state redirect target', () => {
beforeEach(() => {
vi.mocked(jwtService.sign).mockImplementation((payload) => JSON.stringify(payload));
vi.mocked(jwtService.verify).mockImplementation((token) => JSON.parse(token));
vi.mocked(jwtService.sign).mockImplementation((_purpose, payload) => JSON.stringify(payload));
vi.mocked(jwtService.verify).mockImplementation((_purpose, token) => JSON.parse(token));
});
it('round-trips the in-app destination through the signed state', () => {

View File

@ -144,7 +144,7 @@ export class OidcService {
payload.redirectUrl = redirectUrl;
}
return {
signed: this.jwtService.sign(payload, { expiresIn: '15m' }),
signed: this.jwtService.sign('oidcState', payload, { expiresIn: '15m' }),
plaintext: state,
};
}
@ -154,7 +154,7 @@ export class OidcService {
let testMode: boolean | undefined;
let redirectUrl: unknown;
try {
const decodedState = this.jwtService.verify(signedState);
const decodedState = this.jwtService.verify('oidcState', signedState);
state = decodedState?.state;
testMode = decodedState?.testMode;
redirectUrl = decodedState?.redirectUrl;
@ -193,7 +193,7 @@ export class OidcService {
generateNonce() {
const nonce = `n8n_nonce:${randomUUID()}`;
return {
signed: this.jwtService.sign({ nonce }, { expiresIn: '15m' }),
signed: this.jwtService.sign('oidcNonce', { nonce }, { expiresIn: '15m' }),
plaintext: nonce,
};
}
@ -201,7 +201,7 @@ export class OidcService {
verifyNonce(signedNonce: string) {
let nonce: string;
try {
const decodedNonce = this.jwtService.verify(signedNonce);
const decodedNonce = this.jwtService.verify('oidcNonce', signedNonce);
nonce = decodedNonce?.nonce;
} catch (error) {
this.logger.error('Failed to verify nonce', { error });

View File

@ -12,7 +12,7 @@ import { TOKEN_EXCHANGE_ISSUER } from '../../token-exchange.types';
import { ScopedJwtStrategy } from '../scoped-jwt.strategy';
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());
const jwtService = new JwtService(instanceSettings, mock(), mock());
function makeBearerReq(token: string): AuthenticatedRequest {
const req = mock<AuthenticatedRequest>();
@ -21,7 +21,7 @@ function makeBearerReq(token: string): AuthenticatedRequest {
}
function makeScopedJwt(sub: string, actSub?: string): string {
return jwtService.sign({
return jwtService.sign('tokenExchange', {
iss: TOKEN_EXCHANGE_ISSUER,
sub,
...(actSub && { act: { sub: actSub } }),
@ -99,7 +99,9 @@ describe('ScopedJwtStrategy (integration)', () => {
it('rejects tokens that fail validation', async () => {
// Wrong issuer → abstain. Subject missing / disabled / disabled-actor → fail.
expect(await strategy.buildTokenGrant(jwtService.sign({ iss: 'n8n', sub: '1' }))).toBeNull();
expect(
await strategy.buildTokenGrant(jwtService.sign('tokenExchange', { iss: 'n8n', sub: '1' })),
).toBeNull();
expect(
await strategy.buildTokenGrant(makeScopedJwt('422b72e6-2df2-47c9-8082-f8393b088fde')),
).toBe(false);

View File

@ -10,10 +10,11 @@ import { TOKEN_EXCHANGE_ISSUER, type IssuedJwtPayload } from '../../token-exchan
import { ScopedJwtStrategy } from '../scoped-jwt.strategy';
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());
const jwtService = new JwtService(instanceSettings, mock(), mock());
const otherJwtService = new JwtService(
mock<InstanceSettings>({ encryptionKey: 'different-key' }),
mock(),
mock(),
);
/** Create a mock user with role.slug and role.scopes pre-populated for scope assertions. */
@ -36,7 +37,7 @@ function makeUser(
}
function makeTokenExchangeJwt(payload: Partial<IssuedJwtPayload> = {}): string {
return jwtService.sign({
return jwtService.sign('tokenExchange', {
iss: TOKEN_EXCHANGE_ISSUER,
sub: 'subject-id',
iat: Math.floor(Date.now() / 1000),
@ -65,21 +66,27 @@ describe('ScopedJwtStrategy', () => {
it.each([
['empty token', ''],
['non-JWT garbage', 'not-a-jwt'],
['JWT with a non-token-exchange issuer', jwtService.sign({ iss: 'n8n', sub: '123' })],
['JWT with a foreign issuer', jwtService.sign({ iss: 'https://idp.example.com', sub: '1' })],
[
'JWT with a non-token-exchange issuer',
jwtService.sign('tokenExchange', { iss: 'n8n', sub: '123' }),
],
[
'JWT with a foreign issuer',
jwtService.sign('tokenExchange', { iss: 'https://idp.example.com', sub: '1' }),
],
])('returns null (abstains) for %s', async (_name, token) => {
expect(await strategy.buildTokenGrant(token)).toBeNull();
});
it('returns false when signature or expiry fail', async () => {
const expired = jwtService.sign({
const expired = jwtService.sign('tokenExchange', {
iss: TOKEN_EXCHANGE_ISSUER,
sub: 'subject-id',
iat: Math.floor(Date.now() / 1000) - 7200,
exp: Math.floor(Date.now() / 1000) - 1,
jti: 'test-jti',
});
const badSignature = otherJwtService.sign({
const badSignature = otherJwtService.sign('tokenExchange', {
iss: TOKEN_EXCHANGE_ISSUER,
sub: 'subject-id',
iat: Math.floor(Date.now() / 1000),
@ -169,7 +176,7 @@ describe('ScopedJwtStrategy', () => {
const subject = makeUser('subject-id', ['workflow:read']);
userRepository.findOne.mockResolvedValue(subject);
const token = jwtService.sign({
const token = jwtService.sign('tokenExchange', {
iss: 'custom-issuer',
sub: 'subject-id',
iat: Math.floor(Date.now() / 1000),

View File

@ -28,7 +28,7 @@ export class ScopedJwtStrategy implements AuthStrategy {
const issuer = options?.issuer ?? TOKEN_EXCHANGE_ISSUER;
// 1. Decode (unverified) — check iss before expensive signature verification
const decoded = this.jwtService.decode<IssuedJwtPayload>(token);
const decoded = this.jwtService.decodeUnverified<IssuedJwtPayload>(token);
if (!decoded || decoded.iss !== issuer) {
return null; // Not a token-exchange JWT — pass to next strategy
}
@ -36,7 +36,7 @@ export class ScopedJwtStrategy implements AuthStrategy {
// 2. Verify signature + expiry
let payload: IssuedJwtPayload;
try {
payload = this.jwtService.verify<IssuedJwtPayload>(token, {
payload = this.jwtService.verify<IssuedJwtPayload>('tokenExchange', token, {
issuer,
});
} catch (error) {

View File

@ -202,7 +202,7 @@ export class TokenExchangeService {
jti: randomUUID(),
};
const accessToken = this.jwtService.sign(payload);
const accessToken = this.jwtService.sign('tokenExchange', payload);
return {
accessToken,

View File

@ -32,10 +32,9 @@ const mockReqWithoutApiKey = (): AuthenticatedRequest => {
};
async function signAndStoreMcpApiKey(owner: User): Promise<string> {
const apiKey = Container.get(JwtService).sign({
const apiKey = Container.get(JwtService).sign('mcpApiKey', {
sub: owner.id,
iss: API_KEY_ISSUER,
aud: 'mcp-server-api',
jti: randomUUID(),
});
@ -106,7 +105,10 @@ describe('ApiKeyAuthStrategy', () => {
});
it('returns null for a JWT whose issuer does not match (abstain)', async () => {
const tokenExchangeJwt = jwtService.sign({ iss: TOKEN_EXCHANGE_ISSUER, sub: '123' });
const tokenExchangeJwt = jwtService.sign('tokenExchange', {
iss: TOKEN_EXCHANGE_ISSUER,
sub: '123',
});
expect(await strategy.buildTokenGrant(tokenExchangeJwt)).toBeNull();
});
@ -115,7 +117,7 @@ describe('ApiKeyAuthStrategy', () => {
// Each is a short setup with the same expected outcome — folded into one.
expect(await strategy.buildTokenGrant('invalid')).toBe(false);
const unknownKey = jwtService.sign({ sub: '123', iss: API_KEY_ISSUER });
const unknownKey = jwtService.sign('publicApiKey', { sub: '123', iss: API_KEY_ISSUER });
expect(await strategy.buildTokenGrant(unknownKey)).toBe(false);
const expiredOwner = await createOwnerWithApiKey({

View File

@ -5,6 +5,7 @@ import { Container } from '@n8n/di';
import { DataSource, type Repository } from '@n8n/typeorm';
import jsonwebtoken from 'jsonwebtoken';
import { Cipher, InstanceSettings } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import { JwtService } from '@/services/jwt.service';
@ -130,10 +131,10 @@ describe('deployment signing secrets (integration)', () => {
const globalConfig = Container.get(GlobalConfig);
globalConfig.userManagement.jwtSecret = '';
const service = new JwtService(Container.get(InstanceSettings), globalConfig);
const service = new JwtService(Container.get(InstanceSettings), globalConfig, mock());
await service.initialize(repo);
const token = service.sign({ sub: 'roundtrip' });
const token = service.sign('session', { sub: 'roundtrip' });
expect(jsonwebtoken.verify(token, 'preseeded-db-secret')).toMatchObject({ sub: 'roundtrip' });
});
});

View File

@ -1,3 +1,4 @@
import type { Logger } from '@n8n/backend-common';
import type { GlobalConfig } from '@n8n/config';
import jwt from 'jsonwebtoken';
import type { InstanceSettings } from 'n8n-core';
@ -11,8 +12,6 @@ describe('JwtService', () => {
const iat = 1699984313;
const jwtSecret = 'random-string';
const payload = { sub: 1 };
const signedToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTY5OTk4NDMxM30.xNZOAmcidW5ovEF_mwIOzCWkJ70FEO6MFNLK2QRDOeQ';
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
let globalConfig: GlobalConfig;
@ -31,13 +30,13 @@ describe('JwtService', () => {
describe('secret initialization', () => {
it('should read the secret from config, when set', () => {
globalConfig.userManagement.jwtSecret = jwtSecret;
const jwtService = new JwtService(instanceSettings, globalConfig);
const jwtService = new JwtService(instanceSettings, globalConfig, mock());
expect(getJwtSecret(jwtService)).toEqual(jwtSecret);
});
it('should derive the secret from encryption key when not set in config', () => {
globalConfig.userManagement.jwtSecret = '';
const jwtService = new JwtService(instanceSettings, globalConfig);
const jwtService = new JwtService(instanceSettings, globalConfig, mock());
expect(getJwtSecret(jwtService)).toEqual(
'e9e2975005eddefbd31b2c04a0b0f2d9c37d9d718cf3676cddf76d65dec555cb',
);
@ -55,23 +54,96 @@ describe('JwtService', () => {
beforeEach(() => {
globalConfig.userManagement.jwtSecret = jwtSecret;
jwtService = new JwtService(instanceSettings, globalConfig);
jwtService = new JwtService(instanceSettings, globalConfig, mock());
});
it('should sign', () => {
const token = jwtService.sign(payload);
expect(token).toEqual(signedToken);
it('should bind a signed token to the audience of its purpose', () => {
const token = jwtService.sign('session', payload);
expect(jwt.decode(token)).toMatchObject({ sub: 1, iat, aud: 'n8n:session' });
});
it('should decode and verify payload', () => {
const decodedToken = jwtService.verify(signedToken);
const token = jwtService.sign('session', payload);
const decodedToken = jwtService.verify('session', token);
expect(decodedToken.sub).toEqual(1);
expect(decodedToken.iat).toEqual(iat);
});
it('should throw an error on verify if the token is expired', () => {
const expiredToken = jwt.sign(payload, jwtSecret, { expiresIn: -10 });
expect(() => jwtService.verify(expiredToken)).toThrow(jwt.TokenExpiredError);
const expiredToken = jwtService.sign('session', payload, { expiresIn: -10 });
expect(() => jwtService.verify('session', expiredToken)).toThrow(jwt.TokenExpiredError);
});
it('should reject a token signed for a different purpose', () => {
const token = jwtService.sign('passwordReset', payload);
expect(() => jwtService.verify('emailChange', token)).toThrow(jwt.JsonWebTokenError);
});
it('should bind a resource token to the audience it is given', () => {
const resource = 'https://n8n.example.com/mcp-server/http';
const token = jwtService.signForResource(payload, resource);
expect(jwtService.verifyForResource(token, resource)).toMatchObject({ sub: 1 });
expect(() => jwtService.verifyForResource(token, 'https://other.example.com')).toThrow(
jwt.JsonWebTokenError,
);
});
describe('tokens minted before audience binding', () => {
const invitePayload = { inviterId: 'inviter-id', inviteeId: 'invitee-id' };
it('should accept an invite that carries the invite claims, and report it', () => {
const logger = mock<Logger>();
const service = new JwtService(instanceSettings, globalConfig, logger);
const unbound = jwt.sign(invitePayload, jwtSecret);
expect(service.verify('invite', unbound)).toMatchObject(invitePayload);
expect(logger.warn).toHaveBeenCalledWith(
'Accepted a token minted before its purpose carried an audience',
{ purpose: 'invite' },
);
});
it.each([
['no invite claims', payload],
['only an inviter', { inviterId: 'inviter-id' }],
['an empty invitee', { inviterId: 'inviter-id', inviteeId: '' }],
['non-string ids', { inviterId: 1, inviteeId: 2 }],
])('should reject an invite with %s', (_, claims) => {
const unbound = jwt.sign(claims, jwtSecret);
expect(() => jwtService.verify('invite', unbound)).toThrow(jwt.JsonWebTokenError);
});
it.each([
'session',
'tokenExchange',
'publicApiKey',
'oidcState',
'oidcNonce',
'oauthSession',
] as const)('should reject one for %s', (purpose) => {
const unbound = jwt.sign(invitePayload, jwtSecret);
expect(() => jwtService.verify(purpose, unbound)).toThrow(jwt.JsonWebTokenError);
});
it('should not let a bound token stand in for an invite', () => {
const boundElsewhere = jwtService.sign('session', invitePayload);
expect(() => jwtService.verify('invite', boundElsewhere)).toThrow(jwt.JsonWebTokenError);
});
it('should still reject an unbound invite with a bad signature', () => {
const unbound = jwt.sign(invitePayload, 'a-different-secret');
expect(() => jwtService.verify('invite', unbound)).toThrow(jwt.JsonWebTokenError);
});
});
});
@ -88,7 +160,7 @@ describe('JwtService', () => {
it('should use jwtSecret from config and skip DB entirely when set', async () => {
globalConfig.userManagement.jwtSecret = 'env-pinned-secret';
const repo = makeRepo();
const jwtService = new JwtService(instanceSettings, globalConfig);
const jwtService = new JwtService(instanceSettings, globalConfig, mock());
await jwtService.initialize(repo);
@ -100,7 +172,7 @@ describe('JwtService', () => {
it('should use the value from the active DB row when one exists', async () => {
const repo = makeRepo();
repo.findActiveSigningSecret.mockResolvedValue('db-stored-secret');
const jwtService = new JwtService(instanceSettings, globalConfig);
const jwtService = new JwtService(instanceSettings, globalConfig, mock());
await jwtService.initialize(repo);
@ -115,7 +187,7 @@ describe('JwtService', () => {
it('should persist the derived jwtSecret when no active DB row exists', async () => {
const repo = makeRepo();
repo.findActiveSigningSecret.mockResolvedValue(null);
const jwtService = new JwtService(instanceSettings, globalConfig);
const jwtService = new JwtService(instanceSettings, globalConfig, mock());
const derivedSecret = getJwtSecret(jwtService);
await jwtService.initialize(repo);
@ -130,7 +202,7 @@ describe('JwtService', () => {
.mockResolvedValueOnce(null)
.mockResolvedValueOnce('winner-secret');
repo.seedSigningSecret.mockResolvedValue(undefined);
const jwtService = new JwtService(instanceSettings, globalConfig);
const jwtService = new JwtService(instanceSettings, globalConfig, mock());
await jwtService.initialize(repo);

View File

@ -16,7 +16,7 @@ import { PublicApiKeyService } from '../public-api-key.service';
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());
const jwtService = new JwtService(instanceSettings, mock(), mock());
let apiKeyRepository: ApiKeyRepository;
let publicApiKeyService: PublicApiKeyService;

View File

@ -104,12 +104,13 @@ describe('PublicApiKeyService', () => {
});
apiKeyRepository.findOne.mockResolvedValue(existingKey);
jwtService.decode.mockReturnValue({ exp: futureExp });
jwtService.decodeUnverified.mockReturnValue({ exp: futureExp });
jwtService.sign.mockReturnValue('new-token');
const result = await service.rotateApiKey(owner, 'key-1');
expect(jwtService.sign).toHaveBeenCalledWith(
'publicApiKey',
expect.objectContaining({ sub: 'owner-1' }),
expect.objectContaining({ expiresIn: expect.any(Number) }),
);
@ -137,7 +138,7 @@ describe('PublicApiKeyService', () => {
apiKeyRepository.findOne.mockResolvedValue(
mock<ApiKey>({ id: 'key-1', userId: 'owner-1', apiKey: 'old-token' }),
);
jwtService.decode.mockReturnValue({ exp: pastExp });
jwtService.decodeUnverified.mockReturnValue({ exp: pastExp });
await expect(service.rotateApiKey(owner, 'key-1')).rejects.toThrow(BadRequestError);
expect(apiKeyRepository.update).not.toHaveBeenCalled();

View File

@ -569,7 +569,7 @@ describe('UserService', () => {
const result = await userService.getInvitationIdsFromPayload(token);
expect(result).toEqual({ inviterId, inviteeId });
expect(jwtService.verify).toHaveBeenCalledWith(token);
expect(jwtService.verify).toHaveBeenCalledWith('invite', token);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: { role: { slug: GLOBAL_OWNER_ROLE.slug } },
});

View File

@ -9,6 +9,7 @@ import { isApiKeyAuthEnabled } from '@/public-api';
import type { AuthStrategy, AuthStrategyOptions } from './auth-strategy.types';
import { JwtService } from './jwt.service';
import { API_KEY_PURPOSES } from './token-purposes';
import { API_KEY_AUDIENCE, API_KEY_ISSUER, PREFIX_LEGACY_API_KEY } from './public-api-key.service';
const API_KEY_HEADER = 'x-n8n-api-key';
@ -35,8 +36,8 @@ export class ApiKeyAuthStrategy implements AuthStrategy {
// Legacy keys (PREFIX_LEGACY_API_KEY prefix) are never JWTs — skip the decode for them.
// A null decode means the string is not a structurally valid JWT — reject authentication.
if (!token.startsWith(PREFIX_LEGACY_API_KEY)) {
const decoded = this.jwtService.decode<{ iss?: string }>(token);
// Note: JwtService.decode casts its return as T, but jwt.decode() can still return null at runtime.
const decoded = this.jwtService.decodeUnverified<{ iss?: string }>(token);
// Note: decodeUnverified casts its return as T, but jwt.decode() can still return null at runtime.
if (decoded === null) return false;
if (decoded.iss !== issuer) return null;
}
@ -52,10 +53,7 @@ export class ApiKeyAuthStrategy implements AuthStrategy {
if (!token.startsWith(PREFIX_LEGACY_API_KEY)) {
try {
this.jwtService.verify(token, {
issuer,
audience,
});
this.jwtService.verify(API_KEY_PURPOSES[audience], token, { issuer });
} catch (e) {
if (e instanceof TokenExpiredError) return false;
throw e;

View File

@ -1,15 +1,34 @@
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
import { createHash } from 'crypto';
import jwt from 'jsonwebtoken';
import { InstanceSettings, type DeploymentStateRepo } from 'n8n-core';
import {
LEGACY_UNBOUND_PURPOSES,
TOKEN_PURPOSES,
type TokenPurpose,
} from '@/services/token-purposes';
/**
* `audience` is omitted on purpose: it is derived from the token's purpose, so
* a caller cannot bind a token to the wrong audience or forget to bind it.
*/
export type PurposedSignOptions = Omit<jwt.SignOptions, 'audience'>;
export type PurposedVerifyOptions = Omit<jwt.VerifyOptions, 'audience'>;
@Service()
export class JwtService {
private jwtSecret: string = '';
private readonly jwtSecretFromEnv: boolean;
constructor({ encryptionKey }: InstanceSettings, globalConfig: GlobalConfig) {
constructor(
{ encryptionKey }: InstanceSettings,
globalConfig: GlobalConfig,
private readonly logger: Logger,
) {
this.jwtSecretFromEnv = Boolean(globalConfig.userManagement.jwtSecret);
this.jwtSecret = globalConfig.userManagement.jwtSecret;
if (!this.jwtSecret) {
@ -48,16 +67,84 @@ export class JwtService {
if (winner !== null) this.jwtSecret = winner;
}
sign(payload: object, options: jwt.SignOptions = {}): string {
return jwt.sign(payload, this.jwtSecret, options);
/** Signs a token bound to the audience of `purpose`. */
sign(purpose: TokenPurpose, payload: object, options: PurposedSignOptions = {}): string {
return jwt.sign(payload, this.jwtSecret, {
...options,
audience: TOKEN_PURPOSES[purpose],
});
}
decode<T = JwtPayload>(token: string) {
/**
* Signs a token bound to a protected resource rather than to a fixed
* purpose. Only OAuth access tokens need this: their audience is the
* requested resource indicator (RFC 8707), known at runtime.
*/
signForResource(payload: object, audience: string, options: PurposedSignOptions = {}): string {
return jwt.sign(payload, this.jwtSecret, { ...options, audience });
}
/**
* Reads the claims without checking the signature. Use only to route a
* token to the code that then verifies it — never to make a decision.
*/
decodeUnverified<T = JwtPayload>(token: string) {
return jwt.decode(token) as T;
}
verify<T = JwtPayload>(token: string, options: jwt.VerifyOptions = {}) {
return jwt.verify(token, this.jwtSecret, options) as T;
/** Verifies a token and requires it to carry the audience of `purpose`. */
verify<T = JwtPayload>(
purpose: TokenPurpose,
token: string,
options: PurposedVerifyOptions = {},
) {
try {
return jwt.verify(token, this.jwtSecret, {
...options,
audience: TOKEN_PURPOSES[purpose],
}) as T;
} catch (error) {
return this.verifyUnbound<T>(purpose, token, options, error);
}
}
/** Verifies an OAuth access token against the resource(s) it may be used for. */
verifyForResource<T = JwtPayload>(
token: string,
audience: string | [string, ...string[]],
options: PurposedVerifyOptions = {},
) {
return jwt.verify(token, this.jwtSecret, { ...options, audience }) as T;
}
/**
* Accepts a token minted before its purpose carried an `aud` claim, so that
* invite links sent before an upgrade keep working.
*
* This only admits a token that carries no audience at all and carries the
* claims its purpose requires. A token bound to a different audience still
* fails, so the allowance cannot be used to present one kind of bound token
* as another.
*
* DEPRECATED: see `LEGACY_UNBOUND_PURPOSES`.
*/
private verifyUnbound<T>(
purpose: TokenPurpose,
token: string,
options: PurposedVerifyOptions,
verifyError: unknown,
): T {
const hasRequiredClaims = LEGACY_UNBOUND_PURPOSES[purpose];
if (!hasRequiredClaims) throw verifyError;
const claims = jwt.decode(token);
if (!isRecord(claims) || 'aud' in claims || !hasRequiredClaims(claims)) throw verifyError;
const payload = jwt.verify(token, this.jwtSecret, options) as T;
this.logger.warn('Accepted a token minted before its purpose carried an audience', {
purpose,
});
return payload;
}
}

View File

@ -338,13 +338,14 @@ export class PublicApiKeyService {
const nowInSeconds = Math.floor(Date.now() / 1000);
return this.jwtService.sign(
{ sub: user.id, iss: API_KEY_ISSUER, aud: API_KEY_AUDIENCE, jti: randomUUID() },
'publicApiKey',
{ sub: user.id, iss: API_KEY_ISSUER, jti: randomUUID() },
{ ...(expiresAt && { expiresIn: expiresAt - nowInSeconds }) },
);
}
getApiKeyExpiration = (apiKey: string) => {
const decoded = this.jwtService.decode(apiKey);
const decoded = this.jwtService.decodeUnverified(apiKey);
return decoded?.exp ?? null;
};

View File

@ -0,0 +1,78 @@
import type { ApiKeyAudience } from '@n8n/api-types';
/**
* Every JWT that n8n signs with its own secret, keyed by what the token is for.
* The value becomes the token's `aud` claim, and `JwtService` derives it from
* the purpose on both sign and verify — so a token minted for one purpose
* cannot be presented for another.
*
* Add a purpose here to mint a new kind of token.
*
* The values that predate this registry are kept verbatim: they appear in
* tokens that are already issued, and `public-api` / `mcp-server-api` are also
* persisted in the `api_key.audience` column.
*/
export const TOKEN_PURPOSES = {
session: 'n8n:session',
invite: 'n8n:invite',
passwordReset: 'n8n-password-reset',
emailChange: 'n8n-email-change',
oidcState: 'n8n:oidc-state',
oidcNonce: 'n8n:oidc-nonce',
oauthSession: 'n8n:oauth-session',
tokenExchange: 'n8n:token-exchange',
teamsArmTemplate: 'n8n:teams-arm-template',
publicApiKey: 'public-api',
mcpApiKey: 'mcp-server-api',
} as const satisfies Record<string, string> & {
// Keep these two in step with the persisted `api_key.audience` values.
publicApiKey: ApiKeyAudience;
mcpApiKey: ApiKeyAudience;
};
export type TokenPurpose = keyof typeof TOKEN_PURPOSES;
/**
* Known overlap: `mcp-server-api` is also the MCP protected resource's
* pre-RFC-8707 audience (`LEGACY_MCP_AUDIENCE` in `mcp-protected-resource.ts`),
* so the audience alone does not tell an MCP API key from an OAuth access token
* minted for that resource. What separates them is the signed `meta.isOAuth`
* claim the MCP middleware routes on, and the row each kind must have — in
* `api_key` for one, `oauth_access_token` for the other.
*
* Giving API keys an audience of their own would invalidate every key in the
* field: they are persisted, handed to clients, and minted without an expiry,
* and the allowance below admits an absent audience, never a different one.
* Close this together with the legacy audience itself, on the v3 line — see the
* TODO on `verifyJwtWithAllowedAudiences` in `oauth-token.service.ts`.
*/
/** Resolves the audience an API key is stored under to the purpose it was minted for. */
export const API_KEY_PURPOSES = {
'public-api': 'publicApiKey',
'mcp-server-api': 'mcpApiKey',
} as const satisfies Record<ApiKeyAudience, TokenPurpose>;
const isNonEmptyString = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0;
/**
* Purposes whose tokens used to be minted without an `aud` claim, each with
* the claims an audience-less token must carry to be accepted for it.
*
* Only invites are listed. A pending invite link lasts three months, and a
* lost one needs an admin to send it again. Every other purpose is left out:
* a user whose session or token exchange is rejected after an upgrade signs in
* or exchanges again on their own.
*
* The claim check keeps any other audience-less token signed with the same
* secret from being presented as an invite.
*
* DEPRECATED: remove this allowance once pre-upgrade invite links have expired.
* Do not add entries — a new purpose has carried an audience from its first token.
*/
export const LEGACY_UNBOUND_PURPOSES: Partial<
Record<TokenPurpose, (claims: Record<string, unknown>) => boolean>
> = {
invite: (claims) => isNonEmptyString(claims.inviterId) && isNonEmptyString(claims.inviteeId),
};

View File

@ -210,6 +210,7 @@ export class UserService {
Object.entries(toInviteUsers).map(async ([email, id]) => {
// Always use JWT-based tamper-proof invite links
const token = this.jwtService.sign(
'invite',
{
inviterId: owner.id,
inviteeId: id,
@ -452,7 +453,10 @@ export class UserService {
token: string,
): Promise<{ inviterId: string; inviteeId: string }> {
try {
const decoded = this.jwtService.verify<{ inviterId: string; inviteeId: string }>(token);
const decoded = this.jwtService.verify<{ inviterId: string; inviteeId: string }>(
'invite',
token,
);
if (!decoded.inviterId || !decoded.inviteeId) {
this.logger.debug('Invalid JWT token payload - missing inviterId or inviteeId');
throw new BadRequestError('Invalid invite URL');

View File

@ -336,6 +336,7 @@ describe('GET /resolve-signup-token', () => {
test('should validate invite token', async () => {
const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE);
const token = Container.get(JwtService).sign(
'invite',
{ inviterId: owner.id, inviteeId: memberShell.id },
{ expiresIn: '90d' },
);
@ -357,6 +358,7 @@ describe('GET /resolve-signup-token', () => {
license.setQuota('quota:users', 0);
const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE);
const token = Container.get(JwtService).sign(
'invite',
{ inviterId: owner.id, inviteeId: memberShell.id },
{ expiresIn: '90d' },
);
@ -369,6 +371,7 @@ describe('GET /resolve-signup-token', () => {
test('should fail with invalid inputs', async () => {
const { id: inviteeId } = await createUser({ role: { slug: 'global:member' } });
const validToken = Container.get(JwtService).sign(
'invite',
{ inviterId: owner.id, inviteeId },
{ expiresIn: '90d' },
);
@ -397,6 +400,7 @@ describe('GET /resolve-signup-token', () => {
test('should send roles for user-invite-email-click event', async () => {
const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE);
const token = Container.get(JwtService).sign(
'invite',
{ inviterId: owner.id, inviteeId: memberShell.id },
{ expiresIn: '90d' },
);

View File

@ -58,7 +58,7 @@ describe('InvitationController', () => {
});
function invitationToken(inviterId: string, inviteeId: string): string {
return Container.get(JwtService).sign({ inviterId, inviteeId }, { expiresIn: '90d' });
return Container.get(JwtService).sign('invite', { inviterId, inviteeId }, { expiresIn: '90d' });
}
describe('POST /invitations/accept', () => {

View File

@ -1390,12 +1390,14 @@ describe('OIDC service', () => {
});
it('should throw an error for an invalid formatted state', () => {
const invalid = Container.get(JwtService).sign({ state: 'invalid_state' });
const invalid = Container.get(JwtService).sign('oidcState', { state: 'invalid_state' });
expect(() => oidcService.verifyState(invalid)).toThrow(BadRequestError);
});
it('should throw an error for an invalid random part of the state', () => {
const invalid = Container.get(JwtService).sign({ state: 'n8n_state:invalid-state' });
const invalid = Container.get(JwtService).sign('oidcState', {
state: 'n8n_state:invalid-state',
});
expect(() => oidcService.verifyState(invalid)).toThrow(BadRequestError);
});
@ -1404,12 +1406,14 @@ describe('OIDC service', () => {
});
it('should throw an error for an invalid formatted nonce', () => {
const invalid = Container.get(JwtService).sign({ nonce: 'invalid_nonce' });
const invalid = Container.get(JwtService).sign('oidcNonce', { nonce: 'invalid_nonce' });
expect(() => oidcService.verifyNonce(invalid)).toThrow(BadRequestError);
});
it('should throw an error for an invalid random part of the nonce', () => {
const invalid = Container.get(JwtService).sign({ nonce: 'n8n_nonce:invalid-nonce' });
const invalid = Container.get(JwtService).sign('oidcNonce', {
nonce: 'n8n_nonce:invalid-nonce',
});
expect(() => oidcService.verifyNonce(invalid)).toThrow(BadRequestError);
});
});

View File

@ -149,7 +149,7 @@ describe('GET /resolve-password-token', () => {
});
test('should fail if user is not found', async () => {
const token = jwtService.sign({ sub: uuid() });
const token = jwtService.sign('passwordReset', { sub: uuid() });
const response = await testServer.authlessAgent
.get('/resolve-password-token')

View File

@ -1709,7 +1709,7 @@ describe('Public API endpoints with API key scopes', () => {
const ownerWorkflow = await createWorkflow({}, owner);
const jwtService = Container.get(JwtService);
const token = jwtService.sign({
const token = jwtService.sign('tokenExchange', {
iss: TOKEN_EXCHANGE_ISSUER,
sub: member.id,
iat: Math.floor(Date.now() / 1000),

View File

@ -158,7 +158,7 @@ describe('POST /auth/oauth/token', () => {
);
// Issued token is valid and has correct claims
const decoded = jwtService.verify<IssuedJwtPayload>(body.access_token);
const decoded = jwtService.verify<IssuedJwtPayload>('tokenExchange', body.access_token);
expect(decoded.iss).toBe(TOKEN_EXCHANGE_ISSUER);
expect(decoded.sub).toEqual(expect.any(String));
expect(decoded.act).toBeUndefined();
@ -211,9 +211,11 @@ describe('POST /auth/oauth/token', () => {
}).expect(200);
const decodedA = jwtService.verify<IssuedJwtPayload>(
'tokenExchange',
(responseA.body as TokenExchangeSuccessResponse).access_token,
);
const decodedB = jwtService.verify<IssuedJwtPayload>(
'tokenExchange',
(responseB.body as TokenExchangeSuccessResponse).access_token,
);
@ -256,6 +258,7 @@ describe('POST /auth/oauth/token', () => {
}).expect(200);
const decoded = jwtService.verify<IssuedJwtPayload>(
'tokenExchange',
(response.body as TokenExchangeSuccessResponse).access_token,
);
@ -288,6 +291,7 @@ describe('POST /auth/oauth/token', () => {
}).expect(200);
const decoded = jwtService.verify<IssuedJwtPayload>(
'tokenExchange',
(response.body as TokenExchangeSuccessResponse).access_token,
);
expect(decoded.scope).toBe('workflow:read');