Skip to content

Commit 7f409ee

Browse files
Login cauthentication flow change (#300)
Co-authored-by: Grzegorz Krajniak <[email protected]>
1 parent 7a4d72c commit 7f409ee

File tree

7 files changed

+154
-89
lines changed

7 files changed

+154
-89
lines changed

integration-tests/auth.integration-spec.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ describe('AuthController', () => {
2424
await app.init();
2525
});
2626

27-
describe('POST /rest/auth', () => {
27+
describe('GET /callback', () => {
2828
it('should fail due to the lack of auth code param in the query params', async () => {
2929
await request(app.getHttpServer())
30-
.post(`/rest/auth?coder=uio`)
30+
.get(`/callback?coder=uio`)
3131
.accept(acceptLanguage)
3232
.expect(400)
3333
.expect((response) => {
@@ -39,9 +39,11 @@ describe('AuthController', () => {
3939

4040
it('should pass with auth code param', async () => {
4141
await request(app.getHttpServer())
42-
.post(`/rest/auth?code=auth_code`)
42+
.get(
43+
`/callback?code=auth_code&state=aHR0cDovL3N1Yi5sb2NhbGhvc3Q6NDMwMC9fbHVpZ2lOb25jZT1QUUNWODdlck5uVkZzNE1rMzUxNw==`,
44+
)
4345
.accept(acceptLanguage)
44-
.expect(201);
46+
.expect(302);
4547
});
4648
});
4749

src/auth/auth.controller.spec.ts

Lines changed: 58 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ describe('AuthController', () => {
2020

2121
beforeEach(async () => {
2222
jest.resetAllMocks();
23+
(responseMock.redirect as any) = jest.fn((href: string) => href);
2324

2425
const module: TestingModule = await Test.createTestingModule({
2526
imports: [PortalModule.create({})],
@@ -32,69 +33,91 @@ describe('AuthController', () => {
3233
.useValue(cookiesServiceMock)
3334
.compile();
3435
controller = module.get<AuthController>(AuthController);
36+
process.env.BASE_DOMAINS_DEFAULT = 'localhost';
3537
});
3638

3739
it('should be defined', () => {
3840
expect(controller).toBeDefined();
3941
});
4042

4143
describe('auth', () => {
42-
it('should get the config for tenant', async () => {
43-
// arrange
44-
const callback = jest.spyOn(authCallbackMock, 'handleSuccess');
45-
const getTokenForCode = jest.spyOn(
46-
authTokenServiceMock,
47-
'exchangeTokenForCode',
48-
);
49-
requestMock.query = { code: 'foo' };
50-
const idToken = 'id_token';
44+
it('redirects to decoded state URL after successful token exchange', async () => {
45+
const decodedUrl = 'http://sub.localhost:4300/';
46+
requestMock.query = {
47+
code: 'foo',
48+
state: encodeURIComponent(btoa(`${decodedUrl}_luigiNonce=SOME_NONCE`)),
49+
} as any;
50+
5151
const authTokenResponse = {
52-
id_token: idToken,
52+
id_token: 'id',
5353
refresh_token: 'ref',
54-
expires_in: '12312',
55-
access_token: 'access',
54+
access_token: 'acc',
55+
expires_in: '111',
5656
} as AuthTokenData;
57-
getTokenForCode.mockResolvedValue(authTokenResponse);
57+
authTokenServiceMock.exchangeTokenForCode.mockResolvedValue(
58+
authTokenResponse,
59+
);
5860

59-
// act
60-
const tokenResponse = await controller.auth(requestMock, responseMock);
61+
const result = await controller.auth(requestMock, responseMock);
6162

62-
// assert
63-
expect(callback).toHaveBeenCalledWith(
63+
expect(authTokenServiceMock.exchangeTokenForCode).toHaveBeenCalledWith(
6464
requestMock,
6565
responseMock,
66-
authTokenResponse,
66+
'foo',
6767
);
68-
expect(getTokenForCode).toHaveBeenCalledWith(
68+
expect(authCallbackMock.handleSuccess).toHaveBeenCalledWith(
6969
requestMock,
7070
responseMock,
71-
'foo',
71+
authTokenResponse,
7272
);
73-
expect((tokenResponse as AuthTokenData).refresh_token).toBeUndefined();
73+
expect(responseMock.redirect).toHaveBeenCalledWith(decodedUrl);
74+
expect(result).toBe(decodedUrl);
7475
});
7576

76-
it('should log the error if there is a problem retrieving the token', async () => {
77-
// arrange
78-
const getTokenForCode = jest.spyOn(
79-
authTokenServiceMock,
80-
'exchangeTokenForCode',
77+
it('redirects to /logout with error when token exchange fails', async () => {
78+
const origin = 'http://sub.localhost:4300';
79+
const stateUrl = `${origin}/path?x=1#frag`;
80+
requestMock.query = {
81+
code: 'foo',
82+
state: encodeURIComponent(btoa(`${stateUrl}_luigiNonce=N`)),
83+
} as any;
84+
85+
authTokenServiceMock.exchangeTokenForCode.mockRejectedValue(
86+
new Error('boom'),
8187
);
82-
requestMock.query = { code: 'foo' };
83-
getTokenForCode.mockRejectedValue(new Error('error'));
8488

85-
// act
86-
const response = await controller.auth(requestMock, responseMock);
89+
const result = await controller.auth(requestMock, responseMock);
8790

88-
// assert
89-
expect(response).toBeUndefined();
90-
expect(getTokenForCode).toHaveBeenCalledWith(
91+
expect(authCallbackMock.handleFailure).toHaveBeenCalledWith(
9192
requestMock,
9293
responseMock,
93-
'foo',
9494
);
95+
expect(responseMock.redirect).toHaveBeenCalledWith(
96+
`${origin}/logout?error=loginError`,
97+
);
98+
expect(result).toBe(`${origin}/logout?error=loginError`);
9599
});
96100

97-
it('should ', () => {});
101+
it('redirects to /logout with error when state URL domain is not allowed', async () => {
102+
const badOrigin = 'http://malicious.example.com';
103+
const stateUrl = `${badOrigin}/`;
104+
requestMock.query = {
105+
code: 'foo',
106+
state: encodeURIComponent(btoa(`${stateUrl}_luigiNonce=N`)),
107+
} as any;
108+
109+
const result = await controller.auth(requestMock, responseMock);
110+
111+
expect(authTokenServiceMock.exchangeTokenForCode).not.toHaveBeenCalled();
112+
expect(authCallbackMock.handleFailure).toHaveBeenCalledWith(
113+
requestMock,
114+
responseMock,
115+
);
116+
expect(responseMock.redirect).toHaveBeenCalledWith(
117+
`${badOrigin}/logout?error=loginError`,
118+
);
119+
expect(result).toBe(`${badOrigin}/logout?error=loginError`);
120+
});
98121
});
99122

100123
describe('refresh', () => {
@@ -149,7 +172,6 @@ describe('AuthController', () => {
149172

150173
it('should remove the auth cookies on auth server error', async () => {
151174
// arrange
152-
const logoutRedirectUrl = 'logoutRedirectUrl';
153175
cookiesServiceMock.getAuthCookie.mockReturnValue('authCookie');
154176
authTokenServiceMock.exchangeTokenForRefreshToken.mockRejectedValue(
155177
new Error('error'),

src/auth/auth.controller.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import {
1313
UseGuards,
1414
} from '@nestjs/common';
1515
import type { Request, Response } from 'express';
16+
import url from 'url';
1617

17-
@Controller('/rest/auth')
18+
@Controller('/')
1819
export class AuthController {
1920
private logger: Logger = new Logger(AuthController.name);
2021

@@ -26,34 +27,57 @@ export class AuthController {
2627
) {}
2728

2829
@UseGuards(RequestCodeParamGuard)
29-
@Post('')
30-
async auth(
31-
@Req() request: Request,
32-
@Res({ passthrough: true }) response: Response,
33-
): Promise<AuthTokenData | void> {
34-
let authTokenData: AuthTokenData = null;
30+
@Get('callback')
31+
async auth(@Req() request: Request, @Res() response: Response): Response {
32+
const { code, state } = request.query;
33+
let postLoginRedirectUrl = this.createAppStateUrl(state);
34+
3535
try {
36-
authTokenData = await this.authTokenService.exchangeTokenForCode(
36+
if (!this.isDomainOrSubdomain(postLoginRedirectUrl)) {
37+
throw new Error('Bad redirection url: ' + postLoginRedirectUrl);
38+
}
39+
40+
const authTokenData = await this.authTokenService.exchangeTokenForCode(
3741
request,
3842
response,
39-
request.query.code.toString(),
43+
code,
4044
);
45+
46+
await this.handleTokenRetrieval(request, response, authTokenData);
4147
} catch (e: any) {
4248
this.logger.error(
4349
`Error while retrieving token from code, logging out: ${e}`,
4450
);
45-
return await this.handleAuthError(request, response);
51+
await this.handleAuthError(request, response);
52+
postLoginRedirectUrl = new URL(`${postLoginRedirectUrl.origin}/logout`);
53+
postLoginRedirectUrl.searchParams.set('error', 'loginError');
4654
}
47-
return await this.handleTokenRetrieval(request, response, authTokenData);
55+
56+
return response.redirect(postLoginRedirectUrl.href);
57+
}
58+
59+
private isDomainOrSubdomain(appStateUrl: url.URL) {
60+
const baseDomain = process.env['BASE_DOMAINS_DEFAULT'];
61+
if (!baseDomain) return false;
62+
63+
const hostname = appStateUrl.hostname;
64+
return hostname === baseDomain || hostname.endsWith(`.${baseDomain}`);
65+
}
66+
67+
private createAppStateUrl(state: string): url.URL {
68+
const decodedState = atob(decodeURIComponent(state)).split('_luigiNonce=');
69+
const appState = decodeURI(decodedState[0] || '');
70+
return new URL(appState);
4871
}
4972

50-
@Get('refresh')
73+
@Post('rest/auth/refresh')
5174
async refresh(
5275
@Req() request: Request,
5376
@Res({ passthrough: true }) response: Response,
5477
): Promise<AuthTokenData | void> {
5578
const refreshToken = this.cookiesService.getAuthCookie(request);
5679
if (!refreshToken) {
80+
this.logger.warn('No refresh token present');
5781
return;
5882
}
5983

src/portal.module.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ describe('PortalModule', () => {
4545

4646
const expectedModule = ServeStaticModule.forRoot({
4747
rootPath: expectedPath,
48-
exclude: ['/rest'],
48+
exclude: ['/rest', '/callback'],
4949
});
5050

5151
const portalModule = PortalModule.create({

src/portal.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ export class PortalModule implements NestModule {
240240
moduleImports.push(
241241
ServeStaticModule.forRoot({
242242
rootPath: options.frontendDistSources,
243-
exclude: ['/rest'],
243+
exclude: ['/rest', '/callback'],
244244
}),
245245
);
246246
}
Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,61 @@
1-
import { RequestCodeParamGuard } from './request-code-param.guard';
1+
import { RequestCodeParamGuard } from './request-code-param.guard.js';
22
import { ExecutionContext, HttpException, HttpStatus } from '@nestjs/common';
3-
import { Test, TestingModule } from '@nestjs/testing';
4-
5-
// Adjust the import path as needed
63

74
describe('RequestCodeParamGuard', () => {
85
let guard: RequestCodeParamGuard;
96

10-
beforeEach(async () => {
11-
const module: TestingModule = await Test.createTestingModule({
12-
providers: [RequestCodeParamGuard],
13-
}).compile();
7+
const createContext = (query: any): ExecutionContext =>
8+
({
9+
switchToHttp: () => ({
10+
getRequest: () => ({ query }),
11+
}),
12+
}) as unknown as ExecutionContext;
1413

15-
guard = module.get<RequestCodeParamGuard>(RequestCodeParamGuard);
14+
beforeEach(() => {
15+
guard = new RequestCodeParamGuard();
1616
});
1717

18-
it('should be defined', () => {
19-
expect(guard).toBeDefined();
18+
it('returns true when code and state exist', () => {
19+
const ctx = createContext({ code: 'abc', state: 'xyz' });
20+
expect(guard.canActivate(ctx)).toBe(true);
2021
});
2122

22-
it('should allow access when code is provided', () => {
23-
const context = {
24-
switchToHttp: () => ({
25-
getRequest: () => ({
26-
query: { code: 'validCode' },
27-
}),
28-
}),
29-
} as ExecutionContext;
30-
31-
expect(guard.canActivate(context)).toBe(true);
23+
it('throws 400 when code is missing', () => {
24+
const ctx = createContext({ state: 'xyz' });
25+
try {
26+
guard.canActivate(ctx);
27+
fail('should throw');
28+
} catch (e) {
29+
expect(e).toBeInstanceOf(HttpException);
30+
expect((e as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST);
31+
expect((e as HttpException).message).toBe(
32+
"No 'code' was provided in the query.",
33+
);
34+
}
3235
});
3336

34-
it('should throw HttpException when code is not provided', () => {
35-
const context = {
36-
switchToHttp: () => ({
37-
getRequest: () => ({
38-
query: {},
39-
}),
40-
}),
41-
} as ExecutionContext;
37+
it('throws 400 when state is missing', () => {
38+
const ctx = createContext({ code: 'abc' });
39+
try {
40+
guard.canActivate(ctx);
41+
fail('should throw');
42+
} catch (e) {
43+
expect(e).toBeInstanceOf(HttpException);
44+
expect((e as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST);
45+
expect((e as HttpException).message).toBe(
46+
"No 'state' was provided in the query.",
47+
);
48+
}
49+
});
4250

51+
it('throws 400 when both are missing', () => {
52+
const ctx = createContext({});
4353
try {
44-
guard.canActivate(context);
45-
} catch (error) {
46-
expect(error).toBeInstanceOf(HttpException);
47-
expect(error.getStatus()).toBe(HttpStatus.BAD_REQUEST);
48-
expect(error.message).toBe("No 'code' was provided in the query.");
54+
guard.canActivate(ctx);
55+
fail('should throw');
56+
} catch (e) {
57+
expect(e).toBeInstanceOf(HttpException);
58+
expect((e as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST);
4959
}
5060
});
5161
});

src/services/guards/request-code-param.guard.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
export class RequestCodeParamGuard implements CanActivate {
1111
canActivate(context: ExecutionContext): boolean {
1212
const request = context.switchToHttp().getRequest();
13-
const code = request.query.code;
13+
const { code, state } = request.query;
1414

1515
if (!code) {
1616
throw new HttpException(
@@ -19,6 +19,13 @@ export class RequestCodeParamGuard implements CanActivate {
1919
);
2020
}
2121

22+
if (!state) {
23+
throw new HttpException(
24+
"No 'state' was provided in the query.",
25+
HttpStatus.BAD_REQUEST,
26+
);
27+
}
28+
2229
return true;
2330
}
2431
}

0 commit comments

Comments
 (0)