feat: support bi-directional backchannel SAML SLO
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
|
Reference in New Issue
Block a user