Merge pull request #336 from mozilla/328-service-client-tokens

Service Client Tokens
This commit is contained in:
Sean McArthur 2015-10-16 12:31:35 -07:00
Родитель 952c2ca418 799f0e22d3
Коммит f16fac446c
13 изменённых файлов: 485 добавлений и 90 удалений

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

@ -54,7 +54,7 @@
{
"id": "d23dbf62b82eb04e",
"name": "Test Service Client",
"scope": "profile:email",
"scope": "profile",
"jku": "http://127.0.0.1:9019/.well-known/public-keys"
}
]

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

@ -368,20 +368,26 @@ particular user.
#### Request Parameters
- `client_id`: The id returned from client registration.
- `client_secret`: The secret returned from client registration.
- `ttl`: (optional) Seconds that this access_token should be valid.
The default and maximum value is 2 weeks.
- `grant_type`: Either the string `authorization_code` or `refresh_token`.
- `grant_type`: Either `authorization_code`, `refresh_token`, or `urn:ietf:params:oauth:grant-type:jwt-bearer`.
- If `authorization_code`:
- `client_id`: The id returned from client registration.
- `client_secret`: The secret returned from client registration.
- `code`: A string that was received from the [authorization][] endpoint.
- If `refresh_token`:
- `client_id`: The id returned from client registration.
- `client_secret`: The secret returned from client registration.
- `refresh_token`: A string that received from the [token][]
endpoint specifically as a refresh token.
- `scope`: (optional) A subset of scopes provided to this
refresh_token originally, to receive an access_token with less
permissions.
- If `urn:ietf:params:oauth:grant-type:jwt-bearer`:
- `assertion`: A signed JWT assertion. See [Service
Clients][] for more.
**Example:**
@ -501,3 +507,5 @@ A valid request will return JSON with these properties:
[delete]: #post-v1destroy
[verify]: #post-v1verify
[developer-activate]: #post-v1developeractivate
[Service Clients]: ./service-clients.md

92
docs/service-clients.md Normal file
Просмотреть файл

@ -0,0 +1,92 @@
# Service Clients
Do you wish to allow users to authenticate to your app, as well as fetch
some information about them? Then you don't want to be a Service Client.
Be a regular client.
Service Clients exist as privileged apps that need to be able to request
information about a user, without ever having received permission to do
so. Sounds nefarious. However, as said, these are **privileged** apps,
meaning they are also run by **us** (Mozilla). So these apps are not
gaining access to something that we don't already know. Plus, before
ever being promoted to a Service Client, real humans are involved to
make sure it's the correct use case.
## All Powerful
A Service Client exists by being in the config array `serviceClients`.
Each entry (if any) is an object, with the following values:
- `id` - `String`: a unique hex value in identical format to regular
client ids.
- `name` - `String`: a plaintext name for the client that's friendly to
human readers.
- `scope` - `String`: space-separated scopes that this client will be
using when requesting access tokens.
- `jku` - `String`: a unique URL that will host a JWK Set. Unique as in
unique in the current list of Service Clients.
## JKUs and JWK Sets
Imagine a service client with the `jku` of `https://example.dom.ain/keys`.
The document hosted at that URL should contain something like:
```json
{
"keys":[{
"kid":"key-id-can-whatever-1",
"use": "sig",
"kty":"RSA",
"n":"W_lCUvksZMVxW2JLNtoyPPshvSHng28H5FggSBGBjmzv3eHkMgRdc8hpOkgcPwXYxHdVM6udtVdXZtbGN8nUyQX8gxD3AJg-GSrH3UOsoArPLCmcxwIEpk4B0wqwP68oK8dQHt0iK3N-XeCnMpv75ULlVn3LEOZT8CsuNraVOthYeClUb8r1PjRwqRB06QGNqnnhcPMmh-6cRzQ9HmTMz6CDcugiH5n2sjrvpeBugEsnXt3KpzVdSc4usXrIEmLRuFjwFbkzoo7FiAtSoXxBqc074qz8ejm-V0-2Wv3p6ePeLODeYkPQho4Lb1TBdoidr9RHY29Out4mhzb4nUrHHQ",
"e":"AQAB"
}]
}
```
This JKU is important when making requests for access tokens.
## JWTs and Authorization
To request an access token, a service client must generate a signed [JWT][],
and then send it to our [/v1/token][] endpoint.
Here's an example of creating a JWT in JavaScript:
```js
var now = Math.floor(Date.now() / 1000); // in seconds
var header = {
alg: 'RS256',
typ: 'JWT',
jku: 'https://basket.mozilla.org/.well-known/jku',
kid: 'k1'
};
var claims = {
scope: 'profile:email',
aud: 'https://oauth.accounts.firefox.com/v1/token',
iat: now,
exp: now + (60 * 5),
sub: '9b052aebbc48c8376257c777e2a7f009'
};
var token = base64(JSON.stringify(header)) + '.' + base64(JSON.stringify(claims));
var sig = rsa256(Buffer(token, 'base64'), privateKey);
var jwt = token + '.' + base64(sig);
```
Once you have a signed JWT, you would make the following request:
```sh
curl -v \
-X POST \
-H "Content-Type: application/json" \
"https://oauth.accounts.firefox.com/v1/token" \
-d '{
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYmFza2V0LmFjY291bnRzLmZpcmVmb3guY29tLy53ZWxsLWtub3duL2prdSIsImtpZCI6ImsxIn0.eyJzdWIiOiI1OTAxYmQwOTM3NmZhZGFhNTkwMWJkMDkzNzZmYWRhYUBhY2NvdW50cy5maXJlZm94LmNvbSIsInNjb3BlIjoicHJvZmlsZTplbWFpbCIsImF1ZCI6Imh0dHBzOi8vb2F1dGguYWNjb3VudHMuZmlyZWZveC5jb20vdjEvdG9rZW4iLCJpYXQiOjE0NDM2NjI0ODEsImV4cCI6MTQ0MzY2Mjc4MX0.Kmwfq7yZrKpwrcZ78NTLPs8v4ijMhoKVNZ45VJY-skyK_XD_U5DJeKq8IE6PspU6B6p0DPkW1EEKeKOAbpyzFIBi9uG7l329x32JkzXGwybxannbGrdd5DFZbIaBSZDf-64MXbxGBGQ8xy18dfXmgbmNsvYPRZqqS2gmoM1EvWg"
}'
```
The response is described in the [API docs][/v1/token].
[/v1/token]: ./api.md#post-v1token
[JWT]: http://jwt.io/

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

