diff --git a/packages/cli/src/auth/__tests__/auth.service.test.ts b/packages/cli/src/auth/__tests__/auth.service.test.ts index b85a54ec322..882eb7c249c 100644 --- a/packages/cli/src/auth/__tests__/auth.service.test.ts +++ b/packages/cli/src/auth/__tests__/auth.service.test.ts @@ -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; diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index a93fb774324..7bf7758a775 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -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 }]; diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 5b38db031c4..7ace51e5be6 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -718,6 +718,7 @@ export type RelayEventMap = { 'embed-login': { subject: string; issuer: string; + kid: string; clientIp: string; }; diff --git a/packages/cli/src/modules/token-exchange/controllers/__tests__/embed-auth.controller.test.ts b/packages/cli/src/modules/token-exchange/controllers/__tests__/embed-auth.controller.test.ts index 3d907bd33e8..d6e2d9d69aa 100644 --- a/packages/cli/src/modules/token-exchange/controllers/__tests__/embed-auth.controller.test.ts +++ b/packages/cli/src/modules/token-exchange/controllers/__tests__/embed-auth.controller.test.ts @@ -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({ embedEnabled: true }); const tokenExchangeService = mock(); const authService = mock(); const urlService = mock(); +const eventService = mock(); -const controller = new EmbedAuthController(tokenExchangeService, authService, urlService); +const controller = new EmbedAuthController( + config, + tokenExchangeService, + authService, + urlService, + eventService, +); const mockUser = mock({ id: '123', @@ -22,51 +32,111 @@ const mockUser = mock({ 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({ - 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(); + const res = mock(); + 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({ browserId: 'browser-id-123', ip: '192.168.1.1' }); const res = mock(); 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({ - browserId: 'browser-id-456', - }); + it('should login, issue cookie with embed overrides, emit audit event, and redirect', async () => { + const req = mock({ browserId: 'browser-id-456', ip: '10.0.0.1' }); const res = mock(); 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({ - browserId: 'browser-id-789', - }); + it('should not emit audit event or issue cookie on failure', async () => { + const req = mock({ browserId: 'browser-id-789' }); const res = mock(); 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(); }); }); diff --git a/packages/cli/src/modules/token-exchange/controllers/embed-auth.controller.ts b/packages/cli/src/modules/token-exchange/controllers/embed-auth.controller.ts index 3310cc25a70..46495752bb3 100644 --- a/packages/cli/src/modules/token-exchange/controllers/embed-auth.controller.ts +++ b/packages/cli/src/modules/token-exchange/controllers/embed-auth.controller.ts @@ -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() + '/'); } diff --git a/packages/cli/src/modules/token-exchange/services/__tests__/token-exchange.service.test.ts b/packages/cli/src/modules/token-exchange/services/__tests__/token-exchange.service.test.ts index 202f15f35a5..7365147c46f 100644 --- a/packages/cli/src/modules/token-exchange/services/__tests__/token-exchange.service.test.ts +++ b/packages/cli/src/modules/token-exchange/services/__tests__/token-exchange.service.test.ts @@ -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', diff --git a/packages/cli/src/modules/token-exchange/services/token-exchange.service.ts b/packages/cli/src/modules/token-exchange/services/token-exchange.service.ts index f7a84b29736..3154fca285d 100644 --- a/packages/cli/src/modules/token-exchange/services/token-exchange.service.ts +++ b/packages/cli/src/modules/token-exchange/services/token-exchange.service.ts @@ -116,14 +116,17 @@ export class TokenExchangeService { return { claims, resolvedKey }; } - async embedLogin(subjectToken: string): Promise { + 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 { diff --git a/packages/cli/src/modules/token-exchange/token-exchange.config.ts b/packages/cli/src/modules/token-exchange/token-exchange.config.ts index c03bb1e8e29..72ddf73f048 100644 --- a/packages/cli/src/modules/token-exchange/token-exchange.config.ts +++ b/packages/cli/src/modules/token-exchange/token-exchange.config.ts @@ -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; diff --git a/packages/cli/test/integration/token-exchange/embed-auth.api.test.ts b/packages/cli/test/integration/token-exchange/embed-auth.api.test.ts new file mode 100644 index 00000000000..4316f69e520 --- /dev/null +++ b/packages/cli/test/integration/token-exchange/embed-auth.api.test.ts @@ -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 { + 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'); + }); +});