Skip to content

Commit

Permalink
DevelopingSpace#195 SSO and SLO additions (DevelopingSpace#236)
Browse files Browse the repository at this point in the history
* SAML Server, login callback, login page change, sp metadata route, small .env change

SSO and SLO with SimpleSAMLPhp and Samlify

Added SLO callback

* linting concerns

* else

* POST check first, sAMAccountName extract, idp and sp only used in samlServer, new samlServer functions to export funcionality without exposing sp/idp

* Add idp config locally

* cleaned up the code comments, swaped the conditional logic to make some guard clauses, added file to metadata file name

* forgot a console log

* I think this addresses most things

* Metadata secret check and remove fs in saml server, tried to add the relayState stuff in the logic inside of the saml server

* Changed the metadata location properly

* Changed the loginRequest a bit

* passing a string into creating login request so that we can use redirectTo searchParams and if it comes from login directly we just use '/', added taking relaystate out of body in parseLoginResponse and destructure it in callback file.

* narrowed down type of relayState to string

* I forgot to save the changes last time

* changed to string or default '/' for createLogin, used URL constructor in createLoginRequest

* addition to env example

---------

Co-authored-by: stefanaz2 <sfrunza@seneca.ca>
  • Loading branch information
2 people authored and Genne23v committed Feb 25, 2023
1 parent 57d21d6 commit b493447
Show file tree
Hide file tree
Showing 13 changed files with 265 additions and 13 deletions.
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ LOG_LEVEL=debug
# Notifications Email Config
NOTIFICATIONS_EMAIL_USER="no-reply@senecacollege.ca"
SMTP_PORT=1025

# SSO Config
HOSTNAME = http://localhost:8080
# Our apps's Entity ID, which is also the URL to our metadata.
SAML_ENTITY_ID=http://host.docker.internal:8080/sp
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ $ npm run db:studio
> **Note** `npm run build` needs to be executed the first time running the project. As it generates a `build/server.js` script that `npm run dev` depends on. Subsequent times, only `npm run dev` is needed to run the app in development mode.
## SAML Accounts to use in Dev

Our IDP is configured with a few accounts that exist for testing, the usernames and passwords to use are as follows:

| user | pass |
| ----------- | --------- |
| user1 | user1pass |
| user2 | user2pass |
| lippersheyh | telescope |

They can be configured in `./config/simplesamlphp-users`

## `.env` and `./dev-secrets/*`

Some application configuration is managed via environment variables, others as secrets (i.e., files).
Expand Down
22 changes: 15 additions & 7 deletions app/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,25 @@ import {
} from '@chakra-ui/react';
import { LockIcon } from '@chakra-ui/icons';
import type { ActionArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { Form } from '@remix-run/react';

import { createUserSession } from '~/session.server';
import { getUsername } from '~/session.server';
import { createLoginRequest } from '~/saml.server';

export const action = async ({ request }: ActionArgs) => {
return createUserSession({
request: request,
username: 'starchartdev',
remember: false,
redirectTo: '/',
});
// Check if a session with a username exists
const user = await getUsername(request);

// If not then create a login request to the IDP's redirect binding
if (!user) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get('redirectTo') ?? undefined;
const samlRedirectURL = await createLoginRequest(redirectTo);
return redirect(samlRedirectURL);
}

return redirect('/');
};

export default function Login() {
Expand Down
79 changes: 79 additions & 0 deletions app/routes/login/callback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { ActionArgs } from '@remix-run/node';
import { createUserSession } from '~/session.server';
import { parseLoginResponse } from '~/saml.server';
import { redirect } from '@remix-run/node';
import { createUser, getUserByUsername } from '~/models/user.server';

/* This is the post route that the SAML response is bound to. It comes back as formData.
We attempt to extract the SAML response into a json format that we can then use:
The response from SimpleSAMLPhp
{
conditions: {
notBefore: '2023-02-16T17:09:02Z',
notOnOrAfter: '2023-02-16T17:14:32Z'
},
response: {
id: '_cb60d5bbb95d8c820bbb306640ea535e9ffd6f23ca',
issueInstant: '2023-02-16T17:09:32Z',
destination: 'http://localhost:8080/login/callback',
inResponseTo: '_4be46ad1-4c2c-42c7-ba1f-a23c31161fcd'
},
audience: 'http://host.docker.internal:8080/sp',
issuer: 'http://localhost:8081/simplesaml/saml2/idp/metadata.php',
nameID: 'user1@myseneca.ca',
sessionIndex: {
authnInstant: '2023-02-16T17:09:32Z',
sessionNotOnOrAfter: '2023-02-17T01:09:32Z',
sessionIndex: '_6befc9246d7bd30b9e0faea72d0836cddf3ff8d556'
},
attributes: {
uid: '1',
eduPersonAffiliation: 'group1',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': 'user1@myseneca.ca',
email: 'user1@myseneca.ca',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname': 'Johannes',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname': 'Kepler',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name': 'user1@myseneca.ca',
sAMAccountName: 'user1',
'http://schemas.microsoft.com/identity/claims/displayname': 'Johannes Kepler'
}
}
*/
export const action = async ({ request }: ActionArgs) => {
if (request.method !== 'POST') {
// Request method is not post, why are you here?
return redirect('/');
}

const formData = await request.formData();
const body = Object.fromEntries(formData);
const { samlResponse, relayState } = await parseLoginResponse(body);
// Try and extract the username and see if there is an existing user by that name
if (!samlResponse.attributes.sAMAccountName) {
// TODO: Make this redirect to access denied page
return redirect('/');
}
const returnTo = relayState ? relayState : '/';
const username = samlResponse.attributes.sAMAccountName;
// get or create user
let user = await getUserByUsername(username);

// If not create one
if (!user) {
user = await createUser(
username,
samlResponse.attributes['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'],
samlResponse.attributes['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'],
samlResponse.attributes['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']
);
}

// Either way create a session
return createUserSession({
request: request,
username: username,
remember: false,
redirectTo: returnTo,
});
};
11 changes: 9 additions & 2 deletions app/routes/logout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { redirect } from '@remix-run/node';
import { logout } from '~/session.server';
import { getUsername } from '~/session.server';

import type { ActionArgs } from '@remix-run/node';
import { createLogoutRequest } from '~/saml.server';

export const action = async ({ request }: ActionArgs) => {
return logout(request);
const user = await getUsername(request);

// Do the opposite of login
if (user) {
const context = await createLogoutRequest(user);
return redirect(context);
}
};

export const loader = async () => {
Expand Down
15 changes: 15 additions & 0 deletions app/routes/logout/callback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { LoaderArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { logout } from '~/session.server';

// Logout, destroying the session with Starchart
export const loader = async ({ request }: LoaderArgs) => {
const url = new URL(request.url);

const SAMLResponse = url.searchParams.get('SAMLResponse');

if (SAMLResponse) {
return await logout(request);
}
return redirect('/');
};
12 changes: 12 additions & 0 deletions app/routes/sp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { LoaderArgs } from '@remix-run/node';
import { metadata } from '~/saml.server';

export async function loader({ params }: LoaderArgs) {
const meta = metadata();
return new Response(meta, {
status: 200,
headers: {
'Content-Type': 'text/xml',
},
});
}
61 changes: 61 additions & 0 deletions app/saml.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// saml server based on following PR
// https://github.com/remix-run/examples/pull/130/files/ec66b3060fac83eec2389eb0c96aad6d8ea4aed1#diff-02d2b71e481b2495b8a72af14f09fc28238298c7f1d19a540e37c9228985b0da
import * as samlify from 'samlify';
import * as validator from '@authenio/samlify-node-xmllint';
import secrets from './lib/secrets.server';

samlify.setSchemaValidator(validator);

const { SAML_IDP_METADATA } = secrets;
if (!SAML_IDP_METADATA) {
throw new Error('Missing SAML_IDP_METADATA secret');
}

// Here we configure the service provider: https://samlify.js.org/#/sp-configuration

const sp = samlify.ServiceProvider({
entityID: process.env.SAML_ENTITY_ID,
nameIDFormat: ['urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'],
wantAssertionsSigned: true,
assertionConsumerService: [
{
Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
Location: process.env.HOSTNAME + '/login/callback',
},
],
singleLogoutService: [
{
Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
Location: process.env.HOSTNAME + '/logout/callback',
},
],
});

// Take the metadata stood up by the IDP and use it as the metadata for our IDP object
const idp = samlify.IdentityProvider({
metadata: SAML_IDP_METADATA,
});

export function metadata() {
return sp.getMetadata();
}

export async function createLoginRequest(redirectUrl: string = '/') {
const { context } = sp.createLoginRequest(idp, 'redirect');
const url = new URL(context);
url.searchParams.append('RelayState', redirectUrl);
return url.href;
}

export async function createLogoutRequest(user: string) {
const { context } = sp.createLogoutRequest(idp, 'redirect', { nameID: user });
return context;
}

export async function parseLoginResponse(body: { [k: string]: FormDataEntryValue }) {
const { extract } = await sp.parseLoginResponse(idp, 'post', {
body,
});
const relayState = body.RelayState as string;
return { samlResponse: extract, relayState };
}
2 changes: 1 addition & 1 deletion config/saml20-idp-hosted.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
'authproc' => array(
3 => array(
'class' => 'saml:AttributeNameID',
'attribute' => 'email',
'attribute' => 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name',
'Format' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
)
)
Expand Down
22 changes: 22 additions & 0 deletions dev-secrets/SAML_IDP_METADATA
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="http://localhost:8081/simplesaml/saml2/idp/metadata.php">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8081/simplesaml/saml2/idp/SingleLogoutService.php"/>
<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8081/simplesaml/saml2/idp/SSOService.php"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ services:
ports:
- '8081:8080'
environment:
- SIMPLESAMLPHP_SP_ENTITY_ID=${SAML_ENTITY_ID}
- SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=${SSO_LOGIN_CALLBACK_URL}
- SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE=${SLO_LOGOUT_CALLBACK_URL}
- SIMPLESAMLPHP_SP_ENTITY_ID=http://host.docker.internal:8080/sp
- SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=http://localhost:8080/login/callback
- SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE=http://localhost:8080/logout/callback
volumes:
- ./config/simplesamlphp-users.php:/var/www/simplesamlphp/config/authsources.php
- ./config/saml20-idp-hosted.php:/var/www/simplesamlphp/metadata/saml20-idp-hosted.php
30 changes: 30 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
},
"dependencies": {
"@aws-sdk/client-route-53": "^3.276.0",
"@authenio/samlify-node-xmllint": "^2.0.0",
"@chakra-ui/icons": "^2.0.17",
"@chakra-ui/react": "^2.5.0",
"@emotion/react": "^11.10.5",
Expand Down

0 comments on commit b493447

Please sign in to comment.