@ -12,6 +12,7 @@ const env = require('../env');
const logger = require('../logging')('db');
const klass = config.get('db.driver') === 'mysql' ?
require('./mysql') : require('./memory');
const unique = require('../unique');
function clientEquals(configClient, dbClient) {
var props = Object.keys(configClient);
@ -108,6 +109,32 @@ function preClients() {
}
}
function serviceClients() {
var clients = config.get('serviceClients');
if (clients && clients.length) {
logger.debug('serviceClients.loading', clients);
return P.all(clients.map(function(client) {
return exports.getClient(client.id).then(function(existing) {
if (existing) {
logger.verbose('seviceClients.existing', client);
return;
}
return exports.registerClient({
id: client.id,
name: client.name,
hashedSecret: encrypt.hash(unique.secret()),
imageUri: '',
redirectUri: '',
trusted: true,
canGrant: false
});
});
}));
}
}
var driver;
function withDriver() {
if (driver) {
@ -122,7 +149,7 @@ function withDriver() {
return p.then(function(store) {
logger.debug('connected', { driver: config.get('db.driver') });
driver = store;
}).then(preClients).then(function() {
}).then(exports._initialClients).then(function() {
return driver;
});
}
@ -160,5 +187,5 @@ exports.disconnect = function disconnect() {
};
exports._initialClients = function() {
return preClients();
return preClients().then(serviceClients);
};

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

@ -363,8 +363,8 @@ MysqlStore.prototype = {
},
generateAccessToken: function generateAccessToken(vals) {
var t = {
clientId: vals.clientId,
userId: vals.userId,
clientId: buf(vals.clientId),
userId: buf(vals.userId),
email: vals.email,
scope: Scope(vals.scope),
token: unique.token(),

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

@ -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 = 10;
module.exports.level = 11;

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

@ -0,0 +1,5 @@
-- dropping NOT NULL constraint
ALTER TABLE tokens MODIFY COLUMN email VARCHAR(256);
UPDATE dbMetadata SET value = '11' WHERE name = 'schema-patch-level';

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

@ -0,0 +1,5 @@
-- (commented out to avoid accidentally running this in production...)
-- ALTER TABLE tokens MODIFY COLUMN email VARCHAR(256) NOT NULL;
-- UPDATE dbMetadata SET value = '10' WHERE name = 'schema-patch-level';

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

@ -41,7 +41,7 @@ CREATE TABLE IF NOT EXISTS tokens (
FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE,
userId BINARY(16) NOT NULL,
INDEX tokens_user_id(userId),
email VARCHAR(256) NOT NULL,
email VARCHAR(256),
type VARCHAR(16) NOT NULL,
scope VARCHAR(256) NOT NULL,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

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

@ -99,11 +99,7 @@ module.exports = {
validate: {
payload: {
client_id: validators.clientId,
assertion: Joi.string()
// taken from mozilla/persona/lib/validate.js
.min(50)
.max(10240)
.regex(/^[a-zA-Z0-9_\-\.~=]+$/)
assertion: validators.assertion
.required(),
redirect_uri: Joi.string()
.max(256),

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

@ -2,11 +2,14 @@
* 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/. */
// Hello, dear traveller! Please, turn back now. It's dangerous in here!
/*jshint camelcase: false*/
const AppError = require('../error');
const buf = require('buf').hex;
const hex = require('buf').to.hex;
const Joi = require('joi');
const JwTool = require('fxa-jwtool');
const config = require('../config');
const db = require('../db');
@ -16,9 +19,146 @@ const P = require('../promise');
const Scope = require('../scope');
const validators = require('../validators');
const HEX_STRING = validators.HEX_STRING;
const MAX_TTL_S = config.get('expiration.accessToken') / 1000;
const GRANT_AUTHORIZATION_CODE = 'authorization_code';
const GRANT_REFRESH_TOKEN = 'refresh_token';
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) {
SERVICE_CLIENTS[client.jku] = client;
return client.jku;
}));
const PAYLOAD_SCHEMA = Joi.object({
client_id: validators.clientId
.when('grant_type', {
is: GRANT_JWT,
then: Joi.forbidden()
}),
client_secret: validators.clientSecret
.when('grant_type', {
is: GRANT_JWT,
then: Joi.forbidden()
}),
grant_type: Joi.string()
.valid(GRANT_AUTHORIZATION_CODE, GRANT_REFRESH_TOKEN, GRANT_JWT)
.default(GRANT_AUTHORIZATION_CODE)
.optional(),
ttl: Joi.number()
.max(MAX_TTL_S)
.default(MAX_TTL_S)
.optional(),
scope: Joi.alternatives().when('grant_type', {
is: GRANT_REFRESH_TOKEN,
then: Joi.string(),
otherwise: Joi.forbidden()
}),
code: Joi.string()
.length(config.get('unique.code') * 2)
.regex(validators.HEX_STRING)
.required()
.when('grant_type', {
is: GRANT_AUTHORIZATION_CODE,
otherwise: Joi.forbidden()
}),
refresh_token: validators.token
.required()
.when('grant_type', {
is: GRANT_REFRESH_TOKEN,
otherwise: Joi.forbidden()
}),
assertion: validators.assertion
.required()
.when('grant_type', {
is: GRANT_JWT,
otherwise: Joi.forbidden()
})
});
// No? Still want to press on? Well, OK. But you were warned.
//
// This route takes takes an authorization grant, and returns an
// access_token if everything matches up.
//
// Steps from start to finish:
//
// 1. Confirm grant credentials.
// - If grant type is authorization code or refresh token, first
// confirm the client credentials in `confirmClient()`.
// - If grant type is authorization code, proceed to `confirmCode()`.
// - If grant type is refresh token, proceed to `confirmRefreshToken()`.
// - If grant type is a JWT, all information is in the JWT. So jump
// straight to `confirmJwt()`.
// 2. Generate tokens.
// - An options object is passed to `generateTokens()`.
// - An access_token is generated.
// - If grant type is authorization code, and it was created with
// offline access, a refresh_token is also generated.
// 3. The tokens are returned in the response payload.
module.exports = {
validate: {
// stripUnknown is used to allow various oauth2 libraries to be used
// with FxA OAuth. Sometimes, they will send other parameters that
// 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);
}
},
response: {
schema: Joi.object().keys({
access_token: validators.token.required(),
refresh_token: validators.token,
scope: Joi.string().required().allow(''),
token_type: Joi.string().valid('bearer').required(),
expires_in: Joi.number().max(MAX_TTL_S).required(),
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);
});
} else if (params.grant_type === GRANT_REFRESH_TOKEN) {
return confirmClient(params.client_id, params.client_secret)
.then(function() {
return confirmRefreshToken(params);
});
} else if (params.grant_type === GRANT_JWT) {
return confirmJwt(params);
} else {
// else our Joi validation failed us?
logger.critical('joi.grant_type', params.grant_type);
throw Error('unreachable');
}
})
.then(function(vals) {
vals.ttl = params.ttl;
return vals;
})
.then(generateTokens)
.done(reply, reply);
}
};
function confirmClient(id, secret) {
return db.getClient(buf(id)).then(function(client) {
@ -89,6 +229,68 @@ function confirmRefreshToken(params) {
});
}
function confirmJwt(params) {
var assertion = params.assertion;
logger.debug('jwt.confirm', assertion);
return JWTOOL.verify(assertion).catch(function(err) {
logger.info('jwt.invalid.verify', err.message);
throw AppError.invalidAssertion();
}).then(function(payload) {
logger.verbose('jwt.payload', payload);
// this cannot fail! huh, why?
// if the assertion couldn't decode, or the jku was not in our
// trusted list, the assertion would not have verified.
var client = SERVICE_CLIENTS[JwTool.unverify(assertion).header.jku];
// ohai eslint complexity
var uid = _validateJwtSub(payload.sub);
if (payload.aud !== JWT_AUD) {
logger.debug('jwt.invalid.aud', payload.aud);
throw AppError.invalidAssertion();
}
if (!Scope(client.scope).has(payload.scope)) {
logger.debug('jwt.invalid.scopes', {
allowed: client.scope,
requested: payload.scope
});
throw AppError.invalidScopes(payload.scope);
}
var now = Date.now() / 1000;
if ((payload.iat || Infinity) > now) {
logger.debug('jwt.invalid.iat', { now: now, iat: payload.iat });
throw AppError.invalidAssertion();
}
if ((payload.exp || -Infinity) < now) {
logger.debug('jwt.invalid.exp', { now: now, exp: payload.exp });
throw AppError.invalidAssertion();
}
return {
clientId: client.id,
userId: uid,
scope: payload.scope,
};
});
}
function _validateJwtSub(sub) {
if (!sub) {
logger.debug('jwt.invalid.sub.missing');
throw AppError.invalidAssertion();
}
if (sub.length !== 32 || !HEX_STRING.test(sub)) {
logger.debug('jwt.invalid.sub', sub);
throw AppError.invalidAssertion();
}
return sub;
}
function generateTokens(options) {
// we always are generating an access token here
// but depending on options, we may also be generating a refresh_token
@ -113,79 +315,4 @@ function generateTokens(options) {
});
}
var payloadSchema = Joi.object({
client_id: validators.clientId,
client_secret: validators.clientSecret,
grant_type: Joi.string()
.valid(GRANT_AUTHORIZATION_CODE, GRANT_REFRESH_TOKEN)
.default(GRANT_AUTHORIZATION_CODE)
.optional(),
ttl: Joi.number()
.max(MAX_TTL_S)
.default(MAX_TTL_S)
.optional(),
scope: Joi.alternatives().when('grant_type', {
is: GRANT_REFRESH_TOKEN,
then: Joi.string(),
otherwise: Joi.forbidden()
}),
code: Joi.string()
.length(config.get('unique.code') * 2)
.regex(validators.HEX_STRING)
.required()
.when('grant_type', {
is: GRANT_AUTHORIZATION_CODE,
otherwise: Joi.forbidden()
}),
refresh_token: validators.token
.required()
.when('grant_type', {
is: GRANT_REFRESH_TOKEN,
otherwise: Joi.forbidden()
})
});
module.exports = {
validate: {
// stripUnknown is used to allow various oauth2 libraries to be used
// with FxA OAuth. Sometimes, they will send other parameters that
// 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, payloadSchema, { stripUnknown: true }, next);
}
},
response: {
schema: Joi.object().keys({
access_token: validators.token.required(),
refresh_token: validators.token,
scope: Joi.string().required().allow(''),
token_type: Joi.string().valid('bearer').required(),
expires_in: Joi.number().max(MAX_TTL_S).required(),
auth_at: Joi.number(),
})
},
handler: function tokenEndpoint(req, reply) {
var params = req.payload;
confirmClient(params.client_id, params.client_secret).then(function() {
if (params.grant_type === GRANT_AUTHORIZATION_CODE) {
return confirmCode(params.client_id, params.code).then(function(vals) {
vals.ttl = params.ttl;
return vals;
});
} else if (params.grant_type === GRANT_REFRESH_TOKEN) {
return confirmRefreshToken(params).then(function(vals) {
vals.ttl = params.ttl;
return vals;
});
} // else our Joi validation failed us?
})
.then(generateTokens)
.done(reply, reply);
}
};

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

