feat(jwt-access-tokens): Add subscription info to JWT access tokens

Make a backendService request from the oauth server to the auth
server to get the user's subscription info since the oauth server
does not have that state.

fixes #1595
This commit is contained in:
Shane Tomlinson 2019-09-18 14:21:13 +01:00
Родитель a1a0036f86
Коммит 9b1e28e45d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 09D4F897B87A2D19
23 изменённых файлов: 880 добавлений и 58 удалений

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

@ -0,0 +1,82 @@
# Placing subscription info in the `fxa-subscriptions` claim of JWT access tokens
- Status: proposed
- Deciders: Shane Tomlinson
- Date: 2019-09-30
Technical Story: https://github.com/mozilla/fxa/issues/1595
## Context and Problem Statement
A mechanism is needed to inform service providers (SP) that a user has paid for a given subscription. A SP could fetch the user's profile information, however, we prevent some 3rd party SPs from doing so, and instead give them all the information they need in a JWT format access token. Adding subscription information in JWT access tokens will give SPs the information they need to verify users have paid for a subscription.
## Decision Drivers
- Security - Users should not be able to get access to subscriptions they have not paid for.
- Extensibility - Adding subscription info should not inhibit future extensions to JWT access tokens.
- Standards - The [JWT access token draft spec][#jwt-draft-spec] format should be followed as closely as possible.
## Considered Options
1. Add subscriptions to the `scopes` claim
2. Add subscriptions as its own claim, `fxa-subscriptions`
## Decision Outcome
Chosen option: Adding subscription info into its own claim was chosen because FxA's lax scope checking means bad acting users could grant themselves access to subscriptions they have not paid for. See [this bug regarding FxA's lax scope checking][#lax-scope-checking].
### Positive Consequences
- The JWT access token contains all the information an SP needs to verify the user has paid for a subscription.
- Users are unable to grant themselves access to subscriptions they have not paid for.
### Negative Consequences
- SPs must now check two claims from the JWT to ensure a user is able to access a protected resource.
- An additional claim is added that is not defined in [the JWT access token draft spec][#jwt-draft-spec].
## Pros and Cons of the Options
### Add subscriptions to the `scopes` claim
An access token for a user that has paid for `subscription1` would have `subscription1` in it's scope claim.
e.g.,
```json
{
"jti": "cafecafe",
"sub": "deadbeef",
"scope": "profile:read subscription1",
...
}
```
- Good, because subscription information is in `scope` which is most likely the expected claim in which an SP would look for this info.
- Good, because no new claims are added over what's defined in [the JWT access token draft spec][#jwt-draft-spec].
- Bad, because [FxA's lax scope checking][#lax-scope-checking] means users would grant themselves access to subscriptions they have not paid for.
### Add subscriptions to the `fxa-subscriptions` claim
An access token for a user that has paid for `subscription1` would have `subscription1` in it's scope claim.
e.g.,
```json
{
"jti": "cafecafe",
"sub": "deadbeef",
"scope": "profile:read",
"fxa-subscriptions": "subscription1"
...
}
```
- Good, because users are unable to grant themselves access to subscriptions they have not paid for.
- Good, because subscription information is isolated from other possible future JWT extensions.
- Bad, because `fxa-subscriptions` is a non-standard claim and SP developers may not expect to look there.
## Links
[#jwt-draft-spec]: https://tools.ietf.org/html/draft-bertocci-oauth-access-token-jwt-00
[#lax-scope-checking]: https://github.com/mozilla/fxa/issues/2478

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

@ -10,6 +10,8 @@ This log lists the architectural decisions for [project name].
- [ADR-0003](0003-event-broker-for-subscription-platform.md) - Event Broker for Subscription Platform
- [ADR-0004](0004-product-capabilities-for-subscription-services.md) - Product Capabilities for Subscription Services
- [ADR-0005](0005-minimize-password-entry.md) - Minimizing password entry
- [ADR-0006](0006-json-schemas-for-messaging.md) - Utilizing JSON-Schemas, SemVer, and Tooling for JSON Messaging
- [ADR-0007](0007-subscription-claim-jwt-access-token.md) - Placing subscription info in the `fxa-subscriptions` claim of JWT access tokens
<!-- adrlogstop -->

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

@ -708,11 +708,18 @@ const conf = convict({
env: 'OAUTH_CLIENT_INFO_CACHE_TTL',
},
secretKey: {
doc: 'Shared secret for signing server-to-server JWT assertions',
doc: 'Shared secret for signing auth-to-oauth server JWT assertions',
env: 'OAUTH_SERVER_SECRET_KEY',
format: String,
default: 'megaz0rd',
},
jwtSecretKeys: {
doc:
'Comma-separated list of secret keys for verifying oauth-to-auth server JWTs',
env: 'OAUTH_SERVER_SECRETS',
format: Array,
default: ['megaz0rd'],
},
poolee: {
timeout: {
default: '30 seconds',

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

@ -25,11 +25,11 @@ const P = require('./promise');
const Joi = require('joi');
const validators = require('../lib/validators');
const jwt = P.promisifyAll(require('jsonwebtoken'));
const AppError = require('./error');
const config = require('./config');
const logger = require('./logging')('assertion');
const { verifyJWT } = require('../../lib/serverJWT');
const HEX_STRING = /^[0-9a-f]+$/;
const CLAIMS_SCHEMA = Joi.object({
@ -125,35 +125,6 @@ async function verifyBrowserID(assertion) {
return claims;
}
// Verify a JWT assertion.
// Since it's just a symmetric HMAC signature,
// this should be safe and performant enough to do in-proces.
async function verifyJWT(assertion) {
const opts = {
algorithms: ['HS256'],
audience: AUDIENCE,
issuer: ALLOWED_ISSUER,
};
// To allow for key rotation, we may have
// several valid shared secret keys in-flight.
const keys = config.get('authServerSecrets');
for (const key of keys) {
try {
const claims = jwt.verify(assertion, key, opts);
claims.uid = claims.sub;
return claims;
} catch (err) {
// Any error other than 'invalid signature' will not
// be resolved by trying the remaining keys.
if (err.message !== 'invalid signature') {
return error(assertion, err.message);
}
}
}
// None of the keys worked, clearly invalid.
return error(assertion, 'unknown signing key');
}
module.exports = async function verifyAssertion(assertion) {
// We can differentiate between JWTs and BrowserID assertions
// because the former cannot contain "~" while the later always do.
@ -161,7 +132,18 @@ module.exports = async function verifyAssertion(assertion) {
if (/~/.test(assertion)) {
claims = await verifyBrowserID(assertion);
} else {
claims = await verifyJWT(assertion);
try {
claims = await verifyJWT(
assertion,
AUDIENCE,
ALLOWED_ISSUER,
config.get('authServerSecrets'),
error
);
claims.uid = claims.sub;
} catch (err) {
return error(assertion, err.message);
}
}
try {
return await validateClaims(claims);

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

@ -0,0 +1,101 @@
/* 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 createBackendServiceAPI = require('../../lib/backendService');
const Joi = require('joi');
const { signJWT } = require('../../lib/serverJWT');
const AppError = require('./error');
module.exports = (log, config) => {
const AuthServerAPI = createBackendServiceAPI(log, config, 'auth', {
getUserProfile: {
path: '/v1/account/profile',
method: 'GET',
validate: {
headers: {
authorization: Joi.string().required(),
},
response: {
email: Joi.string().optional(),
locale: Joi.string()
.optional()
.allow(null),
authenticationMethods: Joi.array()
.items(Joi.string().required())
.optional(),
authenticatorAssuranceLevel: Joi.number().min(0),
subscriptions: Joi.array()
.items(Joi.string().required())
.optional(),
profileChangedAt: Joi.number().min(0),
},
},
},
});
const api = new AuthServerAPI(config.auth.url, config.auth.poolee);
return {
api,
close() {
api.close();
},
async getUserProfile({ client_id, scope, uid }) {
const claims = {
client_id,
scope,
sub: uid,
};
const jwt = await signJWT(
claims,
config.auth.url,
config.publicUrl,
config.auth.jwtSecretKey
);
try {
return await api.getUserProfile({ authorization: `OAuthJWT ${jwt}` });
} catch (error) {
throw this.mapAuthError(error);
}
},
mapAuthError(error) {
// If it's already an instance of our internal error type,
// then just return it as-is.
if (error instanceof AppError) {
return error;
}
if (!error.errno) {
// If there's no `errno`, it must be some sort of internal implementation error.
// Let it bubble up and be caught by the top-level unexpected-error-handling logic.
throw error;
}
switch (error.errno) {
case 110: {
return AppError.invalidToken();
}
case 998: {
let key;
try {
key = Object.keys(error.output.payload.data.value)[0];
} catch (e) {
// ignore, no key found
}
return AppError.invalidRequestParameter(key);
}
default: {
log.warn('auth_server.mapAuthError', {
err: error,
errno: error.errno,
warning: 'unmapped auth-server errno',
});
return AppError.unexpectedError();
}
}
},
};
};

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

@ -30,6 +30,32 @@ const conf = convict({
format: Boolean,
default: false,
},
auth: {
poolee: {
timeout: {
default: '30 seconds',
doc: 'Time in milliseconds to wait for auth server query completion',
env: 'AUTH_POOLEE_TIMEOUT',
format: 'duration',
},
maxPending: {
default: 1000,
doc: 'Number of pending requests to fxa-auth-server to allow',
env: 'AUTH_POOLEE_MAX_PENDING',
format: 'int',
},
},
jwtSecretKey: {
default: 'megaz0rd',
doc: 'Shared secret for signing oauth-to-auth server JWT assertions',
env: 'AUTH_SERVER_SHARED_SECRET',
format: String,
},
url: {
default: 'http://127.0.0.1:9000',
format: 'url',
},
},
authServerSecrets: {
doc:
'Comma-separated list of secret keys for verifying server-to-server JWTs',

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

@ -77,6 +77,10 @@ AppError.translate = function translate(response) {
return error;
};
AppError.unexpectedError = function unexpectedError() {
return new AppError({});
};
AppError.unknownClient = function unknownClient(clientId) {
return new AppError(
{

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

@ -17,6 +17,7 @@ const amplitude = require('./metrics/amplitude')(
config.getProperties()
);
const sub = require('./jwt_sub');
const authServer = require('./auth_server')(logger, config.getProperties());
const ACR_VALUE_AAL2 = 'AAL2';
const ACCESS_TYPE_OFFLINE = 'offline';
@ -222,5 +223,24 @@ exports.generateAccessToken = async function generateAccessToken(grant) {
return accessToken;
}
// This is awful. The auth server is the canonical source of truth
// for subscription info. If subscription info is needed within the JWT
// access token, then go fetch it from the auth-server using a backend
// service request. Once the two services are merged, we'll be able
// to get this info by directly making a DB call ourselves.
if (grant.scope.contains('profile:subscriptions')) {
const { subscriptions } = await authServer.getUserProfile({
client_id: hex(grant.clientId),
scope: 'profile:subscriptions',
uid: hex(grant.userId),
});
// To avoid mutating the input grant, create a
// copy and add the new property there.
grant = {
...grant,
'fxa-subscriptions': subscriptions,
};
}
return JWTAccessToken.create(accessToken, grant);
};

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

@ -35,6 +35,16 @@ exports.create = async function generateJWTAccessToken(accessToken, grant) {
sub: await sub(grant.userId, grant.clientId, grant.ppidSeed),
};
// Note, a new claim is used rather than scopes because
// FxA's scope checking somewhat blindly accepts user input,
// meaning a malicious user could reload FxA after editing the URL
// to contain subscription name in the scope list and the subscription
// would end up in the user's scope list whether they actually
// paid for it or not. See https://github.com/mozilla/fxa/issues/2478
if (grant['fxa-subscriptions']) {
claims['fxa-subscriptions'] = grant['fxa-subscriptions'].join(' ');
}
return {
...accessToken,
jwt_token: await exports.sign(claims),

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

@ -0,0 +1,103 @@
/* 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/. */
'use strict';
const { assert } = require('chai');
const nock = require('nock');
const auth_serverModule = require('../lib/auth_server');
const mockConfig = {
publicUrl: 'https://accounts.example.com',
auth: {
poolee: {},
jwtSecretKey: 'secret-key-oh-secret-key',
url: 'https://auth.server.com',
},
domain: 'accounts.example.com',
};
describe('lib/auth_server', () => {
const mockLog = {
error() {},
trace() {},
warn() {},
};
const authServer = auth_serverModule(mockLog, mockConfig);
let mockAuthServer;
beforeEach(() => {
mockAuthServer = nock(mockConfig.auth.url).defaultReplyHeaders({
'Content-Type': 'application/json',
});
});
describe('getUserProfile', () => {
it('gets the user profile', async () => {
mockAuthServer.get('/v1/account/profile').reply(200, {
authenticationMethods: ['password'],
authenticatorAssuranceLevel: 1,
email: 'testuser@testuser.com',
ignored: true,
locale: 'fr',
profileChangedAt: 1234,
subscriptions: ['subscription1'],
});
const profile = await authServer.getUserProfile({
uid: 'uid',
client_id: 'deadbeef',
scope: 'profile',
});
assert.deepEqual(profile.authenticationMethods, ['password']);
assert.equal(profile.authenticatorAssuranceLevel, 1);
assert.equal(profile.email, 'testuser@testuser.com');
assert.notProperty(profile, 'ignored');
assert.equal(profile.locale, 'fr');
assert.equal(profile.profileChangedAt, 1234);
assert.deepEqual(profile.subscriptions, ['subscription1']);
});
it('returns correct error for invalidToken', async () => {
mockAuthServer.get('/v1/account/profile').reply(400, {
code: 400,
errno: 110,
message: 'Invalid token',
});
try {
await authServer.getUserProfile({
uid: 'uid',
client_id: 'deadbeef',
scope: 'profile',
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, 108);
assert.equal(err.message, 'Invalid token');
}
});
it('validates the response data', async () => {
mockAuthServer.get('/v1/account/profile').reply(200, {
authenticatorAssuranceLevel: 'AAL1',
});
try {
await authServer.getUserProfile({
uid: 'uid',
client_id: 'deadbeef',
scope: 'profile',
});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.message, 'Invalid request parameter');
assert.equal(
err.output.payload.validation,
'authenticatorAssuranceLevel'
);
}
});
});
});

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

@ -224,6 +224,7 @@ describe('validateRequestedGrant', () => {
describe('generateTokens', () => {
let mockAccessToken;
let mockAuthServer;
let mockAmplitude;
let mockConfig;
let mockDB;
@ -235,7 +236,11 @@ describe('generateTokens', () => {
let grantModule;
beforeEach(() => {
scope = ScopeSet.fromArray(['profile:uid', 'profile:email']);
scope = ScopeSet.fromArray([
'profile:uid',
'profile:email',
'profile:subscriptions',
]);
mockAccessToken = {
expiresAt: Date.now() + 1000,
@ -247,10 +252,14 @@ describe('generateTokens', () => {
requestedGrant = {
clientId: Buffer.from('0123456789', 'hex'),
scope,
userId: Buffer.from('bar'),
userId: Buffer.from('ABCDEF123456', 'hex'),
};
mockAmplitude = sinon.spy();
mockAuthServer = {
getUserProfile: sinon.spy(async () => ({ subscriptions: ['my-sub'] })),
};
mockDB = {
generateAccessToken: sinon.spy(async () => mockAccessToken),
generateIdToken: sinon.spy(async () => ({ token: 'id_token' })),
@ -283,6 +292,7 @@ describe('generateTokens', () => {
};
grantModule = proxyquire('../lib/grant', {
'./auth_server': () => mockAuthServer,
'./config': mockConfig,
'./db': mockDB,
'./jwt_access_token': mockJWTAccessToken,
@ -300,7 +310,10 @@ describe('generateTokens', () => {
assert.strictEqual(result.access_token, 'token');
assert.isNumber(result.expires_in);
assert.strictEqual(result.token_type, 'access_token');
assert.strictEqual(result.scope, 'profile:uid profile:email');
assert.strictEqual(
result.scope,
'profile:uid profile:email profile:subscriptions'
);
assert.isFalse('auth_at' in result);
assert.isFalse('keys_jwe' in result);
@ -312,15 +325,28 @@ describe('generateTokens', () => {
requestedGrant.clientId = '9876543210';
const result = await generateTokens(requestedGrant);
assert.isTrue(mockDB.generateAccessToken.calledOnceWith(requestedGrant));
assert.isTrue(
mockAuthServer.getUserProfile.calledOnceWith({
client_id: '9876543210',
scope: 'profile:subscriptions',
uid: 'abcdef123456',
})
);
assert.strictEqual(result.access_token, 'signed jwt access token');
assert.isTrue(
mockJWTAccessToken.create.calledOnceWith(mockAccessToken, requestedGrant)
mockJWTAccessToken.create.calledOnceWith(mockAccessToken, {
...requestedGrant,
'fxa-subscriptions': ['my-sub'],
})
);
assert.isNumber(result.expires_in);
assert.strictEqual(result.token_type, 'access_token');
assert.strictEqual(result.scope, 'profile:uid profile:email');
assert.strictEqual(
result.scope,
'profile:uid profile:email profile:subscriptions'
);
assert.isFalse('auth_at' in result);
assert.isFalse('keys_jwe' in result);

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

@ -77,6 +77,18 @@ describe('lib/jwt_access_token', () => {
'https://resource.server1.com',
]);
});
it('should propagate `fxa-subscriptions`', async () => {
requestedGrant['fxa-subscriptions'] = ['subscription1', 'subscription2'];
await JWTAccessToken.create(mockAccessToken, requestedGrant);
const signedClaims = mockJWT.sign.args[0][0];
assert.lengthOf(Object.keys(signedClaims), 8);
assert.deepEqual(
signedClaims['fxa-subscriptions'],
'subscription1 subscription2'
);
});
});
describe('tokenId', () => {

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

@ -116,6 +116,7 @@ module.exports = function createBackendServiceAPI(
const querySchema = Joi.compile(validation.query || Joi.object());
const payloadSchema = Joi.compile(validation.payload || Joi.object());
const responseSchema = Joi.compile(validation.response || Joi.any());
const headerSchema = Joi.compile(validation.headers || Joi.object());
let expectedNumArgs = path.params().length;
if (validation.query) {
@ -124,6 +125,9 @@ module.exports = function createBackendServiceAPI(
if (validation.payload) {
expectedNumArgs += 1;
}
if (validation.headers) {
expectedNumArgs += 1;
}
const fullMethodName = `${serviceName}.${methodName}`;
@ -211,6 +215,10 @@ module.exports = function createBackendServiceAPI(
? null
: {};
const headers = validation.headers
? await validate('headers', args[i++], headerSchema)
: {};
const startTime = Date.now();
// Unexpected extra fields in the service response should not be a fatal error,
@ -222,7 +230,7 @@ module.exports = function createBackendServiceAPI(
params,
query,
payload,
this._headers
{ ...this._headers, ...headers }
);
// The statsD dependency is optional

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

@ -4,8 +4,7 @@
'use strict';
const P = require('../promise');
const signJWT = P.promisify(require('jsonwebtoken').sign);
const { signJWT } = require('../serverJWT');
const error = require('../error');
@ -89,12 +88,6 @@ module.exports = {
if (credentials.mustVerify && !credentials.tokenVerified) {
throw error.unverifiedSession();
}
const opts = {
algorithm: 'HS256',
expiresIn: 60,
audience: config.oauth.url,
issuer: config.domain,
};
const claims = {
sub: credentials.uid,
'fxa-generation': credentials.verifierSetAt,
@ -106,6 +99,12 @@ module.exports = {
'fxa-aal': credentials.authenticatorAssuranceLevel,
'fxa-profileChangedAt': credentials.profileChangedAt,
};
return signJWT(claims, config.oauth.secretKey, opts);
return signJWT(
claims,
config.oauth.url,
config.domain,
config.oauth.secretKey,
60
);
},
};

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

@ -966,7 +966,7 @@ module.exports = (
path: '/account/profile',
options: {
auth: {
strategies: ['sessionToken', 'oauthToken'],
strategies: ['sessionToken', 'oauthToken', 'oauthServerJWT'],
},
response: {
schema: {

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

@ -4,12 +4,12 @@
'use strict';
const AppError = require('./error');
const AppError = require('../../error');
const joi = require('joi');
const validators = require('./routes/validators');
const { BEARER_AUTH_REGEX } = require('./routes/validators');
const { OAUTH_SCOPE_OLD_SYNC } = require('./constants');
const ScopeSet = require('../../fxa-shared').oauth.scopes;
const validators = require('../validators');
const { BEARER_AUTH_REGEX } = validators;
const { OAUTH_SCOPE_OLD_SYNC } = require('../../constants');
const ScopeSet = require('../../../../fxa-shared').oauth.scopes;
// the refresh token scheme is currently used by things connected to sync,
// and we're at a transitionary stage of its evolution into something more generic,

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

@ -0,0 +1,41 @@
/* 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/. */
'use strict';
const { verifyJWT } = require('../../serverJWT');
// the serverJWT scheme is used to authenticate requests between
// from the OAuth to Auth server using a JWT in the authorization
// header. The authorization header must have the `OAuthJWT` prefix
module.exports = function schemeServerJWTScheme(audience, issuer, keys, error) {
return function schemeServerJWT(server, options) {
return {
async authenticate(request, h) {
// Trying to re-use "Bearer" as the prefix failed, Hapi thought
// we were trying to use normal OAuth tokens. So, a new prefix.
if (!/^OAuthJWT /.test(request.headers.authorization)) {
throw error.invalidToken();
}
const jwt = request.headers.authorization.replace('OAuthJWT ', '');
let claims;
try {
claims = await verifyJWT(jwt, audience, issuer, keys);
} catch (e) {
throw error.invalidToken();
}
return h.authenticated({
credentials: {
client_id: claims.client_id,
scope: claims.scope.split(/\s+/),
user: claims.sub,
},
});
},
};
};
};

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

@ -12,8 +12,8 @@ const Raven = require('raven');
const path = require('path');
const url = require('url');
const userAgent = require('./userAgent');
const schemeRefreshToken = require('./scheme-refresh-token');
const schemeRefreshToken = require('./routes/auth-schemes/refresh-token');
const schemeServerJWT = require('./routes/auth-schemes/serverJWT');
const { HEX_STRING, IP_ADDRESS } = require('./routes/validators');
function trimLocale(header) {
@ -382,6 +382,17 @@ async function create(log, error, config, routes, db, oauthdb, translator) {
}));
server.auth.strategy('subscriptionsSecret', 'subscriptionsSecret');
server.auth.scheme(
'fxa-oauthServerJWT',
schemeServerJWT(
config.publicUrl,
config.oauth.url,
config.oauth.jwtSecretKeys,
error
)
);
server.auth.strategy('oauthServerJWT', 'fxa-oauthServerJWT');
// routes should be registered after all auth strategies have initialized:
// ref: http://hapijs.com/tutorials/auth

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

@ -0,0 +1,55 @@
/* 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/. */
/**
* signJWT/verifyJWT functions for use with server to server JWTs
*/
const util = require('util');
const jsonwebtoken = require('jsonwebtoken');
const verifyJwt = util.promisify(jsonwebtoken.verify);
const signJwt = util.promisify(jsonwebtoken.sign);
exports.signJWT = async function signJWT(
claims,
audience,
issuer,
key,
expiresIn = 60
) {
const opts = {
algorithm: 'HS256',
expiresIn,
audience,
issuer,
};
return signJwt(claims, key, opts);
};
// Verify a JWT assertion.
// Since it's just a symmetric HMAC signature,
// this should be safe and performant enough to do in-process.
exports.verifyJWT = async function verifyJWT(jwt, audience, issuer, keys) {
const opts = {
algorithms: ['HS256'],
audience,
issuer,
};
// To allow for key rotation, we may have
// several valid shared secret keys in-flight.
for (const key of keys) {
try {
return await verifyJwt(jwt, key, opts);
} catch (err) {
// Any error other than 'invalid signature' will not
// be resolved by trying the remaining keys.
if (err.message !== 'invalid signature') {
throw err;
}
}
}
// None of the keys worked, clearly invalid.
throw new Error('Invalid jwt');
};

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

@ -80,6 +80,16 @@ describe('createBackendServiceAPI', () => {
},
},
},
testGetWithHeaders: {
method: 'GET',
path: '/test_get_with_headers',
validate: {
headers: {
foo: Joi.string().required(),
},
},
},
});
api = new Service(mockServiceURL);
});
@ -262,6 +272,44 @@ describe('createBackendServiceAPI', () => {
}
});
it('validates headers', async () => {
try {
// inalid header type
await api.testGetWithHeaders({ foo: 1 });
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.INTERNAL_VALIDATION_ERROR);
}
try {
// missing header
await api.testGetWithHeaders({});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.errno, error.ERRNO.INTERNAL_VALIDATION_ERROR);
}
try {
// no headers
await api.testGetWithHeaders();
assert.fail('should have thrown');
} catch (err) {
assert.equal(
err.message,
'mock-service.testGetWithHeaders must be called with 1 arguments (0 given)'
);
}
mockService
.get('/test_get_with_headers', body => true)
.reply(200, {
status: 200,
message: 'ok',
});
await api.testGetWithHeaders({ foo: 'buz' });
});
it('validates response body', async () => {
let requestBody;
mockService

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

@ -5,14 +5,14 @@
'use strict';
const { assert } = require('chai');
const error = require('../../lib/error');
const schemeRefreshToken = require('../../lib/scheme-refresh-token');
const error = require('../../../../lib/error');
const schemeRefreshToken = require('../../../../lib/routes/auth-schemes/refresh-token');
const sinon = require('sinon');
const OAUTH_CLIENT_ID = '3c49430b43dfba77';
const OAUTH_CLIENT_NAME = 'Android Components Reference Browser';
describe('lib/scheme-refresh-token', () => {
describe('lib/routes/auth-schemes/refresh-token', () => {
let config;
let db;
let oauthdb;

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

@ -0,0 +1,130 @@
/* 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/. */
'use strict';
const { assert } = require('chai');
const error = require('../../../../lib/error');
const sinon = require('sinon');
const proxyquire = require('proxyquire');
describe('lib/routes/auth-schemes/serverJWT', () => {
describe('not a JWT', () => {
it('returns an invalidToken error', async () => {
const verifyMock = sinon.spy(() =>
Promise.reject(new Error('should not be called'))
);
const scheme = proxyquire(
'../../../../lib/routes/auth-schemes/serverJWT',
{
'../../serverJWT': {
verifyJWT: verifyMock,
},
}
)('audience', 'issuer', ['current'], error)();
try {
await scheme.authenticate(
{
headers: {
authorization: 'not-a-jwt',
},
},
{
authenticated: arg => arg,
}
);
assert.fail('this should have thrown');
} catch (err) {
assert.equal(
err.message,
'Invalid authentication token in request signature'
);
}
assert.isFalse(verifyMock.called);
});
});
describe('invalid JWT', () => {
it('returns an invalidToken error', async () => {
const verifyMock = sinon.spy(() => Promise.reject(new Error('invalid')));
const scheme = proxyquire(
'../../../../lib/routes/auth-schemes/serverJWT',
{
'../../serverJWT': {
verifyJWT: verifyMock,
},
}
)('audience', 'issuer', ['current'], error)();
try {
await scheme.authenticate(
{
headers: {
authorization: 'OAuthJWT j.w.t',
},
},
{
authenticated: arg => arg,
}
);
assert.fail('this should have thrown');
} catch (err) {
assert.equal(
err.message,
'Invalid authentication token in request signature'
);
}
assert.isTrue(
verifyMock.calledOnceWith('j.w.t', 'audience', 'issuer', ['current'])
);
});
});
describe('valid JWT', () => {
it('calls authenticated with the expected param', async () => {
const verifyMock = sinon.spy(() => {
return Promise.resolve({
client_id: 'foo',
scope: 'scope1 scope2',
sub: 'bar',
});
});
const scheme = proxyquire(
'../../../../lib/routes/auth-schemes/serverJWT',
{
'../../serverJWT': {
verifyJWT: verifyMock,
},
}
)('audience', 'issuer', ['current'], error)();
const result = await scheme.authenticate(
{
headers: {
authorization: 'OAuthJWT j.w.t',
},
},
{
authenticated: arg => arg,
}
);
assert.deepEqual(result, {
credentials: {
client_id: 'foo',
scope: ['scope1', 'scope2'],
user: 'bar',
},
});
assert.isTrue(
verifyMock.calledOnceWith('j.w.t', 'audience', 'issuer', ['current'])
);
});
});
});

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

@ -0,0 +1,155 @@
/* 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/. */
'use strict';
const { assert } = require('chai');
const proxyquire = require('proxyquire');
const sinon = require('sinon');
describe('lib/serverJWT', () => {
describe('signJWT', () => {
it('signs the JWT', async () => {
const jsonwebtokenMock = {
sign: sinon.spy(function(claims, key, opts, callback) {
callback(null, 'j.w.t');
}),
};
const serverJWT = proxyquire('../../lib/serverJWT', {
jsonwebtoken: jsonwebtokenMock,
});
const jwt = await serverJWT.signJWT({ foo: 'bar' }, 'biz', 'buz', 'zoom');
assert.equal(jwt, 'j.w.t');
assert.isTrue(
jsonwebtokenMock.sign.calledOnceWith({ foo: 'bar' }, 'zoom', {
algorithm: 'HS256',
expiresIn: 60,
audience: 'biz',
issuer: 'buz',
})
);
});
});
describe('verifyJWT', () => {
describe('signed with the current key', () => {
it('returns the claims', async () => {
const jsonwebtokenMock = {
verify: sinon.spy(function(jwt, key, opts, callback) {
callback(null, { sub: 'foo' });
}),
};
const serverJWT = proxyquire('../../lib/serverJWT', {
jsonwebtoken: jsonwebtokenMock,
});
const claims = await serverJWT.verifyJWT('j.w.t', 'foo', 'bar', [
'current',
'old',
]);
assert.deepEqual(claims, { sub: 'foo' });
assert.isTrue(
jsonwebtokenMock.verify.calledOnceWith('j.w.t', 'current', {
algorithms: ['HS256'],
audience: 'foo',
issuer: 'bar',
})
);
});
});
describe('signed with an old key', () => {
it('returns the claims', async () => {
const jsonwebtokenMock = {
verify: sinon.spy(function(jwt, key, opts, callback) {
if (key === 'current') {
callback(new Error('invalid signature'));
} else {
callback(null, { sub: 'foo' });
}
}),
};
const serverJWT = proxyquire('../../lib/serverJWT', {
jsonwebtoken: jsonwebtokenMock,
});
const claims = await serverJWT.verifyJWT('j.w.t', 'foo', 'bar', [
'current',
'old',
]);
assert.deepEqual(claims, { sub: 'foo' });
assert.isTrue(jsonwebtokenMock.verify.calledTwice);
let args = jsonwebtokenMock.verify.args[0];
assert.equal(args[0], 'j.w.t');
assert.equal(args[1], 'current');
assert.deepEqual(args[2], {
algorithms: ['HS256'],
audience: 'foo',
issuer: 'bar',
});
args = jsonwebtokenMock.verify.args[1];
assert.equal(args[0], 'j.w.t');
assert.equal(args[1], 'old');
assert.deepEqual(args[2], {
algorithms: ['HS256'],
audience: 'foo',
issuer: 'bar',
});
});
});
describe('no key found', () => {
it('throws an `Invalid jwt` error', async () => {
const jsonwebtokenMock = {
verify: sinon.spy(function(jwt, key, opts, callback) {
callback(new Error('invalid signature'));
}),
};
const serverJWT = proxyquire('../../lib/serverJWT', {
jsonwebtoken: jsonwebtokenMock,
});
try {
await serverJWT.verifyJWT('j.w.t', 'foo', 'bar', ['current', 'old']);
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.message, 'Invalid jwt');
}
});
});
describe('invalid JWT', () => {
it('re-throw the verification error', async () => {
const jsonwebtokenMock = {
verify: sinon.spy(function(jwt, key, opts, callback) {
callback(new Error('invalid sub'));
}),
};
const serverJWT = proxyquire('../../lib/serverJWT', {
jsonwebtoken: jsonwebtokenMock,
});
try {
await serverJWT.verifyJWT('j.w.t', 'foo', 'bar', ['current', 'old']);
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.message, 'invalid sub');
}
});
});
});
});