Implement and enforce PKCE
This commit is contained in:
parent
39e1d2a9f4
commit
12bd42a3bb
19
api/package-lock.json
generated
19
api/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in a new issue