From 029fd2d0b0a9a12e5c416508c4c1c3a8ce8d9bb6 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Fri, 3 May 2024 08:25:39 +0000 Subject: [PATCH 1/3] chore(devcontainer): remove keycloak custom container name --- .devcontainer/docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 7182a074..580c9f55 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -36,7 +36,6 @@ services: keycloak: image: quay.io/keycloak/keycloak:21.1 restart: always - container_name: keycloak environment: - KEYCLOAK_ADMIN=admin - KEYCLOAK_ADMIN_PASSWORD=admin From 40d0fe0db665c0db55b001e4f5b54799f96a0921 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Fri, 3 May 2024 08:27:42 +0000 Subject: [PATCH 2/3] chore: add saml_session_id property in access token --- ...0430132947_add_saml_session_id_in_access_tokens.js | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 packages/backend/src/db/migrations/20240430132947_add_saml_session_id_in_access_tokens.js diff --git a/packages/backend/src/db/migrations/20240430132947_add_saml_session_id_in_access_tokens.js b/packages/backend/src/db/migrations/20240430132947_add_saml_session_id_in_access_tokens.js new file mode 100644 index 00000000..42423bd5 --- /dev/null +++ b/packages/backend/src/db/migrations/20240430132947_add_saml_session_id_in_access_tokens.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('access_tokens', (table) => { + table.string('saml_session_id').nullable(); + }); +} + +export async function down(knex) { + return knex.schema.table('access_tokens', (table) => { + table.dropColumn('saml_session_id'); + }); +} From 3da5e13ecd99a40fd464957e262afa22eb4e5a5c Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Fri, 3 May 2024 08:28:53 +0000 Subject: [PATCH 3/3] feat: support bi-directional backchannel SAML SLO --- packages/backend/package.json | 1 + .../helpers/create-auth-token-by-user-id.js | 3 +- packages/backend/src/helpers/passport.js | 56 ++++++++++++++++--- packages/backend/src/models/access-token.js | 32 ++++++++++- .../src/models/saml-auth-provider.ee.js | 54 ++++++++++++++++-- yarn.lock | 5 ++ 6 files changed, 137 insertions(+), 14 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index fee07545..50b8109f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -67,6 +67,7 @@ "pluralize": "^8.0.0", "raw-body": "^2.5.2", "showdown": "^2.1.0", + "uuid": "^9.0.1", "winston": "^3.7.1", "xmlrpc": "^1.3.2" }, diff --git a/packages/backend/src/helpers/create-auth-token-by-user-id.js b/packages/backend/src/helpers/create-auth-token-by-user-id.js index d7da68db..2b82440f 100644 --- a/packages/backend/src/helpers/create-auth-token-by-user-id.js +++ b/packages/backend/src/helpers/create-auth-token-by-user-id.js @@ -4,12 +4,13 @@ import AccessToken from '../models/access-token.js'; const TOKEN_EXPIRES_IN = 14 * 24 * 60 * 60; // 14 days in seconds -const createAuthTokenByUserId = async (userId) => { +const createAuthTokenByUserId = async (userId, samlSessionId) => { const user = await User.query().findById(userId).throwIfNotFound(); const token = await crypto.randomBytes(48).toString('hex'); await AccessToken.query().insert({ token, + samlSessionId, userId: user.id, expiresIn: TOKEN_EXPIRES_IN, }); diff --git a/packages/backend/src/helpers/passport.js b/packages/backend/src/helpers/passport.js index cf3b37d6..9c7ebaa3 100644 --- a/packages/backend/src/helpers/passport.js +++ b/packages/backend/src/helpers/passport.js @@ -5,8 +5,11 @@ import passport from 'passport'; import appConfig from '../config/app.js'; import createAuthTokenByUserId from './create-auth-token-by-user-id.js'; import SamlAuthProvider from '../models/saml-auth-provider.ee.js'; +import AccessToken from '../models/access-token.js'; import findOrCreateUserBySamlIdentity from './find-or-create-user-by-saml-identity.ee.js'; +const asyncNoop = async () => { }; + export default function configurePassport(app) { app.use( passport.initialize({ @@ -19,6 +22,10 @@ export default function configurePassport(app) { { passReqToCallback: true, getSamlOptions: async function (request, done) { + // This is a workaround to avoid session logout which passport-saml enforces + request.logout = asyncNoop; + request.logOut = asyncNoop; + const { issuer } = request.params; const notFoundIssuer = new Error('Issuer cannot be found!'); @@ -35,7 +42,7 @@ export default function configurePassport(app) { return done(null, authProvider.config); }, }, - async function (request, user, done) { + async function signonVerify(request, user, done) { const { issuer } = request.params; const notFoundIssuer = new Error('Issuer cannot be found!'); @@ -53,10 +60,38 @@ export default function configurePassport(app) { user, authProvider ); + + request.samlSessionId = user.sessionIndex; + return done(null, foundUserWithIdentity); }, - function (request, user, done) { - return done(null, null); + async function logoutVerify(request, user, done) { + const { issuer } = request.params; + const notFoundIssuer = new Error('Issuer cannot be found!'); + + if (!issuer) return done(notFoundIssuer); + + const authProvider = await SamlAuthProvider.query().findOne({ + issuer: request.params.issuer, + }); + + if (!authProvider) { + return done(notFoundIssuer); + } + + const foundUserWithIdentity = await findOrCreateUserBySamlIdentity( + user, + authProvider + ); + + const accessToken = await AccessToken.query().findOne({ + revoked_at: null, + saml_session_id: user.sessionIndex, + }).throwIfNotFound(); + + await accessToken.revoke(); + + return done(null, foundUserWithIdentity); } ) ); @@ -73,17 +108,22 @@ export default function configurePassport(app) { '/login/saml/:issuer/callback', passport.authenticate('saml', { session: false, - failureRedirect: '/', - failureFlash: true, }), - async (req, res) => { - const token = await createAuthTokenByUserId(req.currentUser.id); + async (request, response) => { + const token = await createAuthTokenByUserId(request.currentUser.id, request.samlSessionId); const redirectUrl = new URL( `/login/callback?token=${token}`, appConfig.webAppUrl ).toString(); - res.redirect(redirectUrl); + response.redirect(redirectUrl); } ); + + app.post( + '/logout/saml/:issuer', + passport.authenticate('saml', { + session: false, + }), + ); } diff --git a/packages/backend/src/models/access-token.js b/packages/backend/src/models/access-token.js index f40a185f..7fba0cb8 100644 --- a/packages/backend/src/models/access-token.js +++ b/packages/backend/src/models/access-token.js @@ -12,6 +12,7 @@ class AccessToken extends Base { id: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' }, token: { type: 'string', minLength: 32 }, + samlSessionId: { type: ['string', 'null'] }, expiresIn: { type: 'integer' }, revokedAt: { type: ['string', 'null'], format: 'date-time' }, }, @@ -28,8 +29,37 @@ class AccessToken extends Base { }, }); + async terminateRemoteSamlSession() { + if (!this.samlSessionId) { + return; + } + + const user = await this + .$relatedQuery('user'); + + const firstIdentity = await user + .$relatedQuery('identities') + .first(); + + const samlAuthProvider = await firstIdentity + .$relatedQuery('samlAuthProvider') + .throwIfNotFound(); + + const response = await samlAuthProvider.terminateRemoteSession(this.samlSessionId); + + return response; + } + async revoke() { - return await this.$query().patch({ revokedAt: new Date().toISOString() }); + const response = await this.$query().patch({ revokedAt: new Date().toISOString() }); + + try { + await this.terminateRemoteSamlSession(); + } catch (error) { + // TODO: should it silently fail or not? + } + + return response; } } diff --git a/packages/backend/src/models/saml-auth-provider.ee.js b/packages/backend/src/models/saml-auth-provider.ee.js index 21577dc7..431153f9 100644 --- a/packages/backend/src/models/saml-auth-provider.ee.js +++ b/packages/backend/src/models/saml-auth-provider.ee.js @@ -1,5 +1,7 @@ import { URL } from 'node:url'; +import { v4 as uuidv4 } from 'uuid'; import appConfig from '../config/app.js'; +import axios from '../helpers/axios-with-proxy.js'; import Base from './base.js'; import Identity from './identity.ee.js'; import SamlAuthProvidersRoleMapping from './saml-auth-providers-role-mapping.ee.js'; @@ -61,27 +63,71 @@ class SamlAuthProvider extends Base { }); static get virtualAttributes() { - return ['loginUrl']; + return ['loginUrl', 'remoteLogoutUrl']; } get loginUrl() { return new URL(`/login/saml/${this.issuer}`, appConfig.baseUrl).toString(); } - get config() { - const callbackUrl = new URL( + get loginCallBackUrl() { + return new URL( `/login/saml/${this.issuer}/callback`, appConfig.baseUrl ).toString(); + } + get remoteLogoutUrl() { + return this.entryPoint; + } + + get config() { return { - callbackUrl, + callbackUrl: this.loginCallBackUrl, cert: this.certificate, entryPoint: this.entryPoint, issuer: this.issuer, signatureAlgorithm: this.signatureAlgorithm, + logoutUrl: this.remoteLogoutUrl }; } + + generateLogoutRequestBody(sessionId) { + const logoutRequest = ` + + + ${this.issuer} + ${sessionId} + + `; + + const encodedLogoutRequest = Buffer.from(logoutRequest).toString('base64') + + return encodedLogoutRequest + } + + async terminateRemoteSession(sessionId) { + const logoutRequest = this.generateLogoutRequestBody(sessionId); + + const response = await axios.post( + this.remoteLogoutUrl, + new URLSearchParams({ + SAMLRequest: logoutRequest, + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + } + } + ); + + return response; + } } export default SamlAuthProvider; diff --git a/yarn.lock b/yarn.lock index 3f3e96ce..14538334 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16024,6 +16024,11 @@ uuid@^9.0.0: resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz" integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== +uuid@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== + v8-compile-cache@^2.0.3: version "2.3.0" resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz"