@carisls/sso-standard 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.gitlab-ci.yml ADDED
@@ -0,0 +1,16 @@
1
+ stages:
2
+ - publish
3
+
4
+ publish:
5
+ stage: publish
6
+ image: node:22-alpine
7
+ before_script:
8
+ - echo -e "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> .npmrc
9
+ script:
10
+ - npm i
11
+ - npm run build --if-present
12
+ - npm publish --access public
13
+ only:
14
+ - main
15
+ tags:
16
+ - build-image
@@ -0,0 +1,67 @@
1
+ const debugModule = require('debug');
2
+ const debug = debugModule('keycloak-client:token:authorize');
3
+
4
+ function checkException (url, exceptions) {
5
+ if (!exceptions || !exceptions.length)
6
+ return false;
7
+
8
+ for (let i = 0; i < exceptions.length; i++) {
9
+ if (url.length === exceptions[i].length)
10
+ return true;
11
+ else if (url.length > exceptions[i].length && url.substr(0, exceptions[i].length) === exceptions[i])
12
+ return true;
13
+ }
14
+ return false;
15
+ }
16
+ /**
17
+ * @description A helper middleware to perform authorization filtering
18
+ * @param {*} role Role (string) or an array of roles to filter by (optional)
19
+ * @param {*} exceptions Array of urls to skip
20
+ * @param {*} redirectToLogin If user needs to be redirected to login in case of unsuccessful validation
21
+ * @returns
22
+ */
23
+
24
+ function authorize (role, exceptions, redirectToLogin = false) {
25
+ return (req, res, next) => {
26
+ // Check if this is login or sso url
27
+ if (req.url.match(/^\/login/i) || req.url.match(/^\/sso/i)) {
28
+ debug(`Url ${req.url} is exception from default authorization rules`);
29
+ next();
30
+ }
31
+
32
+ // Apply exceptions
33
+ if (checkException(req.url, exceptions)) {
34
+ debug(`Url ${req.url} is exception from authorization rules`);
35
+ next();
36
+ }
37
+
38
+ // If there is no known user, return 401
39
+ else if (!req.user) {
40
+ debug('User is unknown where required');
41
+ if (!redirectToLogin)
42
+ next({ status: 401, message: 'Unauthorized' });
43
+ else
44
+ res.redirect('/login?ReturnUrl=' + encodeURIComponent(req.originalUrl));
45
+ }
46
+
47
+ // If user exists but does not belong to a specified role, return 403
48
+ else if (role && typeof role === 'string' && !req.user.roles?.includes(role)) {
49
+ debug(`User is known, but it does not have an appropriate role '${role}'`);
50
+ next({ status: 403, message: 'Forbidden' });
51
+ }
52
+
53
+ // If user exists but does not belong to any of specified roles, return 403
54
+ else if (role && Array.isArray(role) && !role.some(i => req.user.roles?.includes(i))) {
55
+ debug(`User is known, but it does not have any required role from the list of roles '${role}'`);
56
+ next({ status: 403, message: 'Forbidden' });
57
+ }
58
+
59
+ // If user is authorized
60
+ else {
61
+ debug('User is known' + (role ? ` and has a needed role '${role}'` : ''));
62
+ next();
63
+ }
64
+ };
65
+ };
66
+
67
+ module.exports = authorize;
package/cjs/index.cjs ADDED
@@ -0,0 +1,79 @@
1
+ const { Router } = require('express');
2
+ const { issuerSelectorModule, encryptorModule, HttpError } = require('@carisls/sso-core');
3
+ const standardRouterModule = require('./standard/index.cjs');
4
+ const authorize = require('./authorize.cjs');
5
+
6
+ /**
7
+ *
8
+ * @param {Object} options Options for this router
9
+ * @param {string} options.ssoUrl Base url for provider (usually equal to iss)
10
+ * @param {string} options.clientId SSO Client ID
11
+ * @param {number} options.publicKeyCache Public key caching expiration (in seconds)
12
+ * @param {number} options.expOffset Force nenewal of tokens a bit earlier (in case of problems)
13
+ * @param {Object[]} options.providers An array of different providers
14
+ * @param {Object[]} options.providers[].ssoUrl Base url for provider (usually equal to iss)
15
+ * @param {Object[]} options.providers[].iss If different than ssoUrl
16
+ * @param {Object[]} options.providers[].publicKey If different than ssoUrl
17
+ * @param {string} options.encPassword Password to be used for cookies encryption
18
+ * @param {string} options.encPasswordSalt Password salt to be used for cookies encription (optional)
19
+ * @param {string} options.encIterationCount Iteration Count to be used for cookies encription (optional)
20
+ * @param {function} options.userMapper A custom userMapper (maps token to user object attached to a request)
21
+ * @param {Object} options.paths Customizing of default paths used
22
+ * @param {string} options.paths.login Change default login endpoint from /login
23
+ * @param {string} options.paths.sso Change default sso return endpoint from /sso
24
+ * @param {string} options.paths.afterLogin Change default final endpoint after successful login from /
25
+ * @param {string} options.paths.logout Change default logout init endpoint from /logout
26
+ * @param {string} options.paths.afterLogout Change default logout final endpoint after a successful logout from /
27
+ * @returns
28
+ */
29
+ const router = (options) => {
30
+ // Check options
31
+ if (!options)
32
+ throw Error('You need to set options parameter');
33
+
34
+ if (!options.ssoUrl && !options.providers)
35
+ throw Error('ssoUrl is always required');
36
+
37
+ if (!options.clientId && !options.providers)
38
+ throw Error('clientId is required');
39
+
40
+ if (!options.publicKeyCache)
41
+ options.publicKeyCache = 300;
42
+
43
+ if (!options.expOffset)
44
+ options.expOffset = 0;
45
+
46
+ const router = Router();
47
+
48
+ (async () => {
49
+ const providers = options.providers || [];
50
+ if (!providers.length && options.ssoUrl)
51
+ providers.push({ iss: options.iss || options.ssoUrl, ssoUrl: options.ssoUrl, publicKey: options.publicKey });
52
+ const issuerSelector = await issuerSelectorModule(providers, options.publicKeyCache);
53
+ const encryptor = encryptorModule(options.encPassword, options.encPasswordSalt, options.encIterationCount);
54
+
55
+ standardRouterModule(
56
+ router,
57
+ options.clientId,
58
+ options.clientSecret,
59
+ issuerSelector,
60
+ encryptor,
61
+ options.userMapper,
62
+ options.paths,
63
+ options.expOffset);
64
+
65
+ })()
66
+ .catch((err) => {
67
+ router.all('*', (req, res, next) => {
68
+ next(new HttpError(500, 'SSO settings have failed to load'));
69
+ });
70
+ console.error(err);
71
+ });
72
+
73
+ return router;
74
+ };
75
+
76
+ module.exports = {
77
+ router,
78
+ authorize
79
+ };
@@ -0,0 +1,20 @@
1
+ function accessTokenModule (tokenValidator, encryptor, expOffset = 0) {
2
+ return async function (res, accessToken, cookiesSecure, nonce) {
3
+ // Process access token if returned
4
+ const { token } = await tokenValidator(accessToken, nonce || undefined);
5
+
6
+ // Add access_token to cookies
7
+ const encToken = await encryptor.encrypt(accessToken);
8
+ res.cookie('x-session', encToken, {
9
+ httpOnly: true,
10
+ expires: new Date((token.exp + expOffset) * 1000),
11
+ secure: cookiesSecure,
12
+ sameSite: 'lax'
13
+ });
14
+
15
+ // Return token
16
+ return { token: accessToken };
17
+ };
18
+ }
19
+
20
+ module.exports = accessTokenModule;
@@ -0,0 +1,11 @@
1
+ const cookieParserModule = require('cookie-parser');
2
+ function cookieParser (router) {
3
+ router.all('*', (req, res, next) => {
4
+ if (req.cookie)
5
+ next(); // Cookie parser is already attached
6
+ else
7
+ cookieParserModule()(req, res, next); // Cookie parser is needed
8
+ });
9
+ };
10
+
11
+ module.exports = cookieParser;
@@ -0,0 +1,23 @@
1
+ const { tokenDecoderModule: decodeToken } = require('@carisls/sso-core');
2
+
3
+ function idTokenModule (encryptor) {
4
+ return async function (res, idToken, cookiesSecure) {
5
+ // Process access token if returned
6
+ const token = idToken.includes('.') ? decodeToken.data(idToken) : idToken;
7
+
8
+ // Add access_token to cookies
9
+ const encToken = await encryptor.encrypt(idToken);
10
+ res.cookie('x-session-id', encToken, {
11
+ httpOnly: true,
12
+ // If not provided, refresh_token expires in 8 hours
13
+ expires: token.exp ? new Date(token.exp * 1000) : new Date(new Date().getTime() + 8 * 60 * 60 * 1000),
14
+ secure: cookiesSecure,
15
+ sameSite: 'lax'
16
+ });
17
+
18
+ // Return token
19
+ return { idToken };
20
+ };
21
+ }
22
+
23
+ module.exports = idTokenModule;
@@ -0,0 +1,20 @@
1
+ const { tokenDecoderModule: decodeToken } = require('@carisls/sso-core');
2
+
3
+ function refreshTokenModule (tokenValidator, encryptor) {
4
+ return async function (res, refreshToken, cookiesSecure) {
5
+ // Process access token if returned
6
+ const token = refreshToken.includes('.') ? decodeToken.data(refreshToken) : refreshToken;
7
+
8
+ // Add access_token to cookies
9
+ const encToken = await encryptor.encrypt(refreshToken);
10
+ res.cookie('x-session-sso', encToken, {
11
+ httpOnly: true,
12
+ // If not provided, refresh_token expires in 8 hours
13
+ expires: token.exp ? new Date(token.exp * 1000) : new Date(new Date().getTime() + 8 * 60 * 60 * 1000),
14
+ secure: cookiesSecure,
15
+ sameSite: 'lax'
16
+ });
17
+ };
18
+ }
19
+
20
+ module.exports = refreshTokenModule;
@@ -0,0 +1,25 @@
1
+ function logout (router, client, encryptor, logoutPath, afterLogoutPath) {
2
+ router.get(logoutPath, async (req, res) => {
3
+ let idToken = '';
4
+ if (req.cookies['x-session'])
5
+ res.cookie('x-session', 'Removing...', { maxAge: 0 });
6
+ if (req.cookies['x-session-sso'])
7
+ res.cookie('x-session-sso', 'Removing...', { maxAge: 0 });
8
+ if (req.cookies['x-session-id']) {
9
+ idToken = await encryptor.decrypt(req.cookies['x-session-id']);
10
+ res.cookie('x-session-id', 'Removing...', { maxAge: 0 });
11
+ }
12
+ res.redirect(client.endSessionUrl({
13
+
14
+ // redirect_uri for keycloak
15
+ redirect_uri: `${req.protocol}://${req.headers.host}${afterLogoutPath}`,
16
+
17
+ // id_token_hint and post_logout_redirect_uri for okta
18
+ id_token_hint: idToken,
19
+ post_logout_redirect_uri: `${req.protocol}://${req.headers.host}${afterLogoutPath}`
20
+
21
+ }));
22
+ });
23
+ };
24
+
25
+ module.exports = logout;
@@ -0,0 +1,31 @@
1
+ const { tokenDecoderModule: decodeToken, userMapper: userMapperDefault } = require('@carisls/sso-core');
2
+
3
+ function user (router, encryptor, userMapper) {
4
+ router.all('*', (req, res, next) => {
5
+ // Check if cookie is there
6
+ if (!req.cookies['x-session'] && !req.token) {
7
+ next();
8
+ return;
9
+ }
10
+
11
+ const accessTokenDecoder = req.token ? Promise.resolve(req.token) : encryptor.decrypt(req.cookies['x-session']);
12
+ const idTokenDecoder = req.idToken ? Promise.resolve(req.idToken) : encryptor.decrypt(req.cookies['x-session-id']) ?? Promise.resolve();
13
+
14
+ Promise.all([accessTokenDecoder, idTokenDecoder])
15
+ .then(([tokenEnc, idTokenEnc]) => {
16
+ req.token = tokenEnc;
17
+ req.idToken = idTokenEnc;
18
+ const token = tokenEnc ? decodeToken.data(tokenEnc) : null;
19
+ const idToken = idTokenEnc ? decodeToken.data(idTokenEnc) : null;
20
+ req.user = userMapper ? userMapper(token, idToken) : userMapperDefault(token, idToken);
21
+ })
22
+ .catch(() => {
23
+ res.cookie('x-session', 'Removing...', { maxAge: 0, httpOnly: true, secure: req.protocol === 'https', sameSite: 'lax' });
24
+ })
25
+ .finally(() => {
26
+ next();
27
+ });
28
+ });
29
+ };
30
+
31
+ module.exports = user;
@@ -0,0 +1,17 @@
1
+ const { postForm } = require('@carisls/sso-core');
2
+
3
+ function codeRequest (client) {
4
+ const clientId = client.metadata.client_id;
5
+ const clientSecret = client.metadata.client_secret || undefined;
6
+ const url = client.issuer.token_endpoint;
7
+
8
+ return (code, redirectUri) => postForm(url, {
9
+ client_id: clientId,
10
+ client_secret: clientSecret,
11
+ grant_type: 'authorization_code',
12
+ code,
13
+ redirect_uri: redirectUri
14
+ });
15
+ };
16
+
17
+ module.exports = codeRequest;
@@ -0,0 +1,74 @@
1
+ const cookieParserModule = require('../shared/cookies/cookieParser.cjs');
2
+
3
+ const loginModule = require('./login.cjs');
4
+ const ssoModule = require('./sso.cjs');
5
+ const logoutModule = require('../shared/logout.cjs');
6
+ const refreshRequestModule = require('./refreshRequest.cjs');
7
+ const refreshTokenModule2 = require('../shared/cookies/refreshToken.cjs');
8
+
9
+ const accessTokenModule2 = require('../shared/cookies/accessToken.cjs');
10
+
11
+ const idTokenModule2 = require('../shared/cookies/idToken.cjs');
12
+ const codeRequestModule2 = require('./codeRequest.cjs');
13
+
14
+ const userModule = require('../shared/user.cjs');
15
+
16
+ function index (router, clientId, clientSecret, issuerSelector, encryptor, userMapper, paths, expOffset = 0) {
17
+ // Add cookieParser
18
+ cookieParserModule(router);
19
+
20
+ // Issuer Selector
21
+ const keycloakIssuer = issuerSelector.providers[Object.keys(issuerSelector.providers)[0]].issuer;
22
+ const tokenValidator = issuerSelector.providers[Object.keys(issuerSelector.providers)[0]].validator;
23
+
24
+ // Instantiate client
25
+ const client = new keycloakIssuer.Client({
26
+ client_id: clientId,
27
+ client_secret: clientSecret,
28
+ response_types: ['code']
29
+ });
30
+
31
+ // Instantiate token modules
32
+ const accessTokenModule = accessTokenModule2(tokenValidator, encryptor, expOffset);
33
+ const refreshTokenModule = refreshTokenModule2(tokenValidator, encryptor);
34
+ const idTokenModule = idTokenModule2(encryptor); // id_token must be saved for Okta logout
35
+
36
+ const codeRequestModule = codeRequestModule2(client);
37
+
38
+ // Send to SSO login
39
+ loginModule(router,
40
+ client,
41
+ paths?.login || '/login',
42
+ paths?.sso || '/sso',
43
+ paths?.afterLogin || '/'
44
+ );
45
+
46
+ // Handle return from standard SSO
47
+ ssoModule(router, client,
48
+ paths?.sso || '/sso',
49
+ codeRequestModule,
50
+ accessTokenModule,
51
+ refreshTokenModule,
52
+ idTokenModule);
53
+
54
+ // Remove session
55
+ logoutModule(router,
56
+ client,
57
+ encryptor,
58
+ paths?.logout || '/logout',
59
+ paths?.afterLogout || '/'
60
+ );
61
+
62
+ // Check if session needs/can be refreshed
63
+ refreshRequestModule(router,
64
+ client,
65
+ encryptor,
66
+ accessTokenModule,
67
+ refreshTokenModule,
68
+ idTokenModule);
69
+
70
+ // Inject user from session cookie if present
71
+ userModule(router, encryptor, userMapper);
72
+ };
73
+
74
+ module.exports = index;
@@ -0,0 +1,15 @@
1
+ function login (router, client, loginPath, ssoPath, afterLoginPath) {
2
+ router.get(loginPath, (req, res) => {
3
+ // Set redirectTo
4
+ const redirectTo = req.query.ReturnUrl || afterLoginPath;
5
+
6
+ // Standard flow initiates directly
7
+ res.redirect(client.authorizationUrl({
8
+ redirect_uri: `${req.protocol}://${req.headers.host}${ssoPath}`,
9
+ state: redirectTo ? Buffer.from(redirectTo).toString('hex') : undefined,
10
+ scope: 'openid profile email offline_access groups'
11
+ }));
12
+ });
13
+ };
14
+
15
+ module.exports = login;
@@ -0,0 +1,47 @@
1
+ const { postForm } = require('@carisls/sso-core');
2
+
3
+ function refreshRequest (router, client, encryptor, accessTokenModule, refreshTokenModule, idTokenModule) {
4
+ const clientId = client.metadata.client_id;
5
+ const clientSecret = client.metadata.client_secret || undefined;
6
+ const url = client.issuer.token_endpoint;
7
+
8
+ router.all('*', (req, res, next) => {
9
+ // Check if SSO cookie is there
10
+ if (req.cookies['x-session']?.length > 36 || req.token || !req.cookies['x-session-sso']) {
11
+ next();
12
+ return;
13
+ }
14
+
15
+ encryptor
16
+ .decrypt(req.cookies['x-session-sso'])
17
+ .then(token => postForm(url, {
18
+ client_id: clientId,
19
+ client_secret: clientSecret,
20
+ grant_type: 'refresh_token',
21
+ refresh_token: token
22
+ }))
23
+ .then(tokens => Promise.all([
24
+ accessTokenModule(res, tokens.access_token, req.protocol === 'https'),
25
+ idTokenModule(res, tokens.id_token, req.protocol === 'https'),
26
+ refreshTokenModule(res, tokens.refresh_token, req.protocol === 'https')
27
+ ]))
28
+ .then(([{ token }, { idToken }]) => {
29
+ req.token = token;
30
+ req.idToken = idToken;
31
+ })
32
+ .catch((err) => {
33
+ console.error(err);
34
+ res.cookie('x-session-sso', 'Removing...', {
35
+ maxAge: 0,
36
+ httpOnly: true,
37
+ secure: req.protocol === 'https',
38
+ sameSite: 'lax'
39
+ });
40
+ })
41
+ .finally(() => {
42
+ next();
43
+ });
44
+ });
45
+ };
46
+
47
+ module.exports = refreshRequest;
@@ -0,0 +1,25 @@
1
+ const { HttpError } = require('@carisls/sso-core');
2
+
3
+ function sso (router, client, ssoPath, codeRequestModule, accessTokenModule, refreshTokenModule, idTokenModule) {
4
+ router.get(ssoPath, (req, res, next) => {
5
+ if (!req.query.code) {
6
+ next(new HttpError(500, req.query.error));
7
+ return;
8
+ }
9
+
10
+ codeRequestModule(req.query.code, `${req.protocol}://${req.headers.host}${ssoPath}`)
11
+ .then((tokens) => Promise.all([
12
+ accessTokenModule(res, tokens.access_token, req.protocol === 'https'),
13
+ refreshTokenModule(res, tokens.refresh_token, req.protocol === 'https'),
14
+ idTokenModule(res, tokens.id_token, req.protocol === 'https')
15
+ ]))
16
+ .then(() => {
17
+ res.redirect(req.query.state ? Buffer.from(req.query.state, 'hex').toString('utf8') : '/');
18
+ })
19
+ .catch((err) => {
20
+ next(err);
21
+ });
22
+ });
23
+ };
24
+
25
+ module.exports = sso;
@@ -0,0 +1,67 @@
1
+ import debugModule from 'debug';
2
+ const debug = debugModule('keycloak-client:token:authorize');
3
+
4
+ function checkException (url, exceptions) {
5
+ if (!exceptions || !exceptions.length)
6
+ return false;
7
+
8
+ for (let i = 0; i < exceptions.length; i++) {
9
+ if (url.length === exceptions[i].length)
10
+ return true;
11
+ else if (url.length > exceptions[i].length && url.substr(0, exceptions[i].length) === exceptions[i])
12
+ return true;
13
+ }
14
+ return false;
15
+ }
16
+ /**
17
+ * @description A helper middleware to perform authorization filtering
18
+ * @param {*} role Role (string) or an array of roles to filter by (optional)
19
+ * @param {*} exceptions Array of urls to skip
20
+ * @param {*} redirectToLogin If user needs to be redirected to login in case of unsuccessful validation
21
+ * @returns
22
+ */
23
+
24
+ function authorize (role, exceptions, redirectToLogin = false) {
25
+ return (req, res, next) => {
26
+ // Check if this is login or sso url
27
+ if (req.url.match(/^\/login/i) || req.url.match(/^\/sso/i)) {
28
+ debug(`Url ${req.url} is exception from default authorization rules`);
29
+ next();
30
+ }
31
+
32
+ // Apply exceptions
33
+ if (checkException(req.url, exceptions)) {
34
+ debug(`Url ${req.url} is exception from authorization rules`);
35
+ next();
36
+ }
37
+
38
+ // If there is no known user, return 401
39
+ else if (!req.user) {
40
+ debug('User is unknown where required');
41
+ if (!redirectToLogin)
42
+ next({ status: 401, message: 'Unauthorized' });
43
+ else
44
+ res.redirect('/login?ReturnUrl=' + encodeURIComponent(req.originalUrl));
45
+ }
46
+
47
+ // If user exists but does not belong to a specified role, return 403
48
+ else if (role && typeof role === 'string' && !req.user.roles?.includes(role)) {
49
+ debug(`User is known, but it does not have an appropriate role '${role}'`);
50
+ next({ status: 403, message: 'Forbidden' });
51
+ }
52
+
53
+ // If user exists but does not belong to any of specified roles, return 403
54
+ else if (role && Array.isArray(role) && !role.some(i => req.user.roles?.includes(i))) {
55
+ debug(`User is known, but it does not have any required role from the list of roles '${role}'`);
56
+ next({ status: 403, message: 'Forbidden' });
57
+ }
58
+
59
+ // If user is authorized
60
+ else {
61
+ debug('User is known' + (role ? ` and has a needed role '${role}'` : ''));
62
+ next();
63
+ }
64
+ };
65
+ };
66
+
67
+ export default authorize;
package/esm/index.js ADDED
@@ -0,0 +1,82 @@
1
+ import { Router } from 'express';
2
+ import { issuerSelectorModule, encryptorModule, HttpError } from '@carisls/sso-core';
3
+ import standardRouterModule from './standard/index.js';
4
+ import authorize from '../cjs/authorize.cjs';
5
+
6
+ /**
7
+ *
8
+ * @param {Object} options Options for this router
9
+ * @param {string} options.ssoUrl Base url for provider (usually equal to iss)
10
+ * @param {string} options.clientId SSO Client ID
11
+ * @param {number} options.publicKeyCache Public key caching expiration (in seconds)
12
+ * @param {number} options.expOffset Force nenewal of tokens a bit earlier (in case of problems)
13
+ * @param {Object[]} options.providers An array of different providers
14
+ * @param {Object[]} options.providers[].ssoUrl Base url for provider (usually equal to iss)
15
+ * @param {Object[]} options.providers[].iss If different than ssoUrl
16
+ * @param {Object[]} options.providers[].publicKey If different than ssoUrl
17
+ * @param {string} options.encPassword Password to be used for cookies encryption
18
+ * @param {string} options.encPasswordSalt Password salt to be used for cookies encription (optional)
19
+ * @param {string} options.encIterationCount Iteration Count to be used for cookies encription (optional)
20
+ * @param {function} options.userMapper A custom userMapper (maps token to user object attached to a request)
21
+ * @param {Object} options.paths Customizing of default paths used
22
+ * @param {string} options.paths.login Change default login endpoint from /login
23
+ * @param {string} options.paths.sso Change default sso return endpoint from /sso
24
+ * @param {string} options.paths.afterLogin Change default final endpoint after successful login from /
25
+ * @param {string} options.paths.logout Change default logout init endpoint from /logout
26
+ * @param {string} options.paths.afterLogout Change default logout final endpoint after a successful logout from /
27
+ * @param {boolean} options.useCachedSession Whether or not to store access token to cache instead of a cookie (if larger than 4096)
28
+ * @returns
29
+ */
30
+ const router = (options) => {
31
+ // Check options
32
+ if (!options)
33
+ throw Error('You need to set options parameter');
34
+
35
+ if (!options.ssoUrl && !options.providers)
36
+ throw Error('ssoUrl is always required');
37
+
38
+ if (!options.clientId && !options.providers)
39
+ throw Error('clientId is required');
40
+
41
+ if (!options.publicKeyCache)
42
+ options.publicKeyCache = 300;
43
+
44
+ if (!options.expOffset)
45
+ options.expOffset = 0;
46
+
47
+ const router = Router();
48
+
49
+ (async () => {
50
+ const providers = options.providers || [];
51
+ if (!providers.length && options.ssoUrl)
52
+ providers.push({ iss: options.iss || options.ssoUrl, ssoUrl: options.ssoUrl, publicKey: options.publicKey });
53
+ const issuerSelector = await issuerSelectorModule(providers, options.publicKeyCache);
54
+ const encryptor = encryptorModule(options.encPassword, options.encPasswordSalt, options.encIterationCount);
55
+
56
+ standardRouterModule(
57
+ router,
58
+ options.clientId,
59
+ options.clientSecret,
60
+ issuerSelector,
61
+ encryptor,
62
+ options.userMapper,
63
+ options.paths,
64
+ options.expOffset);
65
+
66
+ })()
67
+ .catch((err) => {
68
+ router.all('*', (req, res, next) => {
69
+ next(new HttpError(500, 'SSO settings have failed to load'));
70
+ });
71
+ console.error(err);
72
+ });
73
+
74
+ return router;
75
+ };
76
+
77
+ export { router, authorize };
78
+
79
+ export default {
80
+ router,
81
+ authorize
82
+ }
@@ -0,0 +1,20 @@
1
+ function accessTokenModule (tokenValidator, encryptor, expOffset = 0) {
2
+ return async function (res, accessToken, cookiesSecure, nonce) {
3
+ // Process access token if returned
4
+ const { token } = await tokenValidator(accessToken, nonce || undefined);
5
+
6
+ // Add access_token to cookies
7
+ const encToken = await encryptor.encrypt(accessToken);
8
+ res.cookie('x-session', encToken, {
9
+ httpOnly: true,
10
+ expires: new Date((token.exp + expOffset) * 1000),
11
+ secure: cookiesSecure,
12
+ sameSite: 'lax'
13
+ });
14
+
15
+ // Return token
16
+ return { token: accessToken };
17
+ };
18
+ }
19
+
20
+ export default accessTokenModule;
@@ -0,0 +1,11 @@
1
+ import cookieParserModule from 'cookie-parser';
2
+ function cookieParser (router) {
3
+ router.all('*', (req, res, next) => {
4
+ if (req.cookie)
5
+ next(); // Cookie parser is already attached
6
+ else
7
+ cookieParserModule()(req, res, next); // Cookie parser is needed
8
+ });
9
+ };
10
+
11
+ export default cookieParser;
@@ -0,0 +1,23 @@
1
+ import { tokenDecoderModule as decodeToken } from '@carisls/sso-core';
2
+
3
+ function idTokenModule (encryptor) {
4
+ return async function (res, idToken, cookiesSecure) {
5
+ // Process access token if returned
6
+ const token = idToken.includes('.') ? decodeToken.data(idToken) : idToken;
7
+
8
+ // Add access_token to cookies
9
+ const encToken = await encryptor.encrypt(idToken);
10
+ res.cookie('x-session-id', encToken, {
11
+ httpOnly: true,
12
+ // If not provided, refresh_token expires in 8 hours
13
+ expires: token.exp ? new Date(token.exp * 1000) : new Date(new Date().getTime() + 8 * 60 * 60 * 1000),
14
+ secure: cookiesSecure,
15
+ sameSite: 'lax'
16
+ });
17
+
18
+ // Return token
19
+ return { idToken };
20
+ };
21
+ }
22
+
23
+ export default idTokenModule;
@@ -0,0 +1,20 @@
1
+ import { tokenDecoderModule as decodeToken } from '@carisls/sso-core';
2
+
3
+ function refreshTokenModule (tokenValidator, encryptor) {
4
+ return async function (res, refreshToken, cookiesSecure) {
5
+ // Process access token if returned
6
+ const token = refreshToken.includes('.') ? decodeToken.data(refreshToken) : refreshToken;
7
+
8
+ // Add access_token to cookies
9
+ const encToken = await encryptor.encrypt(refreshToken);
10
+ res.cookie('x-session-sso', encToken, {
11
+ httpOnly: true,
12
+ // If not provided, refresh_token expires in 8 hours
13
+ expires: token.exp ? new Date(token.exp * 1000) : new Date(new Date().getTime() + 8 * 60 * 60 * 1000),
14
+ secure: cookiesSecure,
15
+ sameSite: 'lax'
16
+ });
17
+ };
18
+ }
19
+
20
+ export default refreshTokenModule;
@@ -0,0 +1,25 @@
1
+ function logout (router, client, encryptor, logoutPath, afterLogoutPath) {
2
+ router.get(logoutPath, async (req, res) => {
3
+ let idToken = '';
4
+ if (req.cookies['x-session'])
5
+ res.cookie('x-session', 'Removing...', { maxAge: 0 });
6
+ if (req.cookies['x-session-sso'])
7
+ res.cookie('x-session-sso', 'Removing...', { maxAge: 0 });
8
+ if (req.cookies['x-session-id']) {
9
+ idToken = await encryptor.decrypt(req.cookies['x-session-id']);
10
+ res.cookie('x-session-id', 'Removing...', { maxAge: 0 });
11
+ }
12
+ res.redirect(client.endSessionUrl({
13
+
14
+ // redirect_uri for keycloak
15
+ redirect_uri: `${req.protocol}://${req.headers.host}${afterLogoutPath}`,
16
+
17
+ // id_token_hint and post_logout_redirect_uri for okta
18
+ id_token_hint: idToken,
19
+ post_logout_redirect_uri: `${req.protocol}://${req.headers.host}${afterLogoutPath}`
20
+
21
+ }));
22
+ });
23
+ };
24
+
25
+ export default logout;
@@ -0,0 +1,31 @@
1
+ import { tokenDecoderModule as decodeToken, userMapper as userMapperDefault } from '@carisls/sso-core';
2
+
3
+ function user (router, encryptor, userMapper) {
4
+ router.all('*', (req, res, next) => {
5
+ // Check if cookie is there
6
+ if (!req.cookies['x-session'] && !req.token) {
7
+ next();
8
+ return;
9
+ }
10
+
11
+ const accessTokenDecoder = req.token ? Promise.resolve(req.token) : encryptor.decrypt(req.cookies['x-session']);
12
+ const idTokenDecoder = req.idToken ? Promise.resolve(req.idToken) : encryptor.decrypt(req.cookies['x-session-id']) ?? Promise.resolve();
13
+
14
+ Promise.all([accessTokenDecoder, idTokenDecoder])
15
+ .then(([tokenEnc, idTokenEnc]) => {
16
+ req.token = tokenEnc;
17
+ req.idToken = idTokenEnc;
18
+ const token = tokenEnc ? decodeToken.data(tokenEnc) : null;
19
+ const idToken = idTokenEnc ? decodeToken.data(idTokenEnc) : null;
20
+ req.user = userMapper ? userMapper(token, idToken) : userMapperDefault(token, idToken);
21
+ })
22
+ .catch(() => {
23
+ res.cookie('x-session', 'Removing...', { maxAge: 0, httpOnly: true, secure: req.protocol === 'https', sameSite: 'lax' });
24
+ })
25
+ .finally(() => {
26
+ next();
27
+ });
28
+ });
29
+ };
30
+
31
+ export default user;
@@ -0,0 +1,17 @@
1
+ import { postForm } from '@carisls/sso-core';
2
+
3
+ function codeRequest (client) {
4
+ const clientId = client.metadata.client_id;
5
+ const clientSecret = client.metadata.client_secret || undefined;
6
+ const url = client.issuer.token_endpoint;
7
+
8
+ return (code, redirectUri) => postForm(url, {
9
+ client_id: clientId,
10
+ client_secret: clientSecret,
11
+ grant_type: 'authorization_code',
12
+ code,
13
+ redirect_uri: redirectUri
14
+ });
15
+ };
16
+
17
+ export default codeRequest;
@@ -0,0 +1,74 @@
1
+ import cookieParserModule from '../shared/cookies/cookieParser.js';
2
+
3
+ import loginModule from './login.js';
4
+ import ssoModule from './sso.js';
5
+ import logoutModule from '../shared/logout.js';
6
+ import refreshRequestModule from './refreshRequest.js';
7
+ import refreshTokenModule2 from '../shared/cookies/refreshToken.js';
8
+
9
+ import accessTokenModule2 from '../shared/cookies/accessToken.js';
10
+
11
+ import idTokenModule2 from '../shared/cookies/idToken.js';
12
+ import codeRequestModule2 from './codeRequest.js';
13
+
14
+ import userModule from '../shared/user.js';
15
+
16
+ function index (router, clientId, clientSecret, issuerSelector, encryptor, userMapper, paths, expOffset = 0) {
17
+ // Add cookieParser
18
+ cookieParserModule(router);
19
+
20
+ // Issuer Selector
21
+ const keycloakIssuer = issuerSelector.providers[Object.keys(issuerSelector.providers)[0]].issuer;
22
+ const tokenValidator = issuerSelector.providers[Object.keys(issuerSelector.providers)[0]].validator;
23
+
24
+ // Instantiate client
25
+ const client = new keycloakIssuer.Client({
26
+ client_id: clientId,
27
+ client_secret: clientSecret,
28
+ response_types: ['code']
29
+ });
30
+
31
+ // Instantiate token modules
32
+ const accessTokenModule = accessTokenModule2(tokenValidator, encryptor, expOffset);
33
+ const refreshTokenModule = refreshTokenModule2(tokenValidator, encryptor);
34
+ const idTokenModule = idTokenModule2(encryptor); // id_token must be saved for Okta logout
35
+
36
+ const codeRequestModule = codeRequestModule2(client);
37
+
38
+ // Send to SSO login
39
+ loginModule(router,
40
+ client,
41
+ paths?.login || '/login',
42
+ paths?.sso || '/sso',
43
+ paths?.afterLogin || '/'
44
+ );
45
+
46
+ // Handle return from standard SSO
47
+ ssoModule(router, client,
48
+ paths?.sso || '/sso',
49
+ codeRequestModule,
50
+ accessTokenModule,
51
+ refreshTokenModule,
52
+ idTokenModule);
53
+
54
+ // Remove session
55
+ logoutModule(router,
56
+ client,
57
+ encryptor,
58
+ paths?.logout || '/logout',
59
+ paths?.afterLogout || '/'
60
+ );
61
+
62
+ // Check if session needs/can be refreshed
63
+ refreshRequestModule(router,
64
+ client,
65
+ encryptor,
66
+ accessTokenModule,
67
+ refreshTokenModule,
68
+ idTokenModule);
69
+
70
+ // Inject user from session cookie if present
71
+ userModule(router, encryptor, userMapper);
72
+ };
73
+
74
+ export default index;
@@ -0,0 +1,15 @@
1
+ function login (router, client, loginPath, ssoPath, afterLoginPath) {
2
+ router.get(loginPath, (req, res) => {
3
+ // Set redirectTo
4
+ const redirectTo = req.query.ReturnUrl || afterLoginPath;
5
+
6
+ // Standard flow initiates directly
7
+ res.redirect(client.authorizationUrl({
8
+ redirect_uri: `${req.protocol}://${req.headers.host}${ssoPath}`,
9
+ state: redirectTo ? Buffer.from(redirectTo).toString('hex') : undefined,
10
+ scope: 'openid profile email offline_access groups'
11
+ }));
12
+ });
13
+ };
14
+
15
+ export default login;
@@ -0,0 +1,47 @@
1
+ import { postForm } from '@carisls/sso-core';
2
+
3
+ function refreshRequest (router, client, encryptor, accessTokenModule, refreshTokenModule, idTokenModule) {
4
+ const clientId = client.metadata.client_id;
5
+ const clientSecret = client.metadata.client_secret || undefined;
6
+ const url = client.issuer.token_endpoint;
7
+
8
+ router.all('*', (req, res, next) => {
9
+ // Check if SSO cookie is there
10
+ if (req.cookies['x-session']?.length > 36 || req.token || !req.cookies['x-session-sso']) {
11
+ next();
12
+ return;
13
+ }
14
+
15
+ encryptor
16
+ .decrypt(req.cookies['x-session-sso'])
17
+ .then(token => postForm(url, {
18
+ client_id: clientId,
19
+ client_secret: clientSecret,
20
+ grant_type: 'refresh_token',
21
+ refresh_token: token
22
+ }))
23
+ .then(tokens => Promise.all([
24
+ accessTokenModule(res, tokens.access_token, req.protocol === 'https'),
25
+ idTokenModule(res, tokens.id_token, req.protocol === 'https'),
26
+ refreshTokenModule(res, tokens.refresh_token, req.protocol === 'https')
27
+ ]))
28
+ .then(([{ token }, { idToken }]) => {
29
+ req.token = token;
30
+ req.idToken = idToken;
31
+ })
32
+ .catch((err) => {
33
+ console.error(err);
34
+ res.cookie('x-session-sso', 'Removing...', {
35
+ maxAge: 0,
36
+ httpOnly: true,
37
+ secure: req.protocol === 'https',
38
+ sameSite: 'lax'
39
+ });
40
+ })
41
+ .finally(() => {
42
+ next();
43
+ });
44
+ });
45
+ };
46
+
47
+ export default refreshRequest;
@@ -0,0 +1,25 @@
1
+ import { HttpError } from '@carisls/sso-core';
2
+
3
+ function sso (router, client, ssoPath, codeRequestModule, accessTokenModule, refreshTokenModule, idTokenModule) {
4
+ router.get(ssoPath, (req, res, next) => {
5
+ if (!req.query.code) {
6
+ next(new HttpError(500, req.query.error));
7
+ return;
8
+ }
9
+
10
+ codeRequestModule(req.query.code, `${req.protocol}://${req.headers.host}${ssoPath}`)
11
+ .then((tokens) => Promise.all([
12
+ accessTokenModule(res, tokens.access_token, req.protocol === 'https'),
13
+ refreshTokenModule(res, tokens.refresh_token, req.protocol === 'https'),
14
+ idTokenModule(res, tokens.id_token, req.protocol === 'https')
15
+ ]))
16
+ .then(() => {
17
+ res.redirect(req.query.state ? Buffer.from(req.query.state, 'hex').toString('utf8') : '/');
18
+ })
19
+ .catch((err) => {
20
+ next(err);
21
+ });
22
+ });
23
+ };
24
+
25
+ export default sso;
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@carisls/sso-standard",
3
+ "version": "1.0.0",
4
+ "description": "A middleware implementing standard flow SSO",
5
+ "main": "cjs/index.cjs",
6
+ "module": "esm/index.js",
7
+ "type": "module",
8
+ "scripts": {
9
+ "test": "echo \"Error: no test specified\" && exit 1"
10
+ },
11
+ "keywords": [
12
+ "sso",
13
+ "openid",
14
+ "okta",
15
+ "keycloak"
16
+ ],
17
+ "author": "Mihovil Strujic <mstrujic@carisls.com>",
18
+ "license": "MIT",
19
+ "dependencies": {
20
+ "@carisls/sso-core": "^1.0.0",
21
+ "cookie-parser": "^1.4.6",
22
+ "express": "^4.19.2"
23
+ }
24
+ }