Implement and enforce PKCE

This commit is contained in:
Paul Bienkowski 2021-02-23 19:32:06 +01:00
parent 39e1d2a9f4
commit 12bd42a3bb
7 changed files with 117 additions and 20 deletions

19
api/package-lock.json generated
View file

@ -2107,6 +2107,11 @@
"which": "^1.2.9"
}
},
"crypto-js": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.3.0.tgz",
"integrity": "sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q=="
},
"crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@ -6582,6 +6587,15 @@
"node-modules-regexp": "^1.0.0"
}
},
"pkce": {
"version": "1.0.0-beta2",
"resolved": "https://registry.npmjs.org/pkce/-/pkce-1.0.0-beta2.tgz",
"integrity": "sha512-wTUwJYyhg1FjUuz9RjdmjorjeOB19Ch2GNIyLfWe5X7Ci4dbHU8ufhI3igZdG9mp43WqrePcIAEGXREuXpzcnA==",
"requires": {
"crypto-js": "^3.1.9-1",
"secure-random": "^1.1.2"
}
},
"pkg-dir": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
@ -7248,6 +7262,11 @@
"xmlchars": "^2.2.0"
}
},
"secure-random": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/secure-random/-/secure-random-1.1.2.tgz",
"integrity": "sha512-H2bdSKERKdBV1SwoqYm6C0y+9EA94v6SUBOWO8kDndc4NoUih7Dv6Tsgma7zO1lv27wIvjlD0ZpMQk7um5dheQ=="
},
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",

View file

@ -47,6 +47,7 @@
"passport-http-bearer": "^1.0.1",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"pkce": "^1.0.0-beta2",
"request": "2.88.2",
"sanitize-filename": "^1.6.3",
"slug": "^3.5.2",

View file

