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

fix: id_token at_hash matching issue OKTA-417486 #906

Closed
Closed
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
# Changelog

## 5.4
## 5.4.0

### Features

- [#908](https://github.com/okta/okta-auth-js/pull/908) Enables dynamic attributes for profile enrollment
- [#906](https://github.com/okta/okta-auth-js/pull/906)
- Checks idToken integrity during token auto renew process
- Enables emitting `renewed` event for `TokenManager.setTokens` method
- Exposes `crypto` util module

## 5.3.1

Expand Down
151 changes: 88 additions & 63 deletions lib/TokenManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
TokenManagerInterface,
RefreshToken
} from './types';
import { ID_TOKEN_STORAGE_KEY, ACCESS_TOKEN_STORAGE_KEY, REFRESH_TOKEN_STORAGE_KEY } from './constants';
import { REFRESH_TOKEN_STORAGE_KEY } from './constants';
import { TokenService } from './services/TokenService';

const DEFAULT_OPTIONS = {
Expand All @@ -54,12 +54,12 @@ export const EVENT_ERROR = 'error';

interface TokenManagerState {
expireTimeouts: Record<string, unknown>;
renewPromise: Record<string, Promise<Token>>;
renewPromise: Promise<Token>;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This type is not exposed, should be ok to change.

}
function defaultState(): TokenManagerState {
return {
expireTimeouts: {},
renewPromise: {}
renewPromise: null
};
}
export class TokenManager implements TokenManagerInterface {
Expand Down Expand Up @@ -182,7 +182,7 @@ export class TokenManager implements TokenManagerInterface {
delete this.state.expireTimeouts[key];

// Remove the renew promise (if it exists)
delete this.state.renewPromise[key];
this.state.renewPromise = null;
}

clearExpireEventTimeoutAll() {
Expand Down Expand Up @@ -280,68 +280,92 @@ export class TokenManager implements TokenManagerInterface {
return key;
}

// eslint-disable-next-line complexity
private getTokenType(token: Token): TokenType {
if (isAccessToken(token)) {
return 'accessToken';
}
if (isIDToken(token)) {
return 'idToken';
}
if(isRefreshToken(token)) {
return 'refreshToken';
}
throw new AuthSdkError('Unknown token type');
}

setTokens(
{ accessToken, idToken, refreshToken }: Tokens,
tokens: Tokens,
// TODO: callbacks can be removed in the next major version OKTA-407224
accessTokenCb?: Function,
idTokenCb?: Function,
refreshTokenCb?: Function
): void {
const handleAdded = (key, token, tokenCb) => {
const handleTokenCallback = (key, token) => {
const type = this.getTokenType(token);
if (type === 'accessToken') {
accessTokenCb && accessTokenCb(key, token);
} else if (type === 'idToken') {
idTokenCb && idTokenCb(key, token);
} else if (type === 'refreshToken') {
refreshTokenCb && refreshTokenCb(key, token);
}
};
const handleAdded = (key, token) => {
this.emitAdded(key, token);
this.setExpireEventTimeout(key, token);
if (tokenCb) {
tokenCb(key, token);
}
handleTokenCallback(key, token);
};
const handleRemoved = (key, token, tokenCb) => {
const handleRenewed = (key, token, oldToken) => {
this.emitRenewed(key, token, oldToken);
this.clearExpireEventTimeout(key);
this.setExpireEventTimeout(key, token);
handleTokenCallback(key, token);
};
const handleRemoved = (key, token) => {
this.clearExpireEventTimeout(key);
this.emitRemoved(key, token);
if (tokenCb) {
tokenCb(key, token);
}
handleTokenCallback(key, token);
};

if (idToken) {
validateToken(idToken, 'idToken');
}
if (accessToken) {
validateToken(accessToken, 'accessToken');
}
if (refreshToken) {
validateToken(refreshToken, 'refreshToken');
}
const idTokenKey = this.getStorageKeyByType('idToken') || ID_TOKEN_STORAGE_KEY;
const accessTokenKey = this.getStorageKeyByType('accessToken') || ACCESS_TOKEN_STORAGE_KEY;
const refreshTokenKey = this.getStorageKeyByType('refreshToken') || REFRESH_TOKEN_STORAGE_KEY;

const types: TokenType[] = ['idToken', 'accessToken', 'refreshToken'];
const existingTokens = this.getTokensSync();

// valid tokens
types.forEach((type) => {
const token = tokens[type];
if (token) {
validateToken(token, type);
}
});

// add token to storage
const tokenStorage = {
...(idToken && { [idTokenKey]: idToken }),
...(accessToken && { [accessTokenKey]: accessToken }),
...(refreshToken && { [refreshTokenKey]: refreshToken })
};
this.storage.setStorage(tokenStorage);
const storage = types.reduce((storage, type) => {
const token = tokens[type];
if (token) {
const storageKey = this.getStorageKeyByType(type) || type;
storage[storageKey] = token;
}
return storage;
}, {});
this.storage.setStorage(storage);

// emit event and start expiration timer
const existingTokens = this.getTokensSync();
if (idToken) {
handleAdded(idTokenKey, idToken, idTokenCb);
} else if (existingTokens.idToken) {
handleRemoved(idTokenKey, existingTokens.idToken, idTokenCb);
}
if (accessToken) {
handleAdded(accessTokenKey, accessToken, accessTokenCb);
} else if (existingTokens.accessToken) {
handleRemoved(accessTokenKey, existingTokens.accessToken, accessTokenCb);
}
if (refreshToken) {
handleAdded(refreshTokenKey, refreshToken, refreshTokenCb);
} else if (existingTokens.refreshToken) {
handleRemoved(refreshTokenKey, existingTokens.refreshToken, refreshTokenCb);
}
types.forEach(type => {
const newToken = tokens[type];
const existingToken = existingTokens[type];
const storageKey = this.getStorageKeyByType(type) || type;
if (newToken && existingToken) { // renew
// call handleRemoved first, since it clears timers
handleRemoved(storageKey, existingToken);
handleAdded(storageKey, newToken);
handleRenewed(storageKey, newToken, existingToken);
} else if (newToken) { // add
handleAdded(storageKey, newToken);
} else if (existingToken) { //remove
handleRemoved(storageKey, existingToken);
}
});
}
/* eslint-enable max-params */

remove(key) {
// Clear any listener for this token
Expand All @@ -364,11 +388,11 @@ export class TokenManager implements TokenManagerInterface {
return validateToken(token);
}

// TODO: renew method should take no param, change in the next major version OKTA-407224
renew(key): Promise<Token> {
// Multiple callers may receive the same promise. They will all resolve or reject from the same request.
var existingPromise = this.state.renewPromise[key];
if (existingPromise) {
return existingPromise;
if (this.state.renewPromise) {
return this.state.renewPromise;
}

try {
Expand All @@ -385,14 +409,13 @@ export class TokenManager implements TokenManagerInterface {

// A refresh token means a replace instead of renewal
// Store the renew promise state, to avoid renewing again
this.state.renewPromise[key] = this.sdk.token.renew(token)
.then(freshToken => {
// store and emit events for freshToken
const oldTokenStorage = this.storage.getStorage();
this.remove(key);
this.add(key, freshToken);
this.emitRenewed(key, freshToken, oldTokenStorage[key]);
return freshToken;
this.state.renewPromise = this.sdk.token.renewTokens()
.then(tokens => {
this.setTokens(tokens);

// resolve token based on the key
const tokenType = this.getTokenType(token);
return tokens[tokenType];
})
.catch(err => {
// If renew fails, remove token and emit error
Expand All @@ -407,10 +430,10 @@ export class TokenManager implements TokenManagerInterface {
})
.finally(() => {
// Remove existing promise key
delete this.state.renewPromise[key];
this.state.renewPromise = null;
});

return this.state.renewPromise[key];
return this.state.renewPromise;
}

clear() {
Expand Down Expand Up @@ -440,6 +463,8 @@ export class TokenManager implements TokenManagerInterface {

}

// TODO: remove this log OKTA-407224
// setTokens behaves differently with add method, it comes with other side effects like it can remove token
if (isLocalhost()) {
(function addWarningsForLocalhost() {
const { add } = TokenManager.prototype;
Expand Down
1 change: 1 addition & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export * from './TransactionManager';
export * from './TokenManager';
export * from './AuthStateManager';
export * from './util';
export * as crypto from './crypto';
4 changes: 3 additions & 1 deletion lib/oidc/handleOAuthResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ export function handleOAuthResponse(sdk: OktaAuth, tokenParams: TokenParams, res
if (refreshToken) {
tokenDict.refreshToken = {
refreshToken: refreshToken,
expiresAt: Number(expiresIn) + now, // should not be used, this is the accessToken expire time
// should not be used, this is the accessToken expire time
// TODO: remove "expiresAt" in the next major version OKTA-407224
expiresAt: Number(expiresIn) + now,
scopes: scopes,
tokenUrl: urls.tokenUrl,
authorizeUrl: urls.authorizeUrl,
Expand Down
2 changes: 2 additions & 0 deletions test/apps/app/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function getConfigFromUrl(): Config {
const responseType = (url.searchParams.get('responseType') || 'id_token,token').split(',');
const responseMode = url.searchParams.get('responseMode') || undefined;
const storage = url.searchParams.get('storage') || undefined;
const expireEarlySeconds = +url.searchParams.get('expireEarlySeconds') || undefined;
const secureCookies = url.searchParams.get('secure') !== 'false'; // On by default
const sameSite = url.searchParams.get('sameSite') || undefined;
const siwVersion = url.searchParams.get('siwVersion') || DEFAULT_SIW_VERSION;
Expand Down Expand Up @@ -98,6 +99,7 @@ export function getConfigFromUrl(): Config {
},
tokenManager: {
storage,
expireEarlySeconds
},
useInteractionCodeFlow
};
Expand Down
2 changes: 2 additions & 0 deletions test/apps/app/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const Form = `
<option value="cookie">Cookie</option>
<option value="memory">Memory</option>
</select><br/>
<label for="expireEarlySeconds">ExpireEarlySeconds</label><input id="f_expireEarlySeconds" name="expireEarlySeconds" type="number" /><br/>
<label for="secure">Secure Cookies</label><br/>
<input id="f_secureCookies-on" name="secure" type="radio" value="true"/>ON<br/>
<input id="f_secureCookies-off" name="secure" type="radio" value="false"/>OFF<br/>
Expand Down Expand Up @@ -89,6 +90,7 @@ export function updateForm(origConfig: Config): void {
(document.getElementById('f_clientSecret') as HTMLInputElement).value = config.clientSecret;
(document.querySelector(`#f_responseMode [value="${config.responseMode || ''}"]`) as HTMLOptionElement).selected = true;
(document.querySelector(`#f_storage [value="${config.storage || ''}"]`) as HTMLOptionElement).selected = true;
(document.getElementById('f_expireEarlySeconds') as HTMLInputElement).value = config.expireEarlySeconds;
(document.querySelector(`#f_sameSite [value="${config.sameSite || ''}"]`) as HTMLOptionElement).selected = true;
(document.getElementById('f_siwVersion') as HTMLInputElement).value = config.siwVersion;
(document.getElementById('f_idps') as HTMLInputElement).value = config.idps;
Expand Down
4 changes: 2 additions & 2 deletions test/e2e/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const config = [
{
app: '@okta/test.app',
spec: [
'tokens.js'
'**/*.js'
],
exclude: [
'refreshTokens.js',
Expand Down Expand Up @@ -47,7 +47,7 @@ const config = [
app: '@okta/test.app.react-mfa-v1',
spec: ['mfa.js'],
flags: [MFA_ENABLED]
}
},
];

const configPredicate = config => {
Expand Down
1 change: 1 addition & 0 deletions test/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"start": "node ./runner"
},
"dependencies": {
"@okta/okta-auth-js": "*",
"@babel/register": "^7.8.2",
"cross-spawn-with-kill": "^1.0.0",
"regenerator-runtime": "^0.13.3"
Expand Down
36 changes: 36 additions & 0 deletions test/e2e/pageobjects/TestApp.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,26 @@ class TestApp {
}, 5000, 'wait for get user info btn');
}

async getIdToken() {
return this.idToken.then(el => el.getText()).then(txt => {
try {
return JSON.parse(txt);
} catch (_) {
return null;
}
});
}

async getAccessToken() {
return this.accessToken.then(el => el.getText()).then(txt => {
try {
return JSON.parse(txt);
} catch (_) {
return null;
}
});
}

async returnHome() {
await browser.waitUntil(async () => this.returnHomeBtn.then(el => el.isDisplayed()));
await this.returnHomeBtn.then(el => el.click());
Expand Down Expand Up @@ -218,6 +238,22 @@ class TestApp {
return browser.waitUntil(async () => this.signinWidget.then(el => el.isDisplayed()), 5000, 'wait for signin widget');
}

async waitForIdTokenRenew() {
const currIdToken = await this.getIdToken();
return browser.waitUntil(async () => {
const newIdToken = await this.getIdToken();
return currIdToken.idToken !== newIdToken.idToken && newIdToken.idToken;
}, 10000, 'wait for id_token renew');
}

async waitForAccessTokenRenew() {
const currAccessToken = await this.getAccessToken();
return browser.waitUntil(async () => {
const newAccessToken = await this.getAccessToken();
return currAccessToken.accessToken !== newAccessToken.accessToken && newAccessToken.accessToken;
}, 10000, 'wait for access_token renew');
}

async assertCallbackSuccess() {
await this.waitForCallbackResult();
await this.success.then(el => el.getText()).then(txt => {
Expand Down
Loading