diff --git a/README.md b/README.md index 9c63ec8..20ef3cf 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,19 @@ Configuration and usage will depend on your IdP. To get started, configure your "sso_login_url": "https://some-idp.org/saml/SSO", "sso_logout_url": "https://some-idp.org/saml/SLO", "certificates": ["idp-cert.crt"] - } + }, + "loginRequestOptions": { + // Optional additional options for the login request, such as "relay_state", see https://www.npmjs.com/package/saml2-js#create_login_request_url + }, + "loginResponseOptions": { + // Optional additional options for the login response, such as "relay_state", see https://www.npmjs.com/package/saml2-js#post_assert + }, + "logoutRequestOptions": { + // Optional additional options for the logout request, such as "relay_state", see https://www.npmjs.com/package/saml2-js#create_logout_response_url + }, + "logoutResponseOptions": { + // Optional additional options for the logout response, such as "relay_state", see https://www.npmjs.com/package/saml2-js#create_logout_request_url + }, } } } @@ -71,10 +83,10 @@ In addition to the `saml2-js` options, the following settings are available: - The [SamlStrategy](#samlstrategy) is invoked, which - Gets the users profile - Finds or creates the user (entity) for that profile -- The [AuthenticationService](https://docs.feathersjs.com/api/authentication/service.html) creates an access token for that entity +- The [AuthenticationService](https://docs.feathersjs.com/api/authentication/service.html) creates an access token for that entity. The token payload will additionally include `nameId` and `sessionIndex` which are required later for logout. - Redirect to the `redirect` URL including the generated access token - The frontend (e.g. [authentication client](https://docs.feathersjs.com/api/authentication/client.html)) uses the returned access token to authenticate -- The frontend can redirect the user to `/saml/logout` to trigger the SAML logout flow (Note: not yet functional) +- The frontend can redirect the user to `/saml/logout?nameId=xxx&sessionIndex=xxx` to trigger the SAML logout flow, providing the `nameId` and `sessionIndex` from the access token payload ### SAML URLs @@ -83,7 +95,8 @@ There are several URLs and redirects that are important for SAML authentication: - `http(s):///saml`: The main URL to initiate the SAML flow. Link to this from the browser. - `http(s):///saml/metadata.xml`: The URL to the generated SP metadata file, to be provided to the IdP. - `http(s):///saml/assert`: The ACS that the IdP will redirect back to for validation. -- `http(s):///saml/logout`: The URL to trigger the SAML logout flow (Note: not yet functional) +- `http(s):///saml/logout`: The URL to trigger the SAML logout flow +- `http(s):///saml/sso`: The SLO endpoint the IdP should redirect to after a successful logout In the browser a SAML flow can be initiated with a link like: @@ -91,6 +104,12 @@ In the browser a SAML flow can be initiated with a link like: Login with IdentityProvider ``` +or + +```html +Logout +``` + ### Redirects > __Note:__ This functionality is stolen directly from [@feathersjs/authentication-oauth](https://github.com/feathersjs/feathers/tree/master/packages/authentication-oauth) @@ -152,10 +171,7 @@ class MySamlStrategy extends SamlStrategy { async getEntityData (samlUser: SamlUser, _existingEntity: any, params) { return { [`email`]: samlUser.attributes.email, - [`fullName`]: samlUser.attributes.fullName, - // NameId and SessionIndex are required if you wish to use SAML Logout as well. This sample stores them on the user in your DB. - [`nameId`]: samlUser.name_id, - [`sessionIndex`]: samlUser.session_index + [`fullName`]: samlUser.attributes.fullName }; } } @@ -209,11 +225,6 @@ class MySamlStrategy extends SamlStrategy { `samlStrategy.authenticate(authentication, params)` is the main endpoint implemented by any [authentication strategy](https://docs.feathersjs.com/api/authentication/strategy.html). It is usually called for authentication requests for this strategy by the [AuthenticationService](https://docs.feathersjs.com/api/authentication/service.html). -## TODO - -- SAML Logout is currently not functional. -- Write tests - ## License Heavily based on [@feathersjs/authentication-oauth](https://github.com/feathersjs/feathers/tree/master/packages/authentication-oauth). All credit to daffl and the other amazing Feathers contributors for all their hard work. :) diff --git a/src/express.ts b/src/express.ts index bb7df8d..459995e 100644 --- a/src/express.ts +++ b/src/express.ts @@ -8,6 +8,7 @@ import { } from '@feathersjs/express'; import { SamlSetupSettings } from './utils'; import { SamlStrategy } from './strategy'; +import { BadRequest } from '@feathersjs/errors'; const debug = Debug('feathers-saml/express'); @@ -37,24 +38,24 @@ export default (options: SamlSetupSettings) => { const authApp = express(); authApp.get('/', async (req, res) => { - sp.create_login_request_url(idp, {}, async (err: Error, login_url: string, request_id: string) => { - if (err != null) { - return res.send(500); - } + sp.create_login_request_url(idp, config.loginRequestOptions ? config.loginRequestOptions : {}, async (err: Error, login_url: string, request_id: string) => { + if (err != null) { + return res.send(500); + } - res.redirect(login_url); - }); + res.redirect(login_url); + }); }); authApp.get('/metadata.xml', async (req, res) => { - res.type('application/xml'); - res.send(sp.create_metadata()); + res.type('application/xml'); + res.send(sp.create_metadata()); }); authApp.post('/assert', async (req, res, next) => { const service = app.defaultAuthentication(authService); const [ strategy ] = service.getStrategies('saml') as SamlStrategy[]; - const params = { + const params: any = { authStrategies: [ strategy.name ] }; const sendResponse = async (data: AuthenticationResult|Error) => { @@ -76,17 +77,22 @@ export default (options: SamlSetupSettings) => { try { const samlResponse: any = await new Promise((resolve, reject) => { - sp.post_assert(idp, { - request_body: req.body - }, - async (err: Error, saml_response: any) => { - if (err != null) { - reject(err); - return; - } - - resolve(saml_response); - }); + let loginResponseOptions: any = {}; + + if (config.loginResponseOptions) { + loginResponseOptions = config.loginResponseOptions; + } + + loginResponseOptions.request_body = req.body; + + sp.post_assert(idp, loginResponseOptions, async (err: Error, saml_response: any) => { + if (err != null) { + reject(err); + return; + } + + resolve(saml_response); + }); }); const authentication = { @@ -94,6 +100,11 @@ export default (options: SamlSetupSettings) => { ...samlResponse }; + params.payload = { + nameId: samlResponse && samlResponse.user && samlResponse.user.name_id ? samlResponse.user.name_id : null, + sessionIndex: samlResponse && samlResponse.user && samlResponse.user.session_index ? samlResponse.user.session_index : null + }; + debug(`Calling ${authService}.create authentication with SAML strategy`); const authResult = await service.create(authentication, params); @@ -107,21 +118,44 @@ export default (options: SamlSetupSettings) => { } }); - // TODO: Implement logout. Need a way to get NameId and SessionIndex here. authApp.get('/logout', async (req, res, next) => { - sp.create_logout_request_url(idp, { - name_id: null, - session_index: null - }, async (err: Error, logout_url: string) => { - if (err != null) { - next(err); - return; - } + const { nameId, sessionIndex } = req.query; - res.redirect(logout_url); - }); + if (!nameId || !sessionIndex) { + return next(new BadRequest('`nameId` and `sessionIndex` must be set in query params')); + } + + let logoutRequestOptions: any = {}; + + if (config.logoutRequestOptions) { + logoutRequestOptions = config.logoutRequestOptions; + } + + logoutRequestOptions.name_id = nameId; + logoutRequestOptions.session_ndex = sessionIndex; + + sp.create_logout_request_url(idp, logoutRequestOptions, async (err: Error, logout_url: string) => { + if (err != null) { + next(err); + return; + } + + res.redirect(logout_url); + }); }); + authApp.get('/slo', async (req, res, next) => { + sp.create_logout_response_url(idp, config.logoutResponseOptions ? config.logoutResponseOptions : {}, async (err: Error, request_url: string) => { + if (err != null) { + next(err); + return; + } + + res.redirect(request_url); + }); + }); + + app.use(path, authApp); }; }; \ No newline at end of file