packages/fxa-auth-server/lib/routes/oauth/token.js (584 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/. */
// This file implements the OAuth "token endpoint", the core endpoint of the OAuth
// system at which clients can exchange their various types of authorization grant
// for some OAuth tokens. There's significant complexity here because of the
// different types of grant:
//
// * `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:
//
// * A short-lived `access_token`
// * A long-lived `refresh_token`, via `access_type=offline`
// * An OpenID Connect `id_token`, via `scope=openid`
//
// 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` with a public client
//
// So, we've tried to make it as readable as possible, but...be careful in there!
/*jshint camelcase: false*/
const crypto = require('crypto');
const OauthError = require('../../oauth/error');
const AuthError = require('../../error');
const buf = require('buf').hex;
const hex = require('buf').to.hex;
const Joi = require('joi');
const {
OAUTH_SCOPE_OLD_SYNC,
OAUTH_SCOPE_SESSION_TOKEN,
} = require('fxa-shared/oauth/constants');
const { config } = require('../../../config');
const encrypt = require('fxa-shared/auth/encrypt');
const util = require('../../oauth/util');
const oauthRouteUtils = require('../utils/oauth');
const token = require('../../oauth/token');
const validators = require('../../oauth/validators');
const { validateRequestedGrant, generateTokens } = require('../../oauth/grant');
const verifyAssertion = require('../../oauth/assertion');
const { makeAssertionJWT } = require('../../oauth/util');
const {
authenticateClient,
clientAuthValidators,
} = require('../../oauth/client');
const ScopeSet = require('fxa-shared').oauth.scopes;
const OAUTH_DOCS = require('../../../docs/swagger/oauth-api').default;
const OAUTH_SERVER_DOCS =
require('../../../docs/swagger/oauth-server-api').default;
const DESCRIPTION =
require('../../../docs/swagger/shared/descriptions').default;
const updateLastAccessTime = config.get(
'lastAccessTimeUpdates.onOAuthTokenCreation'
);
const MAX_TTL_S = config.get('oauthServer.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 ACCESS_TYPE_ONLINE = 'online';
const ACCESS_TYPE_OFFLINE = 'offline';
const DISABLED_CLIENTS = new Set(config.get('oauthServer.disabledClients'));
// These scopes are used to request a one-off exchange of claims or credentials,
// but they don't make sense to use on an ongoing basis via refresh tokens.
const SCOPES_TO_EXCLUDE_FROM_REFRESH_TOKEN_GRANTS = ScopeSet.fromArray([
'openid',
'https://identity.mozilla.com/tokens/session',
]);
const PAYLOAD_SCHEMA = Joi.object({
client_id: clientAuthValidators.clientId.description(DESCRIPTION.clientId),
// 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 and fxa-credentials flows it's optional because of public clients.
client_secret: clientAuthValidators.clientSecret
.when('code_verifier', {
is: Joi.string().required(),
then: Joi.forbidden(),
})
.when('grant_type', {
is: GRANT_REFRESH_TOKEN,
then: Joi.optional(),
})
.when('grant_type', {
is: GRANT_FXA_ASSERTION,
then: Joi.optional(),
})
.description(DESCRIPTION.clientSecret),
redirect_uri: validators.redirectUri
.optional()
.when('grant_type', {
is: GRANT_AUTHORIZATION_CODE,
otherwise: Joi.forbidden(),
})
.description(DESCRIPTION.redirectUri),
grant_type: Joi.string()
.valid(GRANT_AUTHORIZATION_CODE, GRANT_REFRESH_TOKEN, GRANT_FXA_ASSERTION)
.default(GRANT_AUTHORIZATION_CODE)
.optional()
.description(DESCRIPTION.grantTypeOauth),
ttl: Joi.number()
.positive()
.default(MAX_TTL_S)
.optional()
.description(DESCRIPTION.ttlOauth),
scope: Joi.alternatives()
.conditional('grant_type', {
is: GRANT_REFRESH_TOKEN,
then: validators.scope.optional(),
})
.conditional('grant_type', {
is: GRANT_FXA_ASSERTION,
then: validators.scope.required(),
otherwise: Joi.forbidden(),
})
.description(DESCRIPTION.scope),
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(),
})
.description(DESCRIPTION.accessType),
code: Joi.string()
.length(config.get('oauthServer.unique.code') * 2)
.regex(validators.HEX_STRING)
.required()
.when('grant_type', {
is: GRANT_AUTHORIZATION_CODE,
otherwise: Joi.forbidden(),
})
.description(DESCRIPTION.codeOauth),
code_verifier: validators.codeVerifier
.when('code', {
is: Joi.string().required(),
otherwise: Joi.forbidden(),
})
.description(DESCRIPTION.codeVerifier),
refresh_token: validators.token
.required()
.when('grant_type', {
is: GRANT_REFRESH_TOKEN,
otherwise: Joi.forbidden(),
})
.description(DESCRIPTION.refreshToken),
assertion: validators.assertion
.required()
.when('grant_type', {
is: GRANT_FXA_ASSERTION,
otherwise: Joi.forbidden(),
})
.description(DESCRIPTION.assertion),
ppid_seed: validators.ppidSeed.optional().description(DESCRIPTION.ppidSeed),
resource: validators.resourceUrl.optional().description(DESCRIPTION.resource),
});
module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => {
async function validateGrantParameters(client, params) {
let requestedGrant;
switch (params.grant_type) {
case GRANT_AUTHORIZATION_CODE:
requestedGrant = await validateAuthorizationCodeGrant(client, params);
break;
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.
throw Error('unreachable');
}
requestedGrant.name = client.name;
requestedGrant.canGrant = client.canGrant;
requestedGrant.publicClient = client.publicClient;
requestedGrant.grantType = params.grant_type;
requestedGrant.ppidSeed = params.ppid_seed;
requestedGrant.resource = params.resource;
requestedGrant.ttl = Math.min(params.ttl, MAX_TTL_S);
return requestedGrant;
}
async function validateAuthorizationCodeGrant(client, params) {
const code = params.code;
// PKCE should only be used by public clients, and we can check this
// before even looking up the code in the db.
let pkceHashValue;
if (params.code_verifier) {
if (!client.publicClient) {
log.debug('client.notPublicClient', { id: client.id });
throw OauthError.notPublicClient(client.id);
}
pkceHashValue = pkceHash(params.code_verifier);
}
// Does the code actually exist?
const codeObj = await oauthDB.getCode(buf(code));
if (!codeObj) {
log.debug('code.notFound', { code: code });
throw OauthError.unknownCode(code);
}
// Does it belong to this client?
if (!crypto.timingSafeEqual(codeObj.clientId, client.id)) {
log.debug('code.mismatch', {
client: hex(client.id),
code: hex(codeObj.code),
});
throw OauthError.mismatchCode(code, client.id);
}
// Has it expired?
// The + is because loldatemath; without it, it does string concat.
const expiresAt =
+codeObj.createdAt + config.get('oauthServer.expiration.code');
if (Date.now() > expiresAt) {
log.debug('code.expired', { code: code });
throw OauthError.expiredCode(code, expiresAt);
}
// If it used PKCE...
if (codeObj.codeChallenge) {
// Was it using the one variant that we support?
// We should never have written such data, but double-checking in case we add
// other methods in future but forget to update the code here.
if (codeObj.codeChallengeMethod !== 'S256') {
throw OauthError.mismatchCodeChallenge(pkceHashValue);
}
// Did they provide a challenge verifier at all?
if (!pkceHashValue) {
throw OauthError.missingPkceParameters();
}
// Did they provide the *correct* challenge verifier?
if (
!crypto.timingSafeEqual(
Buffer.from(codeObj.codeChallenge),
Buffer.from(pkceHashValue)
)
) {
throw OauthError.mismatchCodeChallenge(pkceHashValue);
}
}
// Is a very confused client is attempting to use PCKE to claim a code
// that wasn't actually created using PKCE?
if (params.code_verifier && !codeObj.codeChallenge) {
throw OauthError.mismatchCodeChallenge(pkceHashValue);
}
// Looks legit! Codes are one-time-use, so remove it from the db.
await oauthDB.removeCode(buf(code));
return codeObj;
}
async function validateRefreshTokenGrant(client, params) {
// Does the refresh token actually exist?
const tokObj = await oauthDB.getRefreshToken(
encrypt.hash(params.refresh_token)
);
if (!tokObj) {
log.debug('refresh_token.notFound', {
refresh_token: params.refresh_token,
});
throw OauthError.invalidToken();
}
// Does it belong to this client?
if (!crypto.timingSafeEqual(tokObj.clientId, client.id)) {
log.debug('refresh_token.mismatch', {
client: hex(client.id),
code: tokObj.clientId,
});
throw OauthError.invalidToken();
}
// Scope should default to those previously requested,
// but can be further limited or increased on request.
if (params.scope) {
// Untrusted clients can not request *extra* scopes using this grant.
// However, we do allow trusted clients to request additional scopes in the
// clients allowedScopes property
if (!tokObj.scope.contains(params.scope)) {
const allowedScopes = ScopeSet.fromArray(
client.allowedScopes ? client.allowedScopes.split(' ') : []
).union(tokObj.scope);
if (!client.trusted || !allowedScopes.contains(params.scope)) {
log.debug('refresh_token.invalidScopes', {
allowed: tokObj.scope,
requested: params.scope,
});
throw OauthError.invalidScopes(
params.scope.difference(tokObj.scope).getScopeValues()
);
}
}
tokObj.scope = params.scope;
}
// Some scopes represent a one-off exchange of claims or credentials and
// don't make sense to use with a refresh token. Exclude them.
tokObj.scope = tokObj.scope.difference(
SCOPES_TO_EXCLUDE_FROM_REFRESH_TOKEN_GRANTS
);
// An additional sanity-check that we don't accidentally grant refresh tokens
// from other refresh tokens. There should be no way to trigger this in practice.
if (tokObj.offline) {
throw OauthError.invalidRequestParameter();
}
return tokObj;
}
async function validateAssertionGrant(client, params) {
// Is the client allowed to do direct grants?
if (!client.canGrant) {
log.warn('grantType.notAllowed', {
id: hex(client.id),
grant_type: 'fxa-credentials',
});
throw OauthError.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 OauthError.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
*/
function pkceHash(input) {
return util.base64URLEncode(
crypto.createHash('sha256').update(input).digest()
);
}
async function tokenHandler(req) {
var params = req.payload;
const client = await authenticateClient(req.headers, params);
// Refuse to generate new access tokens for disabled clients that are already
// connected to the account. We allow disabled clients to claim existing authorization
// codes, because otherwise we risk erroring out halfway through an app login flow
// and presenting a very confusing user experience. The /authorization endpoint refuses
// to create new codes for disabled clients.
if (
DISABLED_CLIENTS.has(hex(client.id)) &&
params.grant_type !== GRANT_AUTHORIZATION_CODE
) {
throw OauthError.disabledClient(hex(client.id));
}
const grant = await validateGrantParameters(client, params);
const tokens = await generateTokens(grant);
const uid = hex(grant.userId);
const oauthClientId = hex(grant.clientId);
req.emitMetricsEvent('token.created', {
service: oauthClientId,
uid,
});
glean.oauth.tokenCreated(req, {
uid,
oauthClientId,
reason: req.payload?.grant_type || '',
});
// the client receiving keys at the end of the scoped keys flow
if (tokens.keys_jwe) {
statsd.increment('oauth.rp.keys-jwe', { clientId: oauthClientId });
}
return tokens;
}
return [
{
method: 'POST',
path: '/token',
config: {
...OAUTH_SERVER_DOCS.TOKEN_POST,
cors: { origin: 'ignore' },
validate: {
headers: clientAuthValidators.headers,
// stripUnknown is used to allow various oauth2 libraries to be used
// with FxA OAuth. Sometimes, they will send other parameters that
// we don't use, such as `response_type`, or something else. Instead
// of giving an error here, we can just ignore them.
payload: PAYLOAD_SCHEMA.options({ stripUnknown: true }),
},
response: {
schema: Joi.object().keys({
access_token: validators.accessToken
.required()
.description(DESCRIPTION.accessToken),
refresh_token: validators.token.description(
DESCRIPTION.refreshTokenOauth
),
id_token: validators.assertion.description(DESCRIPTION.idToken),
session_token_id: validators.sessionTokenId.optional(),
scope: validators.scope.required().description(DESCRIPTION.scope),
token_type: Joi.string()
.valid('bearer')
.required()
.description(DESCRIPTION.tokenType),
expires_in: Joi.number()
.max(MAX_TTL_S)
.required()
.description(DESCRIPTION.expiresIn),
auth_at: Joi.number().description(DESCRIPTION.authAt),
keys_jwe: validators.jwe
.optional()
.description(DESCRIPTION.keysJweOauth),
}),
},
handler: tokenHandler,
},
},
{
method: 'POST',
path: '/oauth/token',
config: {
...OAUTH_DOCS.OAUTH_TOKEN_POST,
auth: {
// XXX TODO: To be able to fully replace the /token route from oauth-server,
// this route must also be able to accept 'client_secret' as Basic Auth in header.
mode: 'optional',
strategy: 'sessionToken',
},
validate: {
// Note: the use of 'alternatives' here means that `grant_type` will default to
// `authorization_code` if a `code` parameter is provided, or `fxa-credentials`
// otherwise. This is intended behaviour.
payload: Joi.alternatives().try(
// authorization code
Joi.object({
grant_type: Joi.string()
.valid('authorization_code')
.default('authorization_code')
.description(DESCRIPTION.grantType),
client_id: validators.clientId.description(DESCRIPTION.clientId),
client_secret: validators.clientSecret
.optional()
.description(DESCRIPTION.clientSecret),
code: validators.authorizationCode.required(),
code_verifier: validators.pkceCodeVerifier.optional(),
redirect_uri: validators.url().optional(),
// Note: the max allowed TTL is currently configured in oauth-server config,
// making it hard to know what limit to set here.
ttl: Joi.number()
.positive()
.optional()
.description(DESCRIPTION.ttlValidate),
ppid_seed: validators.ppidSeed
.optional()
.description(DESCRIPTION.ppidSeed),
resource: validators.resourceUrl
.optional()
.description(DESCRIPTION.resource),
}).xor('client_secret', 'code_verifier'),
// refresh token
Joi.object({
grant_type: Joi.string().valid('refresh_token').required(),
client_id: validators.clientId,
client_secret: validators.clientSecret.optional(),
refresh_token: validators.refreshToken.required(),
scope: validators.scope.optional(),
// Note: the max allowed TTL is currently configured in oauth-server config,
// making it hard to know what limit to set here.
ttl: Joi.number().positive().optional(),
ppid_seed: validators.ppidSeed.optional(),
resource: validators.resourceUrl.optional(),
}),
// credentials
Joi.object({
grant_type: Joi.string()
.valid('fxa-credentials')
.default('fxa-credentials'),
client_id: validators.clientId,
scope: validators.scope.optional(),
access_type: Joi.string()
.valid('online', 'offline')
.default('online'),
// Note: the max allowed TTL is currently configured in oauth-server config,
// making it hard to know what limit to set here.
ttl: Joi.number().positive().optional(),
resource: validators.resourceUrl.optional(),
assertion: Joi.forbidden(),
})
),
},
response: {
schema: Joi.alternatives().try(
// authorization code
Joi.object({
access_token: validators.accessToken
.required()
.description(DESCRIPTION.accessToken),
refresh_token: validators.refreshToken
.optional()
.description(DESCRIPTION.refreshToken),
id_token: validators.assertion
.optional()
.description(DESCRIPTION.idToken),
session_token: validators.sessionToken.optional(),
scope: validators.scope.required().description(DESCRIPTION.scope),
token_type: Joi.string()
.valid('bearer')
.required()
.description(DESCRIPTION.tokenType),
expires_in: Joi.number()
.required()
.description(DESCRIPTION.expiresIn),
auth_at: Joi.number().required().description(DESCRIPTION.authAt),
keys_jwe: validators.jwe.optional(),
}),
// refresh token
Joi.object({
access_token: validators.accessToken.required(),
id_token: validators.assertion.optional(),
scope: validators.scope.required(),
token_type: Joi.string().valid('bearer').required(),
expires_in: Joi.number().required(),
}),
// credentials
Joi.object({
access_token: validators.accessToken.required(),
refresh_token: validators.refreshToken.optional(),
id_token: validators.assertion.optional(),
scope: validators.scope.required(),
auth_at: Joi.number().required(),
token_type: Joi.string().valid('bearer').required(),
expires_in: Joi.number().required(),
})
),
},
},
handler: async function (req) {
const sessionToken = req.auth.credentials;
delete req.headers.authorization;
let grant;
switch (req.payload.grant_type) {
case 'authorization_code':
case 'refresh_token':
try {
grant = await tokenHandler(req);
} catch (err) {
// TODO auth/oauth error reconciliation
if (err.errno === 108) {
throw AuthError.invalidToken();
}
throw err;
}
break;
case 'fxa-credentials':
if (!sessionToken) {
throw AuthError.invalidToken();
}
req.payload.assertion = await makeAssertionJWT(
config.getProperties(),
sessionToken
);
grant = await tokenHandler(req);
break;
default:
throw AuthError.internalValidationError();
}
const scopeSet = ScopeSet.fromString(grant.scope);
if (scopeSet.contains(OAUTH_SCOPE_SESSION_TOKEN)) {
// the OAUTH_SCOPE_SESSION_TOKEN allows the client to create a new session token.
// the sessionTokens live in the auth-server db, we create them here after the oauth-server has validated the request.
let origSessionToken;
try {
origSessionToken = await db.sessionToken(grant.session_token_id);
} catch (e) {
throw AuthError.unknownAuthorizationCode();
}
const newTokenData = await origSessionToken.copyTokenState();
// Update UA info based on the requesting device.
const { ua } = req.app;
const newUAInfo = {
uaBrowser: ua.browser,
uaBrowserVersion: ua.browserVersion,
uaOS: ua.os,
uaOSVersion: ua.osVersion,
uaDeviceType: ua.deviceType,
uaFormFactor: ua.formFactor,
};
const sessionTokenOptions = {
...newTokenData,
...newUAInfo,
};
const newSessionToken =
await db.createSessionToken(sessionTokenOptions);
// the new session token information is later
// used in 'newTokenNotification' to attach it to device records
grant.session_token_id = newSessionToken.id;
grant.session_token = newSessionToken.data;
}
if (grant.refresh_token) {
// if a refresh token has
// been provisioned as part of the flow
// then we want to send some notifications to the user
await oauthRouteUtils.newTokenNotification(
db,
mailer,
devices,
req,
grant
);
}
if (updateLastAccessTime && sessionToken) {
sessionToken.lastAccessTime = Date.now();
await db.touchSessionToken(sessionToken, {}, true);
}
// done with 'session_token_id' at this point, do not return it.
delete grant.session_token_id;
// attempt to record metrics, but swallow the error if one is thrown.
try {
let uid = sessionToken && sessionToken.uid;
// As mentioned in lib/routes/utils/oauth.js, some grant flows won't
// have the uid in `credentials`, so we get it from the oauth DB.
if (!uid) {
const tokenVerify = await token.verify(grant.access_token);
uid = tokenVerify.user;
}
await req.emitMetricsEvent('oauth.token.created', {
grantType: req.payload.grant_type,
uid,
clientId: req.payload.client_id,
service: req.payload.client_id,
});
// We emit the `account.signed`
// event to signal to the flow it has been completed (see flowCompleteSignal).
// A "fxa_activity - cert_signed" event will be emitted since
// "account.signed" is mapped to it. And cert_signed is used in a
// rollup to generate the "fxa_activity - active" event in Amplitude
// (ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1632635), where
// we need the 'service' event property to distinguish between sync
// and browser.
if (
scopeSet.contains(OAUTH_SCOPE_OLD_SYNC) &&
// Desktop requests a profile scope token before adding the device
// to the account. To ensure we record accurate device counts, only
// emit this event if the request is for an oldsync scope, not a
// profile scope. See #6578 for details on the order of API calls
// made by both desktop and fenix.
!scopeSet.contains('profile')
) {
// For desktop, the 'service' parameter for this event gets
// special-cased to 'sync' so that it matches its pre-oauth
// `/certificate/sign` event.
// ref: https://github.com/mozilla/fxa/pull/6581#issuecomment-702248031
// Otherwise, for mobile browsers, just use the existing client ID
// to service name mapping used in the metrics code (see the
// OAUTH_CLIENT_IDS config value). #5143
const service = config
.get('oauth.oldSyncClientIds')
.includes(req.payload.client_id)
? 'sync'
: req.payload.client_id;
await req.emitMetricsEvent('account.signed', {
uid: uid,
device_id: sessionToken.deviceId,
service,
});
}
} catch (ex) {}
return grant;
},
},
];
};