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

Store RT, add scopes to static-spa sample #527

Closed
wants to merge 17 commits into from
Closed
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 4.2.0

### Features
- Adding the ability to use refresh tokens with single page applications (SPA) (Early Access feature - reach out to our support team)
- `scopes` configuration option now handles 'offline_access' as an option, which will use refresh tokens IF your client app is configured to do so in the Okta settings
- If you already have tokens (from a separate instance of auth-js or the okta-signin-widget) those tokens must already include a refresh token and have the 'offline_access' scope
- 'offline_access' is not requested by default. Anyone using the default `scopes` and wishing to add 'offline_access' should pass `scopes: ['openid', 'email', 'offline_access']` to their constructor
- `renewTokens()` will now use an XHR call to replace tokens if the app has a refresh token. This does not rely on "3rd party cookies"
- The `autoRenew` option (defaults to `true`) already calls `renewTokens()` shortly before tokens expire. The `autoRenew` feature will now automatically make use of the refresh token if present
- `signOut()` now revokes the refresh token (if present) by default, which in turn will revoke all tokens minted with that refresh token
- The revoke calls by `signOut()` follow the existing `revokeAccessToken` parameter - when `true` (the default) any refreshToken will be also be revoked, and when `false`, any tokens are not explicitly revoked. This parameter name becomes slightly misleading (as it controls both access AND refresh token revocation) and will change in a future version.

## 4.1.2

