mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
feat(core): Wire up embed login end-to-end with cookie overrides and audit events (no-changelog) (#28303)
This commit is contained in:
parent
b353143543
commit
8810097604
9 changed files with 414 additions and 32 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }];
|
||||
|
|
|
|||
|
|
@ -718,6 +718,7 @@ export type RelayEventMap = {
|
|||
'embed-login': {
|
||||
subject: string;
|
||||
issuer: string;
|
||||
kid: string;
|
||||
clientIp: string;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() + '/');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue