packages/fxa-auth-server/lib/oauth/db/index.js (273 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/. */
const hex = require('buf').to.hex;
const { config } = require('../../../config');
const encrypt = require('fxa-shared/auth/encrypt');
const mysql = require('./mysql');
const redis = require('./redis');
const AccessToken = require('./accessToken');
const { SHORT_ACCESS_TOKEN_TTL_IN_MS } = require('fxa-shared/oauth/constants');
const RefreshTokenMetadata = require('./refreshTokenMetadata');
const { ConnectedServicesDb } = require('fxa-shared/connected-services');
const JWT_ACCESS_TOKENS_ENABLED = config.get(
'oauthServer.jwtAccessTokens.enabled'
);
const JWT_ACCESS_TOKENS_CLIENT_IDS = new Set(
config.get('oauthServer.jwtAccessTokens.enabledClientIds')
);
const REFRESH_LAST_USED_AT_UPDATE_AFTER_MS = config.get(
'oauthServer.refreshToken.updateAfter'
);
function getPocketIds(idNameMap) {
return Object.entries(idNameMap)
.filter(([_, name]) => name.startsWith('pocket'))
.map(([id, _]) => id);
}
const POCKET_IDS = getPocketIds(
config.get('oauthServer.clientIdToServiceNames')
);
class OauthDB extends ConnectedServicesDb {
get mysql() {
return this.db;
}
get redis() {
return this.cache;
}
constructor() {
super(mysql.connect(config.get('oauthServer.mysql')), redis());
// A better inheritance model would be preferable, but for now
// this is still backwards compatible.
for (const functionName of this.mysql.getProxyableFunctions()) {
if (this[functionName] === undefined) {
this[functionName] = (...args) => {
return this.proxy(functionName, ...args);
};
}
}
}
async proxy(method, ...args) {
await this.ready();
return await this.mysql[method](...args);
}
async ready() {
if (!this._isReady) {
this._isReady = initDb(this.mysql);
}
await this._isReady;
}
disconnect() {}
async generateAccessToken(vals) {
await this.ready();
const token = AccessToken.generate(
vals.clientId,
vals.name,
vals.canGrant,
vals.publicClient,
vals.userId,
vals.scope,
vals.profileChangedAt,
vals.expiresAt,
vals.ttl
);
if (
JWT_ACCESS_TOKENS_ENABLED &&
JWT_ACCESS_TOKENS_CLIENT_IDS.has(vals.clientId) &&
token.ttl <= SHORT_ACCESS_TOKEN_TTL_IN_MS
) {
// We avoid revocation concerns with short-lived
// tokens, so we do not store them.
return token;
} else if (POCKET_IDS.includes(hex(vals.clientId))) {
// Pocket tokens are persisted past their expiration for legacy
// reasons: https://bugzilla.mozilla.org/show_bug.cgi?id=1547902
// since they are long lived we continue to store them in mysql
// so that redis can be exclusively ephemeral
await this.mysql._generateAccessToken(token);
} else {
await this.redis.setAccessToken(token);
}
return token;
}
async getAccessToken(id) {
await this.ready();
const t = await this.redis.getAccessToken(id);
if (t) {
return t;
}
return await this.mysql._getAccessToken(id);
}
async removeAccessToken(token) {
await this.ready();
const done = await this.redis.removeAccessToken(token.tokenId);
if (!done) {
return await this.mysql._removeAccessToken(token.tokenId);
}
}
async getAccessTokensByUid(uid) {
await this.ready();
const tokens = await this.redis.getAccessTokens(uid);
const otherTokens = await this.mysql._getAccessTokensByUid(uid);
return tokens.concat(otherTokens);
}
async getRefreshToken(id) {
await this.ready();
const t = await this.mysql._getRefreshToken(id);
if (t) {
const extraMetadata = new RefreshTokenMetadata(new Date());
await this.redis.setRefreshToken(t.userId, id, extraMetadata);
// Periodically update timestamp in MySQL as well.
if (
extraMetadata.lastUsedAt - t.lastUsedAt >
REFRESH_LAST_USED_AT_UPDATE_AFTER_MS
) {
await this.mysql._touchRefreshToken(
t.tokenId,
extraMetadata.lastUsedAt
);
}
Object.assign(t, extraMetadata || {});
}
return t;
}
async getRefreshTokensByUid(uid) {
await this.ready();
const tokens = await this.mysql._getRefreshTokensByUid(uid);
const extraMetadata = await this.redis.getRefreshTokens(uid);
// We'll take this opportunity to clean up any tokens that exist in redis but
// not in mysql, so this loop deletes each token from `extraMetadata` once handled.
for (const t of tokens) {
const id = hex(t.tokenId);
if (id in extraMetadata) {
Object.assign(t, extraMetadata[id]);
delete extraMetadata[id];
}
}
// Now we can prune any tokens found in redis but not mysql.
const toDel = Object.keys(extraMetadata);
if (toDel.length > 0) {
await this.redis.pruneRefreshTokens(uid, toDel);
}
return tokens;
}
async removeRefreshToken(token) {
await this.ready();
await this.redis.removeRefreshToken(token.userId, token.tokenId);
return this.mysql._removeRefreshToken(token.tokenId);
}
async removePublicAndCanGrantTokens(userId) {
await this.redis.removeAccessTokensForPublicClients(userId);
const db = await this.mysql;
await db._removePublicAndCanGrantTokens(userId);
// Note that we do not clear metadata for deleted refresh tokens from redis,
// because it's awkward to enumerate the list of deleted refresh token ids.
// Instead we rely on a future call to `getRefreshTokensByUid` or
// `getRefreshToken` for lazy cleanup.
}
async deleteClientAuthorization(clientId, uid) {
await this.ready();
await this.redis.removeAccessTokensForUserAndClient(uid, clientId);
return await this.mysql._deleteClientAuthorization(clientId, uid);
// Note that we do not clear metadata for deleted refresh tokens from redis,
// because it's awkward to enumerate the list of deleted refresh token ids.
// Instead we rely on a future call to `getRefreshTokensByUid` or
// `getRefreshToken` for lazy cleanup.
}
async deleteClientRefreshToken(refreshTokenId, clientId, uid) {
await this.ready();
const ok = await this.mysql._deleteClientRefreshToken(
refreshTokenId,
clientId,
uid
);
if (ok) {
await this.redis.removeRefreshToken(uid, refreshTokenId);
await this.redis.removeAccessTokensForUserAndClient(uid, clientId);
}
return ok;
}
async removeTokensAndCodes(uid) {
await this.ready();
await this.redis.removeAccessTokensForUser(uid);
await this.redis.removeRefreshTokensForUser(uid);
await this.mysql._removeTokensAndCodes(uid);
}
getPocketIds() {
return POCKET_IDS;
}
async pruneAuthorizationCodes(ttlInMs) {
return await this.mysql._pruneAuthorizationCodes(
ttlInMs || config.get('oauthServer.expiration.code')
);
}
}
// Helper functions
function clientEquals(configClient, dbClient) {
var props = Object.keys(configClient);
for (var i = 0; i < props.length; i++) {
var prop = props[i];
var configProp = hex(configClient[prop]);
var dbProp = hex(dbClient[prop]);
if (configProp !== dbProp) {
return false;
}
}
return true;
}
function convertClientToConfigFormat(client) {
var out = {};
for (var key in client) {
if (key === 'hashedSecret' || key === 'hashedSecretPrevious') {
out[key] = hex(client[key]);
} else if (key === 'trusted' || key === 'canGrant') {
out[key] = !!client[key]; // db stores booleans as 0 or 1.
} else if (typeof client[key] !== 'function') {
out[key] = hex(client[key]);
}
}
return out;
}
async function initDb(db) {
await preClients(db);
await scopes(db);
}
async function preClients(db) {
var clients = config.get('oauthServer.clients');
if (clients && clients.length) {
return await Promise.all(
clients.map(async function (c) {
if (c.secret) {
// eslint-disable-next-line no-console
console.error(
'Do not keep client secrets in the config file.' + // eslint-disable-line no-console
' Use the `hashedSecret` field instead.\n\n' +
'\tclient=%s has `secret` field\n' +
'\tuse hashedSecret="%s" instead',
c.id,
hex(encrypt.hash(c.secret))
);
throw new Error('Do not keep client secrets in the config file.');
}
// ensure the required keys are present.
var REQUIRED_CLIENTS_KEYS = [
'id',
'hashedSecret',
'name',
'imageUri',
'redirectUri',
'trusted',
'canGrant',
];
REQUIRED_CLIENTS_KEYS.forEach(function (key) {
if (!(key in c)) {
throw new Error('Client config has missing keys');
}
});
// ensure booleans are boolean and not undefined
c.trusted = !!c.trusted;
c.canGrant = !!c.canGrant;
c.publicClient = !!c.publicClient;
// Modification of the database at startup in production and stage is
// not preferred. This option will be set to false on those stacks.
if (!config.get('oauthServer.db.autoUpdateClients')) {
return;
}
let client = await db.getClient(c.id);
if (client) {
client = convertClientToConfigFormat(client);
if (!clientEquals(client, c)) {
return await db.updateClient(c);
}
} else {
return await db.registerClient(c);
}
})
);
}
}
/**
* Insert pre-defined list of scopes into the DB
*/
async function scopes(db) {
var scopes = config.get('oauthServer.scopes');
if (scopes && scopes.length) {
return await Promise.all(
scopes.map(async function (s) {
const existing = await db.getScope(s.scope);
if (existing) {
return;
}
return await db.registerScope(s);
})
);
}
}
module.exports = new OauthDB();