fix(scopes): Document scope-handling rules, use shared code to enforce them.

This commit is contained in:
Ryan Kelly 2018-05-02 13:44:57 +10:00
Родитель adfff658c4
Коммит d7ea1fc239
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: A540F07051DE174A
26 изменённых файлов: 1834 добавлений и 2191 удалений

199
docs/scopes.md Normal file
Просмотреть файл

@ -0,0 +1,199 @@
# OAuth Scopes
Each authorization grant in OAuth has an associated "scope",
a list containing one or more "scope values"
that indicate what capabilities the granted token will have.
Each individual scope value indicates a particular capability,
such as the ability to read or write profile data,
or to access the user's data in a particular service.
As defined in [RFC6749 Section 3.3](https://tools.ietf.org/html/rfc6749#section-3.3),
the scope of a token is expressed as
a list of space-delimited, case-sensitive strings,
and it is left up to the service
to define the format and semantics
of the individual scope values
that make up this string.
This document defines the scope values
accepted in the Firefox Accounts ecosystem,
and the rules for parsing and validating them.
## Short-name scope values
FxA supports a small set of "short-name" scope values
that are identified by a short English word.
These correspond either to
scope values defined by external specifications
(e.g. OpenID Connect),
or to legacy scope values
introduced during early development.
**No new short-name scope values should be added.**
Instead we prefer to use URLs for new scope values,
both to ensure uniqueness
and to simplify parsing rules.
Short-name scope values imply read-only access by default,
with write access indicated by the suffix ":write".
The may also have "sub-scopes"
to indicate finer-grained access control.
Each name component may contain only ascii alphanumeric characters
and the underscore.
For example:
* `profile` indicates read-only access
to the user's profile data.
* `profile:write` indicates read/write access
to the user's profile data.
* `profile:display_name` indicates read-only access
to the user's display name, but not any other
profile data.
* `profile:email:write` indicates read/write access
to the user's email address.
The following short-name scope values are recognized
in the FxA ecosystem.
### Profile data
* `profile`: access the user's profile data.
* `profile:uid`: access the user's opaque user id.
* `profile:email`: access the user's email address.
* `profile:locale`: access the user's locale.
* `profile:avatar`: access the user's avatar picture.
* `profile:display_name`: access the user's human-readable display name.
* `profile:amr`: access information about the user's authentication methods and 2FA status.
### OpenID Connect
* `openid`: used to request an OpenID Connect `id_token`.
* `email`: a synonym for `profile:email`, defined by the OIDC spec.
### OAuth Client Management
* `clients`: access the list of OAuth clients connected to a user's account.
* `oauth`: register a new OAuth client record.
### Basket
* `basket`: access the user's subscription data in
[basket](http://basket.readthedocs.io/)
## URL Scopes
For new capabilities, scope values are represented as URLs.
This helps to ensure uniqueness
and reduces ambiguity in parsing.
URL-format scope value imply read/write access by default,
are compared as heirarchical resource references,
and use the hash fragment for permission qualifiers.
For example:
* `https://identity.mozilla.com/apps/oldsync` indicates full
access to the user's data in Firefox Sync.
* `https://identity.mozilla.com/apps/oldsync/bookmarks` indicates
full access to the user's bookmark data in Firefox Sync,
but not to other data types.
* `https://identity.mozilla.com/apps/oldsync#read` indicates
read-only access to the user's data in Firefox Sync.
* `https://identity.mozilla.com/apps/oldsync/history#write` indicates
write-only access to the user's history data in Firefox Sync.
To be a valid scope value, the URL must:
* Be an absolute `https://` URL.
* Have no username, password, or query component.
* If present, have a fragment component consisting only of alphanumeric ascii characters and underscore.
* Remain unchanged when parsed and serialized following the rules in the
[WhatWG URL Spec](https://url.spec.whatwg.org).
The following URL scope values are currently recognized by FxA:
* `https://identity.mozilla.com/apps/oldsync`: access to data in Firefox Sync.
* `https://identity.mozilla.com/apps/notes`: access to data in Firefox Notes.
## Scope Matching and Implication
We say that a scope value A *implies* another scope value B
if they are exactly equal,
or if A represents a more general capability than B.
Similarly, a scope A implies scope value B
if there is some scope value in A that implies B.
This is the basic operation used to check
permissions when processing an OAuth token.
Consumers of OAuth tokens should avoid
directly parsing and comparing scopes where possible,
and instead use the existing implementation
in the `fxa-shared` node module.
For consumers that must implement their own scope checking,
the rules for implication can be summarized as:
* For URL scope values, A implies B if A is a parent resource of B.
* For short-name scope values, split on the ":" character,
and A implies B if either:
* B[-1] is not "write" and A is a prefix of B, or.
* A[-1] is "write", and:
* A[:-1] is a prefix of B, or
* B[-1] is "write" and A[:-1] is a prefix of B[:-1]
More precisely, the algoritm for checking implication is:
* If A is a `https://` URL, then:
* If B is not a `https://` URL, then fail.
* If the origin of B is different than that of A, then fail.
* If the path component list of A is not a prefix of the path
component list of B, then fail.
* If A has a fragment, then:
* If B does not have a fragment, then fail.
* If B has a fragment that differs from A, then fail.
* Otherwise, succeed.
* Otherwise:
* If B is a `https://` URL, then fail.
* Split A and B into components based on `:` delimiter.
* If the last component of B is `write`, then:
* If the last component of A is not `write`, then fail.
* If the last component of A is `write`, remove it.
* If A is not a prefix of B, then fail.
* Otherwise, succeed.
Below are some testcases against which
scope-checking code can be validated.
Valid implications:
* `profile:write` implies `profile`.
* `profile` implies `profile:email`.
* `profile:write` implies `profile:email`.
* `profile:write` implies `profile:email:write`.
* `profile:email:write` implies `profile:email`.
* `profile profile:email:write` implies `profile:email`.
* `profile profile:email:write` implies `profile:display_name`.
* `profile https://identity.mozilla.com/apps/oldsync` implies `profile`.
* `profile https://identity.mozilla.com/apps/oldsync` implies `https://identity.mozilla.com/apps/oldsync`.
* `https://identity.mozilla.com/apps/oldsync` implies `https://identity.mozilla.com/apps/oldsync#read`.
* `https://identity.mozilla.com/apps/oldsync` implies `https://identity.mozilla.com/apps/oldsync/bookmarks`.
* `https://identity.mozilla.com/apps/oldsync` implies `https://identity.mozilla.com/apps/oldsync/bookmarks#read`.
* `https://identity.mozilla.com/apps/oldsync#read` implies `https://identity.mozilla.com/apps/oldsync/bookmarks#read`.
* `https://identity.mozilla.com/apps/oldsync#read profile` implies `https://identity.mozilla.com/apps/oldsync/bookmarks#read`.
Invalid implications:
* `profile:email:write` does *not* imply `profile`.
* `profile:email:write` does *not* imply `profile:write`.
* `profile:email` does *not* imply `profile:display_name`.
* `profilebogey` does *not* imply `profile`.
* `profile:write` does *not* imply `https://identity.mozilla.com/apps/oldsync`.
* `profile profile:email:write` does *not* imply `profile:write`.
* `https` does *not* imply `https://identity.mozilla.com/apps/oldsync`.
* `https://identity.mozilla.com/apps/oldsync` does *not* imply `profile`.
* `https://identity.mozilla.com/apps/oldsync#read` does *not* imply `https://identity.mozilla.com/apps/oldsync/bookmarks`.
* `https://identity.mozilla.com/apps/oldsync#write` does *not* imply `https://identity.mozilla.com/apps/oldsync/bookmarks#read`.
* `https://identity.mozilla.com/apps/oldsync/bookmarks` does *not* imply `https://identity.mozilla.com/apps/oldsync`.
* `https://identity.mozilla.com/apps/oldsync/bookmarks` does *not* imply `https://identity.mozilla.com/apps/oldsync/passwords`.
* `https://identity.mozilla.com/apps/oldsyncer` does *not* imply `https://identity.mozilla.com/apps/oldsync`.
* `https://identity.mozilla.com/apps/oldsync` does *not* imply `https://identity.mozilla.com/apps/oldsyncer`.
* `https://identity.mozilla.org/apps/oldsync` does *not* imply `https://identity.mozilla.com/apps/oldsync`.

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

@ -2,6 +2,8 @@
* 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 ScopeSet = require('fxa-shared').oauth.scopes;
const AppError = require('./error');
const logger = require('./logging')('server.auth');
const token = require('./token');
@ -15,7 +17,7 @@ const WHITELIST = require('./config').get('admin.whitelist').map(function(re) {
exports.AUTH_STRATEGY = 'dogfood';
exports.AUTH_SCHEME = 'bearer';
exports.SCOPE_CLIENT_MANAGEMENT = 'oauth';
exports.SCOPE_CLIENT_MANAGEMENT = ScopeSet.fromArray(['oauth']);
exports.strategy = function() {
return {
@ -32,7 +34,7 @@ exports.strategy = function() {
}
token.verify(tok).done(function tokenFound(details) {
if (details.scope.indexOf(exports.SCOPE_CLIENT_MANAGEMENT) !== -1) {
if (details.scope.contains(exports.SCOPE_CLIENT_MANAGEMENT)) {
logger.debug('check.whitelist');
var blocked = ! WHITELIST.some(function(re) {
return re.test(details.email);
@ -47,6 +49,7 @@ exports.strategy = function() {
}
logger.info('success', details);
details.scope = details.scope.getScopeValues();
reply.continue({
credentials: details
});

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

@ -2,13 +2,15 @@
* 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 ScopeSet = require('fxa-shared').oauth.scopes;
const AppError = require('./error');
const logger = require('./logging')('server.auth_bearer');
const token = require('./token');
const validators = require('./validators');
const authName = 'authBearer';
const authOAuthScope = 'clients:write';
const authOAuthScope = ScopeSet.fromArray(['clients:write']);
exports.AUTH_STRATEGY = authName;
exports.AUTH_SCHEME = authName;
@ -32,6 +34,7 @@ exports.strategy = function() {
token.verify(tok).done(function tokenFound(details) {
logger.info(authName + '.success', details);
details.scope = details.scope.getScopeValues();
reply.continue({
credentials: details
});

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

@ -7,6 +7,7 @@
**/
const unbuf = require('buf').unbuf.hex;
const ScopeSet = require('fxa-shared').oauth.scopes;
module.exports = {
/**
@ -32,7 +33,6 @@ module.exports = {
var activeClients = {};
activeClientTokens.forEach(function (clientTokenObj) {
var clientIdHex = unbuf(clientTokenObj.id);
var scope = String(clientTokenObj.scope).split(/[\s,]+/);
if (! activeClients[clientIdHex]) {
// add the OAuth client if not already in the Object
@ -40,14 +40,11 @@ module.exports = {
id: clientTokenObj.id,
name: clientTokenObj.name,
lastAccessTime: clientTokenObj.createdAt,
scope: new Set()
scope: ScopeSet.fromArray([])
};
}
scope.forEach(function (clientScope) {
// aggregate the scopes from all available tokens
activeClients[clientIdHex].scope.add(clientScope);
});
activeClients[clientIdHex].scope.add(clientTokenObj.scope);
var clientTokenTime = clientTokenObj.createdAt;
if (clientTokenTime > activeClients[clientIdHex].lastAccessTime) {
@ -58,8 +55,7 @@ module.exports = {
// Sort the scopes alphabetically, convert the Object structure to an array
var activeClientsArray = Object.keys(activeClients).map(function (key) {
var scopes = activeClients[key].scope;
activeClients[key].scope = Array.from(scopes.values()).sort();
activeClients[key].scope = activeClients[key].scope.getScopeValues().sort();
return activeClients[key];
});

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

@ -282,7 +282,7 @@ MemoryStore.prototype = {
id: this.tokens[id].clientId,
createdAt: this.tokens[id].createdAt,
name: client.name,
scope: String(this.tokens[id].scope)
scope: this.tokens[id].scope
});
}
}

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

@ -14,7 +14,7 @@ const config = require('../../config');
const encrypt = require('../../encrypt');
const helpers = require('../helpers');
const P = require('../../promise');
const Scope = require('../../scope');
const ScopeSet = require('fxa-shared').oauth.scopes;
const unique = require('../../unique');
const patch = require('./patch');
@ -386,7 +386,7 @@ MysqlStore.prototype = {
codeObj.clientId,
codeObj.userId,
codeObj.email,
codeObj.scope.join(' '),
codeObj.scope.toString(),
codeObj.authAt,
codeObj.amr ? codeObj.amr.join(',') : null,
codeObj.aal || null,
@ -403,7 +403,7 @@ MysqlStore.prototype = {
var hash = encrypt.hash(code);
return this._readOne(QUERY_CODE_FIND, [hash]).then(function(code) {
if (code) {
code.scope = code.scope.split(' ');
code.scope = ScopeSet.fromString(code.scope);
if (code.amr !== null) {
code.amr = code.amr.split(',');
}
@ -420,7 +420,7 @@ MysqlStore.prototype = {
clientId: buf(vals.clientId),
userId: buf(vals.userId),
email: vals.email,
scope: Scope(vals.scope),
scope: vals.scope,
token: unique.token(),
type: 'bearer',
expiresAt: vals.expiresAt || new Date(Date.now() + (vals.ttl * 1000 || MAX_TTL))
@ -446,7 +446,7 @@ MysqlStore.prototype = {
getAccessToken: function getAccessToken(id) {
return this._readOne(QUERY_ACCESS_TOKEN_FIND, [buf(id)]).then(function(t) {
if (t) {
t.scope = t.scope.split(' ');
t.scope = ScopeSet.fromString(t.scope);
}
return t;
});
@ -470,6 +470,9 @@ MysqlStore.prototype = {
return this._read(QUERY_ACTIVE_CLIENT_TOKENS_BY_UID, [
buf(uid)
]).then(function(activeClientTokens) {
activeClientTokens.forEach(t => {
t.scope = ScopeSet.fromString(t.scope);
});
return helpers.aggregateActiveClients(activeClientTokens);
});
},
@ -509,7 +512,7 @@ MysqlStore.prototype = {
clientId: vals.clientId,
userId: vals.userId,
email: vals.email,
scope: Scope(vals.scope)
scope: vals.scope
};
var token = unique.token();
var hash = encrypt.hash(token);
@ -529,7 +532,7 @@ MysqlStore.prototype = {
return this._readOne(QUERY_REFRESH_TOKEN_FIND, [buf(token)])
.then(function(t) {
if (t) {
t.scope = t.scope.split(' ');
t.scope = ScopeSet.fromString(t.scope);
}
return t;
});

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

@ -12,7 +12,7 @@ const config = require('../config');
const db = require('../db');
const logger = require('../logging')('routes.authorization');
const P = require('../promise');
const Scope = require('../scope');
const ScopeSet = require('fxa-shared').oauth.scopes;
const validators = require('../validators');
const verify = require('../browserid');
@ -27,12 +27,12 @@ const PKCE_CODE_CHALLENGE_LENGTH = 43;
const MAX_TTL_S = config.get('expiration.accessToken') / 1000;
const UNTRUSTED_CLIENT_ALLOWED_SCOPES = [
const UNTRUSTED_CLIENT_ALLOWED_SCOPES = ScopeSet.fromArray([
'openid',
'profile:uid',
'profile:email',
'profile:display_name'
];
]);
const allowHttpRedirects = config.get('allowHttpRedirects');
@ -50,18 +50,6 @@ function isLocalHost(url) {
return host === 'localhost' || host === '127.0.0.1';
}
function detectInvalidScopes(requestedScopes, validScopes) {
var invalidScopes = [];
requestedScopes.forEach(function(scope) {
if (validScopes.indexOf(scope) === -1) {
invalidScopes.push(scope);
}
});
return invalidScopes;
}
function generateCode(claims, client, scope, req) {
return db.generateCode({
clientId: client.id,
@ -113,7 +101,7 @@ function generateGrant(claims, client, scope, req) {
access_token: hex(token.token),
token_type: 'bearer',
expires_in: Math.floor((token.expiresAt - Date.now()) / 1000),
scope: scope.join(' '),
scope: scope.toString(),
auth_at: claims['fxa-lastAuthAt']
};
});
@ -230,7 +218,7 @@ module.exports = {
var start = Date.now();
var wantsGrant = req.payload.response_type === TOKEN;
var exitEarly = false;
var scope = Scope(req.payload.scope || []);
var scope = ScopeSet.fromString(req.payload.scope || '');
P.all([
verify(req.payload.assertion).then(function(claims) {
logger.info('time.browserid_verify', { ms: Date.now() - start });
@ -241,7 +229,7 @@ module.exports = {
// Any request for a key-bearing scope should be using a verified token.
// Double-check that here as a defense-in-depth measure.
if (! claims['fxa-tokenVerified']) {
return P.each(scope.values(), scope => {
return P.each(scope.getScopeValues(), scope => {
// Don't bother hitting the DB if other checks have failed.
if (exitEarly) {
return;
@ -272,11 +260,9 @@ module.exports = {
logger.debug('notFound', { id: req.payload.client_id });
throw AppError.unknownClient(req.payload.client_id);
} else if (! client.trusted) {
var invalidScopes = detectInvalidScopes(scope.values(),
UNTRUSTED_CLIENT_ALLOWED_SCOPES);
if (invalidScopes.length) {
throw AppError.invalidScopes(invalidScopes);
var invalidScopes = scope.difference(UNTRUSTED_CLIENT_ALLOWED_SCOPES);
if (! invalidScopes.isEmpty()) {
throw AppError.invalidScopes(invalidScopes.getScopeValues());
}
}
@ -312,7 +298,7 @@ module.exports = {
exitEarly = true;
throw err;
}),
scope.values(),
scope,
req
])
.spread(wantsGrant ? generateGrant : generateCode)

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

@ -8,7 +8,7 @@ const SCOPE_CLIENT_WRITE = require('../../auth_bearer').SCOPE_CLIENT_WRITE;
module.exports = {
auth: {
strategy: 'authBearer',
scope: [SCOPE_CLIENT_WRITE]
scope: SCOPE_CLIENT_WRITE.getImplicantValues()
},
handler: function activeServices(req, reply) {
var clientId = req.params.client_id;

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

@ -27,7 +27,7 @@ function serialize(client, acceptLanguage) {
module.exports = {
auth: {
strategy: 'authBearer',
scope: [SCOPE_CLIENT_WRITE]
scope: SCOPE_CLIENT_WRITE.getImplicantValues()
},
handler: function activeServices(req, reply) {
return db.getActiveClientsByUid(req.auth.credentials.user)

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

@ -10,7 +10,7 @@ const AppError = require('../../error');
module.exports = {
auth: {
strategy: auth.AUTH_STRATEGY,
scope: [auth.SCOPE_CLIENT_MANAGEMENT]
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues()
},
validate: {
params: {

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

@ -23,7 +23,7 @@ function serialize(client) {
module.exports = {
auth: {
strategy: auth.AUTH_STRATEGY,
scope: [auth.SCOPE_CLIENT_MANAGEMENT]
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues()
},
response: {
schema: {

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

@ -15,7 +15,7 @@ const AppError = require('../../error');
module.exports = {
auth: {
strategy: auth.AUTH_STRATEGY,
scope: [auth.SCOPE_CLIENT_MANAGEMENT]
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues()
},
validate: {
payload: {

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

@ -13,7 +13,7 @@ const AppError = require('../../error');
module.exports = {
auth: {
strategy: auth.AUTH_STRATEGY,
scope: [auth.SCOPE_CLIENT_MANAGEMENT]
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues()
},
validate: {
params: {

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

@ -17,7 +17,7 @@ function developerResponse(developer) {
module.exports = {
auth: {
strategy: auth.AUTH_STRATEGY,
scope: [auth.SCOPE_CLIENT_MANAGEMENT]
scope: auth.SCOPE_CLIENT_MANAGEMENT.getImplicantValues()
},
handler: function activateRegistration(req, reply) {
var email = req.auth.credentials.email;

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

@ -10,7 +10,7 @@ const logger = require('../logging')('routes.key_data');
const P = require('../promise');
const validators = require('../validators');
const verify = require('../browserid');
const Scope = require('../scope');
const ScopeSet = require('fxa-shared').oauth.scopes;
const config = require('../config');
const AUTH_EXPIRES_AFTER_MS = config.get('expiration.keyDataAuth');
@ -44,23 +44,17 @@ module.exports = {
payload: req.payload
});
const requestedScopes = Scope(req.payload.scope);
const requestedScopes = ScopeSet.fromString(req.payload.scope);
const requestedClientId = req.payload.client_id;
P.all([
verify(req.payload.assertion),
db.getClient(Buffer.from(requestedClientId, 'hex')).then((client) => {
if (client) {
// find all requested scopes in allowed scopes
const scopeRequests = [];
const allowedScopes = Scope(client.allowedScopes);
requestedScopes.values().forEach((s) => {
if (allowedScopes.has(s)) {
scopeRequests.push(db.getScope(s));
}
});
return P.all(scopeRequests).then((result) => {
// find all requested scopes that are allowed for this client.
const allowedScopes = ScopeSet.fromString(client.allowedScopes);
const scopeLookups = requestedScopes.filtered(allowedScopes).getScopeValues().map(s => db.getScope(s));
return P.all(scopeLookups).then((result) => {
return result.filter((s) => !! s.hasScopedKeys);
});
} else {

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

@ -11,13 +11,13 @@ const buf = require('buf').hex;
const hex = require('buf').to.hex;
const Joi = require('joi');
const JwTool = require('fxa-jwtool');
const ScopeSet = require('fxa-shared').oauth.scopes;
const config = require('../config');
const db = require('../db');
const encrypt = require('../encrypt');
const logger = require('../logging')('routes.token');
const P = require('../promise');
const Scope = require('../scope');
const util = require('../util');
const validators = require('../validators');
@ -36,7 +36,7 @@ const SERVICE_JWTOOL = new JwTool(config.get('serviceClients').map(function(clie
return client.jku;
}));
const SCOPE_OPENID = 'openid';
const SCOPE_OPENID = ScopeSet.fromArray(['openid']);
const ID_TOKEN_EXPIRATION = Math.floor(config.get('openid.ttl') / 1000);
const ID_TOKEN_ISSUER = config.get('openid.issuer');
@ -173,6 +173,7 @@ module.exports = {
},
handler: function tokenEndpoint(req, reply) {
var params = req.payload;
params.scope = ScopeSet.fromString(params.scope || '');
P.try(function() {
// Clients are allowed to provide credentials in either
@ -201,8 +202,8 @@ module.exports = {
}
});
} else if (params.grant_type === GRANT_REFRESH_TOKEN) {
// If the client has a client_secret, check that it's provided and valid in the refresh request
// If the client does not have client_secret, check that one was not provided in the refresh request
// If the client has a client_secret, check that it's provided and valid in the refresh request.
// If the client does not have client_secret, check that one was not provided in the refresh request.
return getClientById(clientId).then(function(client) {
var confirmClientPromise;
@ -231,7 +232,7 @@ module.exports = {
})
.then(function(vals) {
vals.ttl = params.ttl;
if (vals.scope && Scope(vals.scope).has(SCOPE_OPENID)) {
if (vals.scope && vals.scope.contains(SCOPE_OPENID)) {
vals.idToken = true;
}
return vals;
@ -356,7 +357,7 @@ function confirmRefreshToken(params) {
code: tokObj.clientId
});
throw AppError.invalidToken();
} else if (! Scope(tokObj.scope).has(params.scope)) {
} else if (! tokObj.scope.contains(params.scope)) {
logger.debug('refresh_token.invalidScopes', {
allowed: tokObj.scope,
requested: params.scope
@ -402,7 +403,8 @@ function confirmJwt(params) {
throw AppError.invalidAssertion();
}
if (! Scope(client.scope).has(payload.scope)) {
const requestedScope = ScopeSet.fromString(payload.scope);
if (! ScopeSet.fromString(client.scope).contains(requestedScope)) {
logger.debug('jwt.invalid.scopes', {
allowed: client.scope,
requested: payload.scope
@ -427,7 +429,7 @@ function confirmJwt(params) {
return {
clientId: client.id,
userId: uid,
scope: payload.scope,
scope: requestedScope,
email: ''
};
});
@ -485,7 +487,7 @@ function generateTokens(options) {
var json = {
access_token: access.token.toString('hex'),
token_type: access.type,
scope: Scope(access.scope).toString()
scope: access.scope.toString()
};
if (options.authAt) {
json.auth_at = options.authAt;

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

@ -25,6 +25,7 @@ module.exports = {
},
handler: function verify(req, reply) {
token.verify(req.payload.token).then(function(info) {
info.scope = info.scope.getScopeValues();
if (req.payload.email !== undefined) {
logger.warn('email.requested', {
user: info.user,

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

@ -1,68 +0,0 @@
/* 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/. */
function Scope(arr) {
if (arr instanceof Scope) {
return arr;
} else if (! (this instanceof Scope)) {
return new Scope(arr);
}
if (! arr) {
arr = [];
} else if (typeof arr === 'string') {
arr = arr.split(/\s+/);
}
var obj = {};
for (var i = 0; i < arr.length; i++) {
obj[arr[i]] = true;
}
this._values = obj;
}
Scope.prototype = {
_values: undefined,
has: function has(scope) {
return Scope(scope).values().every(function(word) {
if (! word || word.lastIndexOf(':') === word.length - 1) {
return false;
} else if (word in this._values || word + ':write' in this._values) {
return true;
} else {
var parts = word.split(':');
var suffix = parts.pop();
if (suffix === 'write') {
// pop the next one off
// but still require this to be a 'write' scope
if (parts.pop()) {
parts.push('write');
} else {
// this was a weird scope. don't try to fix it, just say NO!
return false;
}
}
var prefix = parts.join(':');
return prefix && this.has(prefix);
}
}, this);
},
values: function values() {
return Object.keys(this._values);
},
toString: function toString() {
return this.values().join(' ');
},
toJSON: function toJSON() {
return this.values();
}
};
module.exports = Scope;

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

@ -2,16 +2,18 @@
* 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 ScopeSet = require('fxa-shared').oauth.scopes;
const AppError = require('./error');
const auth = require('./auth');
const config = require('./config');
const db = require('./db');
const encrypt = require('./encrypt');
const Scope = require('./scope');
const logger = require('./logging')('token');
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
const SCOPES_REQUIRING_EMAIL = ScopeSet.fromArray(['profile:email', 'oauth']);
exports.verify = function verify(token) {
return db.getAccessToken(encrypt.hash(token))
.then(function(token) {
@ -33,7 +35,7 @@ exports.verify = function verify(token) {
logger.warn('token.verify.expired', {
user: token.userId.toString('hex'),
client_id: token.clientId.toString('hex'),
scope: token.scope,
scope: token.scope.toString(),
created_at: token.createdAt,
expires_at: token.expiresAt
});
@ -55,8 +57,7 @@ exports.verify = function verify(token) {
scope: token.scope
};
var scope = Scope(token.scope);
if (scope.has('profile:email') || scope.has(auth.SCOPE_CLIENT_MANAGEMENT)) {
if (token.scope.intersects(SCOPES_REQUIRING_EMAIL)) {
tokenInfo.email = token.email;
}

3439
npm-shrinkwrap.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@ -24,44 +24,44 @@
"node": ">=8"
},
"dependencies": {
"bluebird": "^2.9.14",
"bluebird": "2.11.0",
"buf": "0.1.0",
"commander": "^2.9.0",
"commander": "2.15.1",
"convict": "4.0.2",
"fxa-jwtool": "^0.7.1",
"fxa-jwtool": "0.7.2",
"fxa-notifier-aws": "1.0.0",
"fxa-shared": "1.0.5",
"fxa-shared": "1.0.13",
"hapi": "16.6.3",
"hapi-hpkp": "1.0.0",
"joi": "^10.0.1",
"keypair": "^1.0.1",
"mozlog": "^2.0.3",
"mysql": "^2.5.5",
"mysql-patcher": "^0.7.0",
"joi": "10.6.0",
"keypair": "1.0.1",
"mozlog": "2.2.0",
"mysql": "2.15.0",
"mysql-patcher": "0.7.0",
"newrelic": "2.2.2",
"raven": "2.2.1",
"request": "^2.83.0",
"urijs": "^1.16.1"
"request": "2.85.0",
"urijs": "1.19.1"
},
"devDependencies": {
"eslint-plugin-fxa": "git://github.com/mozilla/eslint-plugin-fxa.git#41504c9dd30e8b52900c15b524946aa0428aef95",
"fxa-conventional-changelog": "1.1.0",
"grunt": "^1.0.1",
"grunt": "1.0.2",
"grunt-bump": "0.8.0",
"grunt-conventional-changelog": "6.1.0",
"grunt-copyright": "^0.3.0",
"grunt-copyright": "0.3.0",
"grunt-eslint": "18.0.0",
"grunt-nodemon": "^0.4.0",
"grunt-nodemon": "0.4.2",
"grunt-nsp": "2.3.1",
"insist": "1.x",
"load-grunt-tasks": "^3.1.0",
"mocha": "^3.4.2",
"nock": "^8.0.0",
"npmshrink": "^1.0.1",
"load-grunt-tasks": "3.5.2",
"mocha": "3.5.3",
"nock": "8.2.2",
"npmshrink": "1.0.1",
"nyc": "11.0.2",
"proxyquire": "^1.6.0",
"read": "^1.0.5",
"sinon": "^1.15.4",
"time-grunt": "^1.1.0"
"proxyquire": "1.8.0",
"read": "1.0.7",
"sinon": "1.17.7",
"time-grunt": "1.4.0"
}
}

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

@ -8,6 +8,7 @@ const nock = require('nock');
const buf = require('buf').hex;
const generateRSAKeypair = require('keypair');
const JWTool = require('fxa-jwtool');
const ScopeSet = require('fxa-shared').oauth.scopes;
const auth = require('../lib/auth');
const config = require('../lib/config');
@ -178,7 +179,7 @@ function getUniqueUserAndToken(cId, options) {
clientId: buf(cId),
userId: buf(uid),
email: email,
scope: options.scopes || [auth.SCOPE_CLIENT_MANAGEMENT]
scope: options.scopes ? ScopeSet.fromArray(options.scopes) : auth.SCOPE_CLIENT_MANAGEMENT
}).then(function (token) {
return {
uid: uid,
@ -358,15 +359,32 @@ describe('/v1', function() {
url: '/authorization',
payload: authParams({
client_id: client.id,
scope: 'profile profile:write profile:uid'
scope: 'profile:write'
})
}).then(function(res) {
assert.equal(res.statusCode, 400);
assertSecurityHeaders(res);
assert.equal(res.result.errno, 114);
assert.ok(res.result.invalidScopes.indexOf('profile') !== -1);
assert.ok(res.result.invalidScopes.indexOf('profile:write') !== -1);
assert.ok(res.result.invalidScopes.indexOf('profile:uid') === -1);
});
});
it('should report all invalid scopes', function() {
var client = clientByName('Untrusted');
mockAssertion().reply(200, VERIFY_GOOD);
return Server.api.post({
url: '/authorization',
payload: authParams({
client_id: client.id,
scope: 'profile:email profile:locale profile:amr'
})
}).then(function(res) {
assert.equal(res.statusCode, 400);
assertSecurityHeaders(res);
assert.equal(res.result.errno, 114);
assert.ok(res.result.invalidScopes.indexOf('profile:email') === -1);
assert.ok(res.result.invalidScopes.indexOf('profile:locale') !== -1);
assert.ok(res.result.invalidScopes.indexOf('profile:amr') !== -1);
});
});
@ -2069,14 +2087,14 @@ describe('/v1', function() {
clientId: buf(clientId),
userId: buf(USERID),
email: VEMAIL,
scope: [auth.SCOPE_CLIENT_MANAGEMENT]
scope: auth.SCOPE_CLIENT_MANAGEMENT
}).then(function(token) {
tok = token.token.toString('hex');
return db.generateAccessToken({
clientId: buf(clientId),
userId: unique(16),
email: 'user@not.allow.ed',
scope: [auth.SCOPE_CLIENT_MANAGEMENT]
scope: auth.SCOPE_CLIENT_MANAGEMENT
});
}).then(function(token) {
badTok = token.token.toString('hex');
@ -3068,6 +3086,7 @@ describe('/v1', function() {
});
})
.then(function (res) {
assert.equal(res.statusCode, 200);
// The API sorts the results by createdAt and then by name
// The precision is one second, this test guarantees that if
// the tokens were created in the same second, they will still be sorted by name.
@ -3185,7 +3204,7 @@ describe('/v1', function() {
assert.equal(result[0].id, client1Id.toString('hex'));
assert.equal(result[0].lastAccessTime, tok.createdAt.getTime(), 'lastAccessTime should be equal to the latest Token createdAt time');
assertSecurityHeaders(res);
assert.deepEqual(result[0].scope, ['clients:write', 'profile', 'profile:write']);
assert.deepEqual(result[0].scope, ['clients:write', 'profile:write']);
});
});
@ -3206,7 +3225,7 @@ describe('/v1', function() {
return getUniqueUserAndToken(client1Id.toString('hex'), {
uid: user1.uid,
email: user1.email,
scopes: ['basket:write', 'profile:email']
scopes: ['basket', 'profile:email']
});
})
.then(function () {
@ -3226,7 +3245,7 @@ describe('/v1', function() {
})
.then(function (res) {
var result = res.result;
assert.deepEqual(result[0].scope, ['basket:write', 'clients:write', 'profile', 'profile:email', 'profile:uid', 'profile:write']);
assert.deepEqual(result[0].scope, ['basket', 'clients:write', 'profile:write']);
});
});

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

@ -10,6 +10,7 @@ const proxyquire = require('proxyquire');
const AppError = require('../lib/error');
const P = require('../lib/promise');
const sinon = require('sinon');
const ScopeSet = require('fxa-shared').oauth.scopes;
const modulePath = '../lib/auth_bearer';
const mockRequest = {
@ -30,7 +31,7 @@ describe('authBearer', function() {
sandbox.stub(dependencies['../../../lib/token'], 'verify', function() {
return P.resolve({
scope: ['bar:foo', 'clients:write'],
scope: ScopeSet.fromArray(['bar:foo', 'clients:write']),
user: 'bar'
});
});

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

@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const assert = require('insist');
const ScopeSet = require('fxa-shared').oauth.scopes;
const helpers = require('../../lib/db/helpers');
const unique = require('../../lib/unique');
@ -17,19 +18,19 @@ describe('aggregateActiveClients', function() {
id: uid,
createdAt: '2017-01-26T14:28:16.219Z',
name: '123Done',
scope: ['profile', 'profile:write']
scope: ScopeSet.fromArray(['basket', 'profile:write'])
},
{
id: uid,
createdAt: '2017-01-27T14:28:16.219Z',
name: '123Done',
scope: ['clients:write']
scope: ScopeSet.fromArray(['clients:write'])
},
{
id: uid,
createdAt: '2017-01-28T14:28:16.219Z',
name: '123Done',
scope: ['profile']
scope: ScopeSet.fromArray(['profile'])
}
];
});
@ -38,7 +39,7 @@ describe('aggregateActiveClients', function() {
var res = helpers.aggregateActiveClients(activeClientTokens);
assert.equal(res[0].id, uid);
assert.equal(res[0].name, '123Done');
assert.deepEqual(res[0].scope, ['clients:write', 'profile', 'profile:write']);
assert.deepEqual(res[0].scope, ['basket', 'clients:write', 'profile:write']);
assert.equal(res[0].lastAccessTime, '2017-01-28T14:28:16.219Z');
});
});

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

@ -7,6 +7,7 @@ const crypto = require('crypto');
const assert = require('insist');
const buf = require('buf').hex;
const hex = require('buf').to.hex;
const ScopeSet = require('fxa-shared').oauth.scopes;
const encrypt = require('../../lib/encrypt');
const db = require('../../lib/db');
@ -130,7 +131,7 @@ describe('db', function() {
var clientId = buf(randomString(8));
var userId = buf(randomString(16));
var email = 'a@b.c';
var scope = ['no-scope'];
var scope = ScopeSet.fromArray(['no_scope']);
var code = null;
var token = null;
var refreshToken = null;
@ -205,7 +206,7 @@ describe('db', function() {
const clientId = buf(randomString(8));
const userId = buf(randomString(16));
const email = 'a@b' + randomString(16) + ' + .c';
const scope = ['no-scope'];
const scope = ['no_scope'];
let tokenIdHash;
let refreshTokenIdHash;
@ -297,7 +298,7 @@ describe('db', function() {
var clientId = buf(randomString(8));
var userId = buf(randomString(16));
var email = 'a@b.c';
var scope = ['no-scope'];
var scope = ['no_scope'];
var code = null;
var refreshToken = null;
@ -524,7 +525,7 @@ describe('db', function() {
var clientId = buf(randomString(8));
var userId = buf(randomString(16));
var email = 'a@b.c';
var scope = ['no-scope'];
var scope = ['no_scope'];
var code = null;
before(function() {

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

@ -1,76 +0,0 @@
/* 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/. */
/*global describe,it*/
const assert = require('insist');
const Scope = require('../lib/scope');
describe('Scope', function() {
describe('constructor', function() {
it('should accept a space-separated string', function() {
var s1 = Scope('a b c');
assert.deepEqual(s1.values(), ['a', 'b', 'c']);
});
it('should accept an array', function() {
var s1 = Scope(['a', 'b', 'c']);
assert.deepEqual(s1.values(), ['a', 'b', 'c']);
});
it('should accept a Scope instance', function() {
var s1 = Scope(['a', 'b', 'c']);
var s2 = Scope(s1);
assert.equal(s1, s2);
});
});
describe('has', function() {
it('should work with a single value', function() {
var s1 = Scope('foo bar');
assert(s1.has('foo'));
assert(s1.has('bar'));
assert(! s1.has('baz'));
});
it('should work with another Scope object', function() {
var s1 = Scope('foo bar');
var s2 = Scope('bar');
assert(s1.has(s2));
assert(! s2.has(s1));
});
it('should allow sub-scopes', function() {
var s1 = Scope('foo bar:baz');
assert(s1.has('foo:dee'));
assert(s1.has('bar:baz'));
assert(s1.has('foo:mah:pa bar:baz:quux'));
assert(! s1.has('bar'));
assert(! s1.has('foo:write'));
assert(! s1.has('foo:dee:write'));
var s2 = Scope('foo bar baz:quux:write');
assert(s2.has('foo bar baz:quux'));
assert(! s2.has('baz:write'));
assert(! s2.has('foo bar baz'));
var s3 = Scope('foo:write');
assert(s3.has('foo:bar'));
assert(s3.has('foo:bar:write'));
assert(! s3.has('foo::write'));
assert(! s3.has('foo:write:::'));
});
});
});