Merge pull request #1875 from automatisch/logout-saml

This commit is contained in:
Ali BARIN
2024-05-13 13:51:49 +02:00
committed by GitHub
8 changed files with 148 additions and 15 deletions

View File

@@ -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

View File

@@ -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"
},

View File

@@ -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');
});
}

View File

@@ -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,
});

View File

@@ -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,
}),
);
}

View File

@@ -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;
}
}

View File

@@ -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 = `
<samlp:LogoutRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
ID="${uuidv4()}"
Version="2.0"
IssueInstant="${new Date().toISOString()}"
Destination="${this.remoteLogoutUrl}">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">${this.issuer}</saml:Issuer>
<samlp:SessionIndex>${sessionId}</samlp:SessionIndex>
</samlp:LogoutRequest>
`;
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;

View File

@@ -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"