Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for custom cognito redirect path #87

Merged
merged 3 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ For an explanation of the interactions between CloudFront, Cognito and Lambda@Ed
* `disableCookieDomain` *boolean* (Optional) Sets domain attribute in cookies, defaults to false (eg: `false`)
* `httpOnly` *boolean* (Optional) Forbids JavaScript from accessing the cookies, defaults to false (eg: `false`). Note, if this is set to `true`, the cookies will not be accessible to Amplify auth if you are using it client side.
* `sameSite` *Strict | Lax | None* (Optional) Allows you to declare if your cookie should be restricted to a first-party or same-site context (eg: `SameSite=None`).
* `parseAuthPath` *string* (Optional) URI path used as redirect target after successful Cognito authentication (eg: `/oauth2/idpresponse`), defaults to the web domain root. Needs to be a path that is handled by the library. When using this parameter, you should also provide a value for `cookiePath` to ensure your cookies are available for the right paths.
* `cookiePath` *string* (Optional) Sets Path attribute in cookies
* `cookieDomain` *string* (Optional) Sets the domain name used for the token cookies
* `cookieSettingsOverrides` *object* (Optional) Cookie settings overrides for different token cookies -- idToken, accessToken and refreshToken
Expand All @@ -73,7 +74,6 @@ For an explanation of the interactions between CloudFront, Cognito and Lambda@Ed
* `logoutConfiguration` *object* (Optional) Enables logout functionality
* `logoutUri` *string* URI path, which when matched with request, logs user out by revoking tokens and clearing cookies
* `logoutRedirectUri` *string* The URI to which the user is redirected to after logging them out
* `parseAuthPath` *string* (Optional) URI path to use for the parse auth handler, when the library is used in an authentication gateway setup
* `csrfProtection` *object* (Optional) Enables CSRF protection
* `nonceSigningSecret` *string* Secret used for signing nonce cookies
* `logLevel` *string* (Optional) Logging level. Default: `'silent'`. One of `'fatal'`, `'error'`, `'warn'`, `'info'`, `'debug'`, `'trace'` or `'silent'`.
Expand Down
74 changes: 74 additions & 0 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@
method: 'POST',
}));
});

Check warning on line 451 in __tests__/index.test.ts

View workflow job for this annotation

GitHub Actions / ci-static-checks

Unexpected any. Specify a different type
describe('_clearCookies', () => {
it('should verify tokens and clear cookies', async () => {
jest.spyOn(authenticator._jwtVerifier, 'verify');
Expand All @@ -463,7 +463,7 @@
expect(response.headers['set-cookie'].length).toBe(5);
});

it('should clear cookies even if tokens cannot be verified', async () => {

Check warning on line 466 in __tests__/index.test.ts

View workflow job for this annotation

GitHub Actions / ci-static-checks

Unexpected any. Specify a different type
jest.spyOn(authenticator._jwtVerifier, 'verify');
authenticator._jwtVerifier.cacheJwks(jwksData);
authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.reject({}));
Expand All @@ -480,7 +480,7 @@

it('should clear cookies and redirect to logoutRedirectUri', async () => {
jest.spyOn(authenticator._jwtVerifier, 'verify');
authenticator._logoutConfiguration = {

Check warning on line 483 in __tests__/index.test.ts

View workflow job for this annotation

GitHub Actions / ci-static-checks

Unexpected any. Specify a different type
logoutUri: '/logout',
logoutRedirectUri: 'https://foobar.com',
};
Expand All @@ -491,7 +491,7 @@
expect(response).toEqual(expect.objectContaining({ status: '302' }));
expect(response.headers['location']?.[0]?.value).toEqual('https://foobar.com');
});

Check warning on line 494 in __tests__/index.test.ts

View workflow job for this annotation

GitHub Actions / ci-static-checks

Unexpected any. Specify a different type
it('should clear cookies and redirect to redirect_uri query param', async () => {
jest.spyOn(authenticator._jwtVerifier, 'verify');
authenticator._jwtVerifier.cacheJwks(jwksData);
Expand All @@ -501,7 +501,7 @@
const response = await (authenticator as any)._clearCookies(request);
expect(response).toEqual(expect.objectContaining({ status: '302' }));
expect(response.headers['location']?.[0]?.value).toEqual('https://foobar.com');
});

Check warning on line 504 in __tests__/index.test.ts

View workflow job for this annotation

GitHub Actions / ci-static-checks

Unexpected any. Specify a different type

it('should clear cookies and redirect to cf domain', async () => {
jest.spyOn(authenticator._jwtVerifier, 'verify');
Expand Down Expand Up @@ -715,13 +715,38 @@
const request = getCloudfrontRequest();
request.Records[0].cf.request.querystring = 'code=54fe5f4e&state=/lol';
return expect(authenticator.handle(request)).resolves.toEqual({ response: 'toto' })
.then(() => {

Check warning on line 718 in __tests__/index.test.ts

View workflow job for this annotation

GitHub Actions / ci-static-checks

Unexpected any. Specify a different type
expect(authenticator._jwtVerifier.verify).toHaveBeenCalled();
expect(authenticator._fetchTokensFromCode).toHaveBeenCalled();
expect(authenticator._getRedirectResponse).toHaveBeenCalledWith(tokenData, 'd111111abcdef8.cloudfront.net', '/lol');
});
});

test('should fetch and set token if code is present (custom redirect)', () => {
const authenticatorWithCustomRedirect : any = new Authenticator({
region: 'us-east-1',
userPoolId: 'us-east-1_abcdef123',
userPoolAppId: '123456789qwertyuiop987abcd',
userPoolDomain: 'my-cognito-domain.auth.us-east-1.amazoncognito.com',
parseAuthPath: '/custom/login/path',
});
jest.spyOn(authenticatorWithCustomRedirect._jwtVerifier, 'verify');
jest.spyOn(authenticatorWithCustomRedirect, '_fetchTokensFromCode');
jest.spyOn(authenticatorWithCustomRedirect, '_getRedirectResponse');
authenticatorWithCustomRedirect._jwtVerifier.verify.mockImplementationOnce(async () => { throw new Error(); });
authenticatorWithCustomRedirect._fetchTokensFromCode.mockResolvedValueOnce(tokenData);
authenticatorWithCustomRedirect._getRedirectResponse.mockReturnValueOnce({ response: 'toto' });
const request = getCloudfrontRequest();
request.Records[0].cf.request.querystring = 'code=54fe5f4e&state=/lol';
return expect(authenticatorWithCustomRedirect.handle(request)).resolves.toEqual({ response: 'toto' })
.then(() => {
expect(authenticatorWithCustomRedirect._jwtVerifier.verify).toHaveBeenCalled();
expect(authenticatorWithCustomRedirect._fetchTokensFromCode).toHaveBeenCalledWith('https://d111111abcdef8.cloudfront.net/custom/login/path', '54fe5f4e');
expect(authenticatorWithCustomRedirect._getRedirectResponse).toHaveBeenCalledWith(tokenData, 'd111111abcdef8.cloudfront.net', '/lol');
});
});


test('should fetch and set token if code is present and when csrfProtection is enabled', () => {
authenticator._jwtVerifier.verify.mockImplementationOnce(async () => { throw new Error(); });
authenticator._fetchTokensFromCode.mockResolvedValueOnce(tokenData);
Expand Down Expand Up @@ -761,13 +786,47 @@
value: 'no-cache',
}],
},
},

Check warning on line 789 in __tests__/index.test.ts

View workflow job for this annotation

GitHub Actions / ci-static-checks

Unexpected any. Specify a different type
)
.then(() => {
expect(authenticator._jwtVerifier.verify).toHaveBeenCalled();
});
});

