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

[SDK-1739] Recover and logout when throwing invalid_grant on Refresh Token #668

Merged
merged 5 commits into from
Dec 8, 2020
Merged
Show file tree
Hide file tree
Changes from 4 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
101 changes: 100 additions & 1 deletion __tests__/Auth0Client/getTokenSilently.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ import {
} from '../constants';

import { releaseLockSpy } from '../../__mocks__/browser-tabs-lock';
import { DEFAULT_AUTH0_CLIENT } from '../../src/constants';
import {
DEFAULT_AUTH0_CLIENT,
INVALID_REFRESH_TOKEN_ERROR_MESSAGE
} from '../../src/constants';
import { GenericError } from '../../src/errors';

jest.mock('unfetch');
jest.mock('es-cookie');
Expand Down Expand Up @@ -1388,5 +1392,100 @@ describe('Auth0Client', () => {
1
);
});

it('when using Refresh Tokens, falls back to iframe when refresh token is expired', async () => {
const auth0 = setup({
useRefreshTokens: true
});

await loginWithRedirect(auth0);

mockFetch.mockReset();
mockFetch.mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => ({
id_token: TEST_ID_TOKEN,
refresh_token: TEST_REFRESH_TOKEN,
access_token: TEST_ACCESS_TOKEN,
expires_in: 86400
})
})
);
// Fail only the first occurring /token request by providing it as mockImplementationOnce.
// The first request will use the mockImplementationOnce implementation,
// while any subsequent will use the mock configured above in mockImplementation.
mockFetch.mockImplementationOnce(() =>
Promise.resolve({
ok: false,
json: () => ({
error: 'invalid_grant',
error_description: INVALID_REFRESH_TOKEN_ERROR_MESSAGE
})
})
);

jest.spyOn(<any>utils, 'runIframe').mockResolvedValue({
code: TEST_CODE,
state: TEST_STATE
});

await auth0.getTokenSilently({ ignoreCache: true });

expect(utils['runIframe']).toHaveBeenCalled();
});

it('when using Refresh Tokens and fallback fails, ensure the user is logged out', async () => {
const auth0 = setup({
useRefreshTokens: true
});

await loginWithRedirect(auth0);

mockFetch.mockReset();
mockFetch.mockImplementationOnce(() =>
Promise.resolve({
ok: false,
json: () => ({
error: 'invalid_grant',
error_description: INVALID_REFRESH_TOKEN_ERROR_MESSAGE
})
})
);

jest.spyOn(auth0, 'logout');
jest.spyOn(utils, 'runIframe').mockRejectedValue(
GenericError.fromPayload({
error: 'login_required',
error_description: 'login_required'
})
);

await expect(
auth0.getTokenSilently({ ignoreCache: true })
).rejects.toThrow('login_required');
expect(auth0.logout).toHaveBeenCalledWith({ localOnly: true });
});

it('when not using Refresh Tokens and login_required is returned, ensure the user is logged out', async () => {
const auth0 = setup();

await loginWithRedirect(auth0);

mockFetch.mockReset();

jest.spyOn(auth0, 'logout');
jest.spyOn(utils, 'runIframe').mockRejectedValue(
GenericError.fromPayload({
error: 'login_required',
error_description: 'login_required'
})
);

await expect(
auth0.getTokenSilently({ ignoreCache: true })
).rejects.toThrow('login_required');
expect(auth0.logout).toHaveBeenCalledWith({ localOnly: true });
});
});
});
88 changes: 54 additions & 34 deletions src/Auth0Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import {
DEFAULT_SCOPE,
RECOVERABLE_ERRORS,
DEFAULT_SESSION_CHECK_EXPIRY_DAYS,
DEFAULT_AUTH0_CLIENT
DEFAULT_AUTH0_CLIENT,
INVALID_REFRESH_TOKEN_ERROR_MESSAGE
} from './constants';

import {
Expand Down Expand Up @@ -830,46 +831,56 @@ export default class Auth0Client {

const timeout =
options.timeoutInSeconds || this.options.authorizeTimeoutInSeconds;
const codeResult = await runIframe(url, this.domainUrl, timeout);

if (stateIn !== codeResult.state) {
throw new Error('Invalid state');
}
try {
const codeResult = await runIframe(url, this.domainUrl, timeout);

const {
scope,
audience,
redirect_uri,
ignoreCache,
timeoutInSeconds,
...customOptions
} = options;
if (stateIn !== codeResult.state) {
throw new Error('Invalid state');
}

const tokenResult = await oauthToken(
{
...this.customOptions,
...customOptions,
const {
scope,
audience,
baseUrl: this.domainUrl,
client_id: this.options.client_id,
code_verifier,
code: codeResult.code,
grant_type: 'authorization_code',
redirect_uri: params.redirect_uri,
auth0Client: this.options.auth0Client
} as OAuthTokenOptions,
this.worker
);
redirect_uri,
ignoreCache,
timeoutInSeconds,
...customOptions
} = options;

const tokenResult = await oauthToken(
{
...this.customOptions,
...customOptions,
scope,
audience,
baseUrl: this.domainUrl,
client_id: this.options.client_id,
code_verifier,
code: codeResult.code,
grant_type: 'authorization_code',
redirect_uri: params.redirect_uri,
auth0Client: this.options.auth0Client
} as OAuthTokenOptions,
this.worker
);

const decodedToken = this._verifyIdToken(tokenResult.id_token, nonceIn);
const decodedToken = this._verifyIdToken(tokenResult.id_token, nonceIn);

return {
...tokenResult,
decodedToken,
scope: params.scope,
audience: params.audience || 'default'
};
return {
...tokenResult,
decodedToken,
scope: params.scope,
audience: params.audience || 'default'
};
} catch (e) {
if (e.error === 'login_required') {
this.logout({
localOnly: true
});
}
throw e;
}
}

private async _getTokenUsingRefreshToken(
Expand Down Expand Up @@ -939,6 +950,15 @@ export default class Auth0Client {
if (e.message === MISSING_REFRESH_TOKEN_ERROR_MESSAGE) {
return await this._getTokenFromIFrame(options);
}
// A refresh token was found, but is it no longer valid.
// Fallback to an iframe.
if (
e.message &&
e.message.indexOf(INVALID_REFRESH_TOKEN_ERROR_MESSAGE) > -1
) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can unify this if block with the conditional above it, rather than having two if's? Feels like we could do it with an or expression.

return await this._getTokenFromIFrame(options);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces an additional request in Safari where we know up front it will return login_required, while for other browsers it might still work.

I do not think the SDK is currently tracking which browsers can use iframe, nor do I think it should so I wonder what you think about this extra request.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine to leave it, we don't do browser detection for this elsewhere and it will work for other browsers that also block third-party cookies.

}

throw e;
}

Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const CACHE_LOCATION_MEMORY = 'memory';
export const CACHE_LOCATION_LOCAL_STORAGE = 'localstorage';
export const MISSING_REFRESH_TOKEN_ERROR_MESSAGE =
'The web worker is missing the refresh token';
export const INVALID_REFRESH_TOKEN_ERROR_MESSAGE = 'invalid refresh token';

/**
* @ignore
Expand Down
4 changes: 4 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,10 @@ <h3 class="mb-5">Other switches</h3>
} else {
_self.error = e;
}

if (e.error === 'login_required') {
_self.isAuthenticated = false;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catches the login_required in our playground, allowing us to reset the authentication state when needed based on the response from Auth0.

});
},
getTokenPopup: function (audience, scope, access_tokens) {
Expand Down