// oauth2.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 'use strict'; const mongoose = require('mongoose'); const OAuth2Client = mongoose.model('OAuth2Client'); const OAuth2AuthorizationCode = mongoose.model('OAuth2AuthorizationCode'); const OAuth2Token = mongoose.model('OAuth2Token'); const uuidv4 = require('uuid').v4; const striptags = require('striptags'); const oauth2orize = require('oauth2orize'); const generatePassword = require('password-generator'); const passport = require('passport'); const BasicStrategy = require('passport-http').BasicStrategy; const ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy; const BearerStrategy = require('passport-http-bearer').Strategy; const { SiteService/*, SiteError*/ } = require('../../lib/site-lib'); class OAuth2Service extends SiteService { constructor (dtp) { super(dtp, module.exports); this.populateOAuth2Token = [ { path: 'user', select: 'username username_lc displayName picture', }, { path: 'client' }, ]; } async start ( ) { await super.start(); const serverOptions = { }; this.log.info('creating OAuth2 server instance', { serverOptions }); this.server = oauth2orize.createServer(serverOptions); this.log.info('registering OAuth2 action handlers'); this.server.grant(oauth2orize.grant.code(this.processGrant.bind(this))); this.server.exchange(oauth2orize.exchange.code(this.processExchangeCode.bind(this))); this.log.info('registering OAuth2 serialization routines'); this.server.serializeClient(this.serializeClient.bind(this)); this.server.deserializeClient(this.deserializeClient.bind(this)); } registerPassport ( ) { const verifyClient = this.verifyClient.bind(this); const verifyHttpBearer = this.verifyHttpBearer.bind(this); const verifyKaleidoscopeBearer = this.verifyKaleidoscopeBearer.bind(this); const basicStrategy = new BasicStrategy(verifyClient); this.log.info('registering Basic strategy', { name: basicStrategy.name }); passport.use(basicStrategy); const clientPasswordStrategy = new ClientPasswordStrategy(verifyClient); this.log.info('registering ClientPassword strategy', { name: clientPasswordStrategy.name }); passport.use(clientPasswordStrategy); const httpBearerStrategy = new BearerStrategy(verifyHttpBearer); this.log.info('registering Bearer strategy', { name: httpBearerStrategy.name }); passport.use(httpBearerStrategy); const kaleidoscopeBearerStrategy = new BearerStrategy(verifyKaleidoscopeBearer); this.log.info('registering Kaleidoscope Bearer strategy'); passport.use(kaleidoscopeBearerStrategy); } async serializeClient (client, done) { this.log.debug('serializeClient', { clientID: client._id.toString() }); return done(null, client._id.toString()); } async deserializeClient (clientID, done) { this.log.debug('deserializeClient', { clientID }); try { const client = await OAuth2Client .findOne({ _id: clientID }) .lean(); this.log.debug('OAuth2 client loaded', { clientID }); return done(null, client); } catch (error) { this.log.error('failed to deserialize OAuth2 client', { clientID, error }); return done(error); } } attachRoutes (app) { const { session: sessionService } = this.dtp.services; const requireLogin = sessionService.authCheckMiddleware({ requireAuth: true, loginUri: '/welcome/login' }); app.get( '/oauth2/authorize', requireLogin, this.server.authorize(this.processAuthorize.bind(this)), this.renderAuthorizeDialog.bind(this), ); app.post( '/oauth2/authorize/decision', requireLogin, this.server.decision(), ); app.post( '/oauth2/token', (req, res, next) => { this.log.debug('POST /oauth2/token', { body: req.body, params: req.params, query: req.query }); return next(); }, passport.authenticate(['basic', 'oauth2-client-password'], { session: false }), this.server.token(), this.server.errorHandler(), ); } async renderAuthorizeDialog (req, res) { res.locals.currentView = 'oauth2-authorize-dialog'; res.locals.oauth2 = req.oauth2; res.locals.transactionID = req.oauth2.transactionID; res.locals.client = req.oauth2.client; res.render('oauth2/authorize-dialog'); } async processAuthorize (clientID, redirectUri, done) { try { const client = await OAuth2Client .findOne({ _id: mongoose.Types.ObjectId(clientID) }) .lean(); if (!client) { this.log.alert('OAuth2 client not found', { clientID }); return done(null, false); } if (client.callbackUrl !== redirectUri) { this.log.alert('OAuth2 client redirect URI mismatch', { redirectUri, officialUri: client.callbackUrl, }); return done(null, false); } this.log.info('client authorization processed', { clientID }); return done(null, client, client.callbackUrl); } catch (error) { this.log.error('failed to process OAuth2 authorize', { error }); return done(error); } } async processGrant (client, redirectUri, user, ares, done) { try { var code = uuidv4(); var ac = new OAuth2AuthorizationCode({ code, client: client._id, redirectUri, user: user._id, scopes: client.scopes, }); await ac.save(); this.log.info('OAuth2 grant processed', { clientID: client._id, scopes: client.scopes }); return done(null, code); } catch (error) { this.log.error('failed to process OAuth2 grant', { error }); return done(error); } } async issueTokens (authCode) { const response = { accessToken: generatePassword(256, false), refreshToken: generatePassword(256, false), params: { coreUserId: authCode.user._id.toString(), username: authCode.user.username, username_lc: authCode.user.username_lc, displayName: authCode.user.displayName, bio: authCode.user.bio, }, }; await Promise.all([ OAuth2Token.create({ type: 'access', token: response.accessToken, user: authCode.user._id, client: authCode.client._id, scope: authCode.scope, }), OAuth2Token.create({ type: 'refresh', token: response.refreshToken, user: authCode.user._id, client: authCode.client._id, scope: authCode.scope, }), ]); return response; } async processExchangeCode (client, code, redirectUri, done) { try { const ac = await OAuth2AuthorizationCode .findOne({ code }) .populate([ { path: 'client', }, { path: 'user', select: 'username username_lc displayName picture bio permissions flags', }, ]); if (!client._id.equals(ac.client._id)) { this.log.alert('OAuth2 client ID mismatch', { provided: client.id, onfile: ac.client._id }); return done(null, false); } if (redirectUri !== ac.redirectUri) { this.log.alert('OAuth2 redirect mismatch', { provided: redirectUri, onfile: ac.redirectUri }); return done(null, false); } const response = await this.issueTokens(ac); this.log.info('OAuth2 grant exchanged for token', { clientID: client._id, params: response.params, }); return done(null, response.accessToken, response.refreshToken, response.params); } catch (error) { this.log.error('failed to process OAuth2 exchange', { error }); return done(error); } } /** * Creates a new OAuth2 client, and generates a Client ID and Secret for it. * @param {Document} clientDefinition The definition of the client to be * created including the name and domain of the node. * @returns new client instance with valid _id. */ async createClient (clientDefinition) { const NOW = new Date(); const PASSWORD_LEN = parseInt(process.env.DTP_CORE_AUTH_PASSWORD_LEN || '64', 10); // scrub up the input data to help prevent shenanigans clientDefinition.name = striptags(clientDefinition.name); clientDefinition.description = striptags(clientDefinition.description); clientDefinition.domain = striptags(clientDefinition.domain); clientDefinition.domainKey = striptags(clientDefinition.domainKey); clientDefinition.company = striptags(clientDefinition.company); clientDefinition.secret = generatePassword(PASSWORD_LEN, false); clientDefinition.coreAuth.scopes = clientDefinition.coreAuth.scopes.map((scope) => striptags(scope)); clientDefinition.coreAuth.callbackUrl = striptags(clientDefinition.coreAuth.callbackUrl); /* * Use an upsert to either update or create the OAuth2 client record for the * calling host. */ const client = await OAuth2Client.findOneAndUpdate( { 'site.domain': clientDefinition.domain, 'site.domainKey': clientDefinition.domainKey, }, { $setOnInsert: { created: NOW, 'site.domain': clientDefinition.domain, 'site.domainKey': clientDefinition.domainKey, }, $set: { updated: NOW, 'site.name': clientDefinition.name, 'site.description': clientDefinition.description, 'site.company': clientDefinition.company, secret: clientDefinition.secret, scopes: clientDefinition.coreAuth.scopes, callbackUrl: clientDefinition.coreAuth.callbackUrl, kaleidoscope: { token: generatePassword(256, false), }, }, }, { upsert: true, // create if it doesn't exist "new": true, // return the modified version }, ); this.log.info('new OAuth2 client updated', { clientId: client._id, site: client.site.name, domain: client.site.domain, }); return client.toObject(); } async updateClient (client, clientDefinition) { await OAuth2Client.updateOne( { _id: client._id }, { $set: { 'admin.notes': striptags(clientDefinition.notes.trim()), 'flags.isActive': clientDefinition.isActive === 'on', }, }, ); } async getClients (search, pagination) { search = search || { }; let query = OAuth2Client .find(search) .sort({ 'site.domainKey': 1 }); if (pagination) { query = query .skip(pagination.skip) .limit(pagination.cpp); } const clients = await query.lean(); return clients; } async getRandomClients (maxCount) { const clients = await OAuth2Client.aggregate([ { $match: { 'flags.isActive': true }, }, { $sample: { size: maxCount }, } ]); return clients; } async getClientById (clientId) { const client = await OAuth2Client .findOne({ _id: clientId }) .lean(); return client; } async getClientByDomain (domain) { const client = await OAuth2Client .findOne({ 'site.domain': domain }) .lean(); return client; } async getClientByDomainKey (domainKey) { const client = await OAuth2Client .findOne({ 'site.domainKey': domainKey }) .lean(); return client; } async verifyClient (clientId, clientSecret, done) { const client = await this.getClientById(clientId); if (!client) { this.log.alert('OAuth2 request from unknown client', { clientId }); return done(null, false); } if (client.secret !== clientSecret) { this.log.alert('OAuth2 client secret mismatch', { clientId }); return done(null, false); } return done(null, client); } async getAccessToken (accessToken) { const token = await OAuth2Token .findOne({ type: 'access', token: accessToken }) .populate(this.populateOAuth2Token) .lean(); return token; } async verifyHttpBearer (accessToken, done) { const token = await this.getAccessToken(accessToken); if (!token) { this.log.error('no bearer token for client', { accessToken }); return done(null, false); } return done(null, token.user, { scope: token.scope }); } /** * Retrieves OAuth2 access/refresh tokens for a specific CoreUser. * @param {CoreUser} user The user for which tokens are wanted. * @param {*} type The type of token wanted (access or refresh), or don't * specify to receive all tokens (unfiltered). * @returns Array of tokens for the specified user, if any. */ async getUserTokens (user, type) { const tokens = await OAuth2Token .find({ user: user._id, type }) .populate(this.populateOAuth2Token) .lean(); return tokens; } async getKaleidoscopeClient (accessToken) { const client = await OAuth2Client .findOne({ 'kaleidoscope.token': accessToken }) .select('-secret -kaleidoscope -admin') // don't fetch them .lean(); if (!client) { return; // we don't have one, be undefined } /* * extreme paranoia also serializes the object to absolutely prevent leaking * a secret even if the underlying Mongoose library has a bug today. */ return { _id: client._id, created: client.created, updated: client.updated, site: client.site, scopes: client.scopes, flags: client.flags, }; } async verifyKaleidoscopeBearer (accessToken, done) { const client = await this.getKaleidoscopeClient(accessToken); if (!client) { this.log.error('no Kaleidoscope token for client', { accessToken }); return done(null, false); } /* * Minor hack here. You don't get a User or CoreUser for use with * Kaleidoscope. This is machine-to-machine, there simply is no "user" in * this transaction. Instead, you get a Client - the machine. * * So, up in controller space, req.user isn't a User or CoreUser for * Kaleidoscope APIs. It is the OAuth2 Client or Service Node. */ return done(null, client); } /** * Removes and fully de-authorizes an OAuth2Client from the system. * @param {OAuth2Client} client the client to be removed */ async removeClient (client) { this.log.info('removing client', { clientId: client._id, }); await OAuth2Client.deleteOne({ _id: client._id }); } } module.exports = { slug: 'oauth2', name: 'oauth2', create: (dtp) => { return new OAuth2Service(dtp); }, };