@ -21,3 +21,10 @@ exports.clientSecret = Joi.string()
exports.token = Joi.string()
.length(config.get('unique.token') * 2)
.regex(exports.HEX_STRING);
// taken from mozilla/persona/lib/validate.js
exports.assertion = Joi.string()
.min(50)
.max(10240)
.regex(/^[a-zA-Z0-9_\-\.~=]+$/);

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

@ -31,6 +31,13 @@ const VERIFY_GOOD = JSON.stringify({
const MAX_TTL_S = config.get('expiration.accessToken') / 1000;
const JWT_PRIV_KEY = JWTool.JWK.fromObject(require('./lib/privkey.json'));
const JWT_PUB_KEY = require('./lib/pubkey.json');
JWT_PUB_KEY.kid = 'dev-1';
JWT_PUB_KEY.use = 'sig';
JWT_PUB_KEY.alg = 'RS';
function mockAssertion() {
var parts = url.parse(config.get('browserid.verificationUrl'));
return nock(parts.protocol + '//' + parts.host).post(parts.path);
@ -1051,6 +1058,127 @@ describe('/v1', function() {
});
describe('?grant_type=jwt', function() {
const JWT_URN = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
const JKU = config.get('serviceClients')[0].jku;
assert.equal(config.get('serviceClients')[0].scope, 'profile',
'test service client scope sanity check');
function sign(payload) {
return JWTool.sign({
header: {
alg: 'RS256',
typ: 'JWT',
jku: JKU,
kid: 'dev-1'
},
payload: {
sub: payload.sub || USERID,
iat: Math.floor(payload.iat || (Date.now() / 1000)),
exp: Math.floor(payload.exp || (Date.now() / 1000 + 60)),
aud: payload.aud || (config.get('publicUrl') + '/v1/token'),
scope: payload.scope || 'profile'
}
}, JWT_PRIV_KEY.pem);
}
function mockJwt() {
var parts = url.parse(JKU);
nock(parts.protocol + '//' + parts.host).get(parts.path)
.reply(200, {
keys: [
JWT_PUB_KEY
]
});
}
function request(payload) {
var assertion = sign(payload);
mockJwt();
return Server.api.post({
url: '/token',
payload: {
grant_type: JWT_URN,
assertion: assertion
}
});
}
describe('response', function() {
it('should work', function() {
return request({
}).then(function(res) {
assert.equal(res.statusCode, 200);
});
});
});
describe('userid', function() {
it('should fail if invalid', function() {
return request({
sub: 'definitely not an fxa uid',
}).then(function(res) {
assert.equal(res.statusCode, 401);
assert.equal(res.result.errno, 104);
});
});
});
describe('audience', function() {
it('should fail if mismatch', function() {
return request({
aud: 'https://not.the.right.aud/ience',
}).then(function(res) {
assert.equal(res.statusCode, 401);
assert.equal(res.result.errno, 104);
});
});
});
describe('issuedat', function() {
it('should fail if in the future', function() {
return request({
iat: 60 + Math.floor(Date.now() / 1000)
}).then(function(res) {
assert.equal(res.statusCode, 401);
assert.equal(res.result.errno, 104);
});
});
});
describe('expiresat', function() {
it('should fail if in the past', function() {
return request({
exp: Math.floor(Date.now() / 1000) - 100
}).then(function(res) {
assert.equal(res.statusCode, 401);
assert.equal(res.result.errno, 104);
});
});
});
describe('scope', function() {
it('should be able to reduce scopes', function() {
return request({
scope: 'profile:email'
}).then(function(res) {
assert.equal(res.statusCode, 200);
assert.equal(res.result.scope, 'profile:email');
});
});
it('should not be able to increase scopes', function() {
return request({
scope: 'nuclear:codes'
}).then(function(res) {
assert.equal(res.statusCode, 400);
assert.equal(res.result.errno, 114);
});
});
});
});
});
describe('/client', function() {