### Bug Fixes
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,7 @@ var config = {
* [signOut](#signout)
* [closeSession](#closesession)
* [revokeAccessToken](#revokeaccesstokenaccesstoken)
* [revokeRefreshToken](#revokerefreshtokenrefreshtoken)
* [forgotPassword](#forgotpasswordoptions)
* [unlockAccount](#unlockaccountoptions)
* [verifyRecoveryToken](#verifyrecoverytokenoptions)
Expand Down Expand Up @@ -739,7 +740,7 @@ if (authClient.isLoginRedirect()) {

> :hourglass: async

Signs the user out of their current [Okta session](https://developer.okta.com/docs/api/resources/sessions) and clears all tokens stored locally in the `TokenManager`. By default, the access token is revoked so it can no longer be used. Some points to consider:
Signs the user out of their current [Okta session](https://developer.okta.com/docs/api/resources/sessions) and clears all tokens stored locally in the `TokenManager`. By default, the refresh token (if any) and access token are revoked so they can no longer be used. Some points to consider:

* Will redirect to an Okta-hosted page before returning to your app.
* If a `postLogoutRedirectUri` has not been specified or configured, `window.location.origin` will be used as the return URI. This URI must be listed in the Okta application's [Login redirect URIs](#login-redirect-uris). If the URI is unknown or invalid the redirect will end on a 400 error page from Okta. This error will be visible to the user and cannot be handled by the app.
Expand All @@ -751,7 +752,8 @@ Signs the user out of their current [Okta session](https://developer.okta.com/do
* `postLogoutRedirectUri` - Setting a value will override the `postLogoutRedirectUri` configured on the SDK.
* `state` - An optional value, used along with `postLogoutRedirectUri`. If set, this value will be returned as a query parameter during the redirect to the `postLogoutRedirectUri`
* `idToken` - Specifies the ID token object. By default, `signOut` will look for a token object named `idToken` within the `TokenManager`. If you have stored the id token object in a different location, you should retrieve it first and then pass it here.
* `revokeAccessToken` - If `false`, the access token will not be revoked. Use this option with care: not revoking the access token may pose a security risk if the token has been leaked outside the application.
* `revokeAccessToken` - If `false` (default: `true`) the access token will not be revoked. Use this option with care: not revoking tokens may pose a security risk if tokens have been leaked outside the application.
* `revokeRefreshToken` - If `false` (default: `true`) the refresh token will not be revoked. Use this option with care: not revoking tokens may pose a security risk if tokens have been leaked outside the application. Revoking a refersh token will revoke any access tokens minted by it, even if `revokeAccessToken` is `false`.
* `accessToken` - Specifies the access token object. By default, `signOut` will look for a token object named `accessToken` within the `TokenManager`. If you have stored the access token object in a different location, you should retrieve it first and then pass it here. This options is ignored if the `revokeAccessToken` option is `false`.

```javascript
Expand Down Expand Up @@ -814,6 +816,13 @@ authClient.closeSession()

Revokes the access token for this application so it can no longer be used to authenticate API requests. The `accessToken` parameter is optional. By default, `revokeAccessToken` will look for a token object named `accessToken` within the `TokenManager`. If you have stored the access token object in a different location, you should retrieve it first and then pass it here. Returns a promise that resolves when the operation has completed. This method will succeed even if the access token has already been revoked or removed.

### `revokeRefreshToken(refreshToken)`

> :hourglass: async

Revokes the refresh token (if any) for this application so it can no longer be used to mint new tokens. The `refreshToken` parameter is optional. By default, `revokeRefreshToken` will look for a token object named `refreshToken` within the `TokenManager`. If you have stored the refresh token object in a different location, you should retrieve it first and then pass it here. Returns a promise that resolves when the operation has completed. This method will succeed even if the refresh token has already been revoked or removed.


### `forgotPassword(options)`

> :hourglass: async
Expand Down
5 changes: 4 additions & 1 deletion lib/AuthStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const DEFAULT_AUTH_STATE = {
isAuthenticated: false,
idToken: null,
accessToken: null,
refreshToken: null,
};
const DEFAULT_PENDING = {
updateAuthStatePromise: null,
Expand Down Expand Up @@ -136,7 +137,7 @@ export class AuthStateManager {
};

this._sdk.tokenManager.getTokens()
.then(({ accessToken, idToken }) => {
.then(({ accessToken, idToken, refreshToken }) => {
shuowu marked this conversation as resolved.
Show resolved Hide resolved
if (cancelablePromise.isCanceled) {
resolve();
return;
Expand All @@ -157,6 +158,7 @@ export class AuthStateManager {
const authState = {
accessToken,
idToken,
refreshToken,
isPending,
isAuthenticated: !!(accessToken && idToken)
};
Expand All @@ -169,6 +171,7 @@ export class AuthStateManager {
.catch(error => emitAndResolve({
accessToken,
idToken,
refreshToken,
isAuthenticated: false,
isPending: false,
error
Expand Down
39 changes: 28 additions & 11 deletions lib/TokenManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ import {
TokenType,
TokenManagerOptions,
isIDToken,
isAccessToken
isAccessToken,
isRefreshToken
} from './types';
import { ID_TOKEN_STORAGE_KEY, ACCESS_TOKEN_STORAGE_KEY } from './constants';
import { ID_TOKEN_STORAGE_KEY, ACCESS_TOKEN_STORAGE_KEY, REFRESH_TOKEN_STORAGE_KEY } from './constants';

const DEFAULT_OPTIONS = {
autoRenew: true,
Expand Down Expand Up @@ -153,8 +154,10 @@ function validateToken(token: Token) {
if (!isObject(token) ||
!token.scopes ||
(!token.expiresAt && token.expiresAt !== 0) ||
(!isIDToken(token) && !isAccessToken(token))) {
throw new AuthSdkError('Token must be an Object with scopes, expiresAt, and an idToken or accessToken properties');
(!isIDToken(token) && !isAccessToken(token) && !isRefreshToken(token))) {
throw new AuthSdkError(
'Token must be an Object with scopes, expiresAt, and one of: an idToken, accessToken, or refreshToken property'
);
}
}

Expand Down Expand Up @@ -185,7 +188,8 @@ function getKeyByType(storage, type: TokenType): string {
const key = Object.keys(tokenStorage).filter(key => {
const token = tokenStorage[key];
return (isAccessToken(token) && type === 'accessToken')
|| (isIDToken(token) && type === 'idToken');
|| (isIDToken(token) && type === 'idToken')
|| (isRefreshToken(token) && type === 'refreshToken');
})[0];
return key;
}
Expand All @@ -206,6 +210,8 @@ function getTokens(storage): Tokens {
tokens.accessToken = token;
} else if (isIDToken(token)) {
tokens.idToken = token;
} else if (isRefreshToken(token)) {
tokens.refreshToken = token;
}
});
return tokens;
Expand All @@ -223,9 +229,10 @@ function setTokens(
sdk,
tokenMgmtRef,
storage,
{ accessToken, idToken }: Tokens,
{ accessToken, idToken, refreshToken }: Tokens,
accessTokenCb?: Function,
idTokenCb?: Function
idTokenCb?: Function,
refreshTokenCb?: Function
): void {
const handleAdded = (key, token, tokenCb) => {
emitAdded(tokenMgmtRef, key, token);
Expand All @@ -250,11 +257,13 @@ function setTokens(
}
const idTokenKey = getKeyByType(storage, 'idToken') || ID_TOKEN_STORAGE_KEY;
const accessTokenKey = getKeyByType(storage, 'accessToken') || ACCESS_TOKEN_STORAGE_KEY;
const refreshTokenKey = getKeyByType(storage, 'refreshToken') || REFRESH_TOKEN_STORAGE_KEY;

// add token to storage
const tokenStorage = {
...(idToken && { [idTokenKey]: idToken }),
...(accessToken && { [accessTokenKey]: accessToken })
...(accessToken && { [accessTokenKey]: accessToken }),
...(refreshToken && { [refreshTokenKey]: refreshToken })
};
storage.setStorage(tokenStorage);

Expand All @@ -270,6 +279,11 @@ function setTokens(
} else if (existingTokens.accessToken) {
handleRemoved(accessTokenKey, existingTokens.accessToken, accessTokenCb);
}
if (refreshToken) {
handleAdded(refreshTokenKey, refreshToken, refreshTokenCb);
} else if (existingTokens.refreshToken) {
handleRemoved(refreshTokenKey, existingTokens.refreshToken, refreshTokenCb);
}
}
/* eslint-enable max-params */

Expand Down Expand Up @@ -304,10 +318,12 @@ function renew(sdk, tokenMgmtRef, storage, key) {
// Remove existing autoRenew timeouts
clearExpireEventTimeoutAll(tokenMgmtRef);

// A refresh token means a replace instead of renewal

// Store the renew promise state, to avoid renewing again
// Renew both tokens in one process
// Renew/refresh all tokens in one process
tokenMgmtRef.renewPromise[key] = sdk.token.renewTokens({
scopes: token.scopes
scopes: token.scopes,
})
.then(function(freshTokens) {
// store and emit events for freshTokens
Expand All @@ -320,7 +336,8 @@ function renew(sdk, tokenMgmtRef, storage, key) {
(accessTokenKey, accessToken) =>
emitRenewed(tokenMgmtRef, accessTokenKey, accessToken, oldTokenStorage[accessTokenKey]),
(idTokenKey, idToken) =>
emitRenewed(tokenMgmtRef, idTokenKey, idToken, oldTokenStorage[idTokenKey])
emitRenewed(tokenMgmtRef, idTokenKey, idToken, oldTokenStorage[idTokenKey]),
// not emitting refresh token as an internal detail, not a usable token
);

// return freshToken by key
Expand Down
36 changes: 33 additions & 3 deletions lib/browser/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
OktaAuthOptions,
AccessToken,
IDToken,
RefreshToken,
TokenAPI,
FeaturesAPI,
SignoutAPI,
Expand Down Expand Up @@ -311,7 +312,21 @@ class OktaAuthBrowser extends OktaAuthBase implements OktaAuth, SignoutAPI {
return this.token.revoke(accessToken);
}

// Revokes accessToken, clears all local tokens, then redirects to Okta to end the SSO session.
// Revokes the refresh token for the application session
async revokeRefreshToken(refreshToken?: RefreshToken) {
if (!refreshToken) {
refreshToken = (await this.tokenManager.getTokens()).refreshToken as RefreshToken;
Copy link
Contributor

Choose a reason for hiding this comment

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

It might be better to get the token from authState (sync access), but as we are also using the same logic in revokeAccessToken, the change can be made in a separate PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We get into a circular dependency if we try that. I brought it up and Aaron suggested we just do this.

Copy link
Contributor

Choose a reason for hiding this comment

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

wondering how the circular dep happens? it should just be an access to the in-memory state

const { refreshToken } = this.authStateManager.getAuthState();

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm going off memory, but as I recall this.authStateManager didn't exist here.

const refreshTokenKey = this.tokenManager._getStorageKeyByType('refreshToken');
this.tokenManager.remove(refreshTokenKey);
}
// Refresh token may have been removed. In this case, we will silently succeed.
if (!refreshToken) {
return Promise.resolve();
}
return this.token.revoke(refreshToken);
}

// Revokes refreshToken or accessToken, clears all local tokens, then redirects to Okta to end the SSO session.
async signOut(options?) {
options = Object.assign({}, options);

Expand All @@ -323,6 +338,7 @@ class OktaAuthBrowser extends OktaAuthBase implements OktaAuth, SignoutAPI {
|| defaultUri;

var accessToken = options.accessToken;
var refreshToken = options.refreshToken;
var revokeAccessToken = options.revokeAccessToken !== false;
var idToken = options.idToken;

Expand All @@ -332,17 +348,26 @@ class OktaAuthBrowser extends OktaAuthBase implements OktaAuth, SignoutAPI {
idToken = (await this.tokenManager.getTokens()).idToken as IDToken;
}


if (revokeAccessToken && typeof refreshToken === 'undefined') {
refreshToken = (await this.tokenManager.getTokens()).refreshToken as RefreshToken;
}

if (revokeAccessToken && typeof accessToken === 'undefined') {
accessToken = (await this.tokenManager.getTokens()).accessToken as AccessToken;
}

// Clear all local tokens
this.tokenManager.clear();
swiftone marked this conversation as resolved.
Show resolved Hide resolved


if (revokeAccessToken && refreshToken) {
await this.revokeRefreshToken(refreshToken);
}

if (revokeAccessToken && accessToken) {
await this.revokeAccessToken(accessToken);
}

// No idToken? This can happen if the storage was cleared.
// Fallback to XHR signOut, then simulate a redirect to the post logout uri
if (!idToken) {
Expand Down Expand Up @@ -426,6 +451,11 @@ class OktaAuthBrowser extends OktaAuthBase implements OktaAuth, SignoutAPI {
return accessToken ? accessToken.accessToken : undefined;
}

getRefreshToken(): string | undefined {
Copy link
Contributor

Choose a reason for hiding this comment

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

so we decided to keep the public method for refresh token?

Copy link
Contributor

Choose a reason for hiding this comment

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

looks like it's neither in readme nor used in code, remove?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We kept it because we're exposing it on authState anyway.

const { refreshToken } = this.authStateManager.getAuthState();
return refreshToken ? refreshToken.refreshToken : undefined;
}

/**
* Store parsed tokens from redirect url
*/
Expand Down
1 change: 1 addition & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ export const CACHE_STORAGE_NAME = 'okta-cache-storage';
export const PKCE_STORAGE_NAME = 'okta-pkce-storage';
export const ACCESS_TOKEN_STORAGE_KEY = 'accessToken';
export const ID_TOKEN_STORAGE_KEY = 'idToken';
export const REFRESH_TOKEN_STORAGE_KEY = 'refreshToken';
export const REFERRER_PATH_STORAGE_KEY = 'referrerPath';
Loading