test('should redirect to auth domain if unauthenticated and no code (custom redirect)', () => {
const authenticatorWithCustomRedirect : any = new Authenticator({
region: 'us-east-1',
userPoolId: 'us-east-1_abcdef123',
userPoolAppId: '123456789qwertyuiop987abcd',
userPoolDomain: 'my-cognito-domain.auth.us-east-1.amazoncognito.com',
parseAuthPath: '/custom/login/path',
});
jest.spyOn(authenticatorWithCustomRedirect._jwtVerifier, 'verify');
authenticator._jwtVerifier.verify.mockImplementationOnce(async () => { throw new Error();});
return expect(authenticatorWithCustomRedirect.handle(getCloudfrontRequest())).resolves.toEqual(
{
status: '302',
headers: {
'location': [{
key: 'Location',
value: 'https://my-cognito-domain.auth.us-east-1.amazoncognito.com/authorize?redirect_uri=https://d111111abcdef8.cloudfront.net/custom/login/path&response_type=code&client_id=123456789qwertyuiop987abcd&state=/lol%3F%3Fparam%3D1',
}],
'cache-control': [{
key: 'Cache-Control',
value: 'no-cache, no-store, max-age=0, must-revalidate',
}],
'pragma': [{
key: 'Pragma',
value: 'no-cache',
}],
},
},
)
.then(() => {
expect(authenticatorWithCustomRedirect._jwtVerifier.verify).toHaveBeenCalled();
});
});

