Skip to content

Commit

Permalink
Finer-grained options for user experience control
Browse files Browse the repository at this point in the history
tokensAvailableHandler now will be provided a boolean indicating whether or not the user has just returned from the OP

logout now takes options to specify precisely which calls should be made to the OP

Both options intended as tools to address the challenge presented by third-party cookie behavior in some browsers
  • Loading branch information
jakefeasel committed Jul 27, 2021
1 parent c81d6bd commit 7b5b81b
Show file tree
Hide file tree
Showing 7 changed files with 74 additions and 40 deletions.
35 changes: 25 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# App Auth JS Helper

Wrapper for [AppAuthJS](https://www.npmjs.com/package/@openid/appauth) to assist with the full OAuth2 / OIDC token life-cycle.
Wrapper for [AppAuthJS](https://www.npmjs.com/package/@openid/appauth) to assist with the full OAuth2 / OIDC token life-cycle in a SPA setting.

## Purpose

The primary goal of both AppAuth and this helper is to allow your single-page application to obtain OAuth2 access tokens and OpenID Connect id tokens. AppAuth for JavaScript provides an SDK for performing a PKCE-based Authorization Code flow within a JavaScript-based application. It is designed to be the generic underlying library for any type of JS app - not necessarily browser-based single-paged applications. The specific patterns for how you would use it within a single-page application are therefore not very clear. The goal of this helper library is to make that specific integration much easier.

There are several aspects that this helper aims to add on top of AppAuth:
There are several aspects that this helper aims to add on top of AppAuth-JS:

- **Simpler application integration**
- **Silent token acquisition**
Expand Down Expand Up @@ -45,9 +45,9 @@ In the case when your request fails because the access token has expired, the id
> request a new access token and retry the protected resource
> request.
Thanks to the identity proxy, you won't have to worry about implementing this retry logic yourself. Just make the calls to your APIs and let the proxy handle the tokens. For more details on how the identity proxy accomplishes this, review this article: [Service Workers as an Identity Proxy](./service_workers.md).
Thanks to the identity proxy, you won't have to worry about implementing this retry logic yourself. Just make the calls to your APIs and let the proxy handle the tokens - your code won't even be aware of the renewals going on behind the scenes. For more details on how the identity proxy accomplishes this, review this article: [Service Workers as an Identity Proxy](./service_workers.md).

AppAuthHelper has two ways to renew an expired access token: a silent authorization code grant (the default behavior), or a refresh token grant. If this happens while the user still has a valid session within the OP (and the OP no longer prompts for consent), a new access token can be silently obtained by initiating a new authorization code grant within a hidden iframe. If you do not want your RP to depend on an active session within the OP (or if the OP requires consent for each authorization code grant) then you can tell AppAuthHelper to use a refresh token grant instead. Be sure the OP has been configured to allow this RP to use the refresh token grant if you choose this option.
AppAuthHelper has two ways to renew an expired access token: a silent authorization code grant (the default behavior), or a refresh token grant. If token expiration happens while the user still has a valid session within the OP (and the OP no longer prompts for consent), a new access token can be silently obtained by initiating a new authorization code grant within a hidden iframe. If you do not want your RP to depend on an active session within the OP (or if the OP requires consent for each authorization code grant) then you can tell AppAuthHelper to use a refresh token grant instead. Be sure the OP has been configured to allow this RP to use the refresh token grant if you choose this option.

## Using this library

Expand Down Expand Up @@ -87,10 +87,20 @@ Once the library is loaded, you have to provide the environmental details along

// this assumes that 'loginIframe' is an iframe that has already been mounted to the DOM
},
tokensAvailableHandler: function (claims) {
tokensAvailableHandler: function (claims, id_token, interactively_logged_in) {
// This is a great place to startup the parts of your SPA that are for logged-in users.

// The "claims" parameter is the content of the id_token, which tells you useful details
// about the logged-in user.
// about the logged-in user. It will be undefined if you aren't using OIDC.

// The "id_token" is the actual id_token value, which can be useful to have in its original form
// for various use-cases; OP-based session management may be one such case.
// See the companion library "oidcsessioncheck" for further details.

// The "interactively_logged_in" parameter is a boolean; it lets your app know that tokens
// are available because the user just returned from the OP (rather than reading them from browser
// storage). This may be useful in some circumstances for user-experience concerns; for example,
// you should take care to avoid looping redirections between the OP and RP by checking this value.

// At this point your application code can start making network calls to the resource servers
// you have configured, above.
Expand Down Expand Up @@ -118,7 +128,7 @@ Once the library is loaded, you have to provide the environmental details along
- extras - Optional simple map of additional key=value pairs you would like to pass to the authorization endpoint.
- tokensAvailableHandler - function to be called when tokens are first available
- interactionRequiredHandler - optional function to be called when the user needs to interact with the OP; for example, to log in.
- renewCooldownPeriod [default: 1] - Minimum time (in seconds) between requests to the authorizationEndpoint for token renewal attempts
- renewCooldownPeriod [default: 1] - Minimum time (in seconds) between requests to the OP for token renewal attempts
- oidc [default: true] - indicate whether or not you want to get back an id_token
- identityProxyPreference [default: serviceWorker] - Preferred identity proxy implementation (serviceWorker or XHR)
- renewStrategy [default: authCode] - Preferred method for obtaining fresh (and down-scoped) access tokens (authCode or refreshToken); see "How it works" for details.
Expand All @@ -141,11 +151,16 @@ If you don't want the default behavior of redirecting your users to the authoriz

*Logging Out:*

AppAuthHelper.logout().then(function () {
AppAuthHelper.logout({
revoke_tokens: true,
end_session: true
}).then(function () {
// whatever your application should do after the tokens are removed
});

Calling `AppAuthHelper.logout()` will trigger calls to both the access token revocation endpoint, as well as the id token end session endpoint. When both of those have completed, the tokens are removed from browser storage. A promise is returned from `logout()`; use `.then()` to do whatever is appropriate for your application after the session is terminated.
Calling `AppAuthHelper.logout(options)` can trigger calls to both the access token revocation endpoint, as well as the id token end session endpoint. By default, it will revoke all access tokens (and if present, the refresh token) and it will call the end session endpoint if there is an id_token available to pass it. If you don't want to revoke tokens or end the OP session as part of logout, you can override this behavior by setting the appropriate option to `false`. Regardless of the options provided, after that process completes the tokens are removed from browser storage. A promise is returned from `logout()`; use `.then()` to do whatever is appropriate for your application after the session is terminated.

You may want to consider setting `end_session: false` if you are calling `logout` in response to an OIDC session check failure (see [oidcSessionCheck](https://github.com/ForgeRock/oidcSessionCheck) for an example of such a case). The session check failure could be due to browser restrictions on third-party cookies, so you may be able to recover from this type of failure by performing a full-page redirection so long as you don't actively try to terminate the OP session first.

### Using Tokens

Expand Down Expand Up @@ -216,4 +231,4 @@ AppAuthHelper was developed by ForgeRock, Inc. Please file issues and open pull

## License

Apache 2.0. Portions Copyright ForgeRock, Inc. 2018-2019
Apache 2.0. Portions Copyright ForgeRock, Inc. 2018-2021
10 changes: 5 additions & 5 deletions TokenManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -361,20 +361,20 @@
}
return headersObj;
},
logout: function () {
logout: function (options) {
return this.fetchTokensFromIndexedDB().then((function (tokens) {
if (!tokens) {
return;
}
var revokeRequests = [];
if (tokens.accessToken) {
if (options.revoke_tokens && tokens.accessToken) {
revokeRequests.push(new AppAuth.RevokeTokenRequest({
client_id: this.appAuthConfig.clientId,
token_type_hint: "access_token",
token: tokens.accessToken
}));
}
if (tokens.refreshToken) {
if (options.revoke_tokens && tokens.refreshToken) {
revokeRequests.push(new AppAuth.RevokeTokenRequest({
client_id: this.appAuthConfig.clientId,
token_type_hint: "refresh_token",
Expand All @@ -384,7 +384,7 @@
return Promise.all(
revokeRequests.concat(
Object.keys(this.appAuthConfig.resourceServers)
.filter(function (rs) { return !!tokens[rs]; })
.filter(function (rs) { return options.revoke_tokens && !!tokens[rs]; })
.map((function (rs) {
return new AppAuth.RevokeTokenRequest({
client_id: this.appAuthConfig.clientId,
Expand All @@ -399,7 +399,7 @@
);
}).bind(this))
).then((function () {
if (this.appAuthConfig.oidc && tokens.idToken && this.client.configuration.endSessionEndpoint) {
if (options.end_session && this.appAuthConfig.oidc && tokens.idToken && this.client.configuration.endSessionEndpoint) {
return fetch(this.client.configuration.endSessionEndpoint + "?id_token_hint=" + tokens.idToken);
} else {
return;
Expand Down
40 changes: 24 additions & 16 deletions appAuthHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* @param {object} config.resourceServers - Map of resource server urls to the scopes which they require. Map values are space-delimited list of scopes requested by this RP for use with this RS
* @param {object} [config.extras] -Additional parameters to include in the authorization request
* @param {function} [config.interactionRequiredHandler] - optional function to be called anytime interaction is required. When not provided, default behavior is to redirect the current window to the authorizationEndpoint
* @param {function} config.tokensAvailableHandler - function to be called every time tokens are available - both initially and upon renewal
* @param {function} config.tokensAvailableHandler - function to be called once tokens are available - either from the browser storage or newly fetched.
* @param {number} [config.renewCooldownPeriod=1] - Minimum time (in seconds) between requests to the authorizationEndpoint for token renewal attempts
* @param {string} [config.redirectUri=appAuthHelperRedirect.html] - The redirect uri registered in the OP
* @param {string} [config.serviceWorkerUri=appAuthServiceWorker.js] - The path to the service worker script
Expand All @@ -37,7 +37,8 @@

this.renewCooldownPeriod = config.renewCooldownPeriod || 1;
this.appAuthConfig = {
appLocation: document.location.href
// discard the &loggedin=true part that might be included by us
appLocation: document.location.href.replace(/#?&loggedin=true$/, "")
};
this.tokensAvailableHandler = config.tokensAvailableHandler;
this.interactionRequiredHandler = config.interactionRequiredHandler;
Expand Down Expand Up @@ -88,12 +89,6 @@
}
switch (e.data.message) {
case "appAuth-tokensAvailable":
var originalWindowHash = localStorage.getItem("originalWindowHash-" + this.appAuthConfig.clientId);
if (originalWindowHash !== null) {
window.location.hash = originalWindowHash;
localStorage.removeItem("originalWindowHash-" + this.appAuthConfig.clientId);
}

// this should only be set as part of token renewal
if (e.data.resourceServer) {
localStorage.removeItem("currentResourceServer");
Expand All @@ -105,9 +100,20 @@

this.identityProxy.tokensRenewed(e.data.resourceServer);
} else {
var originalWindowHash = localStorage.getItem("originalWindowHash-" + this.appAuthConfig.clientId),
returnedFromLogin = !!window.location.hash.match(/&loggedin=true$/);

if (originalWindowHash === null || originalWindowHash === "" || originalWindowHash === "#") {
history.replaceState(undefined, undefined, window.location.href.replace(/#&loggedin=true$/, ""));
} else {
history.replaceState(undefined, undefined, "#" + originalWindowHash.replace("#", ""));
}

localStorage.removeItem("originalWindowHash-" + this.appAuthConfig.clientId);

this.registerIdentityProxy()
.then((function () {
return this.tokensAvailableHandler(e.data.idTokenClaims, e.data.idToken);
return this.tokensAvailableHandler(e.data.idTokenClaims, e.data.idToken, returnedFromLogin);
}).bind(this));
}

Expand All @@ -118,11 +124,9 @@
} else {
// Default behavior for when interaction is required is to redirect to the OP for login.

if (window.location.hash.replace("#","").length) {
// When interaction is required, the current hash state may be lost during redirection.
// Save it in localStorage so that it can be returned to upon successfully authenticating
localStorage.setItem("originalWindowHash-" + this.appAuthConfig.clientId, window.location.hash);
}
// When interaction is required, the current hash state may be lost during redirection.
// Save it in localStorage so that it can be returned to upon successfully authenticating
localStorage.setItem("originalWindowHash-" + this.appAuthConfig.clientId, window.location.hash);
window.location.href = e.data.authorizationUrl;
}

Expand Down Expand Up @@ -209,12 +213,16 @@
* logout() will revoke the access token, use the id_token to end the session on the OP, clear them from the
* local session, and finally notify the SPA that they are gone.
*/
logout: function () {
logout: function (options) {
options = options || {};
options.revoke_tokens = options.revoke_tokens!==false;
options.end_session = options.end_session!==false;
return new Promise((function (resolve) {
this.logoutComplete = resolve;
this.appAuthIframe.contentWindow.postMessage({
message: "appAuth-logout",
config: this.appAuthConfig
config: this.appAuthConfig,
options: options
}, this.iframeOrigin);
}).bind(this));
},
Expand Down
2 changes: 1 addition & 1 deletion appAuthHelperBundle.js

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions appAuthHelperFetchTokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@
})
.finally(() => {
// if we are running in the context of a full window (rather than an iframe)
if (!parent.document.getElementById('AppAuthIframe')) {
if (!parent.document.getElementById("AppAuthIframe")) {
setTimeout(() => {
var appLocation = document.createElement("a");
appLocation.href = appAuthConfig.appLocation || ".";
appLocation.hash = appLocation.hash + "&loggedin=true";
window.location.assign(appLocation.href);
}, 0);
}
Expand All @@ -67,7 +68,7 @@
);
break;
case "appAuth-logout":
tokenManager.logout().then(() => {
tokenManager.logout(e.data.options).then(() => {
parent.postMessage({
message: "appAuth-logoutComplete"
}, TRUSTED_ORIGIN);
Expand Down
2 changes: 1 addition & 1 deletion appAuthHelperFetchTokensBundle.js

Large diffs are not rendered by default.

20 changes: 15 additions & 5 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

<body>
<div id="userDetails"></div>
<a href='#' onclick='AppAuthHelper.logout().then(function () { location.reload(); }); return false;'>Logout</a>
<a href='#' onclick='AppAuthHelper.logout(({ revoke_tokens: true, end_session: true }).then(function () { location.reload(); }); return false;'>Logout</a>
</body>

<script src="appAuthHelperBundle.js"></script>
Expand All @@ -39,20 +39,30 @@

resourceServers: {
"https://default.iam.example.com/am/oauth2/userinfo": "profile",
"https://default.iam.example.com/openidm": "openid"
"https://default.iam.example.com/openidm": "fr:idm:*"
},
/*
interactionRequiredHandler: function () {
interactionRequiredHandler: function (authorization_request_url, error_reported) {
// If you want to handle login at the IDP using some mechanism other than
// the default (standard OAuth2 redirection to the authorizationEndpoint),
// you can add that logic here.
// Call AppAuthHelper.getTokens(); again when login is finished.
},
*/
tokensAvailableHandler: function (claims) {
tokensAvailableHandler: function (claims, id_token, interactively_logged_in) {
// This is a great place to startup the parts of your SPA that are for logged-in users.

// The "claims" parameter is the content of the id_token, which tells you useful details
// about the logged-in user.
// about the logged-in user. It will be undefined if you aren't using OIDC.

// The "id_token" is the actual id_token value, which can be useful to have in its original form
// for various use-cases; OP-based session management may be one such case.
// See the companion library "oidcsessioncheck" for further details.

// The "interactively_logged_in" parameter is a boolean; it lets your app know that tokens
// are available because the user just returned from the OP (rather than reading them from browser
// storage). This may be useful in some circumstances for user-experience concerns; for example,
// you should take care to avoid looping redirections between the OP and RP by checking this value.

// Here is a sample "application" that just makes some requests to
// resource servers and outputs the response on the page.
Expand Down

0 comments on commit 7b5b81b

Please sign in to comment.