// 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); },
};