mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
c4d31db8d6
commit
37ed348665
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ export class UsersController {
|
|||
}
|
||||
|
||||
const token = this.jwtService.sign(
|
||||
'invite',
|
||||
{
|
||||
inviterId,
|
||||
inviteeId,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ export class TokenExchangeService {
|
|||
jti: randomUUID(),
|
||||
};
|
||||
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
const accessToken = this.jwtService.sign('tokenExchange', payload);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
78
packages/cli/src/services/token-purposes.ts
Normal file
78
packages/cli/src/services/token-purposes.ts
Normal 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),
|
||||
};
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user