Skip to content
This repository has been archived by the owner on Apr 3, 2019. It is now read-only.

Commit

Permalink
feat(openid): add initial OpenID Connect support
Browse files Browse the repository at this point in the history
Requesting authorization with the scope `openid` will result in an
`id_token` property being returned along with the `access_token`.

Closes #362
  • Loading branch information
seanmonstar committed Dec 17, 2015
1 parent 996935b commit 93f8758
Show file tree
Hide file tree
Showing 8 changed files with 245 additions and 8 deletions.
9 changes: 9 additions & 0 deletions config/dev.json
Expand Up @@ -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"
}
}
}
15 changes: 15 additions & 0 deletions config/test.json
Expand Up @@ -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",
Expand Down
36 changes: 35 additions & 1 deletion docs/api.md
Expand Up @@ -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]
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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:**

Expand Down Expand Up @@ -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
Expand All @@ -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
41 changes: 40 additions & 1 deletion lib/config.js
Expand Up @@ -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');

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand All @@ -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;
39 changes: 39 additions & 0 deletions 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);
}
};
47 changes: 41 additions & 6 deletions lib/routes/token.js
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -316,8 +349,10 @@ function generateTokens(options) {
if (refresh) {
json.refresh_token = refresh.token.toString('hex');
}
if (idToken) {
json.id_token = idToken;
}
return json;
});
}


5 changes: 5 additions & 0 deletions lib/routing.js
Expand Up @@ -61,6 +61,11 @@ exports.routes = [
method: 'POST',
path: v('/verify'),
config: require('./routes/verify')
},
{
method: 'GET',
path: v('/jwks'),
config: require('./routes/jwks')
}
];

Expand Down
61 changes: 61 additions & 0 deletions test/api.js
Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
});
});
});
});

0 comments on commit 93f8758

Please sign in to comment.