@ -7,14 +7,15 @@ const schema = new mongoose.Schema(
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
client: { type: mongoose.Schema.Types.ObjectId, ref: 'Client', required: true },
scope: { type: String, required: true, defaultValue: '*' },
redirectUri: {type: String, required: true},
expiresAt: {type: Date, required: true},
redirectUri: { type: String, required: true },
expiresAt: { type: Date, required: true },
codeChallenge: { type: String, required: true }, // no need to store the method, it is always "S256"
},
{ timestamps: true },
);
class AuthorizationCode extends mongoose.Model {
static generate(client, user, redirectUri, scope = '*', expiresInSeconds = 60) {
static generate(client, user, redirectUri, scope, codeChallenge, expiresInSeconds = 60) {
const code = crypto.randomBytes(8).toString('hex');
return new AuthorizationCode({
@ -24,6 +25,7 @@ class AuthorizationCode extends mongoose.Model {
redirectUri,
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
scope,
codeChallenge,
});
}
}

View file

@ -1,6 +1,8 @@
const crypto = require('crypto');
const router = require('express').Router();
const passport = require('passport');
const { URL } = require('url');
const { createChallenge } = require('pkce');
const { AuthorizationCode, AccessToken, RefreshToken, Client } = require('../models');
const wrapRoute = require('../_helpers/wrapRoute');
@ -129,6 +131,9 @@ router.get(
redirect_uri: redirectUri,
response_type: responseType,
scope = '*', // fallback to "all" scope
// for PKCE
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
} = req.query;
// 1. Find our client and check if it exists
@ -164,7 +169,22 @@ router.get(
});
}
// 4. Get the scope.
// 4. Verify we're using PKCE with supported (S256) code_challenge_method.
if (!codeChallenge) {
return redirectWithParams(res, redirectUri, {
error: 'invalid_request',
error_description: 'a code_challenge for PKCE is required',
});
}
if (codeChallengeMethod !== 'S256') {
return redirectWithParams(res, redirectUri, {
error: 'invalid_request',
error_description: 'the code_challenge_method for PKCE must be "S256"',
});
}
// 5. Get the scope.
if (!isValidScope(scope)) {
return redirectWithParams(res, redirectUri, {
error: 'invalid_scope',
@ -188,6 +208,7 @@ router.get(
redirectUri,
scope,
expiresAt: new Date().getTime() + 1000 * 60 * 2, // 2 minute decision time
codeChallenge,
};
res.type('html').end(`
@ -223,7 +244,7 @@ router.post(
return res.sendStatus(400);
}
const { clientId, redirectUri, scope, expiresAt } = req.session.authorizationTransaction;
const { clientId, redirectUri, scope, expiresAt, codeChallenge } = req.session.authorizationTransaction;
if (expiresAt < new Date().getTime()) {
return res.status(400).type('html').end(`Your authorization has expired. Please go back and retry the process.`);
@ -235,7 +256,7 @@ router.post(
req.session.authorizationTransaction = null;
if (req.path === '/authorize/approve') {
const code = AuthorizationCode.generate(client, req.user, redirectUri, scope);
const code = AuthorizationCode.generate(client, req.user, redirectUri, scope, codeChallenge);
await code.save();
return redirectWithParams(res, redirectUri, { code: code.code, scope });
@ -258,7 +279,8 @@ router.get(
code,
client_id: clientId,
redirect_uri: redirectUri,
//
// for PKCE
code_verifier: codeVerifier,
} = req.query;
if (!grantType || grantType !== 'authorization_code') {
@ -273,35 +295,54 @@ router.get(
return returnError(res, 'invalid_request', 'code parameter required');
}
if (!code) {
// Call this function to destroy the authorization code (if it exists),
// invalidating it when a single failed request has been received. The
// whole process must be restarted. No trial and error ;)
const destroyAuthCode = async () => {
await AuthorizationCode.deleteOne({ code });
};
if (!clientId) {
await destroyAuthCode();
return returnError(res, 'invalid_client', 'client_id parameter required');
}
if (!redirectUri) {
await destroyAuthCode();
return returnError(res, 'invalid_request', 'redirect_uri parameter required');
}
if (!codeVerifier) {
await destroyAuthCode();
return returnError(res, 'invalid_request', 'code_verifier parameter required');
}
const client = await Client.findOne({ clientId });
if (!client) {
await destroyAuthCode();
return returnError(res, 'invalid_client', 'invalid client_id');
}
const authorizationCode = await AuthorizationCode.findOne({ code });
if ( !authorizationCode ) {
console.log('no code found')
if (!authorizationCode) {
await destroyAuthCode();
return returnError(res, 'invalid_grant', 'invalid authorization code');
}
if (authorizationCode.redirectUri !== redirectUri) {
console.log('redirect_uri mismatch')
await destroyAuthCode();
return returnError(res, 'invalid_grant', 'invalid authorization code');
}
if (authorizationCode.expiresAt <= new Date().getTime()) {
console.log('expired')
await destroyAuthCode();
return returnError(res, 'invalid_grant', 'invalid authorization code');
}
if (!client._id.equals(authorizationCode.client)) {
console.log('client mismatch', authorizationCode.client, client._id)
await destroyAuthCode();
return returnError(res, 'invalid_grant', 'invalid authorization code');
}
if (createChallenge(codeVerifier) !== authorizationCode.codeChallenge) {
await destroyAuthCode();
return returnError(res, 'invalid_grant', 'invalid authorization code');
}

View file

@ -4369,6 +4369,11 @@
"randomfill": "^1.0.3"
}
},
"crypto-js": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.3.0.tgz",
"integrity": "sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q=="
},
"crypto-random-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz",
@ -10643,6 +10648,15 @@
"node-modules-regexp": "^1.0.0"
}
},
"pkce": {
"version": "1.0.0-beta2",
"resolved": "https://registry.npmjs.org/pkce/-/pkce-1.0.0-beta2.tgz",
"integrity": "sha512-wTUwJYyhg1FjUuz9RjdmjorjeOB19Ch2GNIyLfWe5X7Ci4dbHU8ufhI3igZdG9mp43WqrePcIAEGXREuXpzcnA==",
"requires": {
"crypto-js": "^3.1.9-1",
"secure-random": "^1.1.2"
}
},
"pkg-dir": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
@ -13435,6 +13449,11 @@
}
}
},
"secure-random": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/secure-random/-/secure-random-1.1.2.tgz",
"integrity": "sha512-H2bdSKERKdBV1SwoqYm6C0y+9EA94v6SUBOWO8kDndc4NoUih7Dv6Tsgma7zO1lv27wIvjlD0ZpMQk7um5dheQ=="
},
"select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",

View file

@ -14,6 +14,7 @@
"luxon": "^1.25.0",
"node-sass": "^4.14.1",
"ol": "^6.5.0",
"pkce": "^1.0.0-beta2",
"proj4": "^2.7.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",

View file

@ -3,6 +3,7 @@ import globalStore from 'store'
import {setAuth, invalidateAccessToken, resetAuth} from 'reducers/auth'
import {setLogin} from 'reducers/login'
import config from 'config.json'
import {create as createPkce} from 'pkce'
class API {
constructor(store) {
@ -24,32 +25,34 @@ class API {
const response = await window.fetch(url.toString())
const metadata = await response.json()
const {authorization_endpoint: authorizationEndpoint, token_endpoint: tokenEndpoint,
const {
authorization_endpoint: authorizationEndpoint,
token_endpoint: tokenEndpoint,
response_types_supported: responseTypesSupported,
code_challenge_methods_supported: codeChallengeMethodsSupported,
} = metadata
if (!authorizationEndpoint) {
throw new Error('No authorization endpoint');
throw new Error('No authorization endpoint')
}
if (!authorizationEndpoint.startsWith(config.auth.server)) {
throw new Error('Invalid authorization endpoint');
throw new Error('Invalid authorization endpoint')
}
if (!tokenEndpoint) {
throw new Error('No token endpoint');
throw new Error('No token endpoint')
}
if (!tokenEndpoint.startsWith(config.auth.server)) {
throw new Error('Invalid token endpoint');
throw new Error('Invalid token endpoint')
}
if (!Array.isArray(responseTypesSupported) || !responseTypesSupported.includes('code')) {
throw new Error('Authorization code flow not supported or no support advertised.');
throw new Error('Authorization code flow not supported or no support advertised.')
}
if (!Array.isArray(codeChallengeMethodsSupported) || !codeChallengeMethodsSupported.includes('S256')) {
throw new Error('PKCE with S256 not supported or no support advertised.');
throw new Error('PKCE with S256 not supported or no support advertised.')
}
return {authorizationEndpoint, tokenEndpoint}
@ -112,12 +115,18 @@ class API {
}
async exchangeAuthorizationCode(code) {
const codeVerifier = localStorage.getItem('codeVerifier');
if (!codeVerifier) {
throw new Error("No code verifier found");
}
const {tokenEndpoint} = await this.getAuthorizationServerMetadata()
const url = new URL(tokenEndpoint)
url.searchParams.append('code', code)
url.searchParams.append('grant_type', 'authorization_code')
url.searchParams.append('client_id', config.auth.clientId)
url.searchParams.append('redirect_uri', config.auth.redirectUri)
url.searchParams.append('code_verifier', codeVerifier)
const response = await window.fetch(url.toString())
const json = await response.json()
@ -137,11 +146,16 @@ class API {
async makeLoginUrl() {
const {authorizationEndpoint} = await this.getAuthorizationServerMetadata()
const {codeVerifier, codeChallenge} = createPkce()
localStorage.setItem("codeVerifier", codeVerifier);
const loginUrl = new URL(authorizationEndpoint)
loginUrl.searchParams.append('client_id', config.auth.clientId)
loginUrl.searchParams.append('scope', config.auth.scope)
loginUrl.searchParams.append('redirect_uri', config.auth.redirectUri)
loginUrl.searchParams.append('response_type', 'code')
loginUrl.searchParams.append('code_challenge', codeChallenge)
loginUrl.searchParams.append('code_challenge_method', 'S256')
// TODO: Implement PKCE