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:
Vlad Filippov 2017-06-18 19:39:11 -04:00 коммит произвёл GitHub
Родитель db3b55b1b6
Коммит ed59c0e6d2
20 изменённых файлов: 656 добавлений и 21 удалений

Просмотреть файл

@ -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": {

Просмотреть файл

@ -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:**

13
docs/pkce.md Normal file
Просмотреть файл

@ -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';

Просмотреть файл

@ -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) {

27
lib/util.js Normal file
Просмотреть файл

@ -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

Просмотреть файл

@ -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');
});
});
});

131
test/routes/token.js Normal file
Просмотреть файл

@ -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();
});
});
});
});

19
test/util.js Normal file
Просмотреть файл

@ -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);
});
});
});