From af7327f60ea60d841f98042fb1337e781a9ef09b Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Tue, 25 May 2021 23:30:58 +0100 Subject: [PATCH 01/13] Add oidcAuthenticationSupplier Converts an unverified access token into a valid authentication object --- src/modules/oidc/authentication.js | 34 ++++++++++++ src/modules/oidc/authentication.test.js | 74 +++++++++++++++++++++++++ src/modules/oidc/index.js | 1 + 3 files changed, 109 insertions(+) create mode 100644 src/modules/oidc/authentication.js create mode 100644 src/modules/oidc/authentication.test.js create mode 100644 src/modules/oidc/index.js diff --git a/src/modules/oidc/authentication.js b/src/modules/oidc/authentication.js new file mode 100644 index 0000000..958248a --- /dev/null +++ b/src/modules/oidc/authentication.js @@ -0,0 +1,34 @@ +const DEFAULT_NAME = 'System'; + +const authentication = (accessToken, id, name, authorities, clientOnly, claims) => ({ + accessToken, + id, + name, + authorities, + clientOnly, + claims, +}); + +const clientAuthentication = (accessToken, clientId, authorities) => + authentication(accessToken, clientId, DEFAULT_NAME, authorities, true, null); + +const userAuthentication = (accessToken, subject, authorities, name, claims) => + authentication(accessToken, subject, name, authorities, false, claims); + +export const oidcAuthenticationSupplier = (config) => async (accessToken) => { + const { + accessTokenVerifier, + openidScope, + userClaimsSupplier, + } = config; + + const {clientId, scopes} = await accessTokenVerifier(accessToken); + + if (scopes.includes(openidScope)) { + const claims = await userClaimsSupplier(accessToken); + const {sub, name, roles} = claims; + return userAuthentication(accessToken, sub, roles, name, claims); + } + + return clientAuthentication(accessToken, clientId, scopes); +}; diff --git a/src/modules/oidc/authentication.test.js b/src/modules/oidc/authentication.test.js new file mode 100644 index 0000000..1220533 --- /dev/null +++ b/src/modules/oidc/authentication.test.js @@ -0,0 +1,74 @@ +import {oidcAuthenticationSupplier} from './authentication'; + +describe('oidcAuthenticationSupplier', () => { + test('should throw error when access token fails verification', async () => { + const authSupplier = oidcAuthenticationSupplier({ + accessTokenVerifier: (token) => Promise.reject(Error(`error: ${token}`)), + }); + + await expect(authSupplier('invalid token')).rejects.toThrow('error: invalid token'); + }); + + test('should return client authentication from scopes and client ID', async () => { + const authSupplier = oidcAuthenticationSupplier({ + accessTokenVerifier: (token) => Promise.resolve({ + clientId: `client-${token}`, + scopes: ['scope-1', 'scope-2'], + }), + }); + + const auth = await authSupplier('validtoken'); + + expect(auth).toEqual({ + accessToken: 'validtoken', + id: 'client-validtoken', + authorities: ['scope-1', 'scope-2'], + name: 'System', + clientOnly: true, + claims: null, + }); + }); + + test('should throw when user claims cannot be retrieved', async () => { + const authSupplier = oidcAuthenticationSupplier({ + accessTokenVerifier: (token) => Promise.resolve({ + clientId: `client-${token}`, + scopes: ['openid', 'scope-2'], + }), + openidScope: 'openid', + userClaimsSupplier: (token) => Promise.reject(Error(`error: no claims for ${token}`)), + }); + + await expect(authSupplier('validtoken')).rejects.toThrow('error: no claims for validtoken'); + }); + + test('should throw when user claims cannot be retrieved', async () => { + const authSupplier = oidcAuthenticationSupplier({ + accessTokenVerifier: (token) => Promise.resolve({ + clientId: `client-${token}`, + scopes: ['openid', 'scope-2'], + }), + openidScope: 'openid', + userClaimsSupplier: (token) => ({ + sub: 'user-123', + name: `name-${token}`, + roles: ['role-1', 'role-2'], + }), + }); + + const auth = await authSupplier('validtoken'); + + expect(auth).toEqual({ + accessToken: 'validtoken', + id: 'user-123', + authorities: ['role-1', 'role-2'], + name: 'name-validtoken', + clientOnly: false, + claims: { + sub: 'user-123', + name: 'name-validtoken', + roles: ['role-1', 'role-2'], + }, + }) + }); +}); diff --git a/src/modules/oidc/index.js b/src/modules/oidc/index.js new file mode 100644 index 0000000..3efa748 --- /dev/null +++ b/src/modules/oidc/index.js @@ -0,0 +1 @@ +export * from './authentication'; From beb33d45d453aecceccfb0d9854be70850f1ae24 Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Tue, 25 May 2021 23:58:30 +0100 Subject: [PATCH 02/13] Add oidcResourceServerGuard ExpressJS middleware extracting access token from request and converting it into valid authentication --- src/modules/oidc/index.js | 1 + src/modules/oidc/resource-server-guard.js | 24 +++++++ .../oidc/resource-server-guard.test.js | 65 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 src/modules/oidc/resource-server-guard.js create mode 100644 src/modules/oidc/resource-server-guard.test.js diff --git a/src/modules/oidc/index.js b/src/modules/oidc/index.js index 3efa748..fb9e1b1 100644 --- a/src/modules/oidc/index.js +++ b/src/modules/oidc/index.js @@ -1 +1,2 @@ export * from './authentication'; +export * from './resource-server-guard'; diff --git a/src/modules/oidc/resource-server-guard.js b/src/modules/oidc/resource-server-guard.js new file mode 100644 index 0000000..55dc31f --- /dev/null +++ b/src/modules/oidc/resource-server-guard.js @@ -0,0 +1,24 @@ +export const oidcResourceServerGuard = (config) => { + const { + accessTokenSupplier, + authenticationSupplier, + onError, + } = config; + + return async (req, res, next) => { + const accessToken = accessTokenSupplier(req); + + if (!accessToken) { + return onError({req, res, next, error: 'Access token missing'}); + } + + try { + const authentication = await authenticationSupplier(accessToken); + req.authentication = authentication; + } catch (error) { + return onError({req, res, next, error}); + } + + return next(); + }; +}; diff --git a/src/modules/oidc/resource-server-guard.test.js b/src/modules/oidc/resource-server-guard.test.js new file mode 100644 index 0000000..1f9ce42 --- /dev/null +++ b/src/modules/oidc/resource-server-guard.test.js @@ -0,0 +1,65 @@ +import {givenMiddleware} from '../../test-modules'; +import {oidcResourceServerGuard} from './resource-server-guard'; + +const newReq = (headers) => ({ + get: (key) => headers[key.toLowerCase()], +}); + +describe('oidcResourceServerGuard', () => { + test('should reject with error callback when no access token', async () => { + const guard = oidcResourceServerGuard({ + accessTokenSupplier: (req) => null, + onError: (e) => Promise.resolve(e), + }); + + const req = newReq({}); + const res = {}; + const next = () => {}; + + const {error} = await guard(req, res, next); + + expect(error).toBe('Access token missing'); + }); + + test('should reject with error callback when authentication could not be supplied', async () => { + const guard = oidcResourceServerGuard({ + accessTokenSupplier: (req) => req.get('Authorization'), + authenticationSupplier: (token) => Promise.reject(`error: no auth for ${token}`), + onError: (e) => Promise.resolve(e), + }); + + const req = newReq({ + 'authorization': 'access-token', + }); + const res = {}; + const next = () => {}; + + const {error} = await guard(req, res, next); + + expect(error).toBe('error: no auth for access-token'); + }); + + test('should add authentication details to request', async () => { + const guard = oidcResourceServerGuard({ + accessTokenSupplier: (req) => req.get('Authorization'), + authenticationSupplier: (token) => Promise.resolve({ + accessToken: token, + id: 'user-123', + }), + onError: (e) => Promise.reject(e), + }); + + const req = newReq({ + 'authorization': 'access-token', + }); + const res = {}; + const next = () => Promise.resolve(); + + await guard(req, res, next); + + expect(req.authentication).toEqual({ + accessToken: 'access-token', + id: 'user-123', + }); + }); +}); From c2b69d8afc2a28cdd249505e1f4144300c7a45fe Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Wed, 26 May 2021 09:28:45 +0100 Subject: [PATCH 03/13] Add oidcError401 handler Error handler returning a 401 response for ExpressJS --- src/modules/oidc/error-handlers.js | 1 + src/modules/oidc/error-handlers.test.js | 14 ++++++++++++++ src/modules/oidc/index.js | 1 + 3 files changed, 16 insertions(+) create mode 100644 src/modules/oidc/error-handlers.js create mode 100644 src/modules/oidc/error-handlers.test.js diff --git a/src/modules/oidc/error-handlers.js b/src/modules/oidc/error-handlers.js new file mode 100644 index 0000000..28dd1e8 --- /dev/null +++ b/src/modules/oidc/error-handlers.js @@ -0,0 +1 @@ +export const oidcError401 = ({res}) => res.status(401).send(); diff --git a/src/modules/oidc/error-handlers.test.js b/src/modules/oidc/error-handlers.test.js new file mode 100644 index 0000000..73db778 --- /dev/null +++ b/src/modules/oidc/error-handlers.test.js @@ -0,0 +1,14 @@ +import {oidcError401} from './error-handlers'; + +describe('oidcError401', () => { + test('should reply with 401 response', () => { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.send = jest.fn(); + + oidcError401({res}); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.send).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/oidc/index.js b/src/modules/oidc/index.js index fb9e1b1..6d4b1bf 100644 --- a/src/modules/oidc/index.js +++ b/src/modules/oidc/index.js @@ -1,2 +1,3 @@ export * from './authentication'; +export * from './error-handlers'; export * from './resource-server-guard'; From e1dd38f9a8384306ce6db6b730c33262bfefaaba Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Wed, 26 May 2021 15:22:50 +0100 Subject: [PATCH 04/13] Add jwtAccessTokenVerifier Validate and parse JWT access token to extract clientId and scopes --- src/modules/oidc/access-token.js | 4 ++++ src/modules/oidc/access-token.test.js | 27 +++++++++++++++++++++++++++ src/modules/oidc/index.js | 1 + 3 files changed, 32 insertions(+) create mode 100644 src/modules/oidc/access-token.js create mode 100644 src/modules/oidc/access-token.test.js diff --git a/src/modules/oidc/access-token.js b/src/modules/oidc/access-token.js new file mode 100644 index 0000000..c5b315f --- /dev/null +++ b/src/modules/oidc/access-token.js @@ -0,0 +1,4 @@ +export const jwtAccessTokenVerifier = ({jwtVerifier, jwtAccessTokenParser}) => async (jwtAccessToken) => { + const claims = await jwtVerifier(jwtAccessToken); + return jwtAccessTokenParser(claims); +}; diff --git a/src/modules/oidc/access-token.test.js b/src/modules/oidc/access-token.test.js new file mode 100644 index 0000000..95aa842 --- /dev/null +++ b/src/modules/oidc/access-token.test.js @@ -0,0 +1,27 @@ +import {jwtAccessTokenVerifier} from './access-token'; + +describe('jwtAccessTokenVerifier', () => { + test('should throw error if token cannot be verified', async () => { + const jwtVerifier = (token) => Promise.reject(Error(`token not valid: ${token}`)); + const jwtAccessTokenParser = () => []; + const verifier = jwtAccessTokenVerifier({jwtVerifier, jwtAccessTokenParser}); + + await expect(verifier('invalid-token')).rejects.toThrow('token not valid: invalid-token'); + }); + + test('should return parsed access token claims', async () => { + const jwtVerifier = (token) => Promise.resolve({ + sub: `client-${token}`, + scope: 'scope1 scope2', + }); + const jwtAccessTokenParser = (claims) => ({clientId: claims.sub, scopes: claims.scope.split(' ')}); + const verifier = jwtAccessTokenVerifier({jwtVerifier, jwtAccessTokenParser}); + + const tokenClaims = await verifier('validtoken'); + + expect(tokenClaims).toEqual({ + clientId: 'client-validtoken', + scopes: ['scope1', 'scope2'], + }); + }); +}); diff --git a/src/modules/oidc/index.js b/src/modules/oidc/index.js index 6d4b1bf..e71a054 100644 --- a/src/modules/oidc/index.js +++ b/src/modules/oidc/index.js @@ -1,3 +1,4 @@ +export * from './access-token'; export * from './authentication'; export * from './error-handlers'; export * from './resource-server-guard'; From 56e8ba5552016dd40a5640315bfb47d935ab77d5 Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Wed, 26 May 2021 15:55:46 +0100 Subject: [PATCH 05/13] Add simpleJwtAccessTokenParser Parse fixed claims from JWT tokens into consistent object --- src/modules/oidc/access-token.js | 11 ++++++++++ src/modules/oidc/access-token.test.js | 29 ++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/modules/oidc/access-token.js b/src/modules/oidc/access-token.js index c5b315f..45361b6 100644 --- a/src/modules/oidc/access-token.js +++ b/src/modules/oidc/access-token.js @@ -1,3 +1,14 @@ +export const simpleJwtAccessTokenParser = () => (claims) => { + + const mapClaim = (claim) => (map = (v) => v, defaultValue) => + Object.keys(claims).includes(claim) ? map(claims[claim]) : defaultValue; + + return ({ + clientId: mapClaim('sub')(), + scopes: mapClaim('scope')((claim) => claim.split(' '), []), + }); +}; + export const jwtAccessTokenVerifier = ({jwtVerifier, jwtAccessTokenParser}) => async (jwtAccessToken) => { const claims = await jwtVerifier(jwtAccessToken); return jwtAccessTokenParser(claims); diff --git a/src/modules/oidc/access-token.test.js b/src/modules/oidc/access-token.test.js index 95aa842..d166695 100644 --- a/src/modules/oidc/access-token.test.js +++ b/src/modules/oidc/access-token.test.js @@ -1,4 +1,31 @@ -import {jwtAccessTokenVerifier} from './access-token'; +import {simpleJwtAccessTokenParser, jwtAccessTokenVerifier} from './access-token'; + +describe('simpleJwtAccessTokenParser', () => { + test('should return empty scopes array when no `scope` claim', async () => { + const {scopes} = simpleJwtAccessTokenParser()({}); + + expect(scopes).toEqual([]); + }); + + test('should extract and split scopes from `scope` claim', async () => { + const {scopes} = simpleJwtAccessTokenParser()({scope: 'scope1 scope2'}); + + expect(scopes).toEqual(['scope1', 'scope2']); + }); + + test('should default clientId to undefined', async () => { + const {clientId} = simpleJwtAccessTokenParser()({}); + + expect(clientId).toBeUndefined(); + }); + + test('should extract clientId from `sub` claim', async () => { + const {clientId} = simpleJwtAccessTokenParser()({sub: 'client-123'}); + + expect(clientId).toEqual('client-123'); + }); +}); + describe('jwtAccessTokenVerifier', () => { test('should throw error if token cannot be verified', async () => { From d9e51707375c4e41f184e5cacae449c7e34834ec Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Wed, 26 May 2021 16:04:34 +0100 Subject: [PATCH 06/13] Add userInfoRetriever Copy from `defaultUserInfoRetriever` to isolate from future breaking changes. --- src/modules/oidc/index.js | 1 + src/modules/oidc/user-info.js | 4 ++++ src/modules/oidc/user-info.test.js | 21 +++++++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 src/modules/oidc/user-info.js create mode 100644 src/modules/oidc/user-info.test.js diff --git a/src/modules/oidc/index.js b/src/modules/oidc/index.js index e71a054..4b7b653 100644 --- a/src/modules/oidc/index.js +++ b/src/modules/oidc/index.js @@ -2,3 +2,4 @@ export * from './access-token'; export * from './authentication'; export * from './error-handlers'; export * from './resource-server-guard'; +export * from './user-info'; diff --git a/src/modules/oidc/user-info.js b/src/modules/oidc/user-info.js new file mode 100644 index 0000000..4209d7b --- /dev/null +++ b/src/modules/oidc/user-info.js @@ -0,0 +1,4 @@ +import axios from 'axios'; + +export const userInfoRetriever = ({userInfoUri}) => (accessToken) => + axios.get(userInfoUri, {headers: {'Authorization': `Bearer ${accessToken}`}}).then(res => res.data); diff --git a/src/modules/oidc/user-info.test.js b/src/modules/oidc/user-info.test.js new file mode 100644 index 0000000..fa77538 --- /dev/null +++ b/src/modules/oidc/user-info.test.js @@ -0,0 +1,21 @@ +import axios from 'axios'; +import { + userInfoRetriever, +} from './user-info'; + +jest.mock('axios'); + +describe('userInfoRetriever', () => { + test('should fetch user info', async () => { + const userInfoUri = 'https://idam/oidc/userInfo'; + const accessToken = 'access-token-123'; + const expectedResp = {data: {sub: '123'}}; + + axios.get.mockResolvedValue(expectedResp); + + const resp = await userInfoRetriever({userInfoUri})(accessToken); + + expect(resp).toEqual(expectedResp.data); + expect(axios.get).toHaveBeenCalledWith(userInfoUri, {headers: {Authorization: `Bearer ${accessToken}`}}); + }); +}); From 7af042456c7380de1aa56f580b0be734f05e870e Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Thu, 27 May 2021 09:01:36 +0100 Subject: [PATCH 07/13] Add userInfoExtractor Given a map of claim names, extract and parse claims into a consistent object --- src/modules/oidc/user-info.js | 18 +++++++++ src/modules/oidc/user-info.test.js | 59 ++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/modules/oidc/user-info.js b/src/modules/oidc/user-info.js index 4209d7b..aed2d53 100644 --- a/src/modules/oidc/user-info.js +++ b/src/modules/oidc/user-info.js @@ -2,3 +2,21 @@ import axios from 'axios'; export const userInfoRetriever = ({userInfoUri}) => (accessToken) => axios.get(userInfoUri, {headers: {'Authorization': `Bearer ${accessToken}`}}).then(res => res.data); + +export const userInfoExtractor = (claimNamesProvider) => (userInfo) => { + const claimNames = claimNamesProvider(); + + const claimsSchema = [ + {key: 'sub'}, + {key: 'name'}, + {key: 'email'}, + {key: 'roles', map: (roles) => roles.split(','), defaultValue: []}, + {key: 'organisations', map: JSON.parse, defaultValue: {}}, + ]; + + return claimsSchema.reduce((acc, {key, map = (i) => i, defaultValue}) => { + const rawValue = userInfo[claimNames[key]]; + const value = (rawValue !== undefined && rawValue !== null) ? map(rawValue) : defaultValue; + return {...acc, [key]: value}; + }, {}); +}; diff --git a/src/modules/oidc/user-info.test.js b/src/modules/oidc/user-info.test.js index fa77538..2cd1c7c 100644 --- a/src/modules/oidc/user-info.test.js +++ b/src/modules/oidc/user-info.test.js @@ -1,5 +1,6 @@ import axios from 'axios'; import { + userInfoExtractor, userInfoRetriever, } from './user-info'; @@ -19,3 +20,61 @@ describe('userInfoRetriever', () => { expect(axios.get).toHaveBeenCalledWith(userInfoUri, {headers: {Authorization: `Bearer ${accessToken}`}}); }); }); + +describe('userInfoExtractor', () => { + test('should parse user info claims from provided names', async () => { + const claimNames = ({ + sub: 'sub', + name: 'name', + email: 'email', + roles: 'app.quickcase.claims/roles', + organisations: 'app.quickcase.claims/organisations', + }); + + const claims = userInfoExtractor(() => claimNames)({ + 'sub': 'user-123', + 'name': 'Test User', + 'email': 'test-user@quickcase.app', + 'app.quickcase.claims/roles': 'role1,role2', + 'app.quickcase.claims/organisations': '{"ORG1": {"access": "GROUP"}}', + }); + + expect(claims).toEqual({ + sub: 'user-123', + name: 'Test User', + email: 'test-user@quickcase.app', + roles: ['role1', 'role2'], + organisations: { + 'ORG1': { + 'access': 'GROUP', + } + } + }); + }); + + test('should default value when provided names do not match', async () => { + const claimNames = ({ + sub: 'not/sub', + name: 'not/name', + email: 'not/email', + roles: 'not/app.quickcase.claims/roles', + organisations: 'not/app.quickcase.claims/organisations', + }); + + const claims = userInfoExtractor(() => claimNames)({ + 'sub': 'user-123', + 'name': 'Test User', + 'email': 'test-user@quickcase.app', + 'app.quickcase.claims/roles': 'role1,role2', + 'app.quickcase.claims/organisations': '{"ORG1": {"access": "GROUP"}}', + }); + + expect(claims).toEqual({ + sub: undefined, + name: undefined, + email: undefined, + roles: [], + organisations: {} + }); + }); +}); From 3d09c0864e4b05178171a09657b62fe81bdf8257 Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Thu, 27 May 2021 16:18:09 +0100 Subject: [PATCH 08/13] Add claimNamesProvider Provides full, optionally prefixed claim names as configured. --- src/modules/oidc/user-info.js | 14 ++++++++++ src/modules/oidc/user-info.test.js | 45 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/modules/oidc/user-info.js b/src/modules/oidc/user-info.js index aed2d53..9866007 100644 --- a/src/modules/oidc/user-info.js +++ b/src/modules/oidc/user-info.js @@ -1,5 +1,19 @@ import axios from 'axios'; +const DEFAULT_PREFIX = ''; + +const PUBLIC_CLAIMS = ['sub', 'name', 'email']; +const PRIVATE_CLAIMS = ['roles', 'organisations']; + +export const claimNamesProvider = (config) => () => { + const prefix = typeof config.prefix === 'string' ? config.prefix : DEFAULT_PREFIX; + const {names} = config; + return { + ...PUBLIC_CLAIMS.reduce((acc, claim) => ({...acc, [claim]: names[claim]}), {}), + ...PRIVATE_CLAIMS.reduce((acc, claim) => ({...acc, [claim]: prefix + names[claim]}), {}), + }; +}; + export const userInfoRetriever = ({userInfoUri}) => (accessToken) => axios.get(userInfoUri, {headers: {'Authorization': `Bearer ${accessToken}`}}).then(res => res.data); diff --git a/src/modules/oidc/user-info.test.js b/src/modules/oidc/user-info.test.js index 2cd1c7c..f7caebe 100644 --- a/src/modules/oidc/user-info.test.js +++ b/src/modules/oidc/user-info.test.js @@ -1,11 +1,56 @@ import axios from 'axios'; import { + claimNamesProvider, userInfoExtractor, userInfoRetriever, } from './user-info'; jest.mock('axios'); +describe('claimNamesProvider', () => { + test('should provide non-prefixed claim names', () => { + const claimNames = claimNamesProvider({ + prefix: undefined, + names: { + sub: 'custom-sub', + name: 'custom-name', + email: 'custom-email', + roles: 'app.quickcase.claims/roles', + organisations: 'app.quickcase.claims/organisations', + }, + })(); + + expect(claimNames).toEqual({ + sub: 'custom-sub', + name: 'custom-name', + email: 'custom-email', + roles: 'app.quickcase.claims/roles', + organisations: 'app.quickcase.claims/organisations', + }); + }); + + test('should provide prefixed claim names', () => { + const claimNames = claimNamesProvider({ + prefix: 'a-prefix:', + names: { + sub: 'custom-sub', + name: 'custom-name', + email: 'custom-email', + roles: 'app.quickcase.claims/roles', + organisations: 'app.quickcase.claims/organisations', + }, + })(); + + expect(claimNames).toEqual({ + sub: 'custom-sub', + name: 'custom-name', + email: 'custom-email', + roles: 'a-prefix:app.quickcase.claims/roles', + organisations: 'a-prefix:app.quickcase.claims/organisations', + }); + }); +}); + describe('userInfoRetriever', () => { test('should fetch user info', async () => { const userInfoUri = 'https://idam/oidc/userInfo'; From fb603c324a6cde10ed44b959a391c3cf21a3917b Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Thu, 27 May 2021 16:45:07 +0100 Subject: [PATCH 09/13] Add userClaimsSupplier Combine userInfo retrieval and extraction. --- src/modules/oidc/user-info.js | 3 +++ src/modules/oidc/user-info.test.js | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/modules/oidc/user-info.js b/src/modules/oidc/user-info.js index 9866007..a88db60 100644 --- a/src/modules/oidc/user-info.js +++ b/src/modules/oidc/user-info.js @@ -34,3 +34,6 @@ export const userInfoExtractor = (claimNamesProvider) => (userInfo) => { return {...acc, [key]: value}; }, {}); }; + +export const userClaimsSupplier = ({userInfoRetriever, userInfoExtractor}) => (accessToken) => + userInfoRetriever(accessToken).then(userInfoExtractor); diff --git a/src/modules/oidc/user-info.test.js b/src/modules/oidc/user-info.test.js index f7caebe..cea3a10 100644 --- a/src/modules/oidc/user-info.test.js +++ b/src/modules/oidc/user-info.test.js @@ -1,6 +1,7 @@ import axios from 'axios'; import { claimNamesProvider, + userClaimsSupplier, userInfoExtractor, userInfoRetriever, } from './user-info'; @@ -123,3 +124,16 @@ describe('userInfoExtractor', () => { }); }); }); + +describe('userClaimsSupplier', () => { + test('should return extracted claims', async () => { + const supplier = userClaimsSupplier({ + userInfoRetriever: (token) => Promise.resolve({sub: `user-${token}`}), + userInfoExtractor: (userInfo) => ({sub: `extracted: ${userInfo.sub}`}), + }); + + const {sub} = await supplier('a-token'); + + expect(sub).toEqual('extracted: user-a-token'); + }); +}); From 7d98a026af6809ff3ac7ffe928a7984b59183daf Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Fri, 28 May 2021 15:58:05 +0100 Subject: [PATCH 10/13] Config: Add mergeConfig(defaultConfig)(overrides) Config helper to merge default config with runtime overrides. --- README.md | 56 ++++++++++++++++++++++++++++ src/modules/config.js | 16 ++++++++ src/modules/config.test.js | 76 ++++++++++++++++++++++++++++++++++++++ src/modules/index.js | 1 + 4 files changed, 149 insertions(+) create mode 100644 src/modules/config.js create mode 100644 src/modules/config.test.js diff --git a/README.md b/README.md index b478a44..8ac7a88 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ npm i @quickcase/node-toolkit * [Cache](#cache) * [Case](#case) * [Case Access](#case-access) +* [Config](#config) * [Definition](#definition) * [Document](#document) * [Express](#express) @@ -434,6 +435,61 @@ const client = httpClient('http://data-store:4452')(() => Promise.resolve('acces await revokeUserAccess(client)('1234123412341238')('user-1'); ``` +### Config + +Utilities to deal with configuration objects. + +#### mergeConfig(defaultConfig)(overrides) + +Deep merge of a default configuration with partial overrides. + +##### Arguments + +| Name | Type | Description | +|------|------|-------------| +| defaultConfig | object| Required. Object representing the entire configuration contract with default values for all properties. Properties which do not have a default value must be explicitly assigned `undefined`. | +| overrides | object | Required. Subset of `defaultConfig`. Overridden properties must exactly match the shape of `defaultConfig` | + +##### Returns + +`object` with the same shape as `defaultConfig` and containing the merged properties of `defaultConfig` and `overrides`. + +#### Example + +```javascript +import {mergeConfig} from '@quickcase/node-toolkit'; + +const DEFAULT_CONFIG = { + prop1: 'value1', + prop2: { + prop21: 'value21', + prop22: 'value21', + prop23: 'value23', + }, + prop3: undefined, +}; + +const config = mergeConfig(DEFAULT_CONFIG)({ + prop2: { + prop21: undefined, + prop22: 'override21', + prop23: null, + } +}); + +/* +{ + prop1: 'value1', + prop2: { + prop21: 'value21', + prop22: 'override21', + prop23: null, + }, + prop3: undefined, +} +*/ +``` + ### Definition #### fetchCaseType(httpClient)(caseTypeId)() diff --git a/src/modules/config.js b/src/modules/config.js new file mode 100644 index 0000000..bc5b32c --- /dev/null +++ b/src/modules/config.js @@ -0,0 +1,16 @@ +export const mergeConfig = (defaultConfig) => (override = {}) => { + const mergedEntries = Object.entries(defaultConfig) + .map(([key, sourceValue]) => [key, sourceValue, override[key]]) + .map(mergeConfigEntry); + return Object.fromEntries(mergedEntries); +}; + +const mergeConfigEntry = ([key, sourceValue, overrideValue]) => { + if (sourceValue && typeof sourceValue === 'object') { + if (overrideValue && typeof overrideValue === 'object') { + return [key, mergeConfig(sourceValue)(overrideValue)]; + } + return [key, sourceValue]; + } + return [key, overrideValue !== undefined ? overrideValue : sourceValue]; +}; diff --git a/src/modules/config.test.js b/src/modules/config.test.js new file mode 100644 index 0000000..41fd137 --- /dev/null +++ b/src/modules/config.test.js @@ -0,0 +1,76 @@ +import {mergeConfig} from './config'; + +describe('mergeConfig', () => { + test('should return default configuration when no override provided', () => { + const config = mergeConfig({prop1: 'value1'})(undefined); + expect(config).toEqual({prop1: 'value1'}); + }); + + test('should return default configuration when empty override provided', () => { + const config = mergeConfig({prop1: 'value1'})({}); + expect(config).toEqual({prop1: 'value1'}); + }); + + test('should override top-level properties', () => { + const config = mergeConfig({ + prop1: 'value1', + prop2: 'value2', + })({ + prop1: 'value3', + }); + expect(config).toEqual({prop1: 'value3', prop2: 'value2'}); + }); + + test('should override nested properties', () => { + const config = mergeConfig({ + prop1: 'value1', + prop2: { + prop21: 'value21', + prop22: 'value22', + }, + })({ + prop2: { + prop22: 'value--' + } + }); + expect(config).toEqual({ + prop1: 'value1', + prop2: { + prop21: 'value21', + prop22: 'value--', + }, + }); + }); + + test('should ignore incorrect parent node overrides', () => { + const config = mergeConfig({ + prop1: 'value1', + prop2: { + prop21: 'value21', + prop22: 'value22', + }, + })({ + prop2: 'incorrect type', + }); + expect(config).toEqual({ + prop1: 'value1', + prop2: { + prop21: 'value21', + prop22: 'value22', + }, + }); + }); + + test('should accept null overrides', () => { + const config = mergeConfig({ + prop1: 'value1', + prop2: 'value2', + })({ + prop2: null, + }); + expect(config).toEqual({ + prop1: 'value1', + prop2: null, + }); + }); +}); diff --git a/src/modules/index.js b/src/modules/index.js index 7a0c44d..2e470a5 100644 --- a/src/modules/index.js +++ b/src/modules/index.js @@ -1,6 +1,7 @@ export * from './cache'; export * from './case'; export * from './case-access'; +export * from './config'; export * from './definition'; export * from './document'; export * from './express'; From cf0cbee643e3ac37036103db310bcc0106311bc2 Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Tue, 1 Jun 2021 15:57:32 +0100 Subject: [PATCH 11/13] Config: Add camelConfig Helper converting config keys into camel case --- README.md | 36 ++++++++++++++++++++++++++++++++++++ package-lock.json | 29 +++++++++++++++++++++++++---- package.json | 1 + src/modules/config.js | 16 ++++++++++++++++ src/modules/config.test.js | 34 +++++++++++++++++++++++++++++++++- 5 files changed, 111 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8ac7a88..9cd410e 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,42 @@ await revokeUserAccess(client)('1234123412341238')('user-1'); Utilities to deal with configuration objects. +#### camelConfig(config) + +Recursively convert keys in configuration objects to camel case for consistency and ease of use. + +##### Arguments + +| Name | Type | Description | +|------|------|-------------| +| config | object | Required. Object containing the key/value pair of configuration properties. | + +##### Returns + +`object` with the same shape as `config` but for which all keys are now camel case. Values are preserved unaltered. + +#### Example + +```javascript +import {camelConfig} from '@quickcase/node-toolkit'; + +const config = camelConfig({ + 'a-prop-2': { + 'a_prop_21': undefined, + 'a-prop-22': 'override21', + }, +}); + +/* +{ + aProp2: { + aProp21: 'value21', + aProp22: 'override21', + }, +} +*/ +``` + #### mergeConfig(defaultConfig)(overrides) Deep merge of a default configuration with partial overrides. diff --git a/package-lock.json b/package-lock.json index 2dc63f3..6349365 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3276,6 +3276,14 @@ "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + } } }, "@istanbuljs/schema": { @@ -4616,10 +4624,9 @@ "dev": true }, "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", + "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==" }, "caniuse-lite": { "version": "1.0.30001218", @@ -9332,6 +9339,14 @@ "requires": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + } } }, "yargs-unparser": { @@ -9347,6 +9362,12 @@ "yargs": "^14.2.3" }, "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, "cliui": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", diff --git a/package.json b/package.json index 342bec0..cabf503 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "homepage": "https://github.com/quickcase/node-toolkit#readme", "dependencies": { "axios": "^0.21.1", + "camelcase": "^6.2.0", "jsonwebtoken": "^8.5.1", "jwks-rsa": "^2.0.3", "redis": "^3.0.2" diff --git a/src/modules/config.js b/src/modules/config.js index bc5b32c..e4c2654 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -1,3 +1,5 @@ +import camelCase from 'camelcase'; + export const mergeConfig = (defaultConfig) => (override = {}) => { const mergedEntries = Object.entries(defaultConfig) .map(([key, sourceValue]) => [key, sourceValue, override[key]]) @@ -14,3 +16,17 @@ const mergeConfigEntry = ([key, sourceValue, overrideValue]) => { } return [key, overrideValue !== undefined ? overrideValue : sourceValue]; }; + +export const camelConfig = (config) => { + const entries = Object.entries(config) + .map(camelConfigEntry); + return Object.fromEntries(entries); +}; + +const camelConfigEntry = ([key, value]) => { + if (value && typeof value === 'object') { + return [camelCase(key), camelConfig(value)]; + } + + return [camelCase(key), value]; +}; diff --git a/src/modules/config.test.js b/src/modules/config.test.js index 41fd137..6b919be 100644 --- a/src/modules/config.test.js +++ b/src/modules/config.test.js @@ -1,4 +1,4 @@ -import {mergeConfig} from './config'; +import {camelConfig, mergeConfig} from './config'; describe('mergeConfig', () => { test('should return default configuration when no override provided', () => { @@ -74,3 +74,35 @@ describe('mergeConfig', () => { }); }); }); + +describe('camelConfig', () => { + test('should return config as is when already in camel case', () => { + const config = camelConfig({prop1: 'value1'}); + expect(config).toEqual({prop1: 'value1'}); + }); + + test('should camel case top-level properties', () => { + const config = camelConfig({ + prop1: 'value1', + 'a-prop-2': 'value2', + }); + expect(config).toEqual({prop1: 'value1', aProp2: 'value2'}); + }); + + test('should camel case nested properties', () => { + const config = camelConfig({ + prop1: 'value1', + prop2: { + 'a-nested-prop-2': 'value21', + 'another_nested_prop_2': 'value22', + }, + }); + expect(config).toEqual({ + prop1: 'value1', + prop2: { + aNestedProp2: 'value21', + anotherNestedProp2: 'value22', + }, + }); + }); +}); From ae6cf55edf305c09848d3827365d2f4d93657040 Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Tue, 1 Jun 2021 10:43:56 +0100 Subject: [PATCH 12/13] Add QuickCase pre-configured OIDC helpers Aligned with QuickCase standard configuration for ease of use. --- package-lock.json | 5 + package.json | 1 + src/modules/oidc/index.js | 1 + src/modules/oidc/quickcase-config.js | 110 ++++++++++++++++++++ src/modules/oidc/quickcase-config.test.js | 116 ++++++++++++++++++++++ 5 files changed, 233 insertions(+) create mode 100644 src/modules/oidc/quickcase-config.js create mode 100644 src/modules/oidc/quickcase-config.test.js diff --git a/package-lock.json b/package-lock.json index 6349365..bd6ee53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7406,6 +7406,11 @@ "integrity": "sha1-T80kbcXQ44aRkHxEqwAveC0dlMw=", "dev": true }, + "jsonschema": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.4.0.tgz", + "integrity": "sha512-/YgW6pRMr6M7C+4o8kS+B/2myEpHCrxO4PEWnqJNBFMjn7EWXqlQ4tGwL6xTHeRplwuZmcAncdvfOad1nT2yMw==" + }, "jsonwebtoken": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", diff --git a/package.json b/package.json index cabf503..0c32be3 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "axios": "^0.21.1", "camelcase": "^6.2.0", + "jsonschema": "^1.4.0", "jsonwebtoken": "^8.5.1", "jwks-rsa": "^2.0.3", "redis": "^3.0.2" diff --git a/src/modules/oidc/index.js b/src/modules/oidc/index.js index 4b7b653..cdfc4d7 100644 --- a/src/modules/oidc/index.js +++ b/src/modules/oidc/index.js @@ -1,5 +1,6 @@ export * from './access-token'; export * from './authentication'; export * from './error-handlers'; +export * from './quickcase-config'; export * from './resource-server-guard'; export * from './user-info'; diff --git a/src/modules/oidc/quickcase-config.js b/src/modules/oidc/quickcase-config.js new file mode 100644 index 0000000..6a1f720 --- /dev/null +++ b/src/modules/oidc/quickcase-config.js @@ -0,0 +1,110 @@ +import {validate} from 'jsonschema'; +import {camelConfig, mergeConfig} from '../config'; +import { + cachedJwtKeySupplier, + defaultJwtKeySupplier, + defaultJwtVerifier, + headerAccessTokenSupplier, +} from '../oauth2'; +import { + claimNamesProvider, + jwtAccessTokenVerifier, + oidcAuthenticationSupplier, + oidcResourceServerGuard, + oidcError401, + simpleJwtAccessTokenParser, + userClaimsSupplier, + userInfoExtractor, + userInfoRetriever, +} from './'; + +const CONFIG_SCHEMA = { + type: 'object', + properties: { + 'jwk-set-uri': {type: 'string', format: 'uri'}, + 'user-info-uri': {type: 'string', format: 'uri'}, + 'openid-scope': {type: 'string', minLength: 1}, + 'claims': { + type: 'object', + properties: { + 'prefix': {type: 'string', minLength: 0}, + 'names': { + type: 'object', + properties: { + 'sub': {type: 'string', minLength: 1}, + 'name': {type: 'string', minLength: 1}, + 'email': {type: 'string', minLength: 1}, + 'roles': {type: 'string', minLength: 1}, + 'organisations': {type: 'string', minLength: 1}, + }, + required: ['sub', 'name', 'email', 'roles', 'organisations'], + }, + }, + required: ['prefix', 'names'], + }, + }, + required: ['jwk-set-uri', 'user-info-uri', 'openid-scope', 'claims'], +}; + +const OIDC_DEFAULTS = { + 'jwk-set-uri': null, + 'user-info-uri': null, + 'openid-scope': 'openid', + claims: { + prefix: '', + names: { + sub: 'sub', + name: 'name', + email: 'email', + roles: 'app.quickcase.claims/roles', + organisations: 'app.quickcase.claims/organisations', + }, + }, +}; + +const withOidcDefaults = mergeConfig(OIDC_DEFAULTS); + +const sanitiseConfig = (config) => { + const defaultedConfig = withOidcDefaults(config); + + const validationResult = validate(defaultedConfig, CONFIG_SCHEMA); + + if (!validationResult.valid) { + const error = validationResult.errors[0]; + const prefixedProperty = error.property.replace(/^instance/, 'quickcase.oidc'); + error.message = `OIDC configuration property '${prefixedProperty}' ${error.message}` + throw error; + } + + return camelConfig(defaultedConfig); +}; + +export const quickcaseJwtVerifier = (config) => _quickcaseJwtVerifier(sanitiseConfig(config)); + +const _quickcaseJwtVerifier = (oidcConfig) => { + const jwtKeySupplier = defaultJwtKeySupplier({jwksUri: oidcConfig.jwkSetUri}); + const cacheConfig = {ttlMs: 5 * 60 * 1000} // 5 minutes + return defaultJwtVerifier(cachedJwtKeySupplier(cacheConfig)(jwtKeySupplier)); +}; + +export const quickcaseAuthenticationSupplier = (config) => _quickcaseAuthenticationSupplier(sanitiseConfig(config)); + +const _quickcaseAuthenticationSupplier = (oidcConfig) => oidcAuthenticationSupplier({ + accessTokenVerifier: jwtAccessTokenVerifier({ + jwtVerifier: _quickcaseJwtVerifier(oidcConfig), + jwtAccessTokenParser: simpleJwtAccessTokenParser(), + }), + openidScope: oidcConfig.openidScope, + userClaimsSupplier: userClaimsSupplier({ + userInfoRetriever: userInfoRetriever({userInfoUri: oidcConfig.userInfoUri}), + userInfoExtractor: userInfoExtractor(claimNamesProvider(oidcConfig.claims)), + }), +}); + +export const quickcaseResourceServerGuard = (config) => _quickcaseResourceServerGuard(sanitiseConfig(config)); + +const _quickcaseResourceServerGuard = (oidcConfig) => oidcResourceServerGuard({ + accessTokenSupplier: headerAccessTokenSupplier('Authorization'), + authenticationSupplier: _quickcaseAuthenticationSupplier(oidcConfig), + onError: oidcError401, +}); diff --git a/src/modules/oidc/quickcase-config.test.js b/src/modules/oidc/quickcase-config.test.js new file mode 100644 index 0000000..8634e17 --- /dev/null +++ b/src/modules/oidc/quickcase-config.test.js @@ -0,0 +1,116 @@ +import axios from 'axios'; +import jwt from 'jsonwebtoken'; +import jwksClient from 'jwks-rsa'; +import { + quickcaseAuthenticationSupplier, + quickcaseJwtVerifier, + quickcaseResourceServerGuard, +} from './quickcase-config'; +import {givenMiddleware} from '../../test-modules'; + +jest.mock('axios'); +jest.mock('jwks-rsa'); + +describe('quickcaseJwtVerifier', () => { + test('should validate config', () => { + expect(() => quickcaseJwtVerifier({})) + .toThrow(`OIDC configuration property 'quickcase.oidc.jwk-set-uri' is not of a type(s) string`); + }); +}); + +describe('quickcaseAuthenticationSupplier', () => { + test('should validate config', () => { + expect(() => quickcaseAuthenticationSupplier({})) + .toThrow(`OIDC configuration property 'quickcase.oidc.jwk-set-uri' is not of a type(s) string`); + }); +}); + +describe('quickcaseResourceServerGuard', () => { + const KEY_ID = 'key-123'; + const SECRET_KEY = 'thisIsTheSecret'; + + const newJwt = (claims = {}, options = {}) => + jwt.sign(claims, `${KEY_ID}-${SECRET_KEY}`, Object.assign({expiresIn: 5}, options)); + + test('should fail when configuration missing required prop', () => { + expect(() => quickcaseResourceServerGuard({})) + .toThrow(`OIDC configuration property 'quickcase.oidc.jwk-set-uri' is not of a type(s) string`); + }); + + test('should fail when configuration blanking required default', () => { + expect(() => quickcaseResourceServerGuard({ + 'jwk-set-uri': 'https://idam/jwks', + 'user-info-uri': 'https://idam/oidc/userInfo', + claims: { + names: { + roles: '', + }, + }, + })).toThrow(`OIDC configuration property 'quickcase.oidc.claims.names.roles' does not meet minimum length of 1`); + }); + + test('should provide a fully configured resource server guard middleware', async () => { + const oidcConfig = { + 'jwk-set-uri': 'https://idam/jwks', + 'user-info-uri': 'https://idam/oidc/userInfo', + claims: { + prefix: 'qc:', + names: { + roles: 'claims/roles', + organisations: 'claims/organisations', + }, + }, + }; + + axios.get.mockImplementation((url) => ({ + [oidcConfig['jwk-set-uri']]: Promise.resolve({data: {}}), + [oidcConfig['user-info-uri']]: Promise.resolve({data: { + 'sub': 'jdoe-123', + 'name': 'John Doe', + 'email': 'john.doe@quickcase.app', + 'qc:claims/roles': 'roleA,roleB', + 'qc:claims/organisations': '{"OrgA": {"access": "organisation"}}', + }}), + })[url]); + + jwksClient.mockReturnValue({ + getSigningKey: (kid, cb) => cb(null, {rsaPublicKey: `${kid}-${SECRET_KEY}`}), + }); + + const token = newJwt({ + 'sub': 'jdoe-123', + 'scope': 'openid profile', + }, { + keyid: KEY_ID, + }); + + const req = { + get: (header) => ({ + 'authorization': `Bearer ${token}`, + })[header.toLowerCase()], + }; + + const guard = quickcaseResourceServerGuard(oidcConfig); + + await givenMiddleware(guard).when(req).expectNext(); + + expect(req.authentication).toEqual({ + accessToken: token, + authorities: ['roleA', 'roleB'], + clientOnly: false, + id: 'jdoe-123', + name: 'John Doe', + claims: { + sub: 'jdoe-123', + name: 'John Doe', + email: 'john.doe@quickcase.app', + roles: ['roleA', 'roleB'], + organisations: { + 'OrgA': { + 'access': 'organisation' + }, + }, + } + }); + }); +}); From 234248a52c1ced79d489f2e7088e5473df50db54 Mon Sep 17 00:00:00 2001 From: Valentin Laurin Date: Tue, 1 Jun 2021 10:57:43 +0100 Subject: [PATCH 13/13] Export OIDC sub-module --- src/modules/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/index.js b/src/modules/index.js index 2e470a5..6ae14ad 100644 --- a/src/modules/index.js +++ b/src/modules/index.js @@ -8,5 +8,6 @@ export * from './express'; export * from './field'; export * from './http-client'; export * from './oauth2'; +export * from './oidc'; export * from './search'; export * from './redis-gateway';