Compare commits

...

17 Commits

Author SHA1 Message Date
62ab88ab2f Add a more useful OAuth2 config page 2022-11-26 16:12:38 +00:00
0a492b0898 Fixed issue in parsing scope 2022-11-26 14:04:50 +00:00
0fcbe0bb6d Revamped homepage 2022-11-26 13:56:11 +00:00
00e7a9ac5c Guide layout 2022-11-26 12:58:12 +00:00
33c33fab90 Add setup guide for Oauth2 2022-11-26 12:43:52 +00:00
9249a4b82f Add support for retrieving custom attributes in ID token and API response 2022-11-26 11:35:58 +00:00
48ba6351b4 Add basic instructions for Oauth 2022-11-26 11:07:52 +00:00
e51b7c0aeb Add support for reading Oauth2 logs 2022-11-26 10:52:29 +00:00
73799d6e57 Prevent the same code being used multiple times when retrieving a token 2022-11-26 10:38:42 +00:00
223f312089 Fixed JSON implementation 2022-11-26 10:25:45 +00:00
ec6789eb22 Respectfully build ID claims based on the request scopes 2022-11-26 10:02:41 +00:00
4e1bbd9d5e Support for generating access token and ID token 2022-11-26 09:47:37 +00:00
379d842a66 Add support for redirect_uri checking 2022-11-26 09:38:29 +00:00
081afc3377 Issue code. Draft access token response 2022-11-26 00:34:12 +00:00
482b07c12a Basic Oauth2 flow almost implemented 2022-11-26 00:08:20 +00:00
d14e5eb7aa Routes/templates for the basic OAuth app pages 2022-11-25 22:59:31 +00:00
836a136125 Add support for generating OAuth2 client IDs/secrets 2022-11-25 22:51:41 +00:00
19 changed files with 816 additions and 109 deletions

View File

@ -133,6 +133,10 @@ def idp_attribute_route(id, attr_id):
if request.method == 'PUT':
return util.jsonify(idps.update_attribute(util.get_user(required=False), id, attr_id, request.json))
@app.route('/idps/<id>/logs', methods=['GET'])
def idp_logs_route(id):
return util.jsonify(idps.get_logs(util.get_user(required=False), id))
@app.route('/idps/<id>/saml2/logs', methods=['GET'])
def idp_saml_logs_route(id):
return util.jsonify(idps.get_logs(util.get_user(required=False), id))
@app.route('/idps/<id>/oauth2/logs', methods=['GET'])
def idp_oauth_logs_route(id):
return util.jsonify(idps.get_oauth_logs(util.get_user(required=False), id))

View File

@ -1,4 +1,5 @@
import bcrypt, re
from uuid import uuid4
import bcrypt, re, random, string
from OpenSSL import crypto
import pymongo
from bson.objectid import ObjectId
@ -124,6 +125,9 @@ def create_sp(user, id, data):
'callbackUrl': data.get('callbackUrl'),
'logoutUrl': data.get('logoutUrl'),
'logoutCallbackUrl': data.get('logoutCallbackUrl'),
'oauth2ClientId': str(uuid4()),
'oauth2ClientSecret': str(''.join(random.choices(string.ascii_uppercase + string.digits, k=32))),
'oauth2RedirectUri': data.get('oauth2RedirectUri'),
'idp': id
}
result = db.idpSps.insert_one(sp)
@ -147,6 +151,7 @@ def update_sp(user, id, sp_id, data):
'callbackUrl': data.get('callbackUrl'),
'logoutUrl': data.get('logoutUrl'),
'logoutCallbackUrl': data.get('logoutCallbackUrl'),
'oauth2RedirectUri': data.get('oauth2RedirectUri'),
}
db.idpSps.update({'_id': sp_id}, {'$set': update_data})
return db.idpSps.find_one({'_id': sp_id}, {'name': 1, 'entityId': 1, 'serviceUrl': 1, 'callbackUrl': 1, 'logoutUrl': 1, 'logoutCallbackUrl': 1})
@ -315,7 +320,7 @@ def get_logs(user, id):
db = database.get_db()
idp = db.idps.find_one(id)
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t update this IdP')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t access this IdP')
logs = list(db.requests.find({'idp': id}).sort('createdAt', pymongo.DESCENDING).limit(30))
sps = list(db.idpSps.find({'idp': id}, {'name': 1}))
for log in logs:
@ -325,4 +330,19 @@ def get_logs(user, id):
if log['sp'] == sp['_id']:
log['spName'] = sp['name']
break
return {'logs': logs}
def get_oauth_logs(user, id):
id = ObjectId(id)
db = database.get_db()
idp = db.idps.find_one(id)
if not idp: return errors.NotFound('IDP not found')
if not can_manage_idp(user, idp): raise errors.Forbidden('You can\'t access this IdP')
logs = list(db.oauthRequests.find({'idp': id}).sort('createdAt', pymongo.DESCENDING).limit(30))
sps = list(db.idpSps.find({'idp': id}, {'name': 1}))
for log in logs:
for sp in sps:
if log['sp'] == sp['_id']:
log['spName'] = sp['name']
break
return {'logs': logs}

View File

@ -1,2 +1,3 @@
export MONGO_URL="mongodb://localhost"
export MONGO_DATABASE="ssotools"
export JWT_SECRET="SECURESTRING"

View File

