зеркало из https://github.com/mozilla/fxa.git
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:
Родитель
a1a0036f86
Коммит
9b1e28e45d
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Загрузка…
Ссылка в новой задаче