Skip to content

Commit

Permalink
Add optional front-channel logout draft implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
panva committed Jan 16, 2018
1 parent 0c0d99d commit fa1a34f
Show file tree
Hide file tree
Showing 12 changed files with 346 additions and 20 deletions.
45 changes: 45 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,7 @@ default value:
"oauthNativeApps": true,
"pkce": true,
"backchannelLogout": false,
"frontchannelLogout": false,
"claimsParameter": false,
"clientCredentials": false,
"encryption": false,
Expand Down Expand Up @@ -870,6 +871,50 @@ async findById(ctx, id, token) {
}
```

### frontchannelLogoutPendingSource

HTML source rendered when there are pending front-channel logout iframes to be called to trigger RP logouts. It should handle waiting for the frames to be loaded as well as have a timeout mechanism in it.
affects: session management

default value:
```js
async frontchannelLogoutPendingSource(ctx, frames, postLogoutRedirectUri, timeout) {
ctx.body = `<!DOCTYPE html>
<head>
<title>Logout</title>
<style>
iframe {
visibility: hidden;
position: absolute;
left: 0;
top: 0;
height:0;
width:0;
border: none;
}
</style>
</head>
<body>
${frames.join('')}
<script>
var loaded = 0;
function redirect() {
window.location.replace("${postLogoutRedirectUri}");
}
function frameOnLoad() {
loaded += 1;
if (loaded === ${frames.length}) redirect();
}
Array.prototype.slice.call(document.querySelectorAll('iframe')).forEach(function (element) {
element.onload = frameOnLoad;
});
setTimeout(redirect, ${timeout});
</script>
</body>
</html>`;
}
```

### interactionCheck

Helper used by the OP as a final check whether the End-User should be sent to interaction or not, the default behavior is that every RP must be authorized per session and that native application clients always require End-User prompt to be confirmed. Return false if no interaction should be performed, return an object with relevant error, reason, etc. When interaction should be requested
Expand Down
1 change: 1 addition & 0 deletions example/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ module.exports.config = {
backchannelLogout: true, // defaults to false
claimsParameter: true, // defaults to false
encryption: true, // defaults to false
frontchannelLogout: true, // defaults to false
introspection: true, // defaults to false
registration: true, // defaults to false
request: true, // defaults to false
Expand Down
9 changes: 6 additions & 3 deletions lib/actions/authorization/process_response_types.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ const instance = require('../../helpers/weak_cache');
module.exports = (provider) => {
const { IdToken, AccessToken, AuthorizationCode } = provider;

const { features: { pkce, backchannelLogout }, audiences } = instance(provider).configuration();
const {
features: { pkce, backchannelLogout, frontchannelLogout },
audiences,
} = instance(provider).configuration();

async function tokenHandler(ctx) {
const at = new AccessToken({
Expand Down Expand Up @@ -42,7 +45,7 @@ module.exports = (provider) => {
ac.codeChallengeMethod = ctx.oidc.params.code_challenge_method;
}

if (backchannelLogout) {
if (backchannelLogout || frontchannelLogout) {
ac.sid = ctx.oidc.session.sidFor(ctx.oidc.client.clientId);
}

Expand All @@ -61,7 +64,7 @@ module.exports = (provider) => {

token.set('nonce', ctx.oidc.params.nonce);

if (backchannelLogout) {
if (backchannelLogout || frontchannelLogout) {
token.set('sid', ctx.oidc.session.sidFor(ctx.oidc.client.clientId));
}

Expand Down
5 changes: 5 additions & 0 deletions lib/actions/discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ module.exports = function discoveryAction(provider) {
ctx.body.backchannel_logout_supported = true;
ctx.body.backchannel_logout_session_supported = true;
}

if (config.features.frontchannelLogout) {
ctx.body.frontchannel_logout_supported = true;
ctx.body.frontchannel_logout_session_supported = true;
}
}

defaults(ctx.body, config.discovery);
Expand Down
65 changes: 51 additions & 14 deletions lib/actions/end_session.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const { omit } = require('lodash');
const crypto = require('crypto');
const compose = require('koa-compose');
const url = require('url');

const { InvalidClientError, InvalidRequestError } = require('../helpers/errors');
const JWT = require('../helpers/jwt');
Expand All @@ -13,6 +14,10 @@ const paramsMiddleware = require('../shared/get_params');

const parseBody = bodyParser('application/x-www-form-urlencoded');

function frameFor(target) {
return `<iframe src="${target}"></iframe>`;
}

module.exports = function endSessionAction(provider) {
const STATES = new RegExp(`${provider.cookieName('state')}\\.(\\S+)=`, 'g');

Expand All @@ -25,6 +30,14 @@ module.exports = function endSessionAction(provider) {
return client;
}

const {
postLogoutRedirectUri,
logoutSource,
frontchannelLogoutPendingSource,
cookies: { long: cookiesConfig },
features: { backchannelLogout, frontchannelLogout },
} = instance(provider).configuration();

return {
get: compose([
paramsMiddleware(['id_token_hint', 'post_logout_redirect_uri', 'state', 'ui_locales']),
Expand Down Expand Up @@ -75,14 +88,14 @@ module.exports = function endSessionAction(provider) {
clientId: ctx.oidc.client ? ctx.oidc.client.clientId : undefined,
state: ctx.oidc.params.state,
postLogoutRedirectUri: ctx.oidc.params.post_logout_redirect_uri ||
await instance(provider).configuration('postLogoutRedirectUri')(ctx),
await postLogoutRedirectUri(ctx),
};

ctx.type = 'html';
ctx.status = 200;

const formhtml = `<form id="op.logoutForm" method="post" action="${ctx.oidc.urlFor('end_session')}"><input type="hidden" name="xsrf" value="${secret}"/></form>`;
await instance(provider).configuration('logoutSource')(ctx, formhtml);
await logoutSource(ctx, formhtml);

await next();
},
Expand All @@ -108,24 +121,42 @@ module.exports = function endSessionAction(provider) {
async function endSession(ctx, next) {
const params = ctx.oidc.session.logout;

const opts = omit(instance(provider).configuration('cookies.long'), 'maxAge', 'expires');
const opts = omit(cookiesConfig, 'maxAge', 'expires');

let logouts;
if (ctx.oidc.params.logout) {
if (instance(provider).configuration('features.backchannelLogout')) {
if (backchannelLogout || frontchannelLogout) {
const { Client } = provider;
const clientIds = Object.keys(ctx.oidc.session.authorizations || {});
const logouts = clientIds.map(async (visitedClientId) => {

logouts = await clientIds.reduce(async (acc, visitedClientId) => {
acc = await acc; // eslint-disable-line no-param-reassign
const visitedClient = await Client.find(visitedClientId);
if (visitedClient && visitedClient.backchannelLogoutUri) {
return visitedClient.backchannelLogout(
ctx.oidc.session.accountId(),
ctx.oidc.session.sidFor(visitedClient.clientId),
);

if (visitedClient) {
if (visitedClient.backchannelLogoutUri) {
acc.back.push(visitedClient.backchannelLogout(
ctx.oidc.session.accountId(),
ctx.oidc.session.sidFor(visitedClient.clientId),
));
}
if (visitedClient.frontchannelLogoutUri) {
const target = url.parse(visitedClient.frontchannelLogoutUri, true);
target.search = null;
Object.assign(target.query, {
iss: provider.issuer,
sid: ctx.oidc.session.sidFor(visitedClient.clientId),
});
acc.front.push(url.format(target));
}
}
return undefined;
});

await Promise.all(logouts).catch(() => {});
return acc;
}, { front: [], back: [] });

await Promise.all(logouts.back).catch(() => {
// TODO: err logging, event emit
});
}

await ctx.oidc.session.destroy();
Expand Down Expand Up @@ -153,7 +184,13 @@ module.exports = function endSessionAction(provider) {
);

provider.emit('end_session.success', ctx);
ctx.redirect(uri);

if (logouts && logouts.front.length) {
const frames = logouts.front.map(frameFor);
await frontchannelLogoutPendingSource(ctx, frames, uri, provider.httpOptions().timeout);
} else {
ctx.redirect(uri);
}

await next();
},
Expand Down
5 changes: 4 additions & 1 deletion lib/consts/client_attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ const REQUIRED = [
];

const BOOL = [
'require_auth_time',
'backchannel_logout_session_required',
'frontchannel_logout_session_required',
'require_auth_time',
];

const ARYS = [
Expand All @@ -76,6 +77,7 @@ const STRING = [
'client_name',
'client_secret',
'client_uri',
'frontchannel_logout_uri',
'id_token_encrypted_response_alg',
'id_token_encrypted_response_enc',
'id_token_signed_response_alg',
Expand Down Expand Up @@ -117,6 +119,7 @@ const WHEN = {
const WEB_URI = [
'backchannel_logout_uri',
'client_uri',
'frontchannel_logout_uri',
'initiate_login_uri',
'jwks_uri',
'logo_uri',
Expand Down
7 changes: 7 additions & 0 deletions lib/helpers/client_schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ module.exports = function getSchema(provider) {
DEFAULT.backchannel_logout_session_required = false;
}

if (features.frontchannelLogout) {
RECOGNIZED_METADATA.push('frontchannel_logout_session_required');
RECOGNIZED_METADATA.push('frontchannel_logout_uri');

DEFAULT.frontchannel_logout_session_required = false;
}

if (features.request || features.requestUri) {
RECOGNIZED_METADATA.push('request_object_signing_alg');
if (features.encryption) {
Expand Down
9 changes: 7 additions & 2 deletions lib/helpers/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ class Configuration {
throw new Error('pairwiseSalt must be configured when pairwise subjectType is to be supported');
}

if (!this.features.sessionManagement && this.features.backchannelLogout) {
throw new Error('backchannelLogout is only available in conjuction with sessionManagement');
if (!this.features.sessionManagement) {
if (this.features.backchannelLogout) {
throw new Error('backchannelLogout is only available in conjuction with sessionManagement');
}
if (this.features.frontchannelLogout) {
throw new Error('frontchannelLogout is only available in conjuction with sessionManagement');
}
}


Expand Down
47 changes: 47 additions & 0 deletions lib/helpers/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ const DEFAULTS = {
pkce: true,

backchannelLogout: false,
frontchannelLogout: false,
claimsParameter: false,
clientCredentials: false,
encryption: false,
Expand Down Expand Up @@ -300,6 +301,52 @@ const DEFAULTS = {
},


/*
* frontchannelLogoutPendingSource
*
* description: HTML source rendered when there are pending front-channel logout iframes to be
* called to trigger RP logouts. It should handle waiting for the frames to be loaded as well
* as have a timeout mechanism in it.
* affects: session management
*/
async frontchannelLogoutPendingSource(ctx, frames, postLogoutRedirectUri, timeout) {
changeme('frontchannelLogoutPendingSource', 'customize the front-channel logout pending page');
ctx.body = `<!DOCTYPE html>
<head>
<title>Logout</title>
<style>
iframe {
visibility: hidden;
position: absolute;
left: 0;
top: 0;
height:0;
width:0;
border: none;
}
</style>
</head>
<body>
${frames.join('')}
<script>
var loaded = 0;
function redirect() {
window.location.replace("${postLogoutRedirectUri}");
}
function frameOnLoad() {
loaded += 1;
if (loaded === ${frames.length}) redirect();
}
Array.prototype.slice.call(document.querySelectorAll('iframe')).forEach(function (element) {
element.onload = frameOnLoad;
});
setTimeout(redirect, ${timeout});
</script>
</body>
</html>`;
},


/*
* uniqueness
*
Expand Down
20 changes: 20 additions & 0 deletions test/configuration/client_metadata.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,26 @@ describe('Client metadata validation', () => {
mustBeBoolean(this.title, configuration);
});
});

context('features.frontchannelLogout', () => {
const configuration = {
features: {
sessionManagement: true,
frontchannelLogout: true,
},
};
context('frontchannel_logout_uri', function () {
defaultsTo(this.title, undefined);
mustBeString(this.title, undefined, undefined, configuration);
mustBeUri(this.title, ['http', 'https'], configuration);
});

context('frontchannel_logout_session_required', function () {
defaultsTo(this.title, undefined);
defaultsTo(this.title, false, undefined, configuration);
mustBeBoolean(this.title, configuration);
});
});
});

context('jwks_uri', function () {
Expand Down
16 changes: 16 additions & 0 deletions test/frontchannel_logout/frontchannel_logout.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const { clone } = require('lodash');
const config = clone(require('../default.config'));

config.features = { sessionManagement: true, frontchannelLogout: true, alwaysIssueRefresh: true };

module.exports = {
config,
client: {
client_id: 'client',
client_secret: 'secret',
response_types: ['code id_token'],
grant_types: ['implicit', 'authorization_code', 'refresh_token'],
redirect_uris: ['https://client.example.com/cb'],
frontchannel_logout_uri: 'https://client.example.com/frontchannel_logout',
},
};
Loading

0 comments on commit fa1a34f

Please sign in to comment.