feat(pkce): add PKCE support to the oauth server (#466) r=seanmonstar
Fixes https://github.com/mozilla/fxa-oauth-server/issues/465
This commit is contained in:
Родитель
db3b55b1b6
Коммит
ed59c0e6d2
|
@ -14,6 +14,16 @@
|
|||
"trusted": true,
|
||||
"canGrant": false
|
||||
},
|
||||
{
|
||||
"id": "38a6b9b3a65a1871",
|
||||
"hashedSecret": "289a885946ee316844d9ffd0d725ee714901548a1e6507f1a40fb3c2ae0c99f1",
|
||||
"name": "123Done PKCE",
|
||||
"imageUri": "https://mozorg.cdn.mozilla.net/media/img/firefox/new/header-firefox.png",
|
||||
"redirectUri": "http://127.0.0.1:8080/?oauth_pkce_redirect=1",
|
||||
"trusted": true,
|
||||
"canGrant": false,
|
||||
"publicClient": true
|
||||
},
|
||||
{
|
||||
"id": "325b4083e32fe8e7",
|
||||
"hashedSecret": "ded3c396f28123f3fe6b152784e8eab7357c6806cb5175805602a2cd67f85080",
|
||||
|
|
|
@ -45,6 +45,16 @@
|
|||
"redirectUri": "https://example.domain/return?foo=bar",
|
||||
"trusted": false,
|
||||
"canGrant": false
|
||||
},
|
||||
{
|
||||
"id": "38a6b9b3a65a1871",
|
||||
"hashedSecret": "289a885946ee316844d9ffd0d725ee714901548a1e6507f1a40fb3c2ae0c99f1",
|
||||
"name": "Public Client PKCE",
|
||||
"imageUri": "https://mozorg.cdn.mozilla.net/media/img/firefox/new/header-firefox.png",
|
||||
"redirectUri": "https://example.domain/return?foo=bar",
|
||||
"trusted": true,
|
||||
"canGrant": false,
|
||||
"publicClient": true
|
||||
}
|
||||
],
|
||||
"logging": {
|
||||
|
|
10
docs/api.md
10
docs/api.md
|
@ -295,10 +295,6 @@ content-server page.
|
|||
signed in email address will be used as the default.
|
||||
- If `action` is `force_auth`, the user is unable to modify the email
|
||||
address and is unable to sign up if the address is not registered.
|
||||
- `keys`: Optional. Boolean setting, set this if the relier wants to receive encryption keys.
|
||||
- `verification_redirect`: Optional. This option adds a "Proceed" button into the "Account Ready" view. See options for details.
|
||||
- Default. If `verification_redirect` is `no` the account ready view will not show a "Proceed" button that will return to the relier.
|
||||
- If `verification_redirect` is `always` then a "Proceed" button is only displayed if the user verifies in a 2nd browser. If the user verifies in the same browser, they are automatically redirected w/o user interaction.
|
||||
|
||||
**Example:**
|
||||
|
||||
|
@ -323,6 +319,9 @@ back to the client. This code will be traded for a token at the
|
|||
- `redirect_uri`: Optional. If supplied, a string URL of where to redirect afterwards. Must match URL from registration.
|
||||
- `scope`: Optional. A string-separated list of scopes that the user has authorized. This could be pruned by the user at the confirmation dialog.
|
||||
- `access_type`: Optional. A value of `offline` will generate a refresh token along with the access token.
|
||||
- `code_challenge_method`: Required if using [PKCE](pkce.md). Must be `S256`, no other value is accepted.
|
||||
- `code_challenge`: Required if using [PKCE](pkce.md). A minimum length of 43 characters and a maximum length of 128 characters string, encoded as `BASE64URL`.
|
||||
|
||||
|
||||
**Example:**
|
||||
|
||||
|
@ -388,6 +387,9 @@ particular user.
|
|||
- If `urn:ietf:params:oauth:grant-type:jwt-bearer`:
|
||||
- `assertion`: A signed JWT assertion. See [Service
|
||||
Clients][] for more.
|
||||
- if client is type `publicClient:true` and `authorization_code`:
|
||||
- `code_verifier`: Required if using [PKCE](pkce.md).
|
||||
|
||||
|
||||
|
||||
**Example:**
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
# Firefox Accounts OAuth - PKCE Support
|
||||
|
||||
> Proof Key for Code Exchange by OAuth Public Clients
|
||||
|
||||
Firefox Accounts OAuth flow supports the [PKCE RFC7636](https://tools.ietf.org/html/rfc7636).
|
||||
This feature helps us authenticate clients such as WebExtensions and Native apps.
|
||||
Clients that do not have a server component or a secure way to store a `client_secret`.
|
||||
|
||||
To better understand this protocol please read the [Proof Key for Code Exchange (RFC 7636) by Authlete Inc.](https://www.authlete.com/documents/article/pkce/index).
|
||||
|
||||
Please see the [API](API.md) documentation that explains the support parameters - `code_challenge_method`, `code_challenge` and `code_verifier`.
|
||||
|
||||
At this time Firefox Accounts requires you to use the `S256` flow, we do not support the `plain` code challenge method.
|
|
@ -79,6 +79,7 @@ function preClients() {
|
|||
// ensure booleans are boolean and not undefined
|
||||
c.trusted = !!c.trusted;
|
||||
c.canGrant = !!c.canGrant;
|
||||
c.publicClient = !!c.publicClient;
|
||||
|
||||
// Modification of the database at startup in production and stage is
|
||||
// not preferred. This option will be set to false on those stacks.
|
||||
|
|
|
@ -37,7 +37,9 @@ const MAX_TTL = config.get('expiration.accessToken');
|
|||
* scope: <string>,
|
||||
* authAt: <timestamp>,
|
||||
* createdAt: <timestamp>,
|
||||
* offline: <boolean>
|
||||
* offline: <boolean>,
|
||||
* codeChallengeMethod: <string>,
|
||||
* codeChallenge: <string>,
|
||||
* }
|
||||
* },
|
||||
* developers: {
|
||||
|
|
|
@ -126,8 +126,8 @@ MysqlStore.connect = function mysqlConnect(options) {
|
|||
const QUERY_CLIENT_REGISTER =
|
||||
'INSERT INTO clients ' +
|
||||
'(id, name, imageUri, hashedSecret, hashedSecretPrevious, redirectUri,' +
|
||||
'trusted, canGrant) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?);';
|
||||
'trusted, canGrant, publicClient) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);';
|
||||
const QUERY_CLIENT_DEVELOPER_INSERT =
|
||||
'INSERT INTO clientDevelopers ' +
|
||||
'(rowId, developerId, clientId) ' +
|
||||
|
@ -148,7 +148,7 @@ const QUERY_DEVELOPER_INSERT =
|
|||
'VALUES (?, ?);';
|
||||
const QUERY_CLIENT_GET = 'SELECT * FROM clients WHERE id=?';
|
||||
const QUERY_CLIENT_LIST = 'SELECT id, name, redirectUri, imageUri, ' +
|
||||
'canGrant, trusted ' +
|
||||
'canGrant, publicClient, trusted ' +
|
||||
'FROM clients, clientDevelopers, developers ' +
|
||||
'WHERE clients.id = clientDevelopers.clientId AND ' +
|
||||
'developers.developerId = clientDevelopers.developerId AND ' +
|
||||
|
@ -162,8 +162,8 @@ const QUERY_CLIENT_UPDATE = 'UPDATE clients SET ' +
|
|||
'WHERE id=?';
|
||||
const QUERY_CLIENT_DELETE = 'DELETE FROM clients WHERE id=?';
|
||||
const QUERY_CODE_INSERT =
|
||||
'INSERT INTO codes (clientId, userId, email, scope, authAt, offline, code) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?)';
|
||||
'INSERT INTO codes (clientId, userId, email, scope, authAt, offline, code, codeChallengeMethod, codeChallenge) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
||||
const QUERY_ACCESS_TOKEN_INSERT =
|
||||
'INSERT INTO tokens (clientId, userId, email, scope, type, expiresAt, ' +
|
||||
'token) VALUES (?, ?, ?, ?, ?, ?, ?)';
|
||||
|
@ -240,7 +240,8 @@ MysqlStore.prototype = {
|
|||
client.hashedSecretPrevious ? buf(client.hashedSecretPrevious) : null,
|
||||
client.redirectUri,
|
||||
!!client.trusted,
|
||||
!!client.canGrant
|
||||
!!client.canGrant,
|
||||
!!client.publicClient
|
||||
]).then(function() {
|
||||
logger.debug('registerClient.success', { id: hex(id) });
|
||||
client.id = id;
|
||||
|
@ -364,7 +365,9 @@ MysqlStore.prototype = {
|
|||
codeObj.scope.join(' '),
|
||||
codeObj.authAt,
|
||||
!!codeObj.offline,
|
||||
hash
|
||||
hash,
|
||||
codeObj.codeChallengeMethod,
|
||||
codeObj.codeChallenge
|
||||
]).then(function() {
|
||||
return code;
|
||||
});
|
||||
|
|
|
@ -6,4 +6,4 @@
|
|||
// Update this if you add a new patch, and don't forget to update
|
||||
// the documentation for the current schema in ../schema.sql.
|
||||
|
||||
module.exports.level = 17;
|
||||
module.exports.level = 18;
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
-- Add `publicClient` column to the `clients` table.
|
||||
ALTER TABLE clients ADD COLUMN publicClient BOOLEAN DEFAULT FALSE NOT NULL AFTER canGrant;
|
||||
UPDATE clients SET publicClient=false;
|
||||
|
||||
-- Add `codeChallengeMethod` and `codeChallenge` column to the `codes` table.
|
||||
ALTER TABLE codes
|
||||
ADD COLUMN codeChallengeMethod VARCHAR(256) AFTER offline,
|
||||
ADD COLUMN codeChallenge VARCHAR(256) AFTER codeChallengeMethod,
|
||||
ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
UPDATE dbMetadata SET value = '18' WHERE name = 'schema-patch-level';
|
|
@ -0,0 +1,14 @@
|
|||
-- Drop `publicClient` column from the `clients` table.
|
||||
|
||||
-- ALTER TABLE clients DROP COLUMN publicClient,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- Drop `codeChallengeMethod` and `codeChallenge` column from the `codes` table.
|
||||
|
||||
-- ALTER TABLE codes DROP COLUMN codeChallengeMethod,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- ALTER TABLE codes DROP COLUMN codeChallenge,
|
||||
-- ALGORITHM = INPLACE, LOCK = NONE;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '17' WHERE name = 'schema-patch-level';
|
32
lib/error.js
32
lib/error.js
|
@ -232,4 +232,36 @@ AppError.expiredToken = function expiredToken(expiredAt) {
|
|||
});
|
||||
};
|
||||
|
||||
AppError.notPublicClient = function unknownClient(clientId) {
|
||||
return new AppError({
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 116,
|
||||
message: 'Not a public client'
|
||||
}, {
|
||||
clientId: clientId
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
AppError.mismatchCodeChallenge = function mismatchCodeChallenge(pkceHashValue) {
|
||||
return new AppError({
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 117,
|
||||
message: 'Incorrect code_challenge'
|
||||
}, {
|
||||
requestCodeChallenge: pkceHashValue
|
||||
});
|
||||
};
|
||||
|
||||
AppError.missingPkceParameters = function missingPkceParameters() {
|
||||
return new AppError({
|
||||
code: 400,
|
||||
error: 'PKCE parameters missing',
|
||||
errno: 118,
|
||||
message: 'Public clients require PKCE OAuth parameters'
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = AppError;
|
||||
|
|
|
@ -22,6 +22,9 @@ const TOKEN = 'token';
|
|||
const ACCESS_TYPE_ONLINE = 'online';
|
||||
const ACCESS_TYPE_OFFLINE = 'offline';
|
||||
|
||||
const PKCE_SHA256_CHALLENGE_METHOD = 'S256'; // This server only supports S256 PKCE, no 'plain'
|
||||
const PKCE_CODE_CHALLENGE_LENGTH = 43;
|
||||
|
||||
const MAX_TTL_S = config.get('expiration.accessToken') / 1000;
|
||||
|
||||
const UNTRUSTED_CLIENT_ALLOWED_SCOPES = [
|
||||
|
@ -65,7 +68,9 @@ function generateCode(claims, client, scope, req) {
|
|||
email: claims['fxa-verifiedEmail'],
|
||||
scope: scope,
|
||||
authAt: claims['fxa-lastAuthAt'],
|
||||
offline: req.payload.access_type === ACCESS_TYPE_OFFLINE
|
||||
offline: req.payload.access_type === ACCESS_TYPE_OFFLINE,
|
||||
codeChallengeMethod: req.payload.code_challenge_method,
|
||||
codeChallenge: req.payload.code_challenge,
|
||||
}).then(function(code) {
|
||||
logger.debug('redirecting', { uri: req.payload.redirect_uri });
|
||||
|
||||
|
@ -142,6 +147,20 @@ module.exports = {
|
|||
.valid(ACCESS_TYPE_OFFLINE, ACCESS_TYPE_ONLINE)
|
||||
.default(ACCESS_TYPE_ONLINE)
|
||||
.optional(),
|
||||
code_challenge_method: Joi.string()
|
||||
.valid(PKCE_SHA256_CHALLENGE_METHOD)
|
||||
.when('response_type', {
|
||||
is: CODE,
|
||||
then: Joi.optional(),
|
||||
otherwise: Joi.forbidden()
|
||||
}),
|
||||
code_challenge: Joi.string()
|
||||
.length(PKCE_CODE_CHALLENGE_LENGTH)
|
||||
.when('response_type', {
|
||||
is: CODE,
|
||||
then: Joi.optional(),
|
||||
otherwise: Joi.forbidden()
|
||||
})
|
||||
}
|
||||
},
|
||||
response: {
|
||||
|
@ -162,6 +181,7 @@ module.exports = {
|
|||
])
|
||||
},
|
||||
handler: function authorizationEndpoint(req, reply) {
|
||||
/*eslint complexity: [2, 13] */
|
||||
logger.debug('response_type', req.payload.response_type);
|
||||
var start = Date.now();
|
||||
var wantsGrant = req.payload.response_type === TOKEN;
|
||||
|
@ -194,6 +214,19 @@ module.exports = {
|
|||
}
|
||||
}
|
||||
|
||||
// PKCE client enforcement
|
||||
if (client.publicClient &&
|
||||
(! req.payload.code_challenge_method || ! req.payload.code_challenge)) {
|
||||
// only Public Clients support code_challenge
|
||||
logger.info('client.missingPkceParameters');
|
||||
throw AppError.missingPkceParameters();
|
||||
} else if (! client.publicClient &&
|
||||
(req.payload.code_challenge_method || req.payload.code_challenge)) {
|
||||
// non-Public Clients do not allow code challenge
|
||||
logger.info('client.notPublicClient');
|
||||
throw AppError.notPublicClient({ id: req.payload.client_id });
|
||||
}
|
||||
|
||||
var uri = req.payload.redirect_uri || client.redirectUri;
|
||||
|
||||
if (uri !== client.redirectUri) {
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
// Hello, dear traveller! Please, turn back now. It's dangerous in here!
|
||||
|
||||
/*jshint camelcase: false*/
|
||||
const crypto = require('crypto');
|
||||
const AppError = require('../error');
|
||||
const buf = require('buf').hex;
|
||||
const hex = require('buf').to.hex;
|
||||
|
@ -17,6 +18,7 @@ const encrypt = require('../encrypt');
|
|||
const logger = require('../logging')('routes.token');
|
||||
const P = require('../promise');
|
||||
const Scope = require('../scope');
|
||||
const util = require('../util');
|
||||
const validators = require('../validators');
|
||||
|
||||
const HEX_STRING = validators.HEX_STRING;
|
||||
|
@ -53,6 +55,16 @@ const PAYLOAD_SCHEMA = Joi.object({
|
|||
}),
|
||||
|
||||
client_secret: validators.clientSecret
|
||||
.when('grant_type', {
|
||||
is: GRANT_JWT,
|
||||
then: Joi.forbidden()
|
||||
})
|
||||
.when('code_verifier', {
|
||||
is: Joi.string().required(), // if (typeof code_verifier === 'string') {
|
||||
then: Joi.forbidden()
|
||||
}),
|
||||
|
||||
code_verifier: validators.codeVerifier
|
||||
.when('grant_type', {
|
||||
is: GRANT_JWT,
|
||||
then: Joi.forbidden()
|
||||
|
@ -127,7 +139,13 @@ module.exports = {
|
|||
// we don't use, such as `response_type`, or something else. Instead
|
||||
// of giving an error here, we can just ignore them.
|
||||
payload: function validatePayload(value, options, next) {
|
||||
return Joi.validate(value, PAYLOAD_SCHEMA, { stripUnknown: true }, next);
|
||||
return Joi.validate(value, PAYLOAD_SCHEMA, { stripUnknown: true }, function(err, value) {
|
||||
if (err) {
|
||||
logger.info('routes.token.payload', err);
|
||||
}
|
||||
|
||||
next(err, value);
|
||||
});
|
||||
}
|
||||
},
|
||||
response: {
|
||||
|
@ -138,17 +156,24 @@ module.exports = {
|
|||
scope: validators.scope.required().allow(''),
|
||||
token_type: Joi.string().valid('bearer').required(),
|
||||
expires_in: Joi.number().max(MAX_TTL_S).required(),
|
||||
auth_at: Joi.number(),
|
||||
auth_at: Joi.number()
|
||||
})
|
||||
},
|
||||
handler: function tokenEndpoint(req, reply) {
|
||||
var params = req.payload;
|
||||
P.try(function() {
|
||||
if (params.grant_type === GRANT_AUTHORIZATION_CODE) {
|
||||
return confirmClient(params.client_id, params.client_secret)
|
||||
.then(function() {
|
||||
return confirmCode(params.client_id, params.code);
|
||||
});
|
||||
if (params.code_verifier) {
|
||||
return confirmPkceClient(params.client_id)
|
||||
.then(function() {
|
||||
return confirmPkceCode(params.client_id, params.code, params.code_verifier);
|
||||
});
|
||||
} else {
|
||||
return confirmClient(params.client_id, params.client_secret)
|
||||
.then(function() {
|
||||
return confirmCode(params.client_id, params.code);
|
||||
});
|
||||
}
|
||||
} else if (params.grant_type === GRANT_REFRESH_TOKEN) {
|
||||
return confirmClient(params.client_id, params.client_secret)
|
||||
.then(function() {
|
||||
|
@ -174,6 +199,49 @@ module.exports = {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a PKCE code_challenge
|
||||
* See https://tools.ietf.org/html/rfc7636#section-4.6 for details
|
||||
*/
|
||||
function pkceHash(input) {
|
||||
return util.base64URLEncode(crypto.createHash('sha256').update(input).digest());
|
||||
}
|
||||
|
||||
function confirmPkceCode(id, code, pkceVerifier) {
|
||||
return db.getCode(buf(code)).then(function(codeObj) {
|
||||
if (!codeObj) {
|
||||
logger.debug('code.notFound', { code: code });
|
||||
throw AppError.unknownCode(code);
|
||||
}
|
||||
|
||||
const pkceHashValue = pkceHash(pkceVerifier);
|
||||
if (codeObj.codeChallenge &&
|
||||
codeObj.codeChallengeMethod === 'S256' &&
|
||||
pkceHashValue === codeObj.codeChallenge) {
|
||||
return db.removeCode(buf(code)).then(function() {
|
||||
return codeObj;
|
||||
});
|
||||
} else {
|
||||
throw AppError.mismatchCodeChallenge(pkceHashValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function confirmPkceClient(id) {
|
||||
return db.getClient(buf(id)).then(function(client) {
|
||||
if (!client) {
|
||||
logger.debug('client.notFound', { id: id });
|
||||
throw AppError.unknownClient(id);
|
||||
}
|
||||
|
||||
if (!client.publicClient) {
|
||||
logger.debug('client.notPublicClient', { id: id });
|
||||
throw AppError.notPublicClient(id);
|
||||
}
|
||||
|
||||
return client;
|
||||
});
|
||||
}
|
||||
|
||||
function confirmClient(id, secret) {
|
||||
return db.getClient(buf(id)).then(function(client) {
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
/**
|
||||
* .base64URLEncode
|
||||
*
|
||||
* return an encoded Buffer as URL Safe Base64
|
||||
*
|
||||
* Note: This function encodes to the RFC 4648 Spec where '+' is encoded
|
||||
* as '-' and '/' is encoded as '_'. The padding character '=' is
|
||||
* removed.
|
||||
*
|
||||
* @param {Buffer} buf
|
||||
* @return {String}
|
||||
* @api public
|
||||
*/
|
||||
const base64URLEncode = function base64URLEncode(buf) {
|
||||
return buf.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
base64URLEncode: base64URLEncode
|
||||
};
|
|
@ -18,6 +18,9 @@ exports.clientSecret = Joi.string()
|
|||
.regex(exports.HEX_STRING)
|
||||
.required();
|
||||
|
||||
exports.codeVerifier = Joi.string()
|
||||
.length(32); // https://tools.ietf.org/html/rfc7636#section-4.1
|
||||
|
||||
exports.token = Joi.string()
|
||||
.length(config.get('unique.token') * 2)
|
||||
.regex(exports.HEX_STRING);
|
||||
|
|
|
@ -4,7 +4,7 @@ set -eu
|
|||
|
||||
glob=$*
|
||||
if [ -z "$glob" ]; then
|
||||
glob="--recursive test/*.js test/db/*.js"
|
||||
glob="--recursive test/*.js test/routes/*.js test/db/*.js"
|
||||
fi
|
||||
|
||||
./scripts/mocha-coverage.js -R spec $glob --timeout 20000
|
||||
|
|
136
test/api.js
136
test/api.js
|
@ -343,6 +343,45 @@ describe('/v1', function() {
|
|||
});
|
||||
});
|
||||
|
||||
describe('pkce', function() {
|
||||
it('should fail if Public Client is not using code_challenge', function() {
|
||||
var client = clientByName('Public Client PKCE');
|
||||
mockAssertion().reply(200, VERIFY_GOOD);
|
||||
return Server.api.post({
|
||||
url: '/authorization',
|
||||
payload: authParams({
|
||||
client_id: client.id,
|
||||
scope: 'profile profile:write profile:uid',
|
||||
})
|
||||
}).then(function(res) {
|
||||
assert.equal(res.statusCode, 400);
|
||||
assertSecurityHeaders(res);
|
||||
assert.equal(res.result.errno, 118);
|
||||
assert.equal(res.result.error, 'PKCE parameters missing');
|
||||
});
|
||||
});
|
||||
|
||||
it('only works with Public Clients', function() {
|
||||
var client = clientByName('Mocha');
|
||||
mockAssertion().reply(200, VERIFY_GOOD);
|
||||
return Server.api.post({
|
||||
url: '/authorization',
|
||||
payload: authParams({
|
||||
client_id: client.id,
|
||||
scope: 'profile profile:write profile:uid',
|
||||
response_type: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||
})
|
||||
}).then(function(res) {
|
||||
assert.equal(res.statusCode, 400);
|
||||
assertSecurityHeaders(res);
|
||||
assert.equal(res.result.errno, 116);
|
||||
assert.equal(res.result.message, 'Not a public client');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('?client_id', function() {
|
||||
|
||||
it('is required', function() {
|
||||
|
@ -589,6 +628,39 @@ describe('/v1', function() {
|
|||
});
|
||||
});
|
||||
|
||||
it('supports PKCE - code_challenge and code_challenge_method', function() {
|
||||
var client = clientByName('Public Client PKCE');
|
||||
mockAssertion().reply(200, VERIFY_GOOD);
|
||||
return Server.api.post({
|
||||
url: '/authorization',
|
||||
payload: authParams({
|
||||
client_id: client.id,
|
||||
response_type: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||
})
|
||||
}).then(function(res) {
|
||||
assert.equal(res.statusCode, 200);
|
||||
assertSecurityHeaders(res);
|
||||
assert(res.result.redirect);
|
||||
});
|
||||
});
|
||||
|
||||
it('supports code_challenge only with code response_type', function() {
|
||||
mockAssertion().reply(200, VERIFY_GOOD);
|
||||
return Server.api.post({
|
||||
url: '/authorization',
|
||||
payload: authParams({
|
||||
response_type: 'token',
|
||||
code_challenge_method: 'S256',
|
||||
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||
})
|
||||
}).then(function(res) {
|
||||
assert.equal(res.statusCode, 400);
|
||||
assert.equal(res.result.errno, 109);
|
||||
});
|
||||
});
|
||||
|
||||
it('must not be something besides code or token', function() {
|
||||
mockAssertion().reply(200, VERIFY_GOOD);
|
||||
return Server.api.post({
|
||||
|
@ -903,6 +975,70 @@ describe('/v1', function() {
|
|||
|
||||
});
|
||||
|
||||
|
||||
it('consumes code via public client (PKCE)', function() {
|
||||
var code_verifier = 'ywZ_yiNpe-UoGYW.oW95hTjRZ8j_d2kF';
|
||||
var code_verifier_bad = 'zwZ_yiNpe-UoGYW.oW95hTjRZ8j_d2kC';
|
||||
var code_challenge = 'iyW5ScKr22v_QL-rcW_EGlJrDSOymJvrlXlw4j7JBiQ';
|
||||
var secret2 = unique.secret();
|
||||
var oauth_code;
|
||||
var client2 = {
|
||||
name: 'client2Public',
|
||||
hashedSecret: encrypt.hash(secret2),
|
||||
redirectUri: 'https://example.domain',
|
||||
imageUri: 'https://example.foo.domain/logo.png',
|
||||
trusted: true,
|
||||
publicClient: true
|
||||
};
|
||||
return db.registerClient(client2).then(function() {
|
||||
mockAssertion().reply(200, VERIFY_GOOD);
|
||||
return Server.api.post({
|
||||
url: '/authorization',
|
||||
payload: authParams({
|
||||
client_id: client2.id.toString('hex'),
|
||||
response_type: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
code_challenge: code_challenge
|
||||
})
|
||||
}).then(function(res) {
|
||||
assert.equal(res.statusCode, 200);
|
||||
assertSecurityHeaders(res);
|
||||
return url.parse(res.result.redirect, true).query.code;
|
||||
});
|
||||
}).then(function(code) {
|
||||
oauth_code = code;
|
||||
|
||||
return Server.api.post({
|
||||
url: '/token',
|
||||
payload: {
|
||||
client_id: client2.id.toString('hex'),
|
||||
code: oauth_code,
|
||||
code_verifier: code_verifier_bad
|
||||
}
|
||||
});
|
||||
}).then(function(res) {
|
||||
assert.equal(res.statusCode, 400);
|
||||
assert.equal(res.result.errno, 117);
|
||||
assert.equal(res.result.message, 'Incorrect code_challenge');
|
||||
}).then(function(code) {
|
||||
return Server.api.post({
|
||||
url: '/token',
|
||||
payload: {
|
||||
client_id: client2.id.toString('hex'),
|
||||
code: oauth_code,
|
||||
code_verifier: code_verifier
|
||||
}
|
||||
});
|
||||
}).then(function(res) {
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.ok(res.result.access_token);
|
||||
assert.ok(res.result.scope);
|
||||
assert.equal(res.result.token_type, 'bearer');
|
||||
assert.ok(res.result.access_token);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it('must not have expired', function() {
|
||||
this.slow(200);
|
||||
var exp = config.get('expiration.code');
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
'use strict';
|
||||
|
||||
const assert = require('insist');
|
||||
const Joi = require('joi');
|
||||
const route = require('../../lib/routes/authorization');
|
||||
const validation = route.validate.payload;
|
||||
|
||||
const CLIENT_ID = '98e6508e88680e1b';
|
||||
// jscs:disable
|
||||
const BASE64URL_STRING = 'TG9yZW0gSXBzdW0gaXMgc2ltcGx5IGR1bW15IHRleHQgb2YgdGhlIHByaW50aW5nIGFuZCB0eXBlc2V0dGluZyBpbmR1c3RyeS4gTG9yZW0gSXBzdW0gaGFzIGJlZW4gdGhlIGluZHVzdHJ5J3Mgc3RhbmRhcmQgZHVtbXkgdGV4dCBldmVyIHNpbmNlIHRoZSAxNTAwcywgd2hlbiBhbiB1bmtub3duIHByaW50ZXIgdG9vayBhIGdhbGxleSBvZiB0eXBlIGFuZCBzY3JhbWJsZWQgaXQgdG8gbWFrZSBhIHR5cGUgc3BlY2ltZW4gYm9v';
|
||||
// jscs:enable
|
||||
const PKCE_CODE_CHALLENGE = 'iyW5ScKr22v_QL-rcW_EGlJrDSOymJvrlXlw4j7JBiQ';
|
||||
const PKCE_CODE_CHALLENGE_METHOD = 'S256';
|
||||
|
||||
function joiAssertFail(req, param, messagePostfix) {
|
||||
messagePostfix = messagePostfix || 'is required';
|
||||
let fail = null;
|
||||
|
||||
try {
|
||||
Joi.assert(req, validation);
|
||||
} catch (err) {
|
||||
fail = true;
|
||||
assert.ok(err.isJoi);
|
||||
assert.ok(err.name, 'ValidationError');
|
||||
assert.equal(err.details[0].message, `"${param}" ${messagePostfix}`);
|
||||
}
|
||||
|
||||
if (! fail) {
|
||||
throw new Error('Did not throw!');
|
||||
}
|
||||
}
|
||||
|
||||
describe('/authorization POST', function () {
|
||||
it('fails with no client_id', () => {
|
||||
joiAssertFail({
|
||||
foo: 1
|
||||
}, 'client_id');
|
||||
});
|
||||
|
||||
it('fails with no assertion', () => {
|
||||
joiAssertFail({
|
||||
client_id: CLIENT_ID
|
||||
}, 'assertion');
|
||||
});
|
||||
|
||||
it('fails with no state', () => {
|
||||
joiAssertFail({
|
||||
client_id: CLIENT_ID,
|
||||
assertion: BASE64URL_STRING,
|
||||
}, 'state');
|
||||
});
|
||||
|
||||
it('fails with no state', () => {
|
||||
Joi.assert({
|
||||
client_id: CLIENT_ID,
|
||||
assertion: BASE64URL_STRING,
|
||||
state: 'foo'
|
||||
}, validation);
|
||||
});
|
||||
|
||||
describe('PKCE params', function () {
|
||||
it('accepts code_challenge and code_challenge_method', () => {
|
||||
Joi.assert({
|
||||
client_id: CLIENT_ID,
|
||||
assertion: BASE64URL_STRING,
|
||||
state: 'foo',
|
||||
code_challenge: PKCE_CODE_CHALLENGE,
|
||||
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
|
||||
}, validation);
|
||||
});
|
||||
|
||||
it('accepts code_challenge and code_challenge_method', () => {
|
||||
joiAssertFail({
|
||||
client_id: CLIENT_ID,
|
||||
assertion: BASE64URL_STRING,
|
||||
state: 'foo',
|
||||
code_challenge: PKCE_CODE_CHALLENGE,
|
||||
code_challenge_method: 'bad_method',
|
||||
}, 'code_challenge_method', 'must be one of [S256]');
|
||||
});
|
||||
|
||||
it('validates code_challenge', () => {
|
||||
joiAssertFail({
|
||||
client_id: CLIENT_ID,
|
||||
assertion: BASE64URL_STRING,
|
||||
state: 'foo',
|
||||
code_challenge: 'foo',
|
||||
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
|
||||
}, 'code_challenge', 'length must be 43 characters long');
|
||||
});
|
||||
|
||||
it('works with response_type code (non-default)', () => {
|
||||
Joi.assert({
|
||||
client_id: CLIENT_ID,
|
||||
assertion: BASE64URL_STRING,
|
||||
state: 'foo',
|
||||
code_challenge: PKCE_CODE_CHALLENGE,
|
||||
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
|
||||
response_type: 'code'
|
||||
}, validation);
|
||||
});
|
||||
|
||||
it('fails with response_type token', () => {
|
||||
joiAssertFail({
|
||||
client_id: CLIENT_ID,
|
||||
assertion: BASE64URL_STRING,
|
||||
state: 'foo',
|
||||
code_challenge: PKCE_CODE_CHALLENGE,
|
||||
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
|
||||
response_type: 'token'
|
||||
}, 'code_challenge_method', 'is not allowed');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
|
@ -0,0 +1,131 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const assert = require('insist');
|
||||
const route = require('../../lib/routes/token');
|
||||
|
||||
const CLIENT_SECRET = 'b93ef8a8f3e553a430d7e5b904c6132b2722633af9f03128029201d24a97f2a8';
|
||||
const CLIENT_ID = '98e6508e88680e1b';
|
||||
const CODE = 'df6dcfe7bf6b54a65db5742cbcdce5c0a84a5da81a0bb6bdf5fc793eef041fc6';
|
||||
const PKCE_CODE_VERIFIER = 'ywZ_yiNpe-UoGYW.oW95hTjRZ8j_d2kF';
|
||||
const GRANT_JWT = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
|
||||
|
||||
function joiRequired(err, param) {
|
||||
assert.ok(err.isJoi);
|
||||
assert.ok(err.name, 'ValidationError');
|
||||
assert.equal(err.details[0].message, `"${param}" is required`);
|
||||
}
|
||||
|
||||
function joiNotAllowed(err, param) {
|
||||
assert.ok(err.isJoi);
|
||||
assert.ok(err.name, 'ValidationError');
|
||||
assert.equal(err.details[0].message, `"${param}" is not allowed`);
|
||||
}
|
||||
|
||||
describe('/token POST', function () {
|
||||
// route validation function
|
||||
function v(req, cb) {
|
||||
route.validate.payload(req, {}, cb);
|
||||
}
|
||||
|
||||
it('fails with no client_id', (done) => {
|
||||
v({
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: CODE
|
||||
}, (err) => {
|
||||
joiRequired(err, 'client_id');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('valid client_secret scheme', (done) => {
|
||||
v({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: CODE
|
||||
}, (err) => {
|
||||
assert.equal(err, null);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('requires client_secret', (done) => {
|
||||
v({
|
||||
client_id: CLIENT_ID,
|
||||
code: CODE
|
||||
}, (err) => {
|
||||
joiRequired(err, 'client_secret');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts pkce code_verifier instead of client_secret', (done) => {
|
||||
v({
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: PKCE_CODE_VERIFIER,
|
||||
code: CODE
|
||||
}, (err) => {
|
||||
assert.equal(err, null);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('grant_type JWT', () => {
|
||||
it('forbids client_id', (done) => {
|
||||
v({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: CODE,
|
||||
grant_type: GRANT_JWT,
|
||||
}, (err) => {
|
||||
joiNotAllowed(err, 'client_id');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('forbids client_secret', (done) => {
|
||||
v({
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: CODE,
|
||||
grant_type: GRANT_JWT,
|
||||
}, (err) => {
|
||||
joiNotAllowed(err, 'client_secret');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('forbids client_secret', (done) => {
|
||||
v({
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: CODE,
|
||||
grant_type: GRANT_JWT,
|
||||
}, (err) => {
|
||||
joiNotAllowed(err, 'client_secret');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('forbids code_verifier', (done) => {
|
||||
v({
|
||||
code_verifier: PKCE_CODE_VERIFIER,
|
||||
grant_type: GRANT_JWT,
|
||||
}, (err) => {
|
||||
joiNotAllowed(err, 'code_verifier');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('forbids code', (done) => {
|
||||
v({
|
||||
code: CODE,
|
||||
grant_type: GRANT_JWT,
|
||||
}, (err) => {
|
||||
joiNotAllowed(err, 'code');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const assert = require('insist');
|
||||
const util = require('../lib/util');
|
||||
|
||||
describe('util', function () {
|
||||
describe('base64URLEncode', function () {
|
||||
it('properly encodes', function () {
|
||||
var testBase64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/',
|
||||
testBuffer = new Buffer(testBase64, 'base64'),
|
||||
expectedBase64 = testBase64.replace('+', '-').replace('/', '_');
|
||||
|
||||
assert.equal(util.base64URLEncode(testBuffer), expectedBase64);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
Загрузка…
Ссылка в новой задаче