Skip to content

Commit

Permalink
feat: Improve OIDC SSO (#524)
Browse files Browse the repository at this point in the history
The OIDC implementation merged in #491 is flawed for multiple reasons.

It assumes that the access_token returned by the IDP has to be a JWT parseable by the RP which is not the case [1].
Many major IDPs do issue tokens which are not JWTs and RPs should not rely on the contents of these at all.
The only signed token which has a standardized format for direct RP consumption is the OIDC ID token (id_token), but this by default doesn't contain many claims, especially role claims are omitted from them by default for size reasons. To get these additional claims into the ID token, one needs an IDP with support for the "claims" parameter.

It requires manual specification of the JWKS URL which is mandatory in any OIDC discovery document and thus never needs to be manually specified.

It also makes the questionable decision to use a client-side code flow with PKCE where a normal code flow would be much more appropriate as all user data is processed in the backend which can securely hold a client secret (confidential client). This has far wider IDP support, is safer (due to direct involvement of the IDP in obtaining user information) and doesn't require working with ID tokens and claim parameters.

By using a server-side code flow we can also offload most complexity to the server alone, no longer requiring an additional OIDC library on the web client.

Also silent logout doesn't work on most IDPs for security reasons, one needs to actually redirect the user over to the IDP, which then prompts them once more if they actually want to log out.

This implementation should work with any OIDC-compliant IDP and even OAuth 2.0-only IDPs as long as they serve and OIDC discovery document.

[1] rfc-editor.org/rfc/rfc6749#section-5.1
  • Loading branch information
lorenz authored Oct 19, 2023
1 parent 5bf5db2 commit 743f295
Show file tree
Hide file tree
Showing 14 changed files with 191 additions and 495 deletions.
79 changes: 39 additions & 40 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@
"js-cookie": "^3.0.1",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"nanoid": "^5.0.2",
"node-sass": "^8.0.0",
"oidc-client-ts": "^2.3.0",
"photoswipe": "^5.3.3",
"prop-types": "^15.8.1",
"react": "^18.2.0",
Expand Down
13 changes: 5 additions & 8 deletions client/src/sagas/core/services/core.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { apply, call, put, select, take } from 'redux-saga/effects';
import { call, put, select, take } from 'redux-saga/effects';

import request from '../request';
import requests from '../requests';
import selectors from '../../../selectors';
import actions from '../../../actions';
import api from '../../../api';
import i18n from '../../../i18n';
import { createOidcManager } from '../../../utils/oidc-manager';
import { removeAccessToken } from '../../../utils/access-token-storage';

export function* initializeCore() {
Expand Down Expand Up @@ -86,15 +85,13 @@ export function* logout(invalidateAccessToken = true) {

const oidcConfig = yield select(selectors.selectOidcConfig);

if (oidcConfig) {
const oidcManager = createOidcManager(oidcConfig);
yield put(actions.logout());

try {
yield apply(oidcManager, oidcManager.logout);
} catch (error) {} // eslint-disable-line no-empty
if (oidcConfig && oidcConfig.endSessionUrl !== null) {
// Redirect the user to the IDP to log out.
window.location.replace(oidcConfig.endSessionUrl);
}

yield put(actions.logout());
yield take();
}

Expand Down
50 changes: 37 additions & 13 deletions client/src/sagas/login/services/login.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { apply, call, put, select } from 'redux-saga/effects';
import { nanoid } from 'nanoid';
import { call, put, select } from 'redux-saga/effects';
import { replace } from '../../../lib/redux-router';

import selectors from '../../../selectors';
import actions from '../../../actions';
import api from '../../../api';
import { createOidcManager } from '../../../utils/oidc-manager';
import { setAccessToken } from '../../../utils/access-token-storage';
import Paths from '../../../constants/Paths';

Expand All @@ -31,29 +31,53 @@ export function* authenticate(data) {

export function* authenticateWithOidc() {
const oidcConfig = yield select(selectors.selectOidcConfig);
const oidcManager = createOidcManager(oidcConfig);

yield apply(oidcManager, oidcManager.login);
const nonce = nanoid();
window.sessionStorage.setItem('oidc-nonce', nonce);
window.location.replace(`${oidcConfig.authorizationUrl}&nonce=${encodeURIComponent(nonce)}`);
}

export function* authenticateWithOidcCallback() {
const oidcConfig = yield select(selectors.selectOidcConfig);
const oidcManager = createOidcManager(oidcConfig);
const params = new URLSearchParams(window.location.hash.substring(1));
if (params.get('error') !== null) {
yield put(
actions.authenticateWithOidc.failure(
new Error(
`OIDC Authorization error: ${params.get('error')}: ${params.get('error_description')}`,
),
),
);
return;
}

let oidcToken;
try {
({ access_token: oidcToken } = yield apply(oidcManager, oidcManager.loginCallback));
} catch (error) {
yield put(actions.authenticateWithOidc.failure(error));
const nonce = window.sessionStorage.getItem('oidc-nonce');
if (nonce === null) {
yield put(
actions.authenticateWithOidc.failure(
new Error('Unable to process OIDC response: no nonce issued'),
),
);
return;
}

const code = params.get('code');
if (code === null) {
yield put(
actions.authenticateWithOidc.failure(new Error('Invalid OIDC response: no code parameter')),
);
return;
}

window.sessionStorage.removeItem('oidc-nonce');

yield put(replace(Paths.LOGIN));

if (oidcToken) {
if (code !== null) {
let accessToken;
try {
({ item: accessToken } = yield call(api.exchangeToAccessToken, {
token: oidcToken,
code,
nonce,
}));
} catch (error) {
yield put(actions.authenticateWithOidc.failure(error));
Expand Down
27 changes: 0 additions & 27 deletions client/src/utils/oidc-manager.js

This file was deleted.

5 changes: 1 addition & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,10 @@ services:

# - OIDC_ISSUER=
# - OIDC_CLIENT_ID=
# - OIDC_REDIRECT_URI=http://localhost:3000/oidc-callback
# - OIDC_CLIENT_SECRET=
# - OIDC_SCOPES=openid email profile
# - OIDC_JWKS_URI=
# - OIDC_AUDIENCE=
# - OIDC_ADMIN_ROLES=admin
# - OIDC_ROLES_ATTRIBUTE=groups
# - OIDC_SKIP_USER_INFO=true
depends_on:
- postgres

Expand Down
5 changes: 1 addition & 4 deletions server/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,10 @@ DEFAULT_ADMIN_USERNAME=demo

# OIDC_ISSUER=
# OIDC_CLIENT_ID=
# OIDC_REDIRECT_URI=http://localhost:1337/oidc-callback
# OIDC_CLIENT_SECRET=
# OIDC_SCOPES=openid email profile
# OIDC_JWKS_URI=
# OIDC_AUDIENCE=
# OIDC_ADMIN_ROLES=admin
# OIDC_ROLES_ATTRIBUTE=groups
# OIDC_SKIP_USER_INFO=true

## Do not edit this

Expand Down
8 changes: 6 additions & 2 deletions server/api/controllers/access-tokens/exchange.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ const Errors = {

module.exports = {
inputs: {
token: {
code: {
type: 'string',
required: true,
},
nonce: {
type: 'string',
required: true,
},
Expand All @@ -42,7 +46,7 @@ module.exports = {
const remoteAddress = getRemoteAddress(this.req);

const user = await sails.helpers.users
.getOrCreateOneByOidcToken(inputs.token)
.getOrCreateOneByOidcToken(inputs.code, inputs.nonce)
.intercept('invalidToken', () => {
sails.log.warn(`Invalid token! (IP: ${remoteAddress})`);
return Errors.INVALID_TOKEN;
Expand Down
21 changes: 13 additions & 8 deletions server/api/controllers/show-config.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
module.exports = {
fn() {
const oidcClient = sails.hooks.oidc.isActive() ? sails.hooks.oidc.getClient() : null;
return {
item: {
oidc: sails.config.custom.oidcIssuer
? {
issuer: sails.config.custom.oidcIssuer,
clientId: sails.config.custom.oidcClientId,
redirectUri: sails.config.custom.oidcRedirectUri,
scopes: sails.config.custom.oidcScopes,
}
: null,
oidc:
sails.config.custom.oidcIssuer !== ''
? {
authorizationUrl: oidcClient.authorizationUrl({
scope: sails.config.custom.oidcScopes,
response_mode: 'fragment',
}),
endSessionUrl: oidcClient.issuer.end_session_endpoint
? oidcClient.endSessionUrl({})
: null,
}
: null,
},
};
},
Expand Down
Loading

0 comments on commit 743f295

Please sign in to comment.