feat(keys): add keys_jwe support (#486) r=rfk

The oauth-server needs the ability to accept an encrypted bundle of key material from content-server during the oauth dance, and provide it to the relier at completion.

Fixes #484
This commit is contained in:
Vlad Filippov 2017-09-21 10:39:09 -04:00 коммит произвёл GitHub
Родитель efac7bc852
Коммит 6a4efd1bf7
9 изменённых файлов: 105 добавлений и 5 удалений

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

@ -321,6 +321,7 @@ back to the client. This code will be traded for a token at the
- `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`.
- `keys_jwe`: Optional. A JWE bundle to be returned to the client when it redeems the authorization code.
**Example:**
@ -420,6 +421,7 @@ A valid request will return a JSON response with these properties:
- `token_type`: A string representing the token type. Currently will always be "bearer".
- `auth_at`: An integer giving the time at which the user authenticated to the Firefox Accounts server when generating this token, as a UTC unix timestamp (i.e. **seconds since epoch**).
- `id_token`: (Optional) If the authorization was requested with `openid` scope, then this property will contain the OpenID Connect ID Token.
- `keys_jwe`: (Optional) Returns the JWE bundle that if the authorization request had one.
**Example:**

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

@ -163,8 +163,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, codeChallengeMethod, codeChallenge) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)';
'INSERT INTO codes (clientId, userId, email, scope, authAt, offline, code, codeChallengeMethod, codeChallenge, keysJwe) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
const QUERY_ACCESS_TOKEN_INSERT =
'INSERT INTO tokens (clientId, userId, email, scope, type, expiresAt, ' +
'token) VALUES (?, ?, ?, ?, ?, ?, ?)';
@ -371,7 +371,8 @@ MysqlStore.prototype = {
!! codeObj.offline,
hash,
codeObj.codeChallengeMethod,
codeObj.codeChallenge
codeObj.codeChallenge,
codeObj.keysJwe
]).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 = 18;
module.exports.level = 19;

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

@ -0,0 +1,4 @@
ALTER TABLE codes ADD COLUMN keysJwe MEDIUMTEXT,
ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '19' WHERE name = 'schema-patch-level';

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

@ -0,0 +1,4 @@
-- ALTER TABLE codes DROP COLUMN keysJwe,
-- ALGORITHM = INPLACE, LOCK = NONE;
-- UPDATE dbMetadata SET value = '18' WHERE name = 'schema-patch-level';

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

@ -71,6 +71,7 @@ function generateCode(claims, client, scope, req) {
offline: req.payload.access_type === ACCESS_TYPE_OFFLINE,
codeChallengeMethod: req.payload.code_challenge_method,
codeChallenge: req.payload.code_challenge,
keysJwe: req.payload.keys_jwe,
}).then(function(code) {
logger.debug('redirecting', { uri: req.payload.redirect_uri });
@ -156,6 +157,12 @@ module.exports = {
}),
code_challenge: Joi.string()
.length(PKCE_CODE_CHALLENGE_LENGTH)
.when('response_type', {
is: CODE,
then: Joi.optional(),
otherwise: Joi.forbidden()
}),
keys_jwe: validators.jwe
.when('response_type', {
is: CODE,
then: Joi.optional(),

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

@ -160,7 +160,8 @@ 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(),
keys_jwe: validators.jwe.optional()
})
},
handler: function tokenEndpoint(req, reply) {
@ -468,6 +469,9 @@ function generateTokens(options) {
if (idToken) {
json.id_token = idToken;
}
if (options.keysJwe) {
json.keys_jwe = options.keysJwe;
}
return json;
});
}

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

@ -35,3 +35,7 @@ exports.assertion = Joi.string()
.max(10240)
.regex(/^[a-zA-Z0-9_\-\.~=]+$/);
exports.jwe = Joi.string()
.max(1024)
// JWE token format: 'protectedheader.encryptedkey.iv.cyphertext.authenticationtag'
.regex(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/);

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

@ -780,6 +780,74 @@ describe('/v1', function() {
});
});
describe('?keys_jwe', function() {
it('should validate the JWE', () => {
const keys_jwe = 'some_string';
const code_challenge = 'iyW5ScKr22v_QL-rcW_EGlJrDSOymJvrlXlw4j7JBiQ';
return Server.api.post({
url: '/authorization',
payload: authParams({
client_id: clientId,
response_type: 'code',
code_challenge_method: 'S256',
code_challenge: code_challenge,
keys_jwe: keys_jwe
})
}).then((res) => {
assert.equal(res.statusCode, 400);
assert.equal(res.result.errno, 109);
assert.equal(res.result.validation.keys[0], 'keys_jwe');
});
});
it('should return the key bundle in PKCE flow', () => {
const keys_jwe = 'MjU2R0NNIn0..8L7QykCJ5W-YZtbx.Q_8JFsdWXFNg37PCqZA_JJb4BvqAuh3UMyNE.bSOKJkZspycp9DcGRWtH6g';
const code_verifier = 'ywZ_yiNpe-UoGYW.oW95hTjRZ8j_d2kF';
const code_challenge = 'iyW5ScKr22v_QL-rcW_EGlJrDSOymJvrlXlw4j7JBiQ';
const secret2 = unique.secret();
const 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(() => {
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,
keys_jwe: keys_jwe
})
}).then((res) => {
assert.equal(res.statusCode, 200);
return url.parse(res.result.redirect, true).query.code;
});
}).then((code) => {
return Server.api.post({
url: '/token',
payload: {
client_id: client2.id.toString('hex'),
code: code,
code_verifier: code_verifier
}
});
}).then((res) => {
assert.equal(res.statusCode, 200);
assert.equal(res.result.keys_jwe, keys_jwe);
});
});
});
describe('response', function() {
describe('with a trusted client', function() {
it('should redirect to the redirect_uri', function() {
@ -886,6 +954,11 @@ describe('/v1', function() {
assert.equal(res.statusCode, 200);
assertSecurityHeaders(res);
assert.ok(res.result.access_token);
assert.equal(res.result.token_type, 'bearer');
assert.ok(res.result.auth_at);
assert.ok(res.result.expires_in);
assert.equal(res.result.scope, 'a');
assert.equal(res.result.keys_jwe, undefined);
});
});
@ -1037,6 +1110,7 @@ describe('/v1', function() {
assert.ok(res.result.scope);
assert.equal(res.result.token_type, 'bearer');
assert.ok(res.result.access_token);
assert.equal(res.result.keys_jwe, undefined);
});
});