feat(core): Wire up embed login end-to-end with cookie overrides and audit events (no-changelog) (#28303)

This commit is contained in:
Andreas Fitzek 2026-04-10 14:52:46 +02:00 committed by GitHub
parent b353143543
commit 8810097604
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 414 additions and 32 deletions

View file

@ -766,6 +766,25 @@ describe('AuthService', () => {
expect(res.cookie).toHaveBeenCalled();
});
it('should preserve embed cookie attributes when refreshing an embed session', async () => {
userRepository.findOne.mockResolvedValue(user);
const embedToken = authService.issueJWT(user, false, browserId, true);
jest.advanceTimersByTime(6 * Time.days.toMilliseconds);
await authService.resolveJwt(embedToken, req, res);
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', expect.any(String), {
httpOnly: true,
maxAge: 604800000,
sameSite: 'none',
secure: true,
});
const refreshedToken = res.cookie.mock.calls[0].at(1);
const decoded = jwt.decode(refreshedToken) as jwt.JwtPayload;
expect(decoded.isEmbed).toBe(true);
});
it('should not refresh the cookie if jwtRefreshTimeoutHours is set to -1', async () => {
globalConfig.userManagement.jwtRefreshTimeoutHours = -1;

View file

@ -26,6 +26,8 @@ interface AuthJwtPayload {
browserId?: string;
/** This indicates if mfa was used during the creation of this token */
usedMfa?: boolean;
/** This indicates if the session originated from an embed login (cross-site cookie required) */
isEmbed?: boolean;
}
interface IssuedJWT extends AuthJwtPayload {
@ -204,7 +206,14 @@ export class AuthService {
}
}
issueCookie(res: Response, user: User, usedMfa: boolean, browserId?: string) {
issueCookie(
res: Response,
user: User,
usedMfa: boolean,
browserId?: string,
isEmbed?: boolean,
cookieOverrides?: { sameSite?: 'strict' | 'lax' | 'none'; secure?: boolean },
) {
// TODO: move this check to the login endpoint in AuthController
// If the instance has exceeded its user quota, prevent non-owners from logging in
const isWithinUsersLimit = this.license.isWithinUsersLimit();
@ -212,22 +221,23 @@ export class AuthService {
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
const token = this.issueJWT(user, usedMfa, browserId);
const token = this.issueJWT(user, usedMfa, browserId, isEmbed);
const { samesite, secure } = this.globalConfig.auth.cookie;
res.cookie(AUTH_COOKIE_NAME, token, {
maxAge: this.jwtExpiration * Time.seconds.toMilliseconds,
httpOnly: true,
sameSite: samesite,
secure,
sameSite: cookieOverrides?.sameSite ?? samesite,
secure: cookieOverrides?.secure ?? secure,
});
}
issueJWT(user: User, usedMfa: boolean = false, browserId?: string) {
issueJWT(user: User, usedMfa: boolean = false, browserId?: string, isEmbed?: boolean) {
const payload: AuthJwtPayload = {
id: user.id,
hash: this.createJWTHash(user),
browserId: browserId && this.hash(browserId),
usedMfa,
...(isEmbed && { isEmbed }),
};
return this.jwtService.sign(payload, {
expiresIn: this.jwtExpiration,
@ -338,7 +348,17 @@ export class AuthService {
if (jwtPayload.exp * 1000 - Date.now() < this.jwtRefreshTimeout) {
this.logger.debug('JWT about to expire. Will be refreshed');
this.issueCookie(res, user, jwtPayload.usedMfa ?? false, browserId);
const embedCookieOverrides = jwtPayload.isEmbed
? ({ sameSite: 'none' as const, secure: true } as const)
: undefined;
this.issueCookie(
res,
user,
jwtPayload.usedMfa ?? false,
browserId,
jwtPayload.isEmbed,
embedCookieOverrides,
);
}
return [user, { usedMfa: jwtPayload.usedMfa ?? false }];

View file

@ -718,6 +718,7 @@ export type RelayEventMap = {
'embed-login': {
subject: string;
issuer: string;
kid: string;
clientIp: string;
};

View file

@ -4,17 +4,27 @@ import type { Response } from 'express';
import { mock } from 'jest-mock-extended';
import type { AuthService } from '@/auth/auth.service';
import type { EventService } from '@/events/event.service';
import type { AuthlessRequest } from '@/requests';
import type { UrlService } from '@/services/url.service';
import { EmbedAuthController } from '../embed-auth.controller';
import type { TokenExchangeService } from '../../services/token-exchange.service';
import type { TokenExchangeConfig } from '../../token-exchange.config';
const config = mock<TokenExchangeConfig>({ embedEnabled: true });
const tokenExchangeService = mock<TokenExchangeService>();
const authService = mock<AuthService>();
const urlService = mock<UrlService>();
const eventService = mock<EventService>();
const controller = new EmbedAuthController(tokenExchangeService, authService, urlService);
const controller = new EmbedAuthController(
config,
tokenExchangeService,
authService,
urlService,
eventService,
);
const mockUser = mock<User>({
id: '123',
@ -22,51 +32,111 @@ const mockUser = mock<User>({
role: GLOBAL_MEMBER_ROLE,
});
const embedLoginResult = {
user: mockUser,
subject: 'ext-sub-1',
issuer: 'https://issuer.example.com',
kid: 'key-id-1',
};
describe('EmbedAuthController', () => {
beforeEach(() => {
jest.clearAllMocks();
config.embedEnabled = true;
urlService.getInstanceBaseUrl.mockReturnValue('http://localhost:5678');
});
describe('GET /auth/embed', () => {
it('should extract token from query, call embedLogin, issue cookie, and redirect', async () => {
const req = mock<AuthlessRequest>({
browserId: 'browser-id-123',
describe('when disabled', () => {
it('should return 501 for GET and POST when embedEnabled is false', async () => {
config.embedEnabled = false;
const req = mock<AuthlessRequest>();
const res = mock<Response>();
res.status.mockReturnThis();
await controller.getLogin(req, res, new EmbedLoginQueryDto({ token: 'any' }));
expect(res.status).toHaveBeenCalledWith(501);
expect(res.json).toHaveBeenCalledWith({
error: 'server_error',
error_description: 'Embed login is not enabled on this instance',
});
expect(tokenExchangeService.embedLogin).not.toHaveBeenCalled();
jest.clearAllMocks();
res.status.mockReturnThis();
await controller.postLogin(req, res, new EmbedLoginBodyDto({ token: 'any' }));
expect(res.status).toHaveBeenCalledWith(501);
expect(tokenExchangeService.embedLogin).not.toHaveBeenCalled();
});
});
describe('GET /auth/embed', () => {
it('should login, issue cookie with embed overrides, emit audit event, and redirect', async () => {
const req = mock<AuthlessRequest>({ browserId: 'browser-id-123', ip: '192.168.1.1' });
const res = mock<Response>();
const query = new EmbedLoginQueryDto({ token: 'subject-token' });
tokenExchangeService.embedLogin.mockResolvedValue(mockUser);
tokenExchangeService.embedLogin.mockResolvedValue(embedLoginResult);
await controller.getLogin(req, res, query);
expect(tokenExchangeService.embedLogin).toHaveBeenCalledWith('subject-token');
expect(authService.issueCookie).toHaveBeenCalledWith(res, mockUser, true, 'browser-id-123');
expect(authService.issueCookie).toHaveBeenCalledWith(
res,
mockUser,
true,
'browser-id-123',
true,
{
sameSite: 'none',
secure: true,
},
);
expect(eventService.emit).toHaveBeenCalledWith('embed-login', {
subject: 'ext-sub-1',
issuer: 'https://issuer.example.com',
kid: 'key-id-1',
clientIp: '192.168.1.1',
});
expect(res.redirect).toHaveBeenCalledWith('http://localhost:5678/');
});
});
describe('POST /auth/embed', () => {
it('should extract token from body, call embedLogin, issue cookie, and redirect', async () => {
const req = mock<AuthlessRequest>({
browserId: 'browser-id-456',
});
it('should login, issue cookie with embed overrides, emit audit event, and redirect', async () => {
const req = mock<AuthlessRequest>({ browserId: 'browser-id-456', ip: '10.0.0.1' });
const res = mock<Response>();
const body = new EmbedLoginBodyDto({ token: 'subject-token' });
tokenExchangeService.embedLogin.mockResolvedValue(mockUser);
tokenExchangeService.embedLogin.mockResolvedValue(embedLoginResult);
await controller.postLogin(req, res, body);
expect(tokenExchangeService.embedLogin).toHaveBeenCalledWith('subject-token');
expect(authService.issueCookie).toHaveBeenCalledWith(res, mockUser, true, 'browser-id-456');
expect(authService.issueCookie).toHaveBeenCalledWith(
res,
mockUser,
true,
'browser-id-456',
true,
{
sameSite: 'none',
secure: true,
},
);
expect(eventService.emit).toHaveBeenCalledWith('embed-login', {
subject: 'ext-sub-1',
issuer: 'https://issuer.example.com',
kid: 'key-id-1',
clientIp: '10.0.0.1',
});
expect(res.redirect).toHaveBeenCalledWith('http://localhost:5678/');
});
});
describe('error propagation', () => {
it('should propagate errors from TokenExchangeService', async () => {
const req = mock<AuthlessRequest>({
browserId: 'browser-id-789',
});
it('should not emit audit event or issue cookie on failure', async () => {
const req = mock<AuthlessRequest>({ browserId: 'browser-id-789' });
const res = mock<Response>();
const query = new EmbedLoginQueryDto({ token: 'bad-token' });
tokenExchangeService.embedLogin.mockRejectedValue(new Error('Token verification failed'));
@ -75,6 +145,7 @@ describe('EmbedAuthController', () => {
'Token verification failed',
);
expect(authService.issueCookie).not.toHaveBeenCalled();
expect(eventService.emit).not.toHaveBeenCalled();
expect(res.redirect).not.toHaveBeenCalled();
});
});

View file

@ -4,17 +4,21 @@ import { Body, Get, Post, Query, RestController } from '@n8n/decorators';
import type { Response } from 'express';
import { AuthService } from '@/auth/auth.service';
import { EventService } from '@/events/event.service';
import { AuthlessRequest } from '@/requests';
import { UrlService } from '@/services/url.service';
import { TokenExchangeService } from '../services/token-exchange.service';
import { TokenExchangeConfig } from '../token-exchange.config';
@RestController('/auth/embed')
export class EmbedAuthController {
constructor(
private readonly config: TokenExchangeConfig,
private readonly tokenExchangeService: TokenExchangeService,
private readonly authService: AuthService,
private readonly urlService: UrlService,
private readonly eventService: EventService,
) {}
@Get('/', {
@ -22,6 +26,13 @@ export class EmbedAuthController {
ipRateLimit: { limit: 20, windowMs: 1 * Time.minutes.toMilliseconds },
})
async getLogin(req: AuthlessRequest, res: Response, @Query query: EmbedLoginQueryDto) {
if (!this.config.embedEnabled) {
res.status(501).json({
error: 'server_error',
error_description: 'Embed login is not enabled on this instance',
});
return;
}
return await this.handleLogin(query.token, req, res);
}
@ -30,17 +41,30 @@ export class EmbedAuthController {
ipRateLimit: { limit: 20, windowMs: 1 * Time.minutes.toMilliseconds },
})
async postLogin(req: AuthlessRequest, res: Response, @Body body: EmbedLoginBodyDto) {
if (!this.config.embedEnabled) {
res.status(501).json({
error: 'server_error',
error_description: 'Embed login is not enabled on this instance',
});
return;
}
return await this.handleLogin(body.token, req, res);
}
private async handleLogin(subjectToken: string, req: AuthlessRequest, res: Response) {
const user = await this.tokenExchangeService.embedLogin(subjectToken);
const { user, subject, issuer, kid } = await this.tokenExchangeService.embedLogin(subjectToken);
this.authService.issueCookie(res, user, true, req.browserId);
// TODO: Override cookie SameSite=None for embed/iframe usage.
// The standard issueCookie uses the global config's sameSite setting.
// For embed, SameSite=None + Secure is required. Integrate into
// AuthService.issueCookie() options in a follow-up PR.
this.authService.issueCookie(res, user, true, req.browserId, true, {
sameSite: 'none',
secure: true,
});
this.eventService.emit('embed-login', {
subject,
issuer,
kid,
clientIp: req.ip ?? 'unknown',
});
res.redirect(this.urlService.getInstanceBaseUrl() + '/');
}

View file

@ -78,7 +78,12 @@ describe('TokenExchangeService', () => {
const result = await service.embedLogin('valid-token');
expect(result).toBe(mockUser);
expect(result).toEqual({
user: mockUser,
subject: 'external-user-1',
issuer: 'https://issuer.example.com',
kid: 'test-kid',
});
expect(trustedKeyStore.getByKidAndIss).toHaveBeenCalledWith(
'test-kid',
'https://issuer.example.com',

View file

@ -116,14 +116,17 @@ export class TokenExchangeService {
return { claims, resolvedKey };
}
async embedLogin(subjectToken: string): Promise<User> {
async embedLogin(
subjectToken: string,
): Promise<{ user: User; subject: string; issuer: string; kid: string }> {
const { claims, resolvedKey } = await this.verifyToken(subjectToken, {
maxLifetimeSeconds: MAX_TOKEN_LIFETIME_SECONDS,
});
return await this.identityResolutionService.resolve(claims, resolvedKey.allowedRoles, {
const user = await this.identityResolutionService.resolve(claims, resolvedKey.allowedRoles, {
kid: resolvedKey.kid,
issuer: resolvedKey.issuer,
});
return { user, subject: claims.sub, issuer: resolvedKey.issuer, kid: resolvedKey.kid };
}
async exchange(request: TokenExchangeRequest): Promise<IssuedTokenResult> {

View file

@ -6,6 +6,10 @@ export class TokenExchangeConfig {
@Env('N8N_TOKEN_EXCHANGE_ENABLED')
enabled: boolean = false;
/** Whether the embed login endpoint (GET/POST /auth/embed) is enabled. */
@Env('N8N_EMBED_LOGIN_ENABLED')
embedEnabled: boolean = false;
/** Maximum lifetime in seconds for an issued token. */
@Env('N8N_TOKEN_EXCHANGE_MAX_TOKEN_TTL')
maxTokenTtl: number = 900;

View file

@ -0,0 +1,235 @@
process.env.N8N_ENV_FEAT_TOKEN_EXCHANGE = 'true';
import { generateKeyPairSync, randomUUID } from 'node:crypto';
import { testDb } from '@n8n/backend-test-utils';
import { AuthIdentity, AuthIdentityRepository, UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import jwt from 'jsonwebtoken';
import { InstanceSettings } from 'n8n-core';
import { EventService } from '@/events/event.service';
import { TokenExchangeConfig } from '@/modules/token-exchange/token-exchange.config';
import { createOwner, createUser } from '../shared/db/users';
import * as utils from '../shared/utils';
// --- RSA key pair for signing test JWTs ---
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const TEST_KID = 'embed-test-kid';
const TEST_ISSUER = 'https://embed-test-issuer.example.com';
const TEST_AUDIENCE = 'n8n';
const trustedKeysJson = JSON.stringify([
{
type: 'static',
kid: TEST_KID,
algorithms: ['RS256'],
key: publicKey,
issuer: TEST_ISSUER,
expectedAudience: TEST_AUDIENCE,
allowedRoles: ['global:member', 'global:admin'],
},
]);
// --- Seed config BEFORE setupTestServer so module init() picks it up ---
const config = Container.get(TokenExchangeConfig);
config.trustedKeys = trustedKeysJson;
config.embedEnabled = true;
Container.get(InstanceSettings).markAsLeader();
const testServer = utils.setupTestServer({
endpointGroups: ['auth'],
enabledFeatures: ['feat:tokenExchange'],
modules: ['token-exchange'],
});
function signEmbedToken(overrides: Record<string, unknown> = {}): string {
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: `ext-${randomUUID()}`,
iss: TEST_ISSUER,
aud: TEST_AUDIENCE,
iat: now,
exp: now + 30,
jti: randomUUID(),
email: `embed-${randomUUID()}@test.example.com`,
given_name: 'Test',
family_name: 'User',
...overrides,
};
return jwt.sign(payload, privateKey, {
algorithm: 'RS256',
header: { alg: 'RS256', kid: TEST_KID, typ: 'JWT' },
});
}
let eventService: EventService;
beforeAll(() => {
eventService = Container.get(EventService);
});
beforeEach(async () => {
await testDb.truncate(['AuthIdentity', 'ProjectRelation', 'Project', 'User']);
// Every test needs an owner for the license check in issueCookie
await createOwner();
jest.restoreAllMocks();
});
describe('Embed Auth API (integration)', () => {
it('GET /auth/embed — valid token sets cookie, emits audit event, and redirects', async () => {
const sub = `ext-${randomUUID()}`;
const token = signEmbedToken({ sub, email: 'get-test@test.example.com' });
const emitSpy = jest.spyOn(eventService, 'emit');
const res = await testServer.authlessAgent
.get(`/auth/embed?token=${encodeURIComponent(token)}`)
.redirects(0)
.expect(302);
// Cookie assertions
const cookies = res.headers['set-cookie'];
expect(cookies).toBeDefined();
const cookieStr = Array.isArray(cookies) ? cookies.join('; ') : cookies;
expect(cookieStr).toContain('HttpOnly');
expect(cookieStr).toMatch(/SameSite=None/i);
expect(cookieStr).toContain('Secure');
// Audit event
expect(emitSpy).toHaveBeenCalledWith(
'embed-login',
expect.objectContaining({ subject: sub, issuer: TEST_ISSUER }),
);
});
it('POST /auth/embed — valid token sets cookie and redirects', async () => {
const token = signEmbedToken({ email: 'post-test@test.example.com' });
const res = await testServer.authlessAgent
.post('/auth/embed')
.send({ token })
.redirects(0)
.expect(302);
const cookies = res.headers['set-cookie'];
expect(cookies).toBeDefined();
});
it('rejects expired, replayed, bad-signature, and long-lived tokens', async () => {
const now = Math.floor(Date.now() / 1000);
// 1. Expired token
const expired = signEmbedToken({ iat: now - 120, exp: now - 60 });
await testServer.authlessAgent
.get(`/auth/embed?token=${encodeURIComponent(expired)}`)
.expect(401);
// 2. Replayed token (same JTI used twice)
const jti = randomUUID();
const first = signEmbedToken({ jti, email: 'replay@test.example.com' });
await testServer.authlessAgent
.get(`/auth/embed?token=${encodeURIComponent(first)}`)
.redirects(0)
.expect(302);
const replayed = signEmbedToken({ jti, email: 'replay@test.example.com' });
await testServer.authlessAgent
.get(`/auth/embed?token=${encodeURIComponent(replayed)}`)
.expect(401);
// 3. Invalid signature (signed with a different key)
const { privateKey: otherKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
const badSig = jwt.sign(
{
sub: 'x',
iss: TEST_ISSUER,
aud: TEST_AUDIENCE,
iat: now,
exp: now + 30,
jti: randomUUID(),
},
otherKey,
{ algorithm: 'RS256', header: { alg: 'RS256', kid: TEST_KID, typ: 'JWT' } },
);
await testServer.authlessAgent
.get(`/auth/embed?token=${encodeURIComponent(badSig)}`)
.expect(401);
// 4. Token lifetime exceeds 60s
const longLived = signEmbedToken({ iat: now, exp: now + 120 });
await testServer.authlessAgent
.get(`/auth/embed?token=${encodeURIComponent(longLived)}`)
.expect(401);
});
it('JIT provisions a new user on first login', async () => {
const sub = `ext-jit-${randomUUID()}`;
const email = `jit-${randomUUID()}@test.example.com`;
const token = signEmbedToken({ sub, email, given_name: 'Jay', family_name: 'Tee' });
await testServer.authlessAgent
.get(`/auth/embed?token=${encodeURIComponent(token)}`)
.redirects(0)
.expect(302);
// Verify user was created
const userRepo = Container.get(UserRepository);
const user = await userRepo.findOneBy({ email });
expect(user).toBeDefined();
expect(user!.firstName).toBe('Jay');
expect(user!.lastName).toBe('Tee');
// Verify auth identity linked
const identityRepo = Container.get(AuthIdentityRepository);
const identity = await identityRepo.findOneBy({
providerId: sub,
providerType: 'token-exchange',
});
expect(identity).toBeDefined();
expect(identity!.userId).toBe(user!.id);
});
it('syncs profile for returning user', async () => {
const sub = `ext-sync-${randomUUID()}`;
const user = await createUser({
email: `sync-${randomUUID()}@test.example.com`,
firstName: 'Old',
lastName: 'Name',
});
// Link auth identity
const identityRepo = Container.get(AuthIdentityRepository);
await identityRepo.save(AuthIdentity.create(user, sub, 'token-exchange'));
const token = signEmbedToken({
sub,
email: user.email,
given_name: 'New',
family_name: 'Last',
});
await testServer.authlessAgent
.get(`/auth/embed?token=${encodeURIComponent(token)}`)
.redirects(0)
.expect(302);
// Verify profile updated
const userRepo = Container.get(UserRepository);
const updated = await userRepo.findOneBy({ id: user.id });
expect(updated!.firstName).toBe('New');
expect(updated!.lastName).toBe('Last');
});
});