packages/fxa-shared/oauth/scopes.ts (266 lines of code) (raw):
/* 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/. */
import { URL } from 'url';
type Coerceable = ScopeSet | string[] | string;
// These character ranges are from the OAuth RFC,
// https://tools.ietf.org/html/rfc6749#section-3.3
const VALID_SCOPE_VALUE = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
const VALID_SHORT_NAME_VALUE = /^[a-zA-Z0-9_]+$/;
const VALID_FRAGMENT_VALUE = /^#[a-zA-Z0-9_]+$/;
/**
* A `ScopeSet` object represents a set of validated scope string values,
* against which we can perform various membership checks and set-like
* operations such as:
*
* - Checking whether one set of scope values wholly or partly
* contains another set.
*
* - Filtering out scope values that are (or are not) implied
* by another set.
*
* - Aggregating values into a single set.
*
* A quick note on some terminology:
*
* - A "scope" is a string value representing a particular capabilty
* that can be granted to client applications. Such strings can be
* in one of two forms:
*
* - a "short-name scope" such as "profile" or "profile:email"
* - a "URL-format scope" such as "https://identity.mozilla.com/apps/sync"
*
* - We say that a scope "implies" another scope if it represents
* a strictly more general capability. For example:
*
* - the scope "profile" implies "profile:email" and "profile:avatar",
* but does not imply "basket" or "profile:write".
*
* - the scope "profile:write" implies "profile" and "profile:email:write",
* but does not imply "basket" or "basket:write".
*
* - the scope "https://identity.mozilla.com/apps/sync" implies
* "https://identity.mozilla.com/apps/sync/bookmarks", but does not
* imply "https://identity.mozilla.com/apps/lockbox".
*
* - If a scope A implies scope B, then we say that B is a "sub-scope"
* of A, and that A is an "implicant" of B. Any scope value has a
* finite number of implicants.
*
* - We group individual scope values into sets represented by the
* `ScopeSet` object.
*
* We expect that the `ScopeSet` class will be used to process user-provided
* data, so it's important that it provides efficient operations that do
* not open potential DoS vectors. In particular, we need to ensure
* linear-time-or-better membership testing and merging of multiple `ScopeSet`
* instances.
*
* Performance is achieved by using a simple trick: for any given scope value,
* we can pre-compute a finite set of all possible implicants. This allows us
* to implement "A implies B" as a simple string lookup for "A" in the set of
* implicants of "B".
*
* The tradeoff here is memory usage, as we have to store the full
* set of implicants for each scope value in the set. We expect that this
* will not be too problematic in practice, because the size of this set is
* linear in the length of the scope value. A more memory-friendly approach
* could use a trie-like data structure to efficiently store all implicants
* with prefix compression, at the cost of significant code complexity.
*
*/
class ScopeSet {
private _scopesToImplicants: { [key: string]: Set<string> };
private _implicantsToScopes: { [key: string]: Set<string> };
constructor(scopes: string[] = []) {
// To support efficient lookups, we store the set of scopes and
// their fully-expanded set of implicants in a bi-directional mapping.
//
// In one direction, we map each scope in this ScopeSet to its full set of
// implicants. If the scope string `s` is in `this._scopesToImplicants`,
// then `s` is a member of this ScopeSet, and the corresponding value gives
// all possible scopes that might imply it.
this._scopesToImplicants = {};
// In the other direction, we map every implicant of a scope in this
// ScopeSet to the set of scopes that it implies. If the scope string
// `s` is in `this._implicantsToScopes`, then `s` implies at least one
// of the scopes in this ScopeSet, and the corresponding value gives all
// of the scopes that it implies.
this._implicantsToScopes = {};
// Creating `ScopeSet` instances is then a simple matter of building
// up that bi-directional mapping, one scope value at a time.
for (const s of scopes) {
this._addScope(s, new Set(getImplicantValues(s)));
}
}
/*
* Check whether this `ScopeSet` contains the given scope.
*
*/
_hasScope(scope: string) {
return scope in this._scopesToImplicants;
}
/*
* Check whether this `ScopeSet` contains one of the given scopes.
*
*/
_hasSomeScope(scopes: Set<string>) {
for (const scope of scopes) {
if (this._hasScope(scope)) {
return true;
}
}
return false;
}
/*
* Check whether something in this `ScopeSet` is implied by
* the given scope.
*
*/
_hasImplicant(scope: string) {
return scope in this._implicantsToScopes;
}
/*
* Check whether something in this `ScopeSet` is implied by
* one of the given scopes.
*
*/
_hasSomeImplicant(scopes: string[]) {
for (const scope of scopes) {
if (scope in this._implicantsToScopes) {
return true;
}
}
return false;
}
/*
* Iterate over the scopes in this `ScopeSet`, and their
* corresponding sets of implicants.
*
*/
_iterScopes(cb: (scope: string, implicants: Set<string>) => void) {
for (const scope in this._scopesToImplicants) {
cb(scope, this._scopesToImplicants[scope]);
}
}
/*
* Iterate over the implicants of this `ScopeSet`, and their
* corresponding sets of implied scopes.
*
*/
_iterImplicants(cb: (implicant: string, scopes: Set<string>) => void) {
for (const implicant in this._implicantsToScopes) {
cb(implicant, this._implicantsToScopes[implicant]);
}
}
/*
* Iterate over the scopes in this `ScopeSet` that are implied by
* the given scope.
*
*/
_iterImpliedScopes(implicant: string, cb: (s: string) => void) {
const impliedScopes = this._implicantsToScopes[implicant];
if (impliedScopes) {
for (const impliedScope of impliedScopes) {
cb(impliedScope);
}
}
}
/*
* Search through the scopes in this `ScopeSet` to see whether
* any matches the given predicate function. The search terminates
* at the first successful match.
*
*/
_searchScopes(cb: (scope: string, implicants: Set<string>) => boolean) {
for (const scope in this._scopesToImplicants) {
if (cb(scope, this._scopesToImplicants[scope])) {
return true;
}
}
return false;
}
/*
* Search through the implicants of this `ScopeSet` to see whether
* any matches the given predicate function. The search terminates
* at the first successful match.
*
*/
_searchImplicants(cb: (implicant: string, scopes: Set<string>) => boolean) {
for (const implicant in this._implicantsToScopes) {
if (cb(implicant, this._implicantsToScopes[implicant])) {
return true;
}
}
return false;
}
/*
* Add a scope to this `ScopeSet`.
* The new scope may imply some scopes that are already in
* the set, in which case the redundant scopes will be removed in
* order to keep memory usage down and simplify further handling.
*
*/
_addScope(scope: string, implicants: Set<string>) {
// If the scope is already implied by something in this `ScopeSet`,
// then we can safely ignore it.
if (this._hasSomeScope(implicants)) {
return;
}
// If the new scope implies some scopes that are already in this
// `ScopeSet`, then those scopes are now redundant and can be removed.
this._iterImpliedScopes(scope, (implied) => {
this._removeScope(implied);
});
// Add it into the bi-directional mapping.
this._scopesToImplicants[scope] = implicants;
for (const implicant of implicants) {
if (!(implicant in this._implicantsToScopes)) {
this._implicantsToScopes[implicant] = new Set();
}
this._implicantsToScopes[implicant].add(scope);
}
}
/*
* Remove a scope from this `ScopeSet`.
*
* This must maintain the bi-directional mapping between
* scopes and their implicants.
*
*/
_removeScope(scope: string) {
const implicants = this._scopesToImplicants[scope];
for (const implicant of implicants) {
const impliedScopes = this._implicantsToScopes[implicant];
impliedScopes.delete(scope);
if (impliedScopes.size === 0) {
delete this._implicantsToScopes[implicant];
}
}
delete this._scopesToImplicants[scope];
}
toString() {
return this.toJSON().join(' ');
}
toJSON() {
return this.getScopeValues();
}
/**
* Get the list of scope strings in this `ScopeSet`.
*
*/
getScopeValues() {
return Object.keys(this._scopesToImplicants);
}
/**
* Get the list of all scope strings that imply a scope in this `ScopeSet`.
*
* This can be useful to reduce the operation of scope checking to
* a simple string lookup.
*
*/
getImplicantValues() {
return Object.keys(this._implicantsToScopes);
}
/**
* Check whether this `ScopeSet` contains any scope values at all.
*
*/
isEmpty() {
for (const _ in this._scopesToImplicants) {
return false;
}
return true;
}
/**
* Add scope values from another `ScopeSet`.
*
* `A.add(B)` modifies `A` in-place to add the scope values from `B`,
* removing any redundant entries.
*
* Importantly, aggregating multiple `ScopeSet` instances into a single
* set by repeatedly calling this method is linear in the number of
* scope values being processed. This means that it can be safely
* used to aggregate user-provided data without hiding any "accidentally
* quadratic" performance traps.
*
*/
add(other: Coerceable) {
coerce(other)._iterScopes((scope, implicants) => {
this._addScope(scope, implicants);
});
}
/**
* Check whether one set of scopes wholly contains another.
*
* A set of scopes `A` contains another set `B` if every scope value
* in `B` is implied by some scope value in `A`. In other words,
* `A` represents a strictly more general set of capabilities
* than `B`.
*
*/
contains(other: Coerceable) {
return !coerce(other)._searchScopes((scope, implicants) => {
return !this._hasSomeScope(implicants);
});
}
/**
* Check whether one set of scopes intersects with another.
*
* A set of scopes `A` intersects another set `B` if there is some scope
* value in `B` that is implied by a scope value in `A`, or there is
* some scope value in `A` that is implied by a scope value in `B`.
*
*/
intersects(other: Parameters<typeof coerce>[number]) {
other = coerce(other);
return (
other._searchImplicants((implicant) => {
return this._hasScope(implicant);
}) ||
this._searchImplicants((implicant) => {
return (other as ScopeSet)._hasScope(implicant);
})
);
}
/**
* Find the subset of scopes from this `ScopeSet` that are implied
* by another set.
*
* `A.filtered(B)` returns the largest subset of scope values
* from `A` that are implied by some scope value in `B`. It
* returns a new `ScopeSet` instance.
*
* It's useful to think of `A.filtered(B)` as checking a set
* of requested capabilities in `A` against a set of allowed
* capabilities in `B`. The result will be the subset of the
* requested capabilities that are actually allowed.
*
* Note that it does *not* behave strictly like classical set
* intersection, in that there may exist a scope implied by
* both `A` and `B` which is not in `A.filtered(B)`.
* Consider for example:
*
* A = 'profile:email:write'
* B = 'profile'
*
*`A.filtered(B)` in this case will be empty, as 'profile' is a read-only
* scope that cannot imply any ':write' scopes. There is a scope that
* is implied by both `A` and `B` ('profile:email') but since it is not
* directly a member of `A`, it will not appear in the result.
*
*/
filtered(other: Coerceable) {
other = coerce(other);
const result = new ScopeSet();
this._iterScopes((scope, implicants) => {
if ((other as ScopeSet)._hasSomeScope(implicants)) {
result._addScope(scope, implicants);
}
});
return result;
}
/**
* Find the subset of scopes from this `ScopeSet` that are not implied
* by another set.
*
* `A.difference(B)` returns the largest subset of scope values
* from `A` that are *not* implied by a scope in `B`; these are
* precisely those scope values that would have been removed by
* `A.filtered(B)`. It returns a new `ScopeSet` object.
*
*/
difference(other: Coerceable) {
other = coerce(other);
const result = new ScopeSet();
this._iterScopes((scope, implicants) => {
if (!(other as ScopeSet)._hasSomeScope(implicants)) {
result._addScope(scope, implicants);
}
});
return result;
}
/**
* Get the combined set of scope values implied by either this `ScopeSet`
* or another.
*
* `A.union(B)` returns the smallest set of scope values that are implied
* either `A` or `B` (or both). It returns a new `ScopeSet` object.
*
*/
union(other: Coerceable) {
other = coerce(other);
const result = new ScopeSet();
this._iterScopes((scope, implicants) => {
result._addScope(scope, implicants);
});
other._iterScopes((scope, implicants) => {
result._addScope(scope, implicants);
});
return result;
}
}
/**
* A little helper function for coercing different
* kinds of input data into a `ScopeSet` instance.
*
*/
function coerce(scopes: Coerceable) {
if (scopes instanceof ScopeSet) {
return scopes;
}
// If we receive a string, assume it's a single scope.
// We deliberately do not attempt to split the string on whitespace here,
// because we want to force callers to explicitly choose between
// exports.fromString() or exports.fromURLEncodedString depending on
// what encoding they expect to have received the string in.
if (typeof scopes === 'string') {
return new ScopeSet([scopes]);
}
return new ScopeSet(scopes);
}
/**
* An iterator yielding all implicants of the given scope value.
*
*/
function getImplicantValues(value: string) {
if (value.startsWith('https:')) {
return getImplicantValuesForURLScope(value);
} else {
return getImplicantValuesForShortScope(value);
}
}
/**
* Our "short-name scopes" are things like:
*
* * "openid"
* * "profile"
* * "profile:write"
* * "profile:display_name:write"
*
* The scope value is a colon-separated list of
* alphanumeric strings. The special value "write"
* may appear as the last component to indicate write
* access, otherwise access is read-only.
*
* Implication is essentially the prefix relationship
* on the name components, with the additional rule
* only "write" scopes can imply other "write" scopes.
*
*/
function* getImplicantValuesForShortScope(value: string) {
if (!VALID_SCOPE_VALUE.test(value)) {
throw new Error('Invalid scope value: ' + value);
}
// Parse it into its colon-separated name components,
// and handle the special "write" flag if present.
let hasWrite = false;
const names = value.split(':');
names.forEach((name) => {
if (!VALID_SHORT_NAME_VALUE.test(name)) {
throw new Error('Invalid scope value: ' + value);
}
});
if (names[names.length - 1] === 'write') {
hasWrite = true;
names.pop();
// Disallow "write" as a standalone scope.
if (names.length === 0) {
throw new Error('Invalid scope value: ' + value);
}
}
// All prefixes of the name component list will imply this scope.
// In addition, a read-only scope will be implied by the corresponding
// write sope, but not vice-versa.
while (names.length > 0) {
yield names.join(':') + ':write';
if (!hasWrite) {
yield names.join(':');
}
names.pop();
}
}
/**
* Our "url-format scopes" are things like:
*
* * "https://identity.mozilla.com/apps/oldsync"
* * "https://identity.mozilla.com/apps/oldsync/bookmarks#read"
*
* We'll accept basically any sanely-formed, canonicalized https://
* URL, and scope implication boils down to checking whether one
* URL is a child of another.
*
*/
function* getImplicantValuesForURLScope(value: string) {
if (!VALID_SCOPE_VALUE.test(value)) {
throw new Error('Invalid scope value: ' + value);
}
const url = new URL(value);
// Only https:// URLs are allowed.
if (url.protocol !== 'https:') {
throw new Error('Invalid scope value: ' + value);
}
// No credentials or query params are allowed.
if (url.username || url.password || url.search) {
throw new Error('Invalid scope value: ' + value);
}
// The pathname must be non-empty and not end in a slash.
if (!url.pathname || url.pathname.endsWith('/')) {
throw new Error('Invalid scope value: ' + value);
}
// The hash fragment must be alphnumeric.
if (url.hash && !VALID_FRAGMENT_VALUE.test(url.hash)) {
throw new Error('Invalid scope value: ' + value);
}
// The URL must round-trip cleanly through a URL parse,
// to avoid any ambiguity in parsing.
if (url.href !== value) {
throw new Error('Invalid scope value: ' + value);
}
// All parent resource URLs will imply this scope.
// If it has a hash fragment, then this scope will also be
// implied by parent URLs with that same hash fragment.
const pathParts = url.pathname.split('/');
while (pathParts.length > 1) {
yield new URL(pathParts.join('/'), url).href;
if (url.hash) {
yield new URL(pathParts.join('/') + url.hash, url).href;
}
pathParts.pop();
}
}
export const scopeSetHelpers = {
/**
* Parse a list of strings into a Scope object.
*
*/
fromArray(scopesArray: string[]) {
return new ScopeSet(scopesArray);
},
/**
* Parse a space-delimited string into a Scope object.
*
* This function implements the semantics defined in RFC6749, where
* the "scope" input string represents a space-delimited list of
* case-sensitive strings identifying individual scope values.
*
*/
fromString(scopesString: string) {
// Split the string by one or more space characters.
return new ScopeSet(
scopesString.split(/ +/).filter((scopeString) => {
return !!scopeString;
})
);
},
/**
* Parse a url-encoded string into a Scope object.
*
* This function parses a Scope from a "scope" input string
* that has been url-encoded, as you might receive in the query
* parameter of an OAuth authorizaton request. The list is thus
* delimited by `+` rather than ` `, and any percent-encoded
* characters in the scopes will be expanded.
*
*/
fromURLEncodedString(
encodedScopesString: ReturnType<typeof encodeURIComponent>
) {
// Split the string by a literal plus character.
return new ScopeSet(
encodedScopesString
.split(/\+/)
.filter((encodedScopeString) => {
return !!encodedScopeString;
})
.map((encodedScopeString) => {
return decodeURIComponent(encodedScopeString);
})
);
},
};
export default scopeSetHelpers;