Merge pull request #336 from mozilla/328-service-client-tokens
Service Client Tokens
This commit is contained in:
Коммит
f16fac446c
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
14
docs/api.md
14
docs/api.md
|
@ -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
|
||||
|
|
|
@ -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_\-\.~=]+$/);
|
||||
|
||||
|
|
128
test/api.js
128
test/api.js
|
@ -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() {
|
||||
|
|
Загрузка…
Ссылка в новой задаче