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
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
101 changes: 100 additions & 1 deletion __tests__/Auth0Client/getTokenSilently.test.ts
Original file line number Diff line number Diff line change
@@ -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');
@@ -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 });
});
});
});
92 changes: 55 additions & 37 deletions src/Auth0Client.ts
Original file line number Diff line number Diff line change
@@ -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 {
@@ -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 decodedToken = this._verifyIdToken(tokenResult.id_token, nonceIn);
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
);

return {
...tokenResult,
decodedToken,
scope: params.scope,
audience: params.audience || 'default'
};
const decodedToken = this._verifyIdToken(tokenResult.id_token, nonceIn);

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(
@@ -934,11 +945,18 @@ export default class Auth0Client {
this.worker
);
} catch (e) {
// The web worker didn't have a refresh token in memory so
// fallback to an iframe.
if (e.message === MISSING_REFRESH_TOKEN_ERROR_MESSAGE) {
if (
// The web worker didn't have a refresh token in memory so
// fallback to an iframe.
e.message === MISSING_REFRESH_TOKEN_ERROR_MESSAGE ||
// A refresh token was found, but is it no longer valid.
// Fallback to an iframe.
(e.message &&
e.message.indexOf(INVALID_REFRESH_TOKEN_ERROR_MESSAGE) > -1)
) {
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;
}

1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
@@ -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) {