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:
Родитель
efac7bc852
Коммит
6a4efd1bf7
|
@ -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-_]+$/);
|
||||
|
|
74
test/api.js
74
test/api.js
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче