diff --git a/fxa-oauth-server/docs/api.md b/fxa-oauth-server/docs/api.md index b78631400..bc3c44ff1 100644 --- a/fxa-oauth-server/docs/api.md +++ b/fxa-oauth-server/docs/api.md @@ -53,6 +53,7 @@ The currently-defined error responses are: | 400 | 118 | pkce parameters missing | | 400 | 119 | stale authentication timestamp | | 400 | 120 | mismatch acr value | +| 400 | 121 | invalid grant_type | | 500 | 999 | internal server error | ## API Endpoints @@ -329,6 +330,7 @@ back to the client. This code will be traded for a token at the - `assertion`: A FxA assertion for the signed-in user. - `state`: A value that will be returned to the client as-is upon redirection, so that clients can verify the redirect is authentic. - `response_type`: Optional. If supplied, must be either `code` or `token`. `code` is the default. `token` means the implicit grant is desired, and requires that the client have special permission to do so. + - **Note: new implementations should not use `response_type=token`; instead use `grant_type=fxa-credentials` at the [token][] endpoint.** - `ttl`: Optional if `response_type=token`, forbidden if `response_type=code`. Indicates the requested lifespan in seconds for the implicit grant token. The value is subject to an internal maximum limit, so clients must check the `expires_in` result property for the actual TTL. - `redirect_uri`: Optional. If supplied, a string URL of where to redirect afterwards. Must match URL from registration. - `scope`: Optional. A string-separated list of scopes that the user has authorized. This could be pruned by the user at the confirmation dialog. @@ -377,33 +379,47 @@ If requesting an implicit grant (token), the response will match the ### POST /v1/token -After having received a [code][authorization], the client sends that code (most -likely a server-side request) to this endpoint, to receive a -longer-lived token that can be used to access attached services for a -particular user. +After receiving an authorization grant from the user, clients exercise that grant +at this endpoint to obtain tokens that can be used to access attached services +for a particular user. -#### Request Parameters +The following types of grant are possible: +- `authorization_code`: a single-use code as produced by the [authorization][] endpoint, + obtained through a redirect-based authorization flow. +- `refresh_token`: a token previously obtained from this endpoint when using + `access_type=offline`. +- `fxa-credentials`: an FxA identity assertion, obtained by directly authenticating + the user's account. -- `ttl`: (optional) Seconds that this access_token should be valid. +#### Request Parameters - The default and maximum value is 2 weeks. -- `grant_type`: Either `authorization_code`, `refresh_token`. +- `ttl`: (optional) Seconds that the access_token should be valid. + If unspecified this will default to the maximum value allowed by the + server, which is a configurable option but would typically be measured + in minutes or hours. +- `grant_type`: Either `authorization_code`, `refresh_token`, or `fxa-credentials`. - If `authorization_code`: - `client_id`: The id returned from client registration. - `client_secret`: The secret returned from client registration. + Forbidden for public clients, required otherwise. - `code`: A string that was received from the [authorization][] endpoint. + - `code_verifier`: The [PKCE](pkce.md) code verifier. + Required for public clients, forbidden otherwise. - If `refresh_token`: - `client_id`: The id returned from client registration. - `client_secret`: The secret returned from client registration. - This must not be set if the client is a public (PKCE) client. + Forbidden for public (PKCE) clients, required otherwise. - `refresh_token`: A string that received from the [token][] endpoint specifically as a refresh token. - `scope`: (optional) A subset of scopes provided to this refresh_token originally, to receive an access_token with less permissions. - - if client is type `publicClient:true` and `authorization_code`: - - `code_verifier`: Required if using [PKCE](pkce.md). - + - If `fxa-credentials`: + - `client_id`: The id returned from client registration. + - `assertion`: FxA identity assertion authenticating the user. + - `scope`: (optional) A string-separated list of scopes to be authorized. + - `access_type`: (optional) Determines whether to generate a `refresh_token` (if `offline`) + or not (if `online`). **Example:** @@ -427,13 +443,15 @@ curl -v \ A valid request will return a JSON response with these properties: - `access_token`: A string that can be used for authorized requests to service providers. -- `scope`: A string of space-separated permissions that this token has. May differ from requested scopes, since user can deny permissions. -- `refresh_token`: (Optional) A refresh token to fetch a new access token when this one expires. Only will be present if `grant_type=authorization_code` and the original authorization request included `access_type=offline`. +- `scope`: A string of space-separated permissions that this token has. - `expires_in`: **Seconds** until this access token will no longer be valid. - `token_type`: A string representing the token type. Currently will always be "bearer". - `auth_at`: An integer giving the time at which the user authenticated to the Firefox Accounts server when generating this token, as a UTC unix timestamp (i.e. **seconds since epoch**). +- `refresh_token`: (Optional) A refresh token to fetch a new access token when this one expires. Only present if: + - `grant_type=authorization_code` and the original authorization request included `access_type=offline`. + - `grant_type=fxa-credentials` and the request included `access_type=offline`. - `id_token`: (Optional) If the authorization was requested with `openid` scope, then this property will contain the OpenID Connect ID Token. -- `keys_jwe`: (Optional) Returns the JWE bundle that if the authorization request had one. +- `keys_jwe`: (Optional) Returns the JWE bundle of key material for any scopes that have keys, if `grant_type=authorization_code`. **Example:** diff --git a/fxa-oauth-server/lib/db/memory.js b/fxa-oauth-server/lib/db/memory.js index 370978e03..02bbc5a2e 100644 --- a/fxa-oauth-server/lib/db/memory.js +++ b/fxa-oauth-server/lib/db/memory.js @@ -388,7 +388,7 @@ MemoryStore.prototype = { return P.resolve(); }, getScope: function getScope (scope) { - return P.resolve(this.scopes[scope]); + return P.resolve(this.scopes[scope] || null); }, registerScope: function registerScope (scope) { this.scopes[scope.scope] = scope; diff --git a/fxa-oauth-server/lib/db/mysql/index.js b/fxa-oauth-server/lib/db/mysql/index.js index 460998559..403011be6 100644 --- a/fxa-oauth-server/lib/db/mysql/index.js +++ b/fxa-oauth-server/lib/db/mysql/index.js @@ -824,8 +824,13 @@ MysqlStore.prototype = { .then(() => this._write(QUERY_DELETE_REFRESH_TOKEN_FOR_PUBLIC_CLIENTS, [uid])); }, - getScope: function getScope (scope) { - return this._readOne(QUERY_SCOPE_FIND, [scope]); + getScope: async function getScope (scope) { + // We currently only have database entries for URL-format scopes, + // so don't bother hitting the db for common scopes like 'profile'. + if (! scope.startsWith('https://')) { + return null; + } + return await this._readOne(QUERY_SCOPE_FIND, [scope]) || null; }, registerScope: function registerScope (scope) { diff --git a/fxa-oauth-server/lib/error.js b/fxa-oauth-server/lib/error.js index bee543421..b3af28a72 100644 --- a/fxa-oauth-server/lib/error.js +++ b/fxa-oauth-server/lib/error.js @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const util = require('util'); +const hex = require('buf').to.hex; const DEFAULTS = { code: 500, @@ -82,7 +83,7 @@ AppError.unknownClient = function unknownClient(clientId) { errno: 101, message: 'Unknown client' }, { - clientId: clientId + clientId: hex(clientId) }); }; @@ -93,7 +94,7 @@ AppError.incorrectSecret = function incorrectSecret(clientId) { errno: 102, message: 'Incorrect secret' }, { - clientId: clientId + clientId: hex(clientId) }); }; @@ -135,8 +136,8 @@ AppError.mismatchCode = function mismatchCode(code, clientId) { errno: 106, message: 'Incorrect code' }, { - requestCode: code, - client: clientId + requestCode: hex(code), + client: hex(clientId) }); }; @@ -147,7 +148,7 @@ AppError.expiredCode = function expiredCode(code, expiredAt) { errno: 107, message: 'Expired code' }, { - requestCode: code, + requestCode: hex(code), expiredAt: expiredAt }); }; @@ -233,14 +234,14 @@ AppError.expiredToken = function expiredToken(expiredAt) { }); }; -AppError.notPublicClient = function unknownClient(clientId) { +AppError.notPublicClient = function notPublicClient(clientId) { return new AppError({ code: 400, error: 'Bad Request', errno: 116, message: 'Not a public client' }, { - clientId: clientId + clientId: hex(clientId) }); }; @@ -285,4 +286,13 @@ AppError.mismatchAcr = function mismatchAcr(foundValue) { }, {foundValue}); }; +AppError.invalidGrantType = function invalidGrantType() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: 121, + message: 'Invalid grant_type' + }); +}; + module.exports = AppError; diff --git a/fxa-oauth-server/lib/grant.js b/fxa-oauth-server/lib/grant.js new file mode 100644 index 000000000..44f20a2d6 --- /dev/null +++ b/fxa-oauth-server/lib/grant.js @@ -0,0 +1,163 @@ +/* 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 buf = require('buf').hex; +const hex = require('buf').to.hex; + +const config = require('./config'); +const AppError = require('./error'); +const db = require('./db'); +const util = require('./util'); +const ScopeSet = require('fxa-shared').oauth.scopes; +const JwTool = require('fxa-jwtool'); + +const ACR_VALUE_AAL2 = 'AAL2'; +const ACCESS_TYPE_OFFLINE = 'offline'; + +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'); +const ID_TOKEN_KEY = JwTool.JWK.fromObject(config.get('openid.key'), { + iss: ID_TOKEN_ISSUER +}); + +const UNTRUSTED_CLIENT_ALLOWED_SCOPES = ScopeSet.fromArray([ + 'openid', + 'profile:uid', + 'profile:email', + 'profile:display_name' +]); + +// Given a set of verified user identity claims, can the given client +// be granted the specified access to the user's data? +// +// This is a shared helper function responsible for checking: +// * whether the identity claims are sufficient to authorize the requested access +// * whether config allows that particular client to request such access at all +// +// It does *not* perform any user or client authentication, assuming that the +// authenticity of the passed-in details has been sufficiently verified by +// calling code. +module.exports.validateRequestedGrant = async function validateRequestedGrant(verifiedClaims, client, requestedGrant) { + requestedGrant.scope = requestedGrant.scope || ScopeSet.fromArray([]); + + // If the grant request is for specific ACR values, do the identity claims support them? + if (requestedGrant.acr_values) { + const acrTokens = requestedGrant.acr_values.trim().split(/\s+/g); + if (acrTokens.includes(ACR_VALUE_AAL2) && ! (verifiedClaims['fxa-aal'] >= 2)) { + throw AppError.mismatchAcr(verifiedClaims['fxa-aal']); + } + } + + // Is an untrusted client requesting scopes that it's not allowed? + if (! client.trusted) { + const invalidScopes = requestedGrant.scope.difference(UNTRUSTED_CLIENT_ALLOWED_SCOPES); + if (! invalidScopes.isEmpty()) { + throw AppError.invalidScopes(invalidScopes.getScopeValues()); + } + } + + // For key-bearing scopes, is the client allowed to request them? + // We probably want to clean this logic up in the future, but for now, + // all trusted clients are allowed to request all non-key-bearing scopes. + const scopeConfig = {}; + const keyBearingScopes = ScopeSet.fromArray([]); + for (const scope of requestedGrant.scope.getScopeValues()) { + const s = scopeConfig[scope] = await db.getScope(scope); + if (s && s.hasScopedKeys) { + keyBearingScopes.add(scope); + } + } + if (! keyBearingScopes.isEmpty()) { + const invalidScopes = keyBearingScopes.difference(ScopeSet.fromString(client.allowedScopes || '')); + if (! invalidScopes.isEmpty()) { + throw AppError.invalidScopes(invalidScopes.getScopeValues()); + } + // Any request for a key-bearing scope should be using a verified token, + // so we can also double-check that here as a defense-in-depth measure. + // + // Note that this directly reflects the `verified` property of the sessionToken + // used to create the assertion, so it can be true for e.g. sessions that were + // verified by email before 2FA was enabled on the account. Such sessions must + // be able to access sync even after 2FA is enabled, hence checking `verified` + // rather than the `aal`-related properties here. + if (! verifiedClaims['fxa-tokenVerified']) { + throw AppError.invalidAssertion(); + } + } + + // If we grow our per-client config, there are more things we could check here: + // * Is this client allowed to request ACCESS_TYPE_OFFLINE? + // * Is this client allowed to request all the non-key-bearing scopes? + // * Do we expect this client to be using OIDC? + + return { + clientId: client.id, + userId: buf(verifiedClaims.uid), + email: verifiedClaims['fxa-verifiedEmail'], + scope: requestedGrant.scope, + scopeConfig, + offline: (requestedGrant.access_type === ACCESS_TYPE_OFFLINE), + authAt: verifiedClaims['fxa-lastAuthAt'], + amr: verifiedClaims['fxa-amr'], + aal: verifiedClaims['fxa-aal'], + profileChangedAt: verifiedClaims['fxa-profileChangedAt'], + keysJwe: requestedGrant.keys_jwe + }; +}; + +// Generate tokens that will give the holder all the access in the specified grant. +// This always include an access_token, but may also include a refresh_token and/or +// id_token if implied by the grant. +// +// This function does *not* perform any authentication or validation, assuming that +// the specified grant has been sufficiently vetted by calling code. +module.exports.generateTokens = async function generateTokens(grant) { + // We always generate an access_token. + const access = await db.generateAccessToken(grant); + const result = { + access_token: access.token.toString('hex'), + token_type: access.type, + scope: access.scope.toString() + }; + result.expires_in = grant.ttl || Math.floor((access.expiresAt - Date.now()) / 1000); + if (grant.authAt) { + result.auth_at = grant.authAt; + } + if (grant.keysJwe) { + result.keys_jwe = grant.keysJwe; + } + // Maybe also generate a refreshToken? + if (grant.offline) { + const refresh = await db.generateRefreshToken(grant); + result.refresh_token = refresh.token.toString('hex'); + } + // Maybe also generate an idToken? + if (grant.scope && grant.scope.contains(SCOPE_OPENID)) { + result.id_token = await generateIdToken(grant, access); + } + return result; +}; + +function generateIdToken(grant, access) { + var now = Math.floor(Date.now() / 1000); + var claims = { + sub: hex(grant.userId), + aud: hex(grant.clientId), + iss: ID_TOKEN_ISSUER, + iat: now, + exp: now + ID_TOKEN_EXPIRATION, + at_hash: util.generateTokenHash(access.token) + }; + if (grant.amr) { + claims.amr = grant.amr; + } + if (grant.aal) { + claims['fxa-aal'] = grant.aal; + claims.acr = 'AAL' + grant.aal; + } + + return ID_TOKEN_KEY.sign(claims); +} diff --git a/fxa-oauth-server/lib/routes/authorization.js b/fxa-oauth-server/lib/routes/authorization.js index b574fcd21..dde642a1c 100644 --- a/fxa-oauth-server/lib/routes/authorization.js +++ b/fxa-oauth-server/lib/routes/authorization.js @@ -2,7 +2,6 @@ * 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 buf = require('buf').hex; const hex = require('buf').to.hex; const Joi = require('joi'); const URI = require('urijs'); @@ -11,31 +10,21 @@ const AppError = require('../error'); const config = require('../config'); const db = require('../db'); const logger = require('../logging')('routes.authorization'); -const P = require('../promise'); -const ScopeSet = require('fxa-shared').oauth.scopes; const validators = require('../validators'); +const { validateRequestedGrant, generateTokens } = require('../grant'); const verifyAssertion = require('../assertion'); -const CODE = 'code'; -const TOKEN = 'token'; +const RESPONSE_TYPE_CODE = 'code'; +const RESPONSE_TYPE_TOKEN = 'token'; const ACCESS_TYPE_ONLINE = 'online'; const ACCESS_TYPE_OFFLINE = 'offline'; -const ACR_VALUE_AAL2 = 'AAL2'; - const PKCE_SHA256_CHALLENGE_METHOD = 'S256'; // This server only supports S256 PKCE, no 'plain' const PKCE_CODE_CHALLENGE_LENGTH = 43; const MAX_TTL_S = config.get('expiration.accessToken') / 1000; -const UNTRUSTED_CLIENT_ALLOWED_SCOPES = ScopeSet.fromArray([ - 'openid', - 'profile:uid', - 'profile:email', - 'profile:display_name' -]); - const allowHttpRedirects = config.get('allowHttpRedirects'); var ALLOWED_SCHEMES = [ @@ -52,92 +41,9 @@ function isLocalHost(url) { return host === 'localhost' || host === '127.0.0.1'; } -function generateCode(claims, client, scope, req) { - return db.generateCode({ - clientId: client.id, - userId: buf(claims.uid), - email: claims['fxa-verifiedEmail'], - scope: scope, - authAt: claims['fxa-lastAuthAt'], - amr: claims['fxa-amr'], - aal: claims['fxa-aal'], - offline: req.payload.access_type === ACCESS_TYPE_OFFLINE, - codeChallengeMethod: req.payload.code_challenge_method, - codeChallenge: req.payload.code_challenge, - keysJwe: req.payload.keys_jwe, - profileChangedAt: claims['fxa-profileChangedAt'] - }).then(function(code) { - logger.debug('redirecting', { uri: req.payload.redirect_uri }); - - code = hex(code); - const redirect = URI(req.payload.redirect_uri) - .addQuery({ state: req.payload.state, code }); - - const out = { - code, - state: req.payload.state, - redirect: String(redirect) - }; - logger.info('generateCode', { - request: { - client_id: req.payload.client_id, - redirect_uri: req.payload.redirect_uri, - scope: req.payload.scope, - state: req.payload.state, - response_type: req.payload.response_type - }, - response: out - }); - return out; - }); -} - -function generateGrant(claims, client, scope, req) { - return db.generateAccessToken({ - clientId: client.id, - userId: buf(claims.uid), - email: claims['fxa-verifiedEmail'], - scope: scope, - ttl: req.payload.ttl, - profileChangedAt: claims['fxa-profileChangedAt'] - }).then(function(token) { - return { - access_token: hex(token.token), - token_type: 'bearer', - expires_in: Math.floor((token.expiresAt - Date.now()) / 1000), - scope: scope.toString(), - auth_at: claims['fxa-lastAuthAt'] - }; - }); -} - -// Check that PKCE is provided if and only if appropriate. -function checkPKCEParams(req, client) { - if (req.payload.response_type === TOKEN) { - // Direct token grant can't use PKCE. - if (req.payload.code_challenge_method) { - throw new AppError.invalidRequestParameter('code_challenge_method'); - } - if (req.payload.code_challenge) { - throw new AppError.invalidRequestParameter('code_challenge'); - } - } else if (client.publicClient) { - // Public clients *must* use PKCE. - if (! req.payload.code_challenge_method || ! req.payload.code_challenge) { - logger.info('client.missingPkceParameters'); - throw AppError.missingPkceParameters(); - } - } else { - // non-Public Clients can't use PKCE. - if (req.payload.code_challenge_method || req.payload.code_challenge) { - logger.info('client.notPublicClient'); - throw AppError.notPublicClient({ id: req.payload.client_id }); - } - } -} - module.exports = { validate: { + payload: { client_id: validators.clientId, assertion: validators.assertion @@ -150,12 +56,12 @@ module.exports = { }), scope: validators.scope, response_type: Joi.string() - .valid(CODE, TOKEN) - .default(CODE), + .valid(RESPONSE_TYPE_CODE, RESPONSE_TYPE_TOKEN) + .default(RESPONSE_TYPE_CODE), state: Joi.string() .max(256) .when('response_type', { - is: TOKEN, + is: RESPONSE_TYPE_TOKEN, then: Joi.optional(), otherwise: Joi.required() }), @@ -164,7 +70,7 @@ module.exports = { .max(MAX_TTL_S) .default(MAX_TTL_S) .when('response_type', { - is: TOKEN, + is: RESPONSE_TYPE_TOKEN, then: Joi.optional(), otherwise: Joi.forbidden() }), @@ -175,20 +81,24 @@ module.exports = { code_challenge_method: Joi.string() .valid(PKCE_SHA256_CHALLENGE_METHOD) .when('response_type', { - is: CODE, + is: RESPONSE_TYPE_CODE, then: Joi.optional(), otherwise: Joi.forbidden() + }) + .when('code_challenge', { + is: Joi.string().required(), + then: Joi.required() }), code_challenge: Joi.string() .length(PKCE_CODE_CHALLENGE_LENGTH) .when('response_type', { - is: CODE, + is: RESPONSE_TYPE_CODE, then: Joi.optional(), otherwise: Joi.forbidden() }), keys_jwe: validators.jwe .when('response_type', { - is: CODE, + is: RESPONSE_TYPE_CODE, then: Joi.optional(), otherwise: Joi.forbidden() }), @@ -218,104 +128,102 @@ module.exports = { ]) }, handler: async function authorizationEndpoint(req) { - /*eslint complexity: [2, 13] */ - logger.debug('response_type', req.payload.response_type); - var start = Date.now(); - var wantsGrant = req.payload.response_type === TOKEN; - var exitEarly = false; - var scope = req.payload.scope; - return P.all([ - verifyAssertion(req.payload.assertion).then(function(claims) { - logger.info('time.verify_assertion', { ms: Date.now() - start }); - if (! claims) { - exitEarly = true; - throw AppError.invalidAssertion(); - } + const claims = await verifyAssertion(req.payload.assertion); - // Check to see if the acr value requested by oauth matches what is expected - const acrValues = req.payload.acr_values; - if (acrValues) { - const acrTokens = acrValues.split('\s+'); - if (acrTokens.includes(ACR_VALUE_AAL2) && ! (claims['fxa-aal'] >= 2)) { - throw AppError.mismatchAcr(claims['fxa-aal']); - } - } - - // 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.getScopeValues(), scope => { - // Don't bother hitting the DB if other checks have failed. - if (exitEarly) { - return; - } - // We know only URL-format scopes can have keys, - // so avoid trips to the DB for common scopes like 'profile'. - if (scope.startsWith('https://')) { - return db.getScope(scope).then(s => { - if (s && s.hasScopedKeys) { - exitEarly = true; - throw AppError.invalidAssertion(); - } - }); - } - }).then(() => { - return claims; - }); - } - return claims; - }), - db.getClient(Buffer.from(req.payload.client_id, 'hex')).then(function(client) { - logger.info('time.db_get_client', { ms: Date.now() - start }); - if (exitEarly) { - // assertion was invalid, we can just stop here - return; - } - if (! client) { - logger.debug('notFound', { id: req.payload.client_id }); - throw AppError.unknownClient(req.payload.client_id); - } else if (! client.trusted) { - var invalidScopes = scope.difference(UNTRUSTED_CLIENT_ALLOWED_SCOPES); - if (! invalidScopes.isEmpty()) { - throw AppError.invalidScopes(invalidScopes.getScopeValues()); - } - } + const client = await db.getClient(Buffer.from(req.payload.client_id, 'hex')); + if (! client) { + logger.debug('notFound', { id: req.payload.client_id }); + throw AppError.unknownClient(req.payload.client_id); + } + validateClientDetails(client, req.payload); + + const grant = await validateRequestedGrant(claims, client, req.payload); + switch (req.payload.response_type) { + case RESPONSE_TYPE_CODE: + return await generateAuthorizationCode(client, req.payload, grant); + case RESPONSE_TYPE_TOKEN: + return await generateImplicitGrant(client, req.payload, grant); + default: + // Joi validation means this should never happen. + logger.critical('joi.response_type', { response_type: req.payload.response_type }); + throw AppError.invalidResponseType(); + } + } +}; - var uri = req.payload.redirect_uri || client.redirectUri; - if (uri !== client.redirectUri) { - logger.debug('redirect.mismatch', { - param: uri, - registered: client.redirectUri - }); +async function generateAuthorizationCode(client, payload, grant) { + // Clients must use PKCE if and only if they are a pubic client. + if (client.publicClient) { + if (! payload.code_challenge_method || ! payload.code_challenge) { + logger.info('client.missingPkceParameters'); + throw AppError.missingPkceParameters(); + } + } else { + if (payload.code_challenge_method || payload.code_challenge) { + logger.info('client.notPublicClient'); + throw AppError.notPublicClient({ id: payload.client_id }); + } + } - if (config.get('localRedirects') && isLocalHost(uri)) { - logger.debug('redirect.local', { uri: uri }); - } else { - throw AppError.incorrectRedirect(uri); - } + const state = payload.state; - } + let code = await db.generateCode(Object.assign(grant, { + codeChallengeMethod: payload.code_challenge_method, + codeChallenge: payload.code_challenge, + })); + code = hex(code); - if (wantsGrant && ! client.canGrant) { - logger.warn('implicitGrant.notAllowed', { - id: req.payload.client_id - }); - throw AppError.invalidResponseType(); - } + const redirect = URI(payload.redirect_uri).addQuery({ code, state }); - req.payload.redirect_uri = uri; + return { + code, + state, + redirect: String(redirect) + }; +} - checkPKCEParams(req, client); +// N.B. We do not correctly implement the "implicit grant" flow from +// RFC6749 which defines `response_type=token`. Instead we have a +// privileged set of clients that use `response_type=token` for something +// approximating the "resource owner password grant" flow, using an identity +// assertion to just directly grant tokens for their own use. Known current +// users of this functinality include: +// +// * Firefox Desktop, for getting "profile"-scoped tokens to access profile data +// * Firefox for Android, for getting "profile"-scoped tokens to access profile data +// * Firefox for iOS, for getting "profile"-scoped tokens to access profile data +// +// New clients should not do this, and should instead of `grant_type=fxa-credentials` +// on the /token endpoint. +// +// This route is kept for backwards-compatibility only. +async function generateImplicitGrant(client, payload, grant) { + if (! client.canGrant) { + logger.warn('grantType.notAllowed', { + id: hex(client.id), + grant_type: 'fxa-credentials' + }); + throw AppError.invalidResponseType(); + } + return generateTokens(Object.assign(grant, { + ttl: payload.ttl, + })); +} - return client; - }).catch(err => { - exitEarly = true; - throw err; - }), - scope, - req - ]) - .spread(wantsGrant ? generateGrant : generateCode); +function validateClientDetails(client, payload) { + // Clients must use a single specific redirect_uri, + // but they're allowed to not provide one and have us fill it in automatically. + payload.redirect_uri = payload.redirect_uri || client.redirectUri; + if (payload.redirect_uri !== client.redirectUri) { + logger.debug('redirect.mismatch', { + param: payload.redirect_uri, + registered: client.redirectUri + }); + if (config.get('localRedirects') && isLocalHost(payload.redirect_uri)) { + logger.debug('redirect.local', { uri: payload.redirect_uri }); + } else { + throw AppError.incorrectRedirect(payload.redirect_uri); + } } -}; +} diff --git a/fxa-oauth-server/lib/routes/key_data.js b/fxa-oauth-server/lib/routes/key_data.js index 334f16a0b..d2033cb01 100644 --- a/fxa-oauth-server/lib/routes/key_data.js +++ b/fxa-oauth-server/lib/routes/key_data.js @@ -7,10 +7,9 @@ const Joi = require('joi'); const AppError = require('../error'); const db = require('../db'); const logger = require('../logging')('routes.key_data'); -const P = require('../promise'); const validators = require('../validators'); const verifyAssertion = require('../assertion'); -const ScopeSet = require('fxa-shared').oauth.scopes; +const { validateRequestedGrant } = require('../grant'); /** * We don't yet support rotating individual scoped keys, @@ -41,41 +40,31 @@ module.exports = { ]) }, handler: async function keyDataRoute(req) { - logger.debug('keyDataRoute.start', { - params: req.params, - payload: req.payload - }); + const claims = await verifyAssertion(req.payload.assertion); - const requestedScopes = req.payload.scope; - const requestedClientId = req.payload.client_id; + const client = await db.getClient(Buffer.from(req.payload.client_id, 'hex')); + if (! client) { + logger.debug('keyDataRoute.clientNotFound', { id: req.payload.client_id }); + throw AppError.unknownClient(req.payload.client_id); + } - const [claims, scopes] = await P.all([ - verifyAssertion(req.payload.assertion), - db.getClient(Buffer.from(requestedClientId, 'hex')).then((client) => { - if (client) { - // find all requested scopes that are allowed for this client. - const allowedScopes = ScopeSet.fromString(client.allowedScopes || ''); - const scopeLookups = requestedScopes.filtered(allowedScopes).getScopeValues().map(scope => db.getScope(scope)); - return P.all(scopeLookups).then(scopeRecords => { - return scopeRecords.filter(scope => !! (scope && scope.hasScopedKeys)) - .map(scope => { - // When we implement key rotation these values will come from the db. - // For now all scoped keys have the default values. - scope.keyRotationSecret = DEFAULT_KEY_ROTATION_SECRET; - scope.keyRotationTimestamp = DEFAULT_KEY_ROTATION_TIMESTAMP; - return scope; - }); - }); - } else { - logger.debug('keyDataRoute.clientNotFound', { id: req.payload.client_id }); - throw AppError.unknownClient(requestedClientId); - } - }) - ]); + const requestedGrant = await validateRequestedGrant(claims, client, req.payload); + + const keyBearingScopes = []; + for (const scope of req.payload.scope.getScopeValues()) { + const s = requestedGrant.scopeConfig[scope]; + if (s && s.hasScopedKeys) { + // When we implement key rotation these values will come from the db. + // For now all scoped keys have the default values. + s.keyRotationSecret = DEFAULT_KEY_ROTATION_SECRET; + s.keyRotationTimestamp = DEFAULT_KEY_ROTATION_TIMESTAMP; + keyBearingScopes.push(s); + } + } const iat = claims.iat || claims['fxa-lastAuthAt']; const response = {}; - scopes.forEach((keyScope) => { + for (const keyScope of keyBearingScopes) { const keyRotationTimestamp = Math.max(claims['fxa-generation'], keyScope.keyRotationTimestamp); // If the assertion certificate was issued prior to a key-rotation event, // we don't want to revel the new secrets to such stale assertions, @@ -88,7 +77,7 @@ module.exports = { keyRotationSecret: keyScope.keyRotationSecret, keyRotationTimestamp }; - }); + } return response; } diff --git a/fxa-oauth-server/lib/routes/token.js b/fxa-oauth-server/lib/routes/token.js index 9d4afd880..c6ce9de92 100644 --- a/fxa-oauth-server/lib/routes/token.js +++ b/fxa-oauth-server/lib/routes/token.js @@ -9,6 +9,7 @@ // // * `grant_type=authorization_code` for vanilla exchange-a-code-for-a-token OAuth // * `grant_type=refresh_token` for refreshing a previously-granted token + // * `grant_type=fxa-credentials` for directly granting via an FxA identity assertion // // And because of the different types of token that can be requested: // @@ -19,7 +20,7 @@ // And because of the different client authentication methods: // // * `client_secret`, provided in either header or request body - // * PKCE parameters, if using `grant_type=authorization_code` + // * PKCE parameters, if using `grant_type=authorization_code` with a public client // // So, we've tried to make it as readable as possible, but...be careful in there! @@ -29,8 +30,6 @@ const AppError = require('../error'); const buf = require('buf').hex; const hex = require('buf').to.hex; const Joi = require('joi'); -const JwTool = require('fxa-jwtool'); -const ScopeSet = require('fxa-shared').oauth.scopes; const config = require('../config'); const db = require('../db'); @@ -38,19 +37,21 @@ const encrypt = require('../encrypt'); const logger = require('../logging')('routes.token'); const util = require('../util'); const validators = require('../validators'); +const { validateRequestedGrant, generateTokens } = require('../grant'); +const verifyAssertion = require('../assertion'); const MAX_TTL_S = config.get('expiration.accessToken') / 1000; const GRANT_AUTHORIZATION_CODE = 'authorization_code'; const GRANT_REFRESH_TOKEN = 'refresh_token'; +// This is a custom grant type, so we use our standard "fxa-" prefix to avoid collisions. +// It's similar to the "Resource Owner Password Credentials" grant from [1] but uses an +// FxA identity assertion rather than directly specifying a password. +// [1] https://tools.ietf.org/html/rfc6749#section-1.3.3 +const GRANT_FXA_ASSERTION = 'fxa-credentials'; -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'); -const ID_TOKEN_KEY = JwTool.JWK.fromObject(config.get('openid.key'), { - iss: ID_TOKEN_ISSUER -}); +const ACCESS_TYPE_ONLINE = 'online'; +const ACCESS_TYPE_OFFLINE = 'offline'; const REFRESH_LAST_USED_AT_UPDATE_AFTER_MS = config.get('refreshToken.updateAfter'); @@ -68,7 +69,7 @@ const PAYLOAD_SCHEMA = Joi.object({ // The client_secret can be specified in Authorization header or request body, // but not both. In the code flow it is exclusive with `code_verifier`, and - // in the refresh flow it's optional because of public clients. + // in the refresh and fxa-credentials flows it's optional because of public clients. client_secret: validators.clientSecret .when('code_verifier', { is: Joi.string().required(), @@ -78,17 +79,23 @@ const PAYLOAD_SCHEMA = Joi.object({ is: GRANT_REFRESH_TOKEN, then: Joi.optional() }) + .when('grant_type', { + is: GRANT_FXA_ASSERTION, + then: Joi.optional() + }) .when('$headers.authorization', { is: Joi.string().required(), then: Joi.forbidden() }), - code_verifier: validators.codeVerifier, - - redirect_uri: validators.redirectUri.optional(), + redirect_uri: validators.redirectUri.optional() + .when('grant_type', { + is: GRANT_AUTHORIZATION_CODE, + otherwise: Joi.forbidden() + }), grant_type: Joi.string() - .valid(GRANT_AUTHORIZATION_CODE, GRANT_REFRESH_TOKEN) + .valid(GRANT_AUTHORIZATION_CODE, GRANT_REFRESH_TOKEN, GRANT_FXA_ASSERTION) .default(GRANT_AUTHORIZATION_CODE) .optional(), @@ -100,10 +107,18 @@ const PAYLOAD_SCHEMA = Joi.object({ scope: validators.scope .when('grant_type', { - is: GRANT_REFRESH_TOKEN, + is: Joi.string().valid(GRANT_REFRESH_TOKEN, GRANT_FXA_ASSERTION), otherwise: Joi.forbidden() }), + access_type: Joi.string() + .valid(ACCESS_TYPE_OFFLINE, ACCESS_TYPE_ONLINE) + .default(ACCESS_TYPE_ONLINE) + .optional() + .when('grant_type', { + is: GRANT_FXA_ASSERTION, + otherwise: Joi.forbidden() + }), code: Joi.string() .length(config.get('unique.code') * 2) .regex(validators.HEX_STRING) @@ -113,12 +128,26 @@ const PAYLOAD_SCHEMA = Joi.object({ otherwise: Joi.forbidden() }), + code_verifier: validators.codeVerifier + .when('code', { + is: Joi.string().required(), + otherwise: Joi.forbidden() + }), + refresh_token: validators.token .required() .when('grant_type', { is: GRANT_REFRESH_TOKEN, otherwise: Joi.forbidden() + }), + + assertion: validators.assertion + .required() + .when('grant_type', { + is: GRANT_FXA_ASSERTION, + otherwise: Joi.forbidden() }) + }); module.exports = { @@ -179,6 +208,9 @@ async function authenticateClient(params) { } // Check client_secret against both current and previous stored secrets, // to allow for seamless rotation of the secret. + if (! params.client_secret) { + throw new AppError.invalidRequestParameter('client_secret'); + } const submitted = encrypt.hash(buf(params.client_secret)); const stored = client.hashedSecret; if (crypto.timingSafeEqual(submitted, stored)) { @@ -220,6 +252,9 @@ async function validateGrantParameters(client, params) { case GRANT_REFRESH_TOKEN: requestedGrant = await validateRefreshTokenGrant(client, params); break; + case GRANT_FXA_ASSERTION: + requestedGrant = await validateAssertionGrant(client, params); + break; default: // Joi validation means this should never happen. logger.critical('joi.grant_type', { grant_type: params.grant_type } ); @@ -334,6 +369,28 @@ async function validateRefreshTokenGrant(client, params) { return tokObj; } + +async function validateAssertionGrant(client, params) { + // Is the client allowed to do direct grants? + if (! client.canGrant) { + logger.warn('grantType.notAllowed', { + id: hex(client.id), + grant_type: 'fxa-credentials' + }); + throw AppError.invalidGrantType(); + } + // There's no reason a non-public client should ever be allowed + // to do direct grants, check that as well for extra safety. + if (! client.publicClient) { + throw AppError.notPublicClient(client.id); + } + // Did it provide a valid identity assertion? + const claims = await verifyAssertion(params.assertion); + // Is the client allowed to have all the scopes etc in the requested grant? + return await validateRequestedGrant(claims, client, params); +} + + /** * Generate a PKCE code_challenge * See https://tools.ietf.org/html/rfc7636#section-4.6 for details @@ -342,52 +399,3 @@ function pkceHash(input) { return util.base64URLEncode(crypto.createHash('sha256').update(input).digest()); } - -function generateIdToken(options, access) { - var now = Math.floor(Date.now() / 1000); - var claims = { - sub: hex(options.userId), - aud: hex(options.clientId), - iss: ID_TOKEN_ISSUER, - iat: now, - exp: now + ID_TOKEN_EXPIRATION, - at_hash: util.generateTokenHash(access.token) - }; - if (options.amr) { - claims.amr = options.amr; - } - if (options.aal) { - claims['fxa-aal'] = options.aal; - claims.acr = 'AAL' + options.aal; - } - - return ID_TOKEN_KEY.sign(claims); -} - -async function generateTokens(options) { - // We always generate an access_token. - const access = await db.generateAccessToken(options); - const result = { - access_token: access.token.toString('hex'), - token_type: access.type, - scope: access.scope.toString() - }; - result.expires_in = options.ttl; - if (options.authAt) { - result.auth_at = options.authAt; - } - if (options.keysJwe) { - result.keys_jwe = options.keysJwe; - } - // Maybe also generate a refreshToken? - if (options.offline) { - const refresh = await db.generateRefreshToken(options); - result.refresh_token = refresh.token.toString('hex'); - } - // Maybe also generate an idToken? - if (options.scope && options.scope.contains(SCOPE_OPENID)) { - result.id_token = await generateIdToken(options, access); - } - return result; -} - diff --git a/fxa-oauth-server/test/api.js b/fxa-oauth-server/test/api.js index aadb9d8c8..0db861be6 100644 --- a/fxa-oauth-server/test/api.js +++ b/fxa-oauth-server/test/api.js @@ -1869,6 +1869,125 @@ describe('/v1', function() { }); + describe('?grant_type=fxa-credentials', function () { + + const clientId = '98e6508e88680e1a'; + + it('assertion param should be required', async () => { + const res = await Server.api.post({ + url: '/token', + payload: { + client_id: clientId, + grant_type: 'fxa-credentials' + } + }); + assertInvalidRequestParam(res.result, 'assertion'); + assertSecurityHeaders(res); + }); + + it('can directly grant a token with valid assertion', async () => { + mockAssertion().reply(200, VERIFY_GOOD); + const res = await Server.api.post({ + url: '/token', + payload: { + client_id: clientId, + grant_type: 'fxa-credentials', + scope: 'profile testme', + assertion: AN_ASSERTION, + } + }); + assert.equal(res.statusCode, 200); + assert.ok(res.result.expires_in); + assert.ok(res.result.access_token); + assert.equal(res.result.scope, 'profile testme'); + assert.equal(res.result.refresh_token, undefined); + }); + + it('can create a refresh token if requested', async () => { + mockAssertion().reply(200, VERIFY_GOOD); + const res = await Server.api.post({ + url: '/token', + payload: { + client_id: clientId, + grant_type: 'fxa-credentials', + scope: 'profile testme', + access_type: 'offline', + assertion: AN_ASSERTION, + } + }); + assertSecurityHeaders(res); + assert.equal(res.statusCode, 200); + assert.ok(res.result.expires_in); + assert.ok(res.result.access_token); + assert.equal(res.result.scope, 'profile testme'); + assert.ok(res.result.refresh_token); + }); + + it('accepts configurable ttl', async () => { + mockAssertion().reply(200, VERIFY_GOOD); + const res = await Server.api.post({ + url: '/token', + payload: { + client_id: clientId, + grant_type: 'fxa-credentials', + ttl: 42, + assertion: AN_ASSERTION, + } + }); + assertSecurityHeaders(res); + assert.equal(res.statusCode, 200); + assert(res.result.expires_in <= 42); + }); + + it('rejects invalid assertions', async () => { + mockAssertion().reply(400, '{"status":"failure"}'); + const res = await Server.api.post({ + url: '/token', + payload: { + client_id: clientId, + grant_type: 'fxa-credentials', + scope: 'profile testme', + access_type: 'offline', + assertion: AN_ASSERTION, + } + }); + assertSecurityHeaders(res); + assert.equal(res.statusCode, 401); + assert.equal(res.result.message, 'Invalid assertion'); + }); + + it('rejects clients that are not allowed to grant', async () => { + const clientId = NO_KEY_SCOPES_CLIENT_ID; + const res = await Server.api.post({ + url: '/token', + payload: { + client_id: clientId, + grant_type: 'fxa-credentials', + assertion: AN_ASSERTION, + } + }); + assertSecurityHeaders(res); + assert.equal(res.statusCode, 400); + assert.equal(res.result.message, 'Invalid grant_type'); + }); + + it('rejects disallowed scopes', async () => { + mockAssertion().reply(200, VERIFY_GOOD); + const res = await Server.api.post({ + url: '/token', + payload: { + client_id: clientId, + grant_type: 'fxa-credentials', + scope: SCOPE_CAN_SCOPE_KEY, + assertion: AN_ASSERTION, + } + }); + assertSecurityHeaders(res); + assert.equal(res.statusCode, 400); + assert.equal(res.result.message, 'Requested scopes are not allowed'); + }); + }); + describe('?scope=openid', function() { function decodeJWT(b64) { @@ -2699,27 +2818,27 @@ describe('/v1', function() { }); }); - it('succeeds for clients that do not have the scope, returning an empty object', () => { + it('fails for clients that are not allowed the requested scope', () => { genericRequest.payload.client_id = NO_KEY_SCOPES_CLIENT_ID; mockAssertion().reply(200, VERIFY_GOOD); return Server.api.post(genericRequest) .then((res) => { - assert.equal(res.statusCode, 200); + assert.equal(res.statusCode, 400); + assert.equal(res.result.message, 'Requested scopes are not allowed'); assertSecurityHeaders(res); - assert.equal(Object.keys(res.result).length, 0, 'no scoped keys'); }); }); - it('succeeds for clients that have no allowedScopes, returning an empty object', () => { + it('fails for clients that have no allowedScopes', () => { genericRequest.payload.client_id = NO_ALLOWED_SCOPES_CLIENT_ID; mockAssertion().reply(200, VERIFY_GOOD); return Server.api.post(genericRequest) .then((res) => { - assert.equal(res.statusCode, 200); + assert.equal(res.statusCode, 400); + assert.equal(res.result.message, 'Requested scopes are not allowed'); assertSecurityHeaders(res); - assert.equal(Object.keys(res.result).length, 0, 'no scoped keys'); }); }); diff --git a/fxa-oauth-server/test/grant.js b/fxa-oauth-server/test/grant.js new file mode 100644 index 000000000..e2efd21ad --- /dev/null +++ b/fxa-oauth-server/test/grant.js @@ -0,0 +1,150 @@ +/* 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 { assert } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); + +const ScopeSet = require('fxa-shared').oauth.scopes; +const AppError = require('../lib/error'); + +async function assertThrowsAsync(fn, errorLike, errMsgMatcher, message) { + let threw = null; + return fn().catch(err => { + threw = err; + }).then(() => { + // Use synchronous `assert.throws` to get all the nice matching logic. + assert.throws(() => { + if (threw) { + throw threw; + } + }, errorLike, errMsgMatcher, message); + }); +} + +const CLAIMS = { + 'uid': 'ABCDEF123456', + 'fxa-generation': 12345, + 'fxa-verifiedEmail': 'test@example.com', + 'fxa-lastAuthAt': Date.now(), + 'fxa-tokenVerified': true, + 'fxa-amr': ['pwd'], + 'fxa-aal': 1, + 'fxa-profileChangedAt': Date.now() +}; + +const CLIENT = { + id: Buffer.from('0123456789', 'hex'), + trusted: true, +}; + +describe('validateRequestedGrant', () => { + + let mockDB, validateRequestedGrant; + + beforeEach(() => { + mockDB = {}; + validateRequestedGrant = proxyquire('../lib/grant', { './db': mockDB }).validateRequestedGrant; + }); + + it('should return validated grant data', async () => { + const scope = ScopeSet.fromArray(['profile']); + const grant = await validateRequestedGrant(CLAIMS, CLIENT, { scope }); + assert.deepEqual(grant, { + clientId: CLIENT.id, + userId: Buffer.from(CLAIMS.uid, 'hex'), + email: CLAIMS['fxa-verifiedEmail'], + scope, + scopeConfig: { profile: null }, + offline: false, + authAt: CLAIMS['fxa-lastAuthAt'], + amr: CLAIMS['fxa-amr'], + aal: CLAIMS['fxa-aal'], + profileChangedAt: CLAIMS['fxa-profileChangedAt'], + keysJwe: undefined, + }); + }); + + it('should allow unchecked AAL if not requested in acr_values', async () => { + let grant = await validateRequestedGrant(CLAIMS, CLIENT, {}); + assert.equal(grant.aal, 1); + grant = await validateRequestedGrant(CLAIMS, CLIENT, { acr_values: 'AAL1' }); + assert.equal(grant.aal, 1); + }); + + it('should require AAL2 or higher if requested in acr_values', async () => { + const requestedGrant = { + acr_values: 'AAL2' + }; + await assertThrowsAsync(async () => { + await validateRequestedGrant(CLAIMS, CLIENT, requestedGrant); + }, AppError, 'Mismatch acr value'); + let grant = await validateRequestedGrant({...CLAIMS, 'fxa-aal': 2}, CLIENT, requestedGrant); + assert.equal(grant.aal, 2); + grant = await validateRequestedGrant({ ...CLAIMS, 'fxa-aal': 17 }, CLIENT, requestedGrant); + assert.equal(grant.aal, 17); + }); + + it('should correctly split acr_values on whitespace', async () => { + const requestedGrant = { + acr_values: 'AAL4 AAL2 AAL3' + }; + await assertThrowsAsync(async () => { + await validateRequestedGrant(CLAIMS, CLIENT, requestedGrant); + }, AppError, 'Mismatch acr value'); + const grant = await validateRequestedGrant({ ...CLAIMS, 'fxa-aal': 2 }, CLIENT, requestedGrant); + assert.equal(grant.aal, 2); + }); + + it('should reject disallowed scopes for untrusted clients', async () => { + const requestedGrant = { + scope: ScopeSet.fromArray(['profile']) + }; + const grant = await validateRequestedGrant(CLAIMS, { ...CLIENT, trusted: true }, requestedGrant); + assert.equal(grant.scope.toString(), 'profile'); + await assertThrowsAsync(async () => { + await validateRequestedGrant(CLAIMS, { ...CLIENT, trusted: false }, requestedGrant); + }, AppError, 'Requested scopes are not allowed'); + }); + + it('should allow restricted set of scopes for untrusted clients', async () => { + const requestedGrant = { + scope: ScopeSet.fromArray(['profile:uid', 'profile:email']) + }; + let grant = await validateRequestedGrant(CLAIMS, { ...CLIENT, trusted: true }, requestedGrant); + assert.equal(grant.scope.toString(), 'profile:uid profile:email'); + grant = await validateRequestedGrant(CLAIMS, { ...CLIENT, trusted: false }, requestedGrant); + assert.equal(grant.scope.toString(), 'profile:uid profile:email'); + }); + + it('should check key-bearing scopes in the database, and reject if not allowed for that client', async () => { + sinon.stub(mockDB, 'getScope').callsFake(async () => { + return { hasScopedKeys: true }; + }); + const requestedGrant = { + scope: ScopeSet.fromArray(['https://identity.mozilla.com/apps/oldsync']) + }; + await assertThrowsAsync(async () => { + await validateRequestedGrant(CLAIMS, CLIENT, requestedGrant); + }, AppError, 'Requested scopes are not allowed'); + assert.equal(mockDB.getScope.callCount, 1); + + const allowedClient = { ...CLIENT, allowedScopes: 'https://identity.mozilla.com/apps/oldsync' }; + const grant = await validateRequestedGrant(CLAIMS, allowedClient, requestedGrant); + assert.equal(mockDB.getScope.callCount, 2); + assert.equal(grant.scope.toString(), 'https://identity.mozilla.com/apps/oldsync'); + }); + + it('should reject key-bearing scopes requested with claims from an unverified session', async () => { + sinon.stub(mockDB, 'getScope').callsFake(async () => { + return { hasScopedKeys: true }; + }); + const requestedGrant = { + scope: ScopeSet.fromArray(['https://identity.mozilla.com/apps/oldsync']) + }; + await assertThrowsAsync(async () => { + await validateRequestedGrant({ ...CLAIMS, 'fxa-tokenVerified': false }, CLIENT, requestedGrant); + }, AppError, 'Requested scopes are not allowed'); + }); +}); diff --git a/fxa-oauth-server/test/routes/authorization.js b/fxa-oauth-server/test/routes/authorization.js index 0c77f5004..2152a9b0b 100644 --- a/fxa-oauth-server/test/routes/authorization.js +++ b/fxa-oauth-server/test/routes/authorization.js @@ -112,7 +112,7 @@ describe('/authorization POST', function () { code_challenge: PKCE_CODE_CHALLENGE, code_challenge_method: PKCE_CODE_CHALLENGE_METHOD, response_type: 'token' - }, 'code_challenge_method', 'is not allowed'); + }, 'code_challenge', 'is not allowed'); }); }); diff --git a/fxa-oauth-server/test/routes/token.js b/fxa-oauth-server/test/routes/token.js index 8e7655c72..e2f7f89a8 100644 --- a/fxa-oauth-server/test/routes/token.js +++ b/fxa-oauth-server/test/routes/token.js @@ -79,7 +79,8 @@ describe('/token POST', function () { it('forbids client_secret when authz header provided', (done) => { v({ - client_secret: CLIENT_SECRET + client_secret: CLIENT_SECRET, + code: CODE // If we don't send `code`, then the missing `code` will fail validation first. }, { headers: { authorization: 'Basic ABCDEF'