test('should redirect to auth domain and clear csrf cookies if unauthenticated and no code', async () => {
authenticator._jwtVerifier.verify.mockImplementationOnce(async () => { throw new Error(); });
authenticator._csrfProtection = {
Expand Down Expand Up @@ -796,13 +855,28 @@
expect(url.searchParams.get('state')).toBeDefined();

// Cookies
expect(response.headers['set-cookie']).toBeDefined();

Check warning on line 858 in __tests__/index.test.ts

View workflow job for this annotation

GitHub Actions / ci-static-checks

Unexpected any. Specify a different type
const cookies = response.headers['set-cookie'].map(h => h.value);
expect(cookies.find(c => c.match(`.${NONCE_COOKIE_NAME_SUFFIX}=`))).toBeDefined();
expect(cookies.find(c => c.match(`.${NONCE_HMAC_COOKIE_NAME_SUFFIX}=`))).toBeDefined();
expect(cookies.find(c => c.match(`.${PKCE_COOKIE_NAME_SUFFIX}=`))).toBeDefined();
});

test('should redirect to auth domain with custom return redirect if unauthenticated', async () => {
const authenticatorWithCustomRedirect : any = new Authenticator({
region: 'us-east-1',
userPoolId: 'us-east-1_abcdef123',
userPoolAppId: '123456789qwertyuiop987abcd',
userPoolDomain: 'my-cognito-domain.auth.us-east-1.amazoncognito.com',
parseAuthPath: '/custom/login/path',
});
jest.spyOn(authenticatorWithCustomRedirect._jwtVerifier, 'verify');
authenticatorWithCustomRedirect._jwtVerifier.verify.mockImplementationOnce(async () => { throw new Error(); });
const response = await authenticatorWithCustomRedirect.handle(getCloudfrontRequest());
const url = new URL(response.headers['location'][0].value);
expect(url.searchParams.get('redirect_uri')).toEqual('https://d111111abcdef8.cloudfront.net/custom/login/path');
});

test('should revoke tokens and clear cookies if logoutConfiguration is set', () => {
authenticator._logoutConfiguration = { logoutUri: '/logout' };
authenticator._getTokensFromCookie.mockReturnValueOnce({ refreshToken: tokenData.refresh_token });
Expand Down
23 changes: 11 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,25 +526,19 @@ export class Authenticator {
* @return {CloudFrontResultResponse} Redirect response.
*/
_getRedirectToCognitoUserPoolResponse(request: CloudFrontRequest, redirectURI: string): CloudFrontResultResponse {
const cfDomain = request.headers.host[0].value;
let redirectPath = request.uri;
if (request.querystring && request.querystring !== '') {
redirectPath += encodeURIComponent('?' + request.querystring);
}

let oauthRedirectUri = redirectURI;
if (this._parseAuthPath) {
oauthRedirectUri = `https://${cfDomain}/${this._parseAuthPath}`;
}

let csrfTokens: CSRFTokens = {};
let state: string | undefined = redirectPath;
if (this._csrfProtection) {
csrfTokens = generateCSRFTokens(redirectURI, this._csrfProtection.nonceSigningSecret);
state = csrfTokens.state;
}

const userPoolUrl = `https://${this._userPoolDomain}/authorize?redirect_uri=${oauthRedirectUri}&response_type=code&client_id=${this._userPoolAppId}&state=${state}`;
const userPoolUrl = `https://${this._userPoolDomain}/authorize?redirect_uri=${redirectURI}&response_type=code&client_id=${this._userPoolAppId}&state=${state}`;

this._logger.debug(`Redirecting user to Cognito User Pool URL ${userPoolUrl}`);

Expand Down Expand Up @@ -601,9 +595,8 @@ export class Authenticator {
this._logger.debug({ msg: 'Handling Lambda@Edge event', event });

const { request } = event.Records[0].cf;
const requestParams = parse(request.querystring);
const cfDomain = request.headers.host[0].value;
const redirectURI = `https://${cfDomain}`;
const redirectURI = this._parseAuthPath ? `https://${cfDomain}/${this._parseAuthPath}` : `https://${cfDomain}`;

try {
const tokens = this._getTokensFromCookie(request.headers.cookie);
Expand Down Expand Up @@ -631,10 +624,12 @@ export class Authenticator {
}
} catch (err) {
if (this._logoutConfiguration && request.uri.startsWith(this._logoutConfiguration.logoutUri)) {
this._logger.info({ msg: 'Clearing cookies', path: redirectURI });
this._logger.info({ msg: 'Clearing cookies', path: cfDomain });
return this._clearCookies(event);
}
this._logger.debug("User isn't authenticated: %s", err);

const requestParams = parse(request.querystring);
if (requestParams.code) {
return this._fetchTokensFromCode(redirectURI, requestParams.code as string)
.then(tokens => this._getRedirectResponse(tokens, cfDomain, this._getRedirectUriFromState(requestParams.state as string)));
Expand Down Expand Up @@ -678,7 +673,9 @@ export class Authenticator {
};
} catch (err) {
this._logger.debug("User isn't authenticated: %s", err);
return this._getRedirectToCognitoUserPoolResponse(request, redirectURI);
return this._getRedirectToCognitoUserPoolResponse(
request, this._parseAuthPath ? `https://${cfDomain}/${this._parseAuthPath}` : redirectURI,
);
}
}

Expand Down Expand Up @@ -756,7 +753,9 @@ export class Authenticator {
return this._getRedirectResponse(tokens, cfDomain, redirectURI);
} catch (err) {
this._logger.debug("User isn't authenticated: %s", err);
return this._getRedirectToCognitoUserPoolResponse(request, redirectURI);
return this._getRedirectToCognitoUserPoolResponse(
request, this._parseAuthPath ? `https://${cfDomain}/${this._parseAuthPath}` : redirectURI,
);
}
}

Expand Down