Skip to content

Commit 8810097

Browse files
authored
feat(core): Wire up embed login end-to-end with cookie overrides and audit events (no-changelog) (#28303)
1 parent b353143 commit 8810097

File tree

9 files changed

+414
-32
lines changed

9 files changed

+414
-32
lines changed

packages/cli/src/auth/__tests__/auth.service.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,25 @@ describe('AuthService', () => {
766766
expect(res.cookie).toHaveBeenCalled();
767767
});
768768

769+
it('should preserve embed cookie attributes when refreshing an embed session', async () => {
770+
userRepository.findOne.mockResolvedValue(user);
771+
const embedToken = authService.issueJWT(user, false, browserId, true);
772+
773+
jest.advanceTimersByTime(6 * Time.days.toMilliseconds);
774+
await authService.resolveJwt(embedToken, req, res);
775+
776+
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', expect.any(String), {
777+
httpOnly: true,
778+
maxAge: 604800000,
779+
sameSite: 'none',
780+
secure: true,
781+
});
782+
783+
const refreshedToken = res.cookie.mock.calls[0].at(1);
784+
const decoded = jwt.decode(refreshedToken) as jwt.JwtPayload;
785+
expect(decoded.isEmbed).toBe(true);
786+
});
787+
769788
it('should not refresh the cookie if jwtRefreshTimeoutHours is set to -1', async () => {
770789
globalConfig.userManagement.jwtRefreshTimeoutHours = -1;
771790

packages/cli/src/auth/auth.service.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ interface AuthJwtPayload {
2626
browserId?: string;
2727
/** This indicates if mfa was used during the creation of this token */
2828
usedMfa?: boolean;
29+
/** This indicates if the session originated from an embed login (cross-site cookie required) */
30+
isEmbed?: boolean;
2931
}
3032

3133
interface IssuedJWT extends AuthJwtPayload {
@@ -204,30 +206,38 @@ export class AuthService {
204206
}
205207
}
206208

207-
issueCookie(res: Response, user: User, usedMfa: boolean, browserId?: string) {
209+
issueCookie(
210+
res: Response,
211+
user: User,
212+
usedMfa: boolean,
213+
browserId?: string,
214+
isEmbed?: boolean,
215+
cookieOverrides?: { sameSite?: 'strict' | 'lax' | 'none'; secure?: boolean },
216+
) {
208217
// TODO: move this check to the login endpoint in AuthController
209218
// If the instance has exceeded its user quota, prevent non-owners from logging in
210219
const isWithinUsersLimit = this.license.isWithinUsersLimit();
211220
if (user.role.slug !== GLOBAL_OWNER_ROLE.slug && !isWithinUsersLimit) {
212221
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
213222
}
214223

215-
const token = this.issueJWT(user, usedMfa, browserId);
224+
const token = this.issueJWT(user, usedMfa, browserId, isEmbed);
216225
const { samesite, secure } = this.globalConfig.auth.cookie;
217226
res.cookie(AUTH_COOKIE_NAME, token, {
218227
maxAge: this.jwtExpiration * Time.seconds.toMilliseconds,
219228
httpOnly: true,
220-
sameSite: samesite,
221-
secure,
229+
sameSite: cookieOverrides?.sameSite ?? samesite,
230+
secure: cookieOverrides?.secure ?? secure,
222231
});
223232
}
224233

225-
issueJWT(user: User, usedMfa: boolean = false, browserId?: string) {
234+
issueJWT(user: User, usedMfa: boolean = false, browserId?: string, isEmbed?: boolean) {
226235
const payload: AuthJwtPayload = {
227236
id: user.id,
228237
hash: this.createJWTHash(user),
229238
browserId: browserId && this.hash(browserId),
230239
usedMfa,
240+
...(isEmbed && { isEmbed }),
231241
};
232242
return this.jwtService.sign(payload, {
233243
expiresIn: this.jwtExpiration,
@@ -338,7 +348,17 @@ export class AuthService {
338348

339349
if (jwtPayload.exp * 1000 - Date.now() < this.jwtRefreshTimeout) {
340350
this.logger.debug('JWT about to expire. Will be refreshed');
341-
this.issueCookie(res, user, jwtPayload.usedMfa ?? false, browserId);
351+
const embedCookieOverrides = jwtPayload.isEmbed
352+
? ({ sameSite: 'none' as const, secure: true } as const)
353+
: undefined;
354+
this.issueCookie(
355+
res,
356+
user,
357+
jwtPayload.usedMfa ?? false,
358+
browserId,
359+
jwtPayload.isEmbed,
360+
embedCookieOverrides,
361+
);
342362
}
343363

344364
return [user, { usedMfa: jwtPayload.usedMfa ?? false }];

packages/cli/src/events/maps/relay.event-map.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,7 @@ export type RelayEventMap = {
718718
'embed-login': {
719719
subject: string;
720720
issuer: string;
721+
kid: string;
721722
clientIp: string;
722723
};
723724

packages/cli/src/modules/token-exchange/controllers/__tests__/embed-auth.controller.test.ts

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,69 +4,139 @@ import type { Response } from 'express';
44
import { mock } from 'jest-mock-extended';
55

66
import type { AuthService } from '@/auth/auth.service';
7+
import type { EventService } from '@/events/event.service';
78
import type { AuthlessRequest } from '@/requests';
89
import type { UrlService } from '@/services/url.service';
910

1011
import { EmbedAuthController } from '../embed-auth.controller';
1112
import type { TokenExchangeService } from '../../services/token-exchange.service';
13+
import type { TokenExchangeConfig } from '../../token-exchange.config';
1214

15+
const config = mock<TokenExchangeConfig>({ embedEnabled: true });
1316
const tokenExchangeService = mock<TokenExchangeService>();
1417
const authService = mock<AuthService>();
1518
const urlService = mock<UrlService>();
19+
const eventService = mock<EventService>();
1620

17-
const controller = new EmbedAuthController(tokenExchangeService, authService, urlService);
21+
const controller = new EmbedAuthController(
22+
config,
23+
tokenExchangeService,
24+
authService,
25+
urlService,
26+
eventService,
27+
);
1828

1929
const mockUser = mock<User>({
2030
id: '123',
2131
email: 'user@example.com',
2232
role: GLOBAL_MEMBER_ROLE,
2333
});
2434

35+
const embedLoginResult = {
36+
user: mockUser,
37+
subject: 'ext-sub-1',
38+
issuer: 'https://issuer.example.com',
39+
kid: 'key-id-1',
40+
};
41+
2542
describe('EmbedAuthController', () => {
2643
beforeEach(() => {
2744
jest.clearAllMocks();
45+
config.embedEnabled = true;
2846
urlService.getInstanceBaseUrl.mockReturnValue('http://localhost:5678');
2947
});
3048

31-
describe('GET /auth/embed', () => {
32-
it('should extract token from query, call embedLogin, issue cookie, and redirect', async () => {
33-
const req = mock<AuthlessRequest>({
34-
browserId: 'browser-id-123',
49+
describe('when disabled', () => {
50+
it('should return 501 for GET and POST when embedEnabled is false', async () => {
51+
config.embedEnabled = false;
52+
const req = mock<AuthlessRequest>();
53+
const res = mock<Response>();
54+
res.status.mockReturnThis();
55+
56+
await controller.getLogin(req, res, new EmbedLoginQueryDto({ token: 'any' }));
57+
58+
expect(res.status).toHaveBeenCalledWith(501);
59+
expect(res.json).toHaveBeenCalledWith({
60+
error: 'server_error',
61+
error_description: 'Embed login is not enabled on this instance',
3562
});
63+
expect(tokenExchangeService.embedLogin).not.toHaveBeenCalled();
64+
65+
jest.clearAllMocks();
66+
res.status.mockReturnThis();
67+
68+
await controller.postLogin(req, res, new EmbedLoginBodyDto({ token: 'any' }));
69+
70+
expect(res.status).toHaveBeenCalledWith(501);
71+
expect(tokenExchangeService.embedLogin).not.toHaveBeenCalled();
72+
});
73+
});
74+
75+
describe('GET /auth/embed', () => {
76+
it('should login, issue cookie with embed overrides, emit audit event, and redirect', async () => {
77+
const req = mock<AuthlessRequest>({ browserId: 'browser-id-123', ip: '192.168.1.1' });
3678
const res = mock<Response>();
3779
const query = new EmbedLoginQueryDto({ token: 'subject-token' });
38-
tokenExchangeService.embedLogin.mockResolvedValue(mockUser);
80+
tokenExchangeService.embedLogin.mockResolvedValue(embedLoginResult);
3981

4082
await controller.getLogin(req, res, query);
4183

4284
expect(tokenExchangeService.embedLogin).toHaveBeenCalledWith('subject-token');
43-
expect(authService.issueCookie).toHaveBeenCalledWith(res, mockUser, true, 'browser-id-123');
85+
expect(authService.issueCookie).toHaveBeenCalledWith(
86+
res,
87+
mockUser,
88+
true,
89+
'browser-id-123',
90+
true,
91+
{
92+
sameSite: 'none',
93+
secure: true,
94+
},
95+
);
96+
expect(eventService.emit).toHaveBeenCalledWith('embed-login', {
97+
subject: 'ext-sub-1',
98+
issuer: 'https://issuer.example.com',
99+
kid: 'key-id-1',
100+
clientIp: '192.168.1.1',
101+
});
44102
expect(res.redirect).toHaveBeenCalledWith('http://localhost:5678/');
45103
});
46104
});
47105

48106
describe('POST /auth/embed', () => {
49-
it('should extract token from body, call embedLogin, issue cookie, and redirect', async () => {
50-
const req = mock<AuthlessRequest>({
51-
browserId: 'browser-id-456',
52-
});
107+
it('should login, issue cookie with embed overrides, emit audit event, and redirect', async () => {
108+
const req = mock<AuthlessRequest>({ browserId: 'browser-id-456', ip: '10.0.0.1' });
53109
const res = mock<Response>();
54110
const body = new EmbedLoginBodyDto({ token: 'subject-token' });
55-
tokenExchangeService.embedLogin.mockResolvedValue(mockUser);
111+
tokenExchangeService.embedLogin.mockResolvedValue(embedLoginResult);
56112

57113
await controller.postLogin(req, res, body);
58114

59115
expect(tokenExchangeService.embedLogin).toHaveBeenCalledWith('subject-token');
60-
expect(authService.issueCookie).toHaveBeenCalledWith(res, mockUser, true, 'browser-id-456');
116+
expect(authService.issueCookie).toHaveBeenCalledWith(
117+
res,
118+
mockUser,
119+
true,
120+
'browser-id-456',
121+
true,
122+
{
123+
sameSite: 'none',
124+
secure: true,
125+
},
126+
);
127+
expect(eventService.emit).toHaveBeenCalledWith('embed-login', {
128+
subject: 'ext-sub-1',
129+
issuer: 'https://issuer.example.com',
130+
kid: 'key-id-1',
131+
clientIp: '10.0.0.1',
132+
});
61133
expect(res.redirect).toHaveBeenCalledWith('http://localhost:5678/');
62134
});
63135
});
64136

65137
describe('error propagation', () => {
66-
it('should propagate errors from TokenExchangeService', async () => {
67-
const req = mock<AuthlessRequest>({
68-
browserId: 'browser-id-789',
69-
});
138+
it('should not emit audit event or issue cookie on failure', async () => {
139+
const req = mock<AuthlessRequest>({ browserId: 'browser-id-789' });
70140
const res = mock<Response>();
71141
const query = new EmbedLoginQueryDto({ token: 'bad-token' });
72142
tokenExchangeService.embedLogin.mockRejectedValue(new Error('Token verification failed'));
@@ -75,6 +145,7 @@ describe('EmbedAuthController', () => {
75145
'Token verification failed',
76146
);
77147
expect(authService.issueCookie).not.toHaveBeenCalled();
148+
expect(eventService.emit).not.toHaveBeenCalled();
78149
expect(res.redirect).not.toHaveBeenCalled();
79150
});
80151
});

packages/cli/src/modules/token-exchange/controllers/embed-auth.controller.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,35 @@ import { Body, Get, Post, Query, RestController } from '@n8n/decorators';
44
import type { Response } from 'express';
55

66
import { AuthService } from '@/auth/auth.service';
7+
import { EventService } from '@/events/event.service';
78
import { AuthlessRequest } from '@/requests';
89
import { UrlService } from '@/services/url.service';
910

1011
import { TokenExchangeService } from '../services/token-exchange.service';
12+
import { TokenExchangeConfig } from '../token-exchange.config';
1113

1214
@RestController('/auth/embed')
1315
export class EmbedAuthController {
1416
constructor(
17+
private readonly config: TokenExchangeConfig,
1518
private readonly tokenExchangeService: TokenExchangeService,
1619
private readonly authService: AuthService,
1720
private readonly urlService: UrlService,
21+
private readonly eventService: EventService,
1822
) {}
1923

2024
@Get('/', {
2125
skipAuth: true,
2226
ipRateLimit: { limit: 20, windowMs: 1 * Time.minutes.toMilliseconds },
2327
})
2428
async getLogin(req: AuthlessRequest, res: Response, @Query query: EmbedLoginQueryDto) {
29+
if (!this.config.embedEnabled) {
30+
res.status(501).json({
31+
error: 'server_error',
32+
error_description: 'Embed login is not enabled on this instance',
33+
});
34+
return;
35+
}
2536
return await this.handleLogin(query.token, req, res);
2637
}
2738

@@ -30,17 +41,30 @@ export class EmbedAuthController {
3041
ipRateLimit: { limit: 20, windowMs: 1 * Time.minutes.toMilliseconds },
3142
})
3243
async postLogin(req: AuthlessRequest, res: Response, @Body body: EmbedLoginBodyDto) {
44+
if (!this.config.embedEnabled) {
45+
res.status(501).json({
46+
error: 'server_error',
47+
error_description: 'Embed login is not enabled on this instance',
48+
});
49+
return;
50+
}
3351
return await this.handleLogin(body.token, req, res);
3452
}
3553

3654
private async handleLogin(subjectToken: string, req: AuthlessRequest, res: Response) {
37-
const user = await this.tokenExchangeService.embedLogin(subjectToken);
55+
const { user, subject, issuer, kid } = await this.tokenExchangeService.embedLogin(subjectToken);
3856

39-
this.authService.issueCookie(res, user, true, req.browserId);
40-
// TODO: Override cookie SameSite=None for embed/iframe usage.
41-
// The standard issueCookie uses the global config's sameSite setting.
42-
// For embed, SameSite=None + Secure is required. Integrate into
43-
// AuthService.issueCookie() options in a follow-up PR.
57+
this.authService.issueCookie(res, user, true, req.browserId, true, {
58+
sameSite: 'none',
59+
secure: true,
60+
});
61+
62+
this.eventService.emit('embed-login', {
63+
subject,
64+
issuer,
65+
kid,
66+
clientIp: req.ip ?? 'unknown',
67+
});
4468

4569
res.redirect(this.urlService.getInstanceBaseUrl() + '/');
4670
}

packages/cli/src/modules/token-exchange/services/__tests__/token-exchange.service.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,12 @@ describe('TokenExchangeService', () => {
7878

7979
const result = await service.embedLogin('valid-token');
8080

81-
expect(result).toBe(mockUser);
81+
expect(result).toEqual({
82+
user: mockUser,
83+
subject: 'external-user-1',
84+
issuer: 'https://issuer.example.com',
85+
kid: 'test-kid',
86+
});
8287
expect(trustedKeyStore.getByKidAndIss).toHaveBeenCalledWith(
8388
'test-kid',
8489
'https://issuer.example.com',

packages/cli/src/modules/token-exchange/services/token-exchange.service.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,17 @@ export class TokenExchangeService {
116116
return { claims, resolvedKey };
117117
}
118118

119-
async embedLogin(subjectToken: string): Promise<User> {
119+
async embedLogin(
120+
subjectToken: string,
121+
): Promise<{ user: User; subject: string; issuer: string; kid: string }> {
120122
const { claims, resolvedKey } = await this.verifyToken(subjectToken, {
121123
maxLifetimeSeconds: MAX_TOKEN_LIFETIME_SECONDS,
122124
});
123-
return await this.identityResolutionService.resolve(claims, resolvedKey.allowedRoles, {
125+
const user = await this.identityResolutionService.resolve(claims, resolvedKey.allowedRoles, {
124126
kid: resolvedKey.kid,
125127
issuer: resolvedKey.issuer,
126128
});
129+
return { user, subject: claims.sub, issuer: resolvedKey.issuer, kid: resolvedKey.kid };
127130
}
128131

129132
async exchange(request: TokenExchangeRequest): Promise<IssuedTokenResult> {

packages/cli/src/modules/token-exchange/token-exchange.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ export class TokenExchangeConfig {
66
@Env('N8N_TOKEN_EXCHANGE_ENABLED')
77
enabled: boolean = false;
88

9+
/** Whether the embed login endpoint (GET/POST /auth/embed) is enabled. */
10+
@Env('N8N_EMBED_LOGIN_ENABLED')
11+
embedEnabled: boolean = false;
12+
913
/** Maximum lifetime in seconds for an issued token. */
1014
@Env('N8N_TOKEN_EXCHANGE_MAX_TOKEN_TTL')
1115
maxTokenTtl: number = 900;

0 commit comments

Comments
 (0)