From 93f87582ac6e55eaee8a1a9b4d0257a490d3cb3d Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Wed, 2 Dec 2015 17:32:31 -0800 Subject: [PATCH] feat(openid): add initial OpenID Connect support Requesting authorization with the scope `openid` will result in an `id_token` property being returned along with the `access_token`. Closes #362 --- config/dev.json | 9 +++++++ config/test.json | 15 +++++++++++ docs/api.md | 36 +++++++++++++++++++++++++- lib/config.js | 41 +++++++++++++++++++++++++++++- lib/routes/jwks.js | 39 +++++++++++++++++++++++++++++ lib/routes/token.js | 47 +++++++++++++++++++++++++++++----- lib/routing.js | 5 ++++ test/api.js | 61 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 lib/routes/jwks.js diff --git a/config/dev.json b/config/dev.json index b6cd60205..49f39c6d6 100644 --- a/config/dev.json +++ b/config/dev.json @@ -91,5 +91,14 @@ "logging": { "level": "ALL", "fmt": "pretty" + }, + "openid": { + "key": { + "kty": "RSA", + "kid": "2015.12.02-1", + "n":"xaQHsKpu1KSK-YEMoLzZS7Xxciy3fsGrhrrqW_JBrq3IRmeGLaqlE80zcpIVnStyp9tbet2niYTemt8ug591YWO5Y-S0EgQyFTxnGjzNOvAL6Cd2iGie9QeSehfFLNyRPdQiadYw07fw-h5gweMpVJv8nTgS-Bcorlw9JQM6Il1cUpbP0Lt-F_5qrzlaOiTEAAb4JGOusVh0n-MZfKt7w0mikauMH5KfhflwQDn4YTzRkWJzlldXr1Cs0ZkYzOwS4Hcoku7vd6lqCUO0GgZvkuvCFqdVKzpa4CGboNdfIjcGVF4f1CTQaQ0ao51cwLzq1pgi5aWYhVH7lJcm6O_BQw", + "e":"AQAB", + "d":"EM21aavT6Hhk6Hm0XSYxQ2KguJhcsYY90yKpMlASjYtw76t1mQRdLKXRrfgFpms_QE5CJNwblnGZi4lWJxKzpCgaZwfW14FL0Mpl6bEpsc0e9goE5ewfN64BIihLN1k5cAxNMLppRFbrQhi7GUD7DpqEi8lss3Mknk5xVGhF1Q38i5wSPLaLNgdt7QUIRdCCsrVFwnj83e8Rmmchr2-LXg2P_2KbVwdKfLuDiaYgDr2OELiK3VZa3WMexLrQHXGf1bvuK9xg6DNQ5Oe3slNWe7a0cpNR5oPX8HjqREmKciCFxHSA5o0ogyu5YvVjvZuh4Fm1iAM1fJNzYpabd_D8IQ" + } } } diff --git a/config/test.json b/config/test.json index 1d488832c..70412dbcf 100644 --- a/config/test.json +++ b/config/test.json @@ -50,6 +50,21 @@ "level": "error", "fmt": "pretty" }, + "openid": { + "key": { + "kty": "RSA", + "kid": "2015.12.16-1", + "n":"xaQHsKpu1KSK-YEMoLzZS7Xxciy3fsGrhrrqW_JBrq3IRmeGLaqlE80zcpIVnStyp9tbet2niYTemt8ug591YWO5Y-S0EgQyFTxnGjzNOvAL6Cd2iGie9QeSehfFLNyRPdQiadYw07fw-h5gweMpVJv8nTgS-Bcorlw9JQM6Il1cUpbP0Lt-F_5qrzlaOiTEAAb4JGOusVh0n-MZfKt7w0mikauMH5KfhflwQDn4YTzRkWJzlldXr1Cs0ZkYzOwS4Hcoku7vd6lqCUO0GgZvkuvCFqdVKzpa4CGboNdfIjcGVF4f1CTQaQ0ao51cwLzq1pgi5aWYhVH7lJcm6O_BQw", + "e":"AQAB", + "d":"EM21aavT6Hhk6Hm0XSYxQ2KguJhcsYY90yKpMlASjYtw76t1mQRdLKXRrfgFpms_QE5CJNwblnGZi4lWJxKzpCgaZwfW14FL0Mpl6bEpsc0e9goE5ewfN64BIihLN1k5cAxNMLppRFbrQhi7GUD7DpqEi8lss3Mknk5xVGhF1Q38i5wSPLaLNgdt7QUIRdCCsrVFwnj83e8Rmmchr2-LXg2P_2KbVwdKfLuDiaYgDr2OELiK3VZa3WMexLrQHXGf1bvuK9xg6DNQ5Oe3slNWe7a0cpNR5oPX8HjqREmKciCFxHSA5o0ogyu5YvVjvZuh4Fm1iAM1fJNzYpabd_D8IQ" + }, + "oldKey": { + "kty": "RSA", + "kid": "2015.12.02-1", + "n":"xaQHsKpu1KSK-YEMoLzZS7Xxciy3esGrhrrqW_JBrq3IRmeGLaqlE80zcpIVnStyp9tbet2niYTemt8ug591YWO5Y-S0EgQyFTxnGjzNOvAL6Cd2iGie9QeSehfFLNyRPdQiadYw07fw-h5gweMpVJs8nTgS-Bcorlw9JQM6Il1cUpbP0Lt-F_5qrzlaOiTEAAb4JGOusVh0n-MZfKt7w0mikauMH5KfhflwQDn4YTzRkWJzlldXr1Cs0ZkYzOwS4Hcoku7vd6lqCUO0GgZvkuvCFqdVKzpa4CGboNdfIjcGVF4f1CTQaQ0ao51cwLzq1pgi5aWYhVH7lJcm6O_BQw", + "e":"AQAC" + } + }, "serviceClients": [ { "id": "d23dbf62b82eb04e", diff --git a/docs/api.md b/docs/api.md index b60e55709..3113544e1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -54,6 +54,7 @@ The currently-defined error responses are: - [GET /v1/authorization][redirect] +- [GET /v1/jwks][jwks] - [POST /v1/authorization][authorization] - [POST /v1/token][token] - [POST /v1/destroy][delete] @@ -283,7 +284,7 @@ content-server page. - `client_id`: The id returned from client registration. - `state`: A value that will be returned to the client as-is upon redirection, so that clients can verify the redirect is authentic. - `redirect_uri`: Optional. If supplied, a string URL of where to redirect afterwards. Must match URL from registration. -- `scope`: Optional. A space-separated list of scopes that the user has authorized. This could be pruned by the user at the confirmation dialog. +- `scope`: Optional. A space-separated list of scopes that the user has authorized. This could be pruned by the user at the confirmation dialog. If this includes the scope `openid`, this will be an OpenID Connect authentication request. - `access_type`: Optional. If provided, should be `online` or `offline`. `offline` will result in a refresh_token being provided, so that the access_token can be refreshed after it expires. - `action`: Optional. If provided, should be `signup`, `signin`, or `force_auth`. Send to improve the user experience, based on whether they clicked on a Sign In or Sign Up button. `force_auth` requires the user to sign in using the address specified in `email`. If unspecified then Firefox Accounts will try choose intelligently between `signin` and `signup` based on the user's browser state. - `email`: Optional if `action` is `signup` or `signin`. Required if `action` @@ -415,6 +416,7 @@ A valid request will return a JSON response with these properties: - `expires_in`: **Seconds** until this access token will no longer be valid. - `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. **Example:** @@ -496,6 +498,37 @@ A valid request will return JSON with these properties: } ``` +### GET /v1/jwks + +This endpoint returns the [JWKs](https://tools.ietf.org/html/rfc7517) +that are used for signing OpenID Connect id tokens. + +#### Request + +```sh +curl -v "https://oauth.accounts.firefox.com/v1/jwks" +``` + +#### Response + +A valid response will return JSON of the `keys`. + +**Example:** + +```json +{ + "keys": [ + "alg": "RS256", + "use": "sig", + "kty": "RSA", + "kid": "2015.12.02-1", + "n":"xaQHsKpu1KSK-YEMoLzZS7Xxciy3esGrhrrqW_JBrq3IRmeGLaqlE80zcpIVnStyp9tbet2niYTemt8ug591YWO5Y-S0EgQyFTxnGjzNOvAL6Cd2iGie9QeSehfFLNyRPdQiadYw07fw-h5gweMpVJs8nTgS-Bcorlw9JQM6Il1cUpbP0Lt-F_5qrzlaOiTEAAb4JGOusVh0n-MZfKt7w0mikauMH5KfhflwQDn4YTzRkWJzlldXr1Cs0ZkYzOwS4Hcoku7vd6lqCUO0GgZvkuvCFqdVKzpa4CGboNdfIjcGVF4f1CTQaQ0ao51cwLzq1pgi5aWYhVH7lJcm6O_BQw", + "e":"AQAC" + ] +} +``` + + [client]: #get-v1clientid [register]: #post-v1clientregister [clients]: #get-v1clients @@ -507,5 +540,6 @@ A valid request will return JSON with these properties: [delete]: #post-v1destroy [verify]: #post-v1verify [developer-activate]: #post-v1developeractivate +[jwks]: #get-v1jwks [Service Clients]: ./service-clients.md diff --git a/lib/config.js b/lib/config.js index d73e7b7da..cb9aacf2c 100644 --- a/lib/config.js +++ b/lib/config.js @@ -2,6 +2,7 @@ * 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('assert'); const fs = require('fs'); const path = require('path'); @@ -152,6 +153,27 @@ const conf = convict({ default: 10 } }, + openid: { + key: { + doc: 'Private JWK to sign id_tokens', + default: {} + }, + oldKey: { + doc: 'The previous public key that was used to sign id_tokens', + default: {} + }, + issuer: { + // this should match `issuer` in the 'OpenID Provider Metadata' document + // from the fxa-content-server + doc: 'The value of the `iss` property of the id_token', + default: 'https://accounts.firefox.com' + }, + ttl: { + doc: 'Number of milliseconds until id_token should expire', + default: '5 minutes', + format: 'duration' + } + }, publicUrl: { format: 'url', env: 'PUBLIC_URL', @@ -220,7 +242,6 @@ conf.validate(options); // custom validation, since we cant yet specify rules for inside arrays conf.get('serviceClients').forEach(function(client) { - const assert = require('assert'); assert(client.id, 'client id required'); assert.equal(client.id.length, 16, 'client id must be 16 hex digits'); assert.equal(Buffer(client.id, 'hex').toString('hex'), client.id, @@ -230,4 +251,22 @@ conf.get('serviceClients').forEach(function(client) { assert.equal(typeof client.jku, 'string', 'client jku required'); }); +var key = conf.get('openid.key'); +assert.equal(key.kty, 'RSA', 'openid.key.kty must be RSA'); +assert(key.kid, 'openid.key.kid is required'); +assert(key.n, 'openid.key.n is required'); +assert(key.e, 'openid.key.e is required'); +assert(key.d, 'openid.key.d is required'); + +var oldKey = conf.get('openid.oldKey'); +if (Object.keys(oldKey).length) { + assert.equal(oldKey.kty, 'RSA', 'openid.oldKey.kty must be RSA'); + assert(oldKey.kid, 'openid.oldKey.kid is required'); + assert.notEqual(key.kid, oldKey.kid, + 'openid.key.kid must differ from oldKey'); + assert(oldKey.n, 'openid.oldKey.n is required'); + assert(oldKey.e, 'openid.oldKey.e is required'); + assert(!oldKey.d, 'openid.oldKey.d is forbidden'); +} + module.exports = conf; diff --git a/lib/routes/jwks.js b/lib/routes/jwks.js new file mode 100644 index 000000000..7b7322b12 --- /dev/null +++ b/lib/routes/jwks.js @@ -0,0 +1,39 @@ +/* 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 config = require('../config'); + +const KEYS = (function() { + var priv = config.get('openid.key'); + + function pub(key) { + // Hey, this is important. Listen up. + // + // This function pulls out only the **PUBLIC** pieces of this key. + // For RSA, that's the `e` and `n` values. + // + // BE CAREFUL IF YOU REFACTOR THIS. Thanks. + return { + kty: key.kty, + alg: 'RS256', + kid: key.kid, + use: 'sig', + n: key.n, + e: key.e + }; + } + + var keys = [pub(priv)]; + var old = config.get('openid.oldKey'); + if (Object.keys(old).length) { + keys.push(pub(old)); + } + return { keys: keys }; +})(); + +module.exports = { + handler: function jwks(req, reply) { + reply(KEYS); + } +}; diff --git a/lib/routes/token.js b/lib/routes/token.js index ae43d40fb..070f2d38f 100644 --- a/lib/routes/token.js +++ b/lib/routes/token.js @@ -29,11 +29,19 @@ const GRANT_JWT = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; const JWT_AUD = config.get('publicUrl') + '/v1/token'; const SERVICE_CLIENTS = {}; -const JWTOOL = new JwTool(config.get('serviceClients').map(function(client) { +const SERVICE_JWTOOL = new JwTool(config.get('serviceClients').map(function(client) { SERVICE_CLIENTS[client.jku] = client; return client.jku; })); +const SCOPE_OPENID = 'openid'; + +const ID_TOKEN_EXPIRATION = Math.floor(config.get('openid.ttl') / 1000); +const ID_TOKEN_ISSUER = config.get('openid.issuer'); +const ID_TOKEN_KEY = JwTool.JWK.fromObject(config.get('openid.key'), { + iss: ID_TOKEN_ISSUER +}); + const PAYLOAD_SCHEMA = Joi.object({ client_id: validators.clientId @@ -123,6 +131,7 @@ module.exports = { schema: Joi.object().keys({ access_token: validators.token.required(), refresh_token: validators.token, + id_token: validators.assertion, scope: Joi.string().required().allow(''), token_type: Joi.string().valid('bearer').required(), expires_in: Joi.number().max(MAX_TTL_S).required(), @@ -152,6 +161,9 @@ module.exports = { }) .then(function(vals) { vals.ttl = params.ttl; + if (vals.scope && Scope(vals.scope).has(SCOPE_OPENID)) { + vals.idToken = true; + } return vals; }) .then(generateTokens) @@ -233,7 +245,7 @@ function confirmJwt(params) { var assertion = params.assertion; logger.debug('jwt.confirm', assertion); - return JWTOOL.verify(assertion).catch(function(err) { + return SERVICE_JWTOOL.verify(assertion).catch(function(err) { logger.info('jwt.invalid.verify', err.message); throw AppError.invalidAssertion(); }).then(function(payload) { @@ -296,14 +308,35 @@ function _validateJwtSub(sub) { return sub; } +function generateIdToken(options) { + var now = Math.floor(Date.now() / 1000); + var claims = { + sub: hex(options.userId), + aud: hex(options.clientId), + iss: ID_TOKEN_ISSUER, + iat: now, + exp: now + ID_TOKEN_EXPIRATION + }; + return ID_TOKEN_KEY.sign(claims); +} + function generateTokens(options) { // we always are generating an access token here // but depending on options, we may also be generating a refresh_token - var promises = [db.generateAccessToken(options)]; + var promises = { + access: db.generateAccessToken(options) + }; if (options.offline) { - promises.push(db.generateRefreshToken(options)); + promises.refresh = db.generateRefreshToken(options); + } + if (options.idToken) { + promises.idToken = generateIdToken(options); } - return P.all(promises).spread(function(access, refresh) { + return P.props(promises).then(function(result) { + var access = result.access; + var refresh = result.refresh; + var idToken = result.idToken; + var json = { access_token: access.token.toString('hex'), token_type: access.type, @@ -316,8 +349,10 @@ function generateTokens(options) { if (refresh) { json.refresh_token = refresh.token.toString('hex'); } + if (idToken) { + json.id_token = idToken; + } return json; }); } - diff --git a/lib/routing.js b/lib/routing.js index f528f4fb7..e4b04248f 100644 --- a/lib/routing.js +++ b/lib/routing.js @@ -61,6 +61,11 @@ exports.routes = [ method: 'POST', path: v('/verify'), config: require('./routes/verify') + }, + { + method: 'GET', + path: v('/jwks'), + config: require('./routes/jwks') } ]; diff --git a/test/api.js b/test/api.js index 85c319e59..a0e7ed8c4 100644 --- a/test/api.js +++ b/test/api.js @@ -1179,6 +1179,39 @@ describe('/v1', function() { }); }); + describe('?scope=openid', function() { + + function decodeJWT(b64) { + var jwt = b64.split('.'); + return { + header: JSON.parse(Buffer(jwt[0], 'base64').toString('utf-8')), + claims: JSON.parse(Buffer(jwt[1], 'base64').toString('utf-8')) + }; + } + + it('should return an id_token', function() { + return newToken({ scope: 'openid' }).then(function(res) { + assert.equal(res.statusCode, 200); + assert(res.result.access_token); + assert(res.result.id_token); + var jwt = decodeJWT(res.result.id_token); + var header = jwt.header; + var claims = jwt.claims; + + assert.equal(header.alg, 'RS256'); + assert.equal(header.kid, config.get('openid.key').kid); + + assert.equal(claims.sub, USERID); + assert.equal(claims.aud, clientId); + assert.equal(claims.iss, config.get('openid.issuer')); + var now = Math.floor(Date.now() / 1000); + assert(claims.iat <= now); + assert(claims.exp > now); + }); + }); + + }); + }); describe('/client', function() { @@ -1905,4 +1938,32 @@ describe('/v1', function() { }); }); }); + + describe('/jwks', function() { + it('should not include the private part of the key', function() { + return Server.api.get({ + url: '/jwks' + }).then(function(res) { + assert.equal(res.statusCode, 200); + + var key = res.result.keys[0]; + assert(key.n); + assert(key.e); + assert(!key.d); + }); + }); + + it('should include the oldKey if present', function() { + return Server.api.get({ + url: '/jwks' + }).then(function(res) { + assert.equal(res.statusCode, 200); + + var keys = res.result.keys; + assert.equal(keys.length, 2); + assert(!keys[1].d); + assert.notEqual(keys[0].kid, keys[1].kid); + }); + }); + }); });