@ -1,16 +1,27 @@
const express = require('express')
const { ObjectId } = require('mongodb'); // or ObjectID
const { ObjectId } = require('mongodb'); // or ObjectID
const bcrypt = require('bcryptjs');
const uuidv4 = require('uuid/v4');
const crypto = require('crypto');
const cookieParser = require('cookie-parser')
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');
const database = require('./database.js');
const idp = require('./idp.js');
JWT_SECRET = process.env.JWT_SECRET;
const app = express();
app.use(cookieParser());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.json());
const SCOPES = {
'openid': 'SSO Tools will issue a token containing your basic account details to the service provider',
'email': 'Allow the service provider to read the email address associated with your account',
'profile': 'Allow the service provider to read the name and other attributes associated with your account',
};
function pageTemplate(content) {
return `<!DOCTYPE html><html><head>
@ -22,13 +33,12 @@ function pageTemplate(content) {
</head><body> <div class="container">${content}</div></body></html>`;
}
function loginForm(res, requestId, currentIdp, currentSp, error) {
function loginForm(res, postPath, requestId, currentIdp, currentSp, error) {
res.status(error ? 401 : 200).send(
pageTemplate(
`<h2>${currentSp ? `Login to access ${currentSp.name}`:'Login'}</h2><h5>You're authenticating with ${currentIdp ? currentIdp.name: ''}</h5>
${currentSp ? '<p>After you\'ve logged-in, you\'ll be redirected back to '+currentSp.name+'.</p>' : ''}
`<h2>${currentSp ? `Login to access ${currentSp.name}`:'Login'}</h2><h5>You're authenticating with ${currentIdp ? currentIdp.name: ''}</h5>
${error ? `<p style="color:red;">${error}</p>` : ''}
<form method="post" action="/${currentIdp && currentIdp.code}/${currentSp ? `saml/login` : 'login'}">
<form method="post" action="/${currentIdp && currentIdp.code}/${currentSp ? postPath : 'login'}">
<input name="email" type="email" placeholder="Email address"/> <input name="password" type="password" placeholder="Password"/>
<input name="requestId" type="hidden" value="${requestId}" />
<button type="submit" value="Login" class="waves-effect waves-light btn">Login</button>
@ -37,10 +47,35 @@ function loginForm(res, requestId, currentIdp, currentSp, error) {
);
}
function confirmScopes(res, user, scope, requestId, currentIdp, currentSp, error) {
const requestScopes = [];
scope.forEach(s => {
if (SCOPES[s]) requestScopes.push(SCOPES[s]);
});
const requestScopesHtml = requestScopes.map(s => s && `<p>- ${s}</p>`).join(' ');
res.status(error ? 401 : 200).send(
pageTemplate(
`<h2>${currentSp?.name} is requesting access to your ${currentIdp?.name} account</h2>
${user ? `<p>You're currently logged-in as ${user.firstName} ${user.lastName}</p>` : ''}
<h5>Please confirm that you authorize the following:</h5>
${requestScopesHtml}
<form method="post" action="/${currentIdp?.code}/oauth2/confirm">
<input name="requestId" type="hidden" value="${requestId}" />
<button type="submit" value="Login" class="waves-effect waves-light btn">Authorize</button>
</form>`
),
);
}
function errorPage(res, message, status) {
res.status(status || 400).send(pageTemplate(`<h4>There was a problem fulfilling your request.</h3><h4>${message}</h4>`));
}
function errorJson(res, message, status) {
res.status(status || 400).json({ success: false, message: message });
}
async function getIdp(code) {
const idpCode = code && code.toLowerCase();
if (!idpCode) return null;
@ -134,7 +169,7 @@ async function sendAssertion(res, user, requestId, thisIdp, thisSp, sessionId) {
app.get('/:code', async (req, res) => {
const thisIdp = await getIdp(req.params.code);
if (!thisIdp) return errorPage(res, `There is no IDP service available at this URL.`, 404);
const user = await getUser(req, thisIdp);
const user = await getUser(req, thisIdp);
const sps = [];
if (user) {
@ -153,7 +188,7 @@ app.get('/:code', async (req, res) => {
<table>
<thead><tr><th>Name</th><th></th></thead>
<tbody>
${sps.map(s =>
${sps.map(s =>
`<tr>
<td>${s.name}</td>
<td style="text-align:right;"><a class='btn blue' href="${s.serviceUrl}">Visit Service</a><a class='btn green' href="https://idp.sso.tools/${thisIdp.code}/saml/login/initiate?entityId=${s.entityId}" style="margin-left: 10px;">IDP-initiated login</a></td></tr>`
@ -174,6 +209,11 @@ app.get('/:code', async (req, res) => {
</div>`));
});
/*
SAML2 HANDLERS
*/
// Received login request from SP
app.get('/:code/saml/login/request', async (req, res) => {
try{
@ -201,7 +241,7 @@ app.get('/:code/saml/login/request', async (req, res) => {
type: 'loginRequest',
data: info.login
});
if (!info.login.forceAuthn) {
const user = await getUser(req, thisIdp);
if (user) {
@ -211,7 +251,7 @@ app.get('/:code/saml/login/request', async (req, res) => {
return await sendAssertion(res, user, info.login.id, thisIdp, thisSp, sessionId);
}
}
return loginForm(res, info.login.id, thisIdp, thisSp);
return loginForm(res, 'saml/login', info.login.id, thisIdp, thisSp);
}
}
catch(err) {
@ -234,7 +274,7 @@ app.get('/:code/saml/logout/request', async (req, res) => {
}
if (info.logout) {
const fields = info.logout;
const IdpSps = await database.collection('idpSps');
const thisSp = await IdpSps.findOne({idp: thisIdp._id, entityId: fields.issuer});
if (!thisSp) return errorPage(res, `The Service Provider requesting authentication is not currently registered with the IDP ${thisIdp.name}. If you think you are seeing this message in error, please check your Service Provider configuration. For reference, the issuer of the authentication request is "${fields.issuer}"`);
@ -254,12 +294,12 @@ app.get('/:code/saml/logout/request', async (req, res) => {
if (user.email.toLowerCase() !== fields.nameId.toLowerCase()) return errorPage(res, 'The currently logged-in user does not match the user making the logout request.', 403);
const IdpUsers = await database.collection('idpUsers');
await IdpUsers.updateOne({_id: user._id}, {$unset: {sessionIds: ''}});
await IdpUsers.updateOne({_id: user._id}, {$unset: {sessionIds: ''}});
const encodedResponse = Buffer.from(fields.response).toString('base64');
return {
statusCode: 302,
headers: {
headers: {
'Location': `${thisSp.serviceUrl ? `${thisSp.serviceUrl}?SAMLResponse=${encodedResponse}` : `/${thisIdp.code}`}`,
'Set-Cookie': 'sessionId=deleted; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
},
@ -294,8 +334,8 @@ app.get('/:code/saml/login/initiate', async (req, res) => {
app.post('/:code/saml/login', async (req, res) => {
const Requests = await database.collection('requests');
const request = await Requests.findOne({'data.id': req.body.requestId});
if (!request) return loginForm(res, req.body.requestId, null, null, 'This login request is not valid.');
if (!request) return loginForm(res, 'saml/login', req.body.requestId, null, null, 'This login request is not valid.');
const thisIdp = await getIdp(req.params.code);
if (!thisIdp) return errorPage(res, `There is no IDP service available at this URL.`, 404);
@ -307,7 +347,7 @@ app.post('/:code/saml/login', async (req, res) => {
const user = await IdpUsers.findOne({email: req.body.email.toLowerCase(), idp: thisIdp._id });
if (!user || !bcrypt.compareSync(req.body.password, user.password.toString())) {
return loginForm(res, req.body.requestId, thisIdp, thisSp, 'The email address or password is incorrect. Remember that you need to login as a user registered with the IDP, and not your SSO Tools account.');
return loginForm(res, 'saml/login', req.body.requestId, thisIdp, thisSp, 'The email address or password is incorrect. Remember that you need to login as a user registered with the IDP, and not your SSO Tools account.');
}
const sessionId = uuidv4();
await IdpUsers.updateOne({_id: user._id}, {$addToSet: {sessionIds: sessionId}});
@ -315,6 +355,248 @@ app.post('/:code/saml/login', async (req, res) => {
return await sendAssertion(res, user, request.data.id, thisIdp, thisSp, sessionId);
});
/*
OAUTH2 HANDLERS
*/
// Handle requests to SP-initiated login for OAuth2
app.get('/:code/oauth2/authorize', async (req, res) => {
const clientId = req.query.client_id;
const scope = req.query.scope && req.query.scope.split(' ');
const redirectUri = req.query.redirect_uri;
const responseType = req.query.response_type;
if (!clientId) return errorPage(res, 'No client ID was provided');
if (!redirectUri) return errorPage(res, 'Redirect URI is required');
if (responseType !== 'code') return errorPage(res, 'Response type must equal "code"');
const thisIdp = await getIdp(req.params.code);
if (!thisIdp) return errorPage(res, `There is no IDP service available at this URL.`, 404);
const IdpSps = await database.collection('idpSps');
const thisSp = await IdpSps.findOne({ oauth2ClientId: clientId });
if (!thisSp) return errorPage(res, 'The client ID you provided is invalid');
if (thisSp.oauth2RedirectUri !== redirectUri) return errorPage(res, `The Redirect URI specified doesn't match what is registered for this service provider`, 400);
const OauthRequests = await database.collection('oauthRequests');
const result = await OauthRequests.insertOne({
createdAt: new Date(),
idp: thisIdp._id,
sp: thisSp._id,
type: 'authorizeRequest',
scope: scope,
redirectUri: redirectUri,
clientId: clientId,
data: {
clientId: clientId,
scope: scope,
redirectUri: redirectUri,
responseType: responseType,
}
});
const user = await getUser(req, thisIdp);
if (!user) {
return loginForm(res, 'oauth2/login', result.insertedId, thisIdp, thisSp, `Please login to ${thisIdp.name} in order to continue to ${thisSp.name}`);
}
return await confirmScopes(res, user, scope, result.insertedId, thisIdp, thisSp);
});
// Handle Oauth2 login form
app.post('/:code/oauth2/login', async (req, res) => {
const OauthRequests = await database.collection('oauthRequests');
const request = await OauthRequests.findOne({'_id': ObjectId(req.body.requestId)});
if (!request) return loginForm(res, 'oauth2/login', req.body.requestId, null, null, 'This login request is not valid.');
const thisIdp = await getIdp(req.params.code);
if (!thisIdp) return errorPage(res, `There is no IDP service available at this URL.`, 404);
const IdpSps = await database.collection('idpSps');
const thisSp = await IdpSps.findOne({idp: thisIdp._id, _id: request.sp});
if (!thisSp) return errorPage(res, `The Service Provider requesting authentication is not currently registered with the IDP ${thisIdp.name}. If you think you are seeing this message in error, please check your Service Provider configuration.`);
const IdpUsers = await database.collection('idpUsers');
const user = await IdpUsers.findOne({email: req.body.email.toLowerCase(), idp: thisIdp._id });
if (!user || !bcrypt.compareSync(req.body.password, user.password.toString())) {
return loginForm(res, 'oauth2/login', req.body.requestId, thisIdp, thisSp, 'The email address or password is incorrect. Remember that you need to login as a user registered with the IDP, and not your SSO Tools account.');
}
const sessionId = uuidv4();
await IdpUsers.updateOne({_id: user._id}, {$addToSet: {sessionIds: sessionId}});
res.append('Set-Cookie', `sessionId=${sessionId}; Path=/`);
return await confirmScopes(res, user, request.scope, req.body.requestId, thisIdp, thisSp);
});
// Handle Oauth2 scope confirmation
app.post('/:code/oauth2/confirm', async (req, res) => {
const OauthRequests = await database.collection('oauthRequests');
const request = await OauthRequests.findOne({'_id': ObjectId(req.body.requestId)});
if (!request) return loginForm(res, 'oauth2/login', req.body.requestId, null, null, 'This login request is not valid.');
const thisIdp = await getIdp(req.params.code);
if (!thisIdp) return errorPage(res, `There is no IDP service available at this URL.`, 404);
const IdpSps = await database.collection('idpSps');
const thisSp = await IdpSps.findOne({idp: thisIdp._id, _id: request.sp});
if (!thisSp) return errorPage(res, `The Service Provider requesting authentication is not currently registered with the IDP ${thisIdp.name}. If you think you are seeing this message in error, please check your Service Provider configuration.`);
const user = await getUser(req, thisIdp);
if (!user) {
return loginForm(res, 'oauth2/login', req.body.requestId, thisIdp, thisSp, 'You need to be logged-in to access this page.');
}
const oauthCode = crypto.randomBytes(16).toString('hex');
await OauthRequests.insertOne({
createdAt: new Date(),
idp: thisIdp._id,
sp: thisSp._id,
type: 'authorizedScope',
scope: request.scope,
redirectUri: request.redirectUri,
clientId: request.clientId,
code: oauthCode,
data: request,
});
const OauthSessions = await database.collection('oauthSessions');
await OauthSessions.insertOne({
createdAt: new Date(),
idp: thisIdp._id,
sp: thisSp._id,
user: user._id,
scope: request.scope,
code: oauthCode,
data: {
scope: request.scope,
clientId: request.clientId,
code: oauthCode,
}
});
res.redirect(`${request.redirectUri}?code=${oauthCode}`);
});
// Handle Oauth2 token request
app.post('/:code/oauth2/token', async (req, res) => {
const clientId = req.body.client_id;
const clientSecret = req.body.client_secret;
const code = req.body.code;
const redirectUri = req.body.redirect_uri;
const grantType = req.body.grant_type;
if (!clientId) return errorJson(res, 'Client ID is required');
if (!clientSecret) return errorJson(res, 'Client secret is required');
if (!redirectUri) return errorJson(res, 'Redirect URI is required');
if (!code) return errorJson(res, 'Authorization code is required');
if (grantType !== 'authorization_code') return errorJson(res, 'Grant type must equal "authorization_code"');
const thisIdp = await getIdp(req.params.code);
if (!thisIdp) return errorJson(res, `There is no IDP service available at this URL.`, 404);
const OauthSessions = await database.collection('oauthSessions');
const oauthSession = await OauthSessions.findOne({code: code, idp: thisIdp._id});
if (!oauthSession) return errorJson(res, `No valid OAuth2 session is available with these details.`, 404);
if (oauthSession.consumed) return errorJson(res, 'This session code has already been redeemed. Please repeat the authorization process to obtain a new code.');
const IdpSps = await database.collection('idpSps');
const thisSp = await IdpSps.findOne({_id: oauthSession.sp, oauth2ClientId: clientId, oauth2ClientSecret: clientSecret});
if (!thisSp) return errorJson(res, `A service provider matching your information could not be found. Please check your client ID and secret`);
if (thisSp.oauth2RedirectUri !== redirectUri) return errorJson(res, `The Redirect URI specified doesn't match what is registered for this service provider`, 400);
const IdpUsers = await database.collection('idpUsers');
const user = await IdpUsers.findOne({_id: oauthSession.user});
if (!user) return errorJson(res, 'Could not find the user associated with this session', 404);
// Prepare ID Token (if in scope) and access token
const returnData = {};
if (oauthSession.scope.indexOf('openid') > -1) {
const claims = {sub: user._id};
if (oauthSession.scope.indexOf('email') > -1) claims.email = user.email;
if (oauthSession.scope.indexOf('profile') > -1) {
claims.given_name = user.firstName;
claims.family_name = user.lastName;
const customAttributes = await getAttributes(thisIdp);
customAttributes.forEach(a => {
const key = a.samlMapping || a.name;
if (key) {
const value = (user.attributes && user.attributes[a._id]) || a.defaultValue;
if (value) claims[key] = value;
}
});
}
returnData['id_token'] = jwt.sign(claims, JWT_SECRET);
}
returnData['access_token'] = crypto.randomBytes(40).toString('hex');
const OauthRequests = await database.collection('oauthRequests');
await OauthRequests.insertOne({
createdAt: new Date(),
idp: thisIdp._id,
sp: thisSp._id,
type: 'tokenRequest',
scope: oauthSession.scope,
code: code,
data: {
scope: oauthSession.scope,
code: code,
clientId: clientId,
clientSecret: 'REDACTED',
redirectUri: redirectUri,
grantType: grantType,
}
});
await OauthRequests.insertOne({
createdAt: new Date(),
idp: thisIdp._id,
sp: thisSp._id,
type: 'tokenResponse',
scope: oauthSession.scope,
data: returnData,
});
await OauthSessions.updateOne({_id: oauthSession._id}, {$set: {consumed: true, accessToken: returnData['access_token']}});
res.json(returnData);
});
// Handle API request to get user's profile
app.get('/:code/api/users/me', async (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader) return errorJson(res, 'This resource requires authorization', 403);
const thisIdp = await getIdp(req.params.code);
if (!thisIdp) return errorJson(res, `There is no IDP service available at this URL.`, 404);
const OauthSessions = await database.collection('oauthSessions');
const oauthSession = await OauthSessions.findOne({accessToken: authHeader, idp: thisIdp._id});
if (!oauthSession) return errorJson(res, `The acceess token provided is not valid.`, 403);
console.log(oauthSession);
const IdpUsers = await database.collection('idpUsers');
const user = await IdpUsers.findOne({_id: oauthSession.user});
if (!user) return errorJson(res, 'Could not find the user associated with this session', 404);
const returnData = {id: user._id};
if (oauthSession.scope.indexOf('email') > -1) returnData.email = user.email;
if (oauthSession.scope.indexOf('profile') > -1) {
returnData.firstName = user.firstName;
returnData.lastName = user.lastName;
const customAttributes = await getAttributes(thisIdp);
customAttributes.forEach(a => {
const key = a.samlMapping || a.name;
if (key) {
const value = (user.attributes && user.attributes[a._id]) || a.defaultValue;
if (value) returnData[key] = value;
}
});
}
res.json(returnData);
});
/*
GENERAL LOGIN / LOGOUT HANDLERS
*/
// Login to IdP
app.post('/:code/login', async (req, res) => {
const thisIdp = await getIdp(req.params.code);
@ -324,7 +606,7 @@ app.post('/:code/login', async (req, res) => {
const user = await IdpUsers.findOne({email: req.body.email.toLowerCase(), idp: thisIdp._id });
if (!user || !bcrypt.compareSync(req.body.password, user.password.toString())) {
return loginForm(res, null, thisIdp, null, 'The email address or password is incorrect. Remember that you need to login as a user registered with the IDP, and not your SSO Tools account.');
return loginForm(res, 'login', 'null', thisIdp, null, 'The email address or password is incorrect. Remember that you need to login as a user registered with the IDP, and not your SSO Tools account.');
}
const sessionId = uuidv4();
await IdpUsers.updateOne({_id: user._id}, {$addToSet: {sessionIds: sessionId}});
@ -338,7 +620,7 @@ app.get('/:code/logout', async (req, res) => {
const user = await getUser(req, thisIdp);
if (user) {
const IdpUsers = await database.collection('idpUsers');
await IdpUsers.updateOne({_id: user._id}, {$unset: {sessionIds: ''}});
await IdpUsers.updateOne({_id: user._id}, {$unset: {sessionIds: ''}});
}
res.append('Set-Cookie', 'sessionId=deleted; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT');
res.redirect(`/${thisIdp.code}`);

View File

@ -1,9 +1,7 @@
{
"name": "ssotoolsidp",
"version": "1.0.0",
"scripts": {
},
"scripts": {},
"dependencies": {
"async": "^2.6.1",
"bcryptjs": "^2.4.3",
@ -12,6 +10,7 @@
"crypto": "^1.0.1",
"debug": "^4.1.1",
"express": "^4.17.1",
"jsonwebtoken": "^8.5.1",
"mongodb": "^3.1.10",
"pem": "^1.13.2",
"saml2-js": "^2.0.3",

View File

@ -136,6 +136,11 @@ bson@^1.1.0:
resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.0.tgz#bee57d1fb6a87713471af4e32bcae36de814b5b0"
integrity sha512-9Aeai9TacfNtWXOYarkFJRW2CWo+dRon+fuLZYJmvLV3+MiUp0bEI6IAZfXEIg7/Pl/7IWlLaDnhzTsD81etQA==
buffer-equal-constant-time@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==
bytes@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
@ -338,6 +343,13 @@ duplexer3@^0.1.4:
resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
ecdsa-sig-formatter@1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
dependencies:
safe-buffer "^5.0.1"
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@ -689,6 +701,39 @@ json-buffer@3.0.0:
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898"
integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=
jsonwebtoken@^8.5.1:
version "8.5.1"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d"
integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==
dependencies:
jws "^3.2.2"
lodash.includes "^4.3.0"
lodash.isboolean "^3.0.3"
lodash.isinteger "^4.0.4"
lodash.isnumber "^3.0.3"
lodash.isplainobject "^4.0.6"
lodash.isstring "^4.0.1"
lodash.once "^4.0.0"
ms "^2.1.1"
semver "^5.6.0"
jwa@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==
dependencies:
buffer-equal-constant-time "1.0.1"
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jws@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
dependencies:
jwa "^1.4.1"
safe-buffer "^5.0.1"
keyv@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
@ -708,6 +753,41 @@ lodash-node@~2.4.1:
resolved "https://registry.yarnpkg.com/lodash-node/-/lodash-node-2.4.1.tgz#ea82f7b100c733d1a42af76801e506105e2a80ec"
integrity sha1-6oL3sQDHM9GkKvdoAeUGEF4qgOw=
lodash.includes@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==
lodash.isboolean@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==
lodash.isinteger@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==
lodash.isnumber@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==
lodash.isplainobject@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==
lodash.isstring@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==
lodash.once@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==
lodash@^4.17.10:
version "4.17.11"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
@ -1043,6 +1123,11 @@ safe-buffer@5.1.2, safe-buffer@^5.1.2:
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
safe-buffer@^5.0.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
"safer-buffer@>= 2.1.2 < 3":
version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
@ -1086,7 +1171,7 @@ semver@^5.1.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==
semver@^5.7.1:
semver@^5.6.0, semver@^5.7.1:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==

View File

@ -53,6 +53,7 @@
<img :src="logoLight" style="height:50px;"/>
<div>
<v-btn size='small' class="ma-1 umami--click--support-button-footer" prepend-icon="mdi-party-popper" href="https://ko-fi.com/wilw88" target="_blank" rel="noopener noreferrer">Support SSO Tools</v-btn>
<v-btn size='small' variant='outlined' dark href='https://git.wilw.dev/wilw/sso-tools' target='_blank' rel='noopener noreferrer' class="ma-1">Source code</v-btn>
<v-btn size='small' variant='outlined' dark to='/privacy' class="ma-1">Privacy Policy</v-btn>
<v-btn size='small' variant='outlined' dark to="/terms" class="ma-1">Terms of Use</v-btn>
</div>

View File

@ -11,7 +11,7 @@
<v-btn block class="mt-3 umami--click--support-button-dashboard" prepend-icon="mdi-coffee" href="https://ko-fi.com/wilw88" target="_blank" rel="noopener noreferrer">Buy me a coffee</v-btn>
</v-alert>
<v-alert v-if="loggedIn" color="green-darken-1" icon="mdi-flash">
<v-alert v-if="loggedIn" color="primary" icon="mdi-flash">
<h3>Thanks for being a member!</h3>
<p>If you need any support with SSO Tools, or with connecting applications using SAML2, please get in touch with us.</p>
</v-alert>

View File

@ -0,0 +1,15 @@
<template>
<v-container style="max-width: 600px">
<router-view />
</v-container>
</template>
<script>
export default {
name: 'Guide',
}
</script>
<style scoped>
</style>

View File

@ -3,16 +3,16 @@
<div class="bg-indigo-lighten-5">
<v-container class="d-block d-sm-flex align-center">
<div class="w-auto pa-5">
<h1 class="mb-5">Set-up and test single sign-on in your web, mobile, and desktop apps</h1>
<p>With SSO Tools it is easy to spin-up your own custom identity providers, allowing you to start testing your <span class="bg-yellow-lighten-3 rounded-md pa-1">SAML2</span> applications in minutes.</p>
<v-btn class="mt-5" to="/idps/new" color="primary" prepend-icon="mdi-plus"> Create your own IdP</v-btn>
<h1 class="mb-5">Implement and test single sign-on in your web, mobile, and desktop apps</h1>
<p>With SSO Tools it is easy to spin-up your own custom identity providers, allowing you to start building and testing your <span class="bg-yellow-lighten-3 rounded-md pa-1">SAML2</span>, <span class="bg-yellow-lighten-3 rounded-md pa-1">OAuth2</span>, and <span class="bg-yellow-lighten-3 rounded-md pa-1">OpenID Connect</span> applications in minutes.</p>
<v-btn class="mt-5" to="/idps/new" color="primary" prepend-icon="mdi-rocket"> Get started</v-btn>
<div class="d-flex align-center mt-2">
<v-icon icon="mdi-star" class="mr-2 text-primary" />
<p><small>It's free &amp; you don't have to register an account</small></p>
</div>
<div class="d-flex align-center mt-1">
<v-icon icon="mdi-star" class="mr-2 text-primary" />
<p><small>SSO Tools is fully open-source</small></p>
<p><small>SSO Tools is <a href='https://git.wilw.dev/wilw/sso-tools' target='_blank' rel='noopener noreferrer'>fully open-source</a></small></p>
</div>
</div>
<div class="w-auto">
@ -21,18 +21,48 @@
</v-container>
</div>
<v-container>
<div class="text-center mt-10 ml-5 mr-5">
<h2>Get confident with your SSO apps</h2>
<p>Develop and fully test out your applications with single sign-on to radically improve your offering for <strong>enterprise customers</strong>.</p>
<p>SSO Tools is great for small SaaS organisations who need to learn about and <strong>provide federated authentication</strong> to their customers.</p>
<v-container class="d-block d-sm-flex mt-10 mb-10">
<div class="pa-5 rounded-lg bg-grey-lighten-4 flex-grow-1 elevation-15 ma-sm-5 mb-10 mb-sm-0">
<h3>A full single sign-on suite</h3>
<p>Use SSO Tools to create an identity provider that mimics the SSO capabilities of services like <span class="bg-yellow-lighten-3 rounded-md pa-1">Microsoft Active Directory</span>, <span class="bg-yellow-lighten-3 rounded-md pa-1">Stripe</span>, and <span class="bg-yellow-lighten-3 rounded-md pa-1">Facebook</span>.</p>
<p class="mt-3">You can create apps, register users, build custom attributes, and more, to fully ensure your app has the SSO capabilities you need.</p>
<v-alert class="mt-5" size="small" color="primary"><strong>Top tip:</strong> Include SSO Tools in your local dev workflow and your CI/CD pipeline to help prevent regression.</v-alert>
</div>
<div class="pa-5 rounded-lg bg-grey-lighten-4 flex-grow-1 elevation-15 ma-sm-5">
<h3>Learn how to implement SSO</h3>
<p>SSO Tools is simple and forgiving. Feel free to make as many mistakes as you need while you develop your solutions: SSO Tools does not enforce strict rate limiting on its IdPs.</p>
<p class="mt-3">SSO Tools shows you the requests it receives and makes during the single sign-on flows, helping you to identify problems and debug your app.</p>
<div class="mt-5">
<h4 class="mb-3">Learn how to implement:</h4>
<v-btn class="mr-2 mb-2" size='small' to="/guides/oauth2" color="green">OAuth2 & OpenID Connect</v-btn>
<v-btn class="mr-2 mb-2" size='small' color="green" disabled>SAML2 (coming soon...)</v-btn>
</div>
</div>
</v-container>
<div class="bg-indigo-lighten-5 pt-10 pb-10">
<v-container>
<h2>Why use SSO Tools?</h2>
<p>Single sign-on is great for individuals and businesses. It offers extra security and greater control to its users.</p>
<p class="mt-5">If you are building apps for businesses or enterprise customers, single sign-on is often an essential requirement in procurement. However, it can be hard to understand and implement.</p>
<p class="mt-5">SSO Tools is a free resource aimed at helping people build and maintain SSO solutions in their applications - no matter if you are a solo developer or a member of a large software team.</p>
<v-btn class="mt-5" to="/idps/new" color="primary" prepend-icon="mdi-rocket">Get started</v-btn>
</v-container>
</div>
<v-container>
<div class="d-block d-sm-flex ml-5 ml-sm-16 mr-5 mr-sm-16 mt-16">
<div class="w-auto mr-5 mb-5">
<h2>Quick and simple: we focus on the essentials</h2>
<p>Use the SSO Tools sandbox to quickly spin-up identity providers, register users and set-up your service providers in order to test the entire SSO lifecycle.</p>
<p>You don't need to worry about signing up for complicated SSO providers, and <strong>you don't even need an account to get started</strong>.</p>
<p>You don't need to worry about signing up for complicated or expensive SSO providers, and <strong>you don't even need an account to get started</strong>.</p>
</div>
<div class="w-auto">
<img :src="usersImage" style="width:100%;border-radius:3px;box-shadow:0px 4px 10px rgba(0,0,0,0.2);" />
@ -43,7 +73,7 @@
<div class="w-auto">
<img :src="secureImage" style="width:100%;border-radius:3px;box-shadow:0px 4px 10px rgba(0,0,0,0.2);" />
</div>
<div class="w-auto ml-5 mt-5">
<div class="w-auto ml-5">
<h2>Personalised and secure</h2>
<p>Each of your identity providers is given its own path at our IdP subdomain, allowing you to easily separate different IdP configurations for testing different functions.</p>
<h2 class="mt-5">Emulate a real-world IdP</h2>
@ -56,57 +86,34 @@
<div class="w-auto mr-5 mb-5">
<h2>Follow industry standards</h2>
<p>SSO Tools allows you to test your SSO implementations in your apps following protocols and standards used by the vast majority of large companies, educational institutions, government agencies, and more.</p>
<p>We currently support the SAML2 protocol, which works well with your customers using LDAP and/or Microsoft Active Directory. Each IdP you create can have different settings to enable you to fully test out your services and your multi-tenancy support.</p>
<p>Each IdP you create can have different settings to enable you to fully test out your services and your multi-tenancy support.</p>
</div>
<div class="w-auto">
<img :src="samlImage" style="width:100%;border-radius:3px;box-shadow:0px 4px 10px rgba(0,0,0,0.2);" />
</div>
</div>
<div class="d-flex flex-column align-center bg-indigo-lighten-5 mt-16 pa-10 rounded-lg elevation-5">
<div class="w-75">
<h1>Features &amp; roadmap</h1>
<p>SSO Tools is offered as a free service. For any questions, or to get in touch, please do so via <a href="mailto:hello@sso.tools" target="_blank" rel="noopener noreferrer">email</a>.</p>
</div>
<div class="d-block d-sm-flex mt-10">
<div class="mr-2">
<h3>What you get now</h3>
<v-list>
<v-list-item v-for="feature in features" :key="feature">
<v-list-item-icon><v-icon icon="mdi-check-circle" class="text-green mr-2"/></v-list-item-icon>
<v-list-item-content>
{{ feature }}
</v-list-item-content>
</v-list-item>
</v-list>
</div>
<div class="mt-5 mt-sm-0">
<h3>What's coming</h3>
<v-list>
<v-list-item v-for="item in roadmap" :key="item" avatar>
<v-list-item-icon><v-icon icon="mdi-clock-outline" class="text-grey-lighten-1 mr-2" /></v-list-item-icon>
<v-list-item-content>
{{ item }}
</v-list-item-content>
</v-list-item>
</v-list>
</div>
</div>
</div>
<div class="mt-10 d-flex justify-center">
<div class="w-75">
<h1 class="mb-8">👋 Hi there, thanks for visiting</h1>
<p class="mb-2">SSO Tools is created and maintained by a solo developer. During my work as part of a growing startup selling enterprise SaaS, the need for providing larger organisations with federated authentication became hugely important.</p>
<p class="mb-2">I spent a lot of my spare time reading-up and learning about the different approaches to SSO via SAML2, with a focus on security whilst also making set-up easy for our customers.</p>
<p class="mb-2">In my spare time I began working on SSO Tools so that I could easily test sign-on and logout flows across different use-cases.</p>
<p class="mb-2">SSO Tools is free and open-source software, and you can <a href='https://git.wilw.dev/wilw/sso-tools' target='_blank' rel='noopener noreferrer'>check out the source code</a> if you are interested. <strong>I hope you find it useful!</strong></p>
</div>
</div>
</v-container>
<div class="bg-indigo-lighten-5 mt-10 pt-10 pb-10">
<v-container>
<div class="text-center">
<h1>Additional features</h1>
<p>SSO Tools is offered as a free service. For any questions, or to get in touch, please do so via <a href="mailto:hello@sso.tools" target="_blank" rel="noopener noreferrer">email</a>.</p>
</div>
<div class="d-block d-sm-flex mt-10 justify-center">
<v-list class="bg-transparent">
<v-list-item v-for="feature in features" :key="feature">
<v-list-item-icon><v-icon icon="mdi-check-circle" class="text-green mr-2"/></v-list-item-icon>
<v-list-item-content>
{{ feature }}
</v-list-item-content>
</v-list-item>
</v-list>
</div>
</v-container>
</div>
</div>
</template>
@ -122,14 +129,7 @@ export default {
return {
landingImage, usersImage, secureImage, samlImage,
features: [
'Create unlimited IdPs and apps', 'Support for custom user attributes & mappings', 'IdP- and SP-initiated SAML2 sign-on', 'SAML2 ForceAuthn respected', 'SP-initiated log-out', 'Request and response logging'
],
roadmap: [
'OAuth2 and OpenID Connect support',
'Full SAML2 SLO process (i.e. log-out all signed-in SPs)',
'Improved certificate/signature handling',
'Support for automatic metadata registration',
'Support for metadata download',
'SAML2, OAuth2, and OpenID Connect support', 'Create unlimited IdPs and apps', 'Support for custom user attributes & mappings', 'IdP- and SP-initiated SAML2 sign-on', 'SAML2 ForceAuthn respected', 'Full request and response logging'
],
}
},

View File

@ -30,9 +30,23 @@
<v-list-item-content>SAML2 configuration</v-list-item-content>
</v-list-item>
<v-list-item :to="`/idps/${idp._id}/logs`" prepend-icon="mdi-format-list-bulleted">
<v-list-item :to="`/idps/${idp._id}/saml/logs`" prepend-icon="mdi-format-list-bulleted">
<v-list-item-content>SAML2 logs</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
<v-list-item :to="`/idps/${idp._id}/oauth`" prepend-icon="mdi-swap-horizontal">
<v-list-item-content>OAuth2 configuration</v-list-item-content>
</v-list-item>
<v-list-item :to="`/idps/${idp._id}/oauth/guide`" prepend-icon="mdi-lifebuoy">
<v-list-item-content>OAuth2 setup guide</v-list-item-content>
</v-list-item>
<v-list-item :to="`/idps/${idp._id}/oauth/logs`" prepend-icon="mdi-format-list-bulleted">
<v-list-item-content>OAuth2 logs</v-list-item-content>
</v-list-item>
</v-list>
</v-card>

View File

@ -0,0 +1,82 @@
<template>
<div>
<p>You can use the details below to configure your OAuth2-compatibile service providers to use this IdP as an identity source.</p>
<v-alert type="info" class="mt-5">To start using a service provider with this IdP you will also need to register it on the <router-link style="color:white;" :to="`/idps/${idp._id}/sps`">Connected apps</router-link> page.</v-alert>
<v-alert color="green" class="mt-5">For help in getting started with implementing OAuth2, check out the <router-link style="color:white;" :to="`/idps/${idp._id}/oauth/guide`">OAuth2 & OpenID Connect guide</router-link>.</v-alert>
<div class=" mt-5 pa-5 bg-grey-lighten-3 rounded-lg">
<h4>Authorization URL</h4>
<p>This is where your app should redirect users in order to begin the single sign-on flow.</p>
<v-text-field class="mt-3 mb-1" readonly label="Authorization URL" :model-value="`https://idp.sso.tools/${idp.code}/oauth2/authorize`" />
<p>This URL should be accessed via HTTP <code>GET</code> and it expects the following parameters:</p>
<ul class="ml-5 mt-2">
<li><code>client_id</code>: The client ID for the app (available on the Connected apps page).</li>
<li><code>scope</code>: A space-separated list of requested permissions (see available scopes below).</li>
<li><code>redirect_uri</code>: The URL to send the user back to after authentication. This must match the one declared in the app settings on the Connected apps page.</li>
<li><code>response_type</code>: This should just be set to the value "code".</li>
</ul>
<h4 class="mt-5">The following scopes can be requested</h4>
<v-list lines="one" class="bg-transparent">
<v-list-item prepend-icon="mdi-check-circle-outline"
title="email"
subtitle="To allow the service provider to request access to the user's email address"
></v-list-item>
<v-list-item prepend-icon="mdi-check-circle-outline"
title="profile"
subtitle="To allow the service provider to request access to the user's name and other attributes"
></v-list-item>
<v-list-item prepend-icon="mdi-check-circle-outline"
title="openid"
subtitle="The IdP will send an ID token, along with the access token, containing scoped profile claims"
></v-list-item>
</v-list>
<v-alert color="blue-lighten-4" class="mt-5"><strong>Coming soon:</strong> An "offline_access" scope for managing OAuth2 token-refresh flows.</v-alert>
</div>
<div class=" mt-5 pa-5 bg-grey-lighten-3 rounded-lg">
<h4 >Token URL</h4>
<p>This is the URL used to obtain an ID token and access token using the <code>code</code> returned from the IdP after authentication.</p>
<v-text-field class="mt-3 mb-1" readonly label="Token URL" :model-value="`https://idp.sso.tools/${idp.code}/oauth2/token`" />
<p>This URL should be accessed via HTTP <code>POST</code> with a body in JSON format containing the following fields:</p>
<ul class="ml-5 mt-2">
<li><code>code</code>: The code received back after authentication.</li>
<li><code>client_id</code>: The client ID (as shown on the Connected apps page).</li>
<li><code>client_secret</code>: The client secret (as shown on the Connected apps page).</li>
<li><code>redirect_uri</code>: The same redirect URI we used earlier, again.</li>
<li><code>grant_type</code>: This should be set to the value "authorization_code".</li>
</ul>
<p>The response is a JSON object containing an <code>access_token</code> field and a <code>id_token</code> field if the <code>openid</code> scope was requested.</p>
</div>
<div class=" mt-5 pa-5 bg-grey-lighten-3 rounded-lg">
<h4>API endpoint URL</h4>
<p>This URL represents an example API endpoint that can be called to check the validity of the <code>access_token</code>.</p>
<v-text-field class="mt-3 mb-1" readonly label="Example API URL" :model-value="`https://idp.sso.tools/${idp.code}/api/users/me`" />
<p>To call this, make an HTTP <code>GET</code> request to the URL with the <code>access_token</code> stored in the request's <code>Authorization</code> header.</p>
</div>
</div>
</template>
<script>
import api from '../api';
export default {
name: 'IDPOAuth',
props: ['idp'],
data() {
return {
}
},
methods: {
},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,123 @@
<template>
<div>
<h1>A guide to OAuth2 and OpenID Connect</h1>
<p>Setting-up an OAuth2 app for the first time might seem daunting, but once you understand the flow you'll be easily able to implement single sign-on and OAuth2-based API access for your apps in no time.</p>
<v-alert class="mt-8 mb-5">
<h3>TL;DR?</h3>
<p v-if="idp">If you're already familiar with OAuth2 and OpenID Connect, then <router-link :to="`/idps/${idp._id}/oauth`">check out the available URLs and scopes</router-link> to get started.</p>
<p v-if="!idp">If you're already familiar with OAuth2 and OpenID Connect, then you can simply check out the available URLs and scopes directly from your SSO Tools IdP dashboard to get started.</p>
<v-btn v-if="!idp" class="mt-8" to="/idps/new" color="primary" prepend-icon="mdi-rocket">Get started</v-btn>
</v-alert>
<h3>OAuth2 Overview</h3>
<p>OAuth2 allows services to work together to enable single sign-on, or two allow the two services to communicate with each other on a user's behalf. When you choose options like "login with Facebook" or "connect to Stripe", OAuth2 is what is powering the interactions behind the scenes.</p>
<p class="mt-4">Under OAuth2 the user logs-in directly with their identity provider (IdP), and authorizes the IdP to reveal information about them declared in the "scope" (such as a user ID, email address, or even profile information). This information can then be used by the calling service provider ("app") in order to provision the user with an account or to log the user in. The app never sees the username and password used to login and only has access to the information declared in the scope authorized by the user.</p>
<p class="mt-4">Some IDPs may also issue an "access token" during the authentication process. This token can be used for ongoing communication between the two services. For example, the app might use the access token to retrieve or update the user's profile on the IdP, or carry out other actions (such as handling a payment with Stripe).</p>
<h3 class="mt-8">What is the OpenID Connect part about?</h3>
<p>OpenID Connect acts as an extension to OAuth2. Implementations of this standard seem to vary, but the general idea is that the calling app requests an additional "openid" scope from the IdP. If granted, the IdP will issue a special ID token, encoded with useful information about the user, back to the app after a successful authorization is complete.</p>
<p class="mt-4">Under this approach, the app can avoid having to do additional lookups (using the access token) to the IdP in order to retrieve the required user profile information to log the user in.</p>
<p class="mt-4">SSO Tools supports OAuth2 with or without OpenID Connect.</p>
<h3 class="mt-8">A simplified OAuth2 flow</h3>
<p>A user "logging-in" via OAuth2 will cause the following steps to occur.</p>
<ol class="mt-5">
<li>The user visits the app (e.g. a website or mobile app). The app shows an option to login via OAuth2 (e.g. "Login with Facebook" or "Login with Your App").</li>
<li>The user selects this option and is redirected to the IdP (e.g. "Facebook" or "Your App") to authenticate themselves. Usually this would involve logging-in with a username and password. The app includes a list of scopes to ask for in its request.</li>
<li>After a successful login, the IdP displays the scopes requested by the app, and asks the user to authorize the IdP to provide the requested information or permissions on the user's behalf.</li>
<li>After authorization, the user is redirected back to the app armed with a special "authorization code".</li>
<li>The app reads this code and uses this to request a token directly from the IdP.</li>
<li>The IdP responds with an ID token (if the "openid" scope was requested) and - usually - an access token.</li>
<li>The app can read the ID token to retrieve user details in order to match or setup an account for the user, and to log the user in. If an access token is also included, the app can use this for ongoing communication with the IdP if required.</li>
</ol>
<h3 class="mt-8">Implementing the OAuth2 flow in your app</h3>
<p>The following steps describe how to implement OAuth2 SSO in your app using an IdP, such as one provided by SSO Tools.</p>
<h4 class="mt-5">Step 1: Create an IdP and an app declaration</h4>
<p>In SSO Tools, follow the steps to create a new IdP and app (service provider). In this guide we'll assume your IdP's issuer (code) is `myidp`.</p>
<p class="mt-4">When creating your app, you'll need to provide a "redirect URI". This the place the user will be redirected back to after successful authentication. If you don't know this right away, you can always change it later.</p>
<h4 class="mt-5">Step 2: Redirect the user to the IdP to authenticate</h4>
<p>In your app, create a button or link that redirects the user to the IdP's "authorization URL". In SSO Tools, these take the form of "https://idp.sso.tools/ISSUER/oauth2/authorize", replacing "ISSUER" with the issuer code for your IdP (e.g. "myidp").</p>
<p class="mt-4">Along with this request, you'll have to include some additional query parameters:</p>
<ul class="mt-4">
<li>`client_id`: The client ID for the app generated by your IdP (this can be found on the IdP's connected apps page).</li>
<li>`scope`: The space-separated list of requested permissions.</li>
<li>`redirect_uri`: The URL to send the user back to after authentication. This must match the one declared in the app settings on the IdP.</li>
<li>`response_type`: This should just be set to the value "code".</li>
</ul>
<p class="mt-4">For the scope parameter, SSO Tools currently supports "email" (to allow reading the user's email address), "profile" (to allow reading the user's name and profile information), and "openid" (to receive an ID token after authentication).</p>
<p class="mt-4">As such, an example request to redirect the user to on SSO Tools could look like the following:</p>
<p class="mt-4">https://idp.sso.tools/myidp/oauth2/authorize?client_id=abc123&scope=profile%20email%20openid&redirect_uri=https://myapp.com/oauth/callback&response_type=code</p>
<h4 class="mt-5">Step 3: The user authenticates</h4>
<p>Your app can relax for a bit now, since the IdP now takes over in handling the user's authentication and seeking approval for the scopes your app has requested.</p>
<h4 class="mt-5">Step 4: Receive the user back after authentication</h4>
<p>Assuming all went well with the authentication, the IdP will now redirect the user back to your app at the redirect URI you specified. If you don't yet have a route to handle this, then you should create one now and ensure this is registered in the app on SSO Tools and included in your request in Step 2.</p>
<p class="mt-4">When the user arrives back, they'll have a `code` parameter in their request URL. For example, if your redirect URI is "https://myapp.com/oauth/callback" your app will receive a request at "https://myapp.com/oauth/callback?code=xzy321"</p>
<p class="mt-4">Your app should read this code and make it available for the next step</p>
<h4 class="mt-5">Step 5: Request a token from the IdP</h4>
<p>Next, your app will need to make a request directly to the IdP on its "token" endpoint. On SSO Tools, these endpoints look like "https://idp.sso.tools/ISSUER/oauth2/token". To this endpoint, your app should send an HTTP `POST` request containing the following data in JSON format:</p>
<ul class="mt-4">
<li>`code`: The code received in Step 4.</li>
<li>`client_id`: The client ID (as shown on the connected apps page).</li>
<li>`client_secret`: The client secret (as shown on the connected apps page).</li>
<li>`redirect_uri`: The same redirect URI we used earlier, again.</li>
<li>`grant_type`: This should be set to the value "authorization_code".</li>
</ul>
<p class="mt-4">For example, given the information above, a sample token request could look like this:</p>
<pre class="mt-4">
POST https://idp.sso.tools/myidp/oauth2/token
Accept: application/json
Content-Type: application/json
{
"code": "xyz321",
"client_id": "abc123",
"client_secret": "SECRET",
"redirect_uri": "https://myapp.com/oauth/callback",
"grant_type": "authorization_code"
}
</pre>
<p class="mt-4">Assuming all went well, the response to the request should contain an `access_token` field (containing a string representing an access token usable against the SSO Tools API for the IdP). If the "openid" scope was requested the response will also include an "id_token" field (containing a string representing a JWT that can be decoded to retrieve the user profile information requested by the scope).</p>
<h4 class="mt-5">Step 6 (optional): Interact with the IdP's API</h4>
<p>Depending on your setup, Step 5 might be the last step needed to complete single sign-on with OAuth2 and OpenID Connect. If your app needs to then communicate with an API for additional data flow (e.g. to power an integration), then read on.</p>
<p class="mt-4">The "access_token" received in Step 5 can now be used against the IdP's API in order to retrieve the user's information. To do so, include the access token in the Authorization header and send off a request to "https://idp.sso.tools/ISSUER/api/users/me".</p>
<p class="mt-4">For example, given the above information, your request could look like this:</p>
<pre class="mt-4">
GET https://idp.sso.tools/myidp/api/users/me
Accept: application/json
Authorization: ACCESSTOKEN
</pre>
<p class="mt-4">Assuming all went well, the IdP will respond with information about the current user, as defined in the requested scopes.</p>
</div>
</template>
<script>
import api from '../api';
export default {
name: 'IDPOAuth',
props: ['idp'],
data() {
return {
idp: this.idp,
}
},
methods: {
},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,61 @@
<template>
<div>
<p>Below you'll find the latest OAuth2 requests and responses logged by SSOTools for your IdP.</p>
<v-alert class="mt-5" type="info">Checking the logs can be useful in debugging your SSO setup. You can use it to compare against what you are sending or receiving in your application.</v-alert>
<v-btn v-on:click="fetchLogs" class="mt-10 mb-5" prepend-icon="mdi-refresh">Refresh logs</v-btn>
<v-alert v-if="!logs.length" :value="true" border="left" color="blue-grey" dark>No OAuth2 requests have been logged against this IdP yet.</v-alert>
<v-card v-for="log in logs" :key="log._id" style="padding: 5; margin-bottom: 15px">
<v-card-title>
<div class="d-flex justify-space-between">
<p class="mr-2">{{formatDate(log.createdAt)}}</p>
<p class="mr-2">{{log.spName || log.sp}}</p>
<p>{{log.type}}</p>
</div>
</v-card-title>
<v-card-text>
<textarea readonly rows="10" style="width: 100%; font-size: 8; font-family: monospace;" v-model="log.formattedData" />
</v-card-text>
</v-card>
</div>
</template>
<script>
import api from '../api';
import moment from 'moment';
export default {
name: 'IDPOauthLogs',
props: ['idp'],
data() {
return {
logs: [],
loadingLogs: false,
tableHeaders: [{ text: 'Date and time' }, { text: 'Service' }, { text: 'Type' }, { text: 'Data' }],
}
},
created () {
this.fetchLogs();
},
methods: {
fetchLogs() {
this.loadingLogs = true;
api.req('GET', `/idps/${this.idp._id}/oauth2/logs`, null, resp => {
this.logs = resp.logs;
this.logs.forEach(l => l.formattedData = JSON.stringify(l.data, null, 2));
this.loadingLogs = false;
}, err => console.log(err));
},
formatDate(date) {
return moment(date).format('L HH:mm:ss');
}
},
}
</script>
<style scoped>
</style>

View File

@ -1,7 +1,7 @@
<template>
<div>
<p>You can use the details below to configure your SAML2-compatibile service providers to use this IDP as an identity source.</p>
<v-alert type="info" class="mt-5">To start using a service provider with this IDP you will also need to register it on the <router-link style="color:white;" :to="`/idps/${idp._id}/sps`">Connected apps</router-link> page.</v-alert>
<p>You can use the details below to configure your SAML2-compatibile service providers to use this IdP as an identity source.</p>
<v-alert type="info" class="mt-5">To start using a service provider with this IdP you will also need to register it on the <router-link style="color:white;" :to="`/idps/${idp._id}/sps`">Connected apps</router-link> page.</v-alert>
<div style="margin-top:20px;" />
<v-text-field readonly label="Sign-on URL (single login service through the HTTP redirect binding)" :model-value="`https://idp.sso.tools/${idp.code}/saml/login/request`" />

View File

@ -43,7 +43,7 @@ export default {
methods: {
fetchLogs() {
this.loadingLogs = true;
api.req('GET', `/idps/${this.idp._id}/logs`, null, resp => {
api.req('GET', `/idps/${this.idp._id}/saml2/logs`, null, resp => {
this.logs = resp.logs;
this.logs.forEach(l => l.formattedData = JSON.stringify(l.data, null, 2));
this.loadingLogs = false;

View File

@ -12,7 +12,8 @@
<thead>
<tr>
<th>App name</th>
<th>Configuration</th>
<th>SAML2 configuration</th>
<th>OAuth2 configuration</th>
<th />
</tr>
</thead>
@ -27,6 +28,14 @@
<span v-if="app.logoutUrl"><br />Logout: {{app.logoutUrl}}</span>
<span v-if="app.logoutCallbackUrl"><br />Logout callback: {{app.logoutCallbackUrl}}</span>
</td>
<td>
<v-alert size="small" v-if="!app.oauth2ClientId">
This app was not created with a OAuth2 configuration.
</v-alert>
<span v-if="app.oauth2ClientId">Client ID: {{app.oauth2ClientId}}</span>
<span v-if="app.oauth2ClientSecret"><br />Client secret: {{app.oauth2ClientSecret}}</span>
<span v-if="app.oauth2RedirectUri"><br />Redirect URI: {{app.oauth2RedirectUri}}</span>
</td>
<td>
<v-btn flat>
Manage
@ -59,7 +68,10 @@
<v-text-field class="mb-2" label="EntityID" v-model="newSP.entityId" hint="This is a URL to uniquely identify your service. It is sometimes the same as the metadata URL." placeholder="https://sp.example.com/metdadata"/>
<v-text-field class="mb-2" label="ACS URL" v-model="newSP.callbackUrl" hint="Assertion Consumer Service, or callback URL using the HTTP POST binding." placeholder="https://sp.example.com/callback"/>
<v-text-field class="mb-2" label="Logout URL" v-model="newSP.logoutUrl" hint="The URL we will redirect IDP-initiated logout requests to." placeholder="https://sp.example.com/logout"/>
<v-text-field label="Logout callback URL" v-model="newSP.logoutCallbackUrl" hint="The URL we will redirect users to after an SP-initiated logout." placeholder="https://sp.example.com/logout/callback"/>
<v-text-field class="mb-2" label="Logout callback URL" v-model="newSP.logoutCallbackUrl" hint="The URL we will redirect users to after an SP-initiated logout." placeholder="https://sp.example.com/logout/callback"/>
<h3 class="mb-2">OAuth2 settings (optional)</h3>
<v-text-field class="mb-2" label="Redirect URI" v-model="newSP.oauth2RedirectUri" hint="The URI users will be redirected to after successful authorization." placeholder="https://myapp.com/oauth/callback"/>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
@ -83,7 +95,7 @@ export default {
sps: [],
loadingSps: false,
tableHeaders: [{ text: 'Name' }, { text: 'URLs' }, { text: '' }],
newSP: { name: '', entityId: '', serviceUrl: '', callbackUrl: '', logoutUrl: '', logoutCallbackUrl: '' },
newSP: { name: '', entityId: '', serviceUrl: '', callbackUrl: '', logoutUrl: '', logoutCallbackUrl: '', oauth2RedirectUri: ''},
dialog: false,
editing: false,
}
@ -99,11 +111,11 @@ export default {
openDialog (event) {
this.dialog = true;
this.editing = false;
this.newSP = { name: '', entityId: '', serviceUrl: '', callbackUrl: '', logoutUrl: '', logoutCallbackUrl: '' };
this.newSP = { name: '', entityId: '', serviceUrl: '', callbackUrl: '', logoutUrl: '', logoutCallbackUrl: '', oauth2RedirectUri: '' };
},
create (event) {
const { _id, name, entityId, serviceUrl, callbackUrl, logoutUrl, logoutCallbackUrl } = this.newSP;
const data = {name, entityId, serviceUrl, callbackUrl, logoutUrl, logoutCallbackUrl};
const { _id, name, entityId, serviceUrl, callbackUrl, logoutUrl, logoutCallbackUrl, oauth2RedirectUri } = this.newSP;
const data = {name, entityId, serviceUrl, callbackUrl, logoutUrl, logoutCallbackUrl, oauth2RedirectUri};
if (_id && this.editing) {
api.req('PUT', `/idps/${this.idp._id}/sps/${_id}`, data, resp => {
this.sps.map(s => {

View File

@ -1,11 +1,9 @@
<template>
<div>
<v-container>
<v-container class="ml-auto mr-auto" style="max-width: 600px">
<div v-if="page === 0">
<div style="text-align:center; margin-bottom: 30px;">
<h1>Create a new identity provider (IdP)</h1>
<h1 class="mb-10">Create a new identity provider (IdP)</h1>
<p>IdPs are the core of the single sign-on system. They maintain user identities (profile information and passwords) and are where the actual user authentication takes place.</p>
<p>You can connect your apps (service providers) to your IdP in order to give them single sign-on functionality.</p>
</div>
@ -14,8 +12,8 @@
<p>A human-friendly identifier you can use to recognise the IdP.</p>
<v-text-field v-model="name" label="Friendly name" required autofocus ></v-text-field>
<h3>Issuer &amp; IdP location</h3>
<p>This is a URL that uniquely identifies the IdP on the Internet. We'll host it at the idp.sso.tools subdomain with a path of your choice. The path must be unique and "URL friendly" (i.e. generally letters and numbers).</p>
<h3>Issuer name</h3>
<p>This is a unique machine-friendly name (letters and numbers only) for your IdP. We'll use this to host your IdP on the internet, and it can always be changed later.</p>
<v-text-field v-model="code" label="Issuer" required placeholder="myidp" prefix="https://idp.sso.tools/" ></v-text-field>
<v-alert type="info" :value="true" v-if="!loggedIn">

View File

@ -21,7 +21,11 @@ import IDPUsers from './components/IdpUsers.vue';
import IDPSettings from './components/IdpSettings.vue';
import IDPSPs from './components/IdpSps.vue';
import IDPSAML from './components/IdpSaml.vue';
import IDPLogs from './components/IdpLogs.vue';
import IDPSAMLLogs from './components/IdpSamlLogs.vue';
import IDPOAuth from './components/IdpOauth.vue';
import IDPOAuthGuide from './components/IdpOauthGuide.vue';
import IDPOAuthLogs from './components/IdpOauthLogs.vue';
import GuideLayout from './components/Guide.vue';
import PrivacyPolicy from './components/legal/PrivacyPolicy.vue';
import TermsOfUse from './components/legal/TermsOfUse.vue';
@ -73,6 +77,9 @@ const router = createRouter({
{ path: '/account', component: Account },
{ path: '/password/reset', component: ResetPassword },
{ path: '/dashboard', component: Dashboard },
{ path: '/guides', component: GuideLayout, children: [
{ path: 'oauth2', component: IDPOAuthGuide },
]},
{ path: '/idps/new', component: NewIDP },
{ path: '/idps/:id', component: IDP, children: [
{ path: '', component: IDPHome },
@ -80,7 +87,10 @@ const router = createRouter({
{ path: 'settings', component: IDPSettings },
{ path: 'sps', component: IDPSPs },
{ path: 'saml', component: IDPSAML },
{ path: 'logs', component: IDPLogs }
{ path: 'saml/logs', component: IDPSAMLLogs },
{ path: 'oauth', component: IDPOAuth },
{ path: 'oauth/guide', component: IDPOAuthGuide },
{ path: 'oauth/logs', component: IDPOAuthLogs },
] },
]
})