Создаем OpenID Connect на базе Firebase

28 сентября, 2022
7 минут(ы) чтения

functions/package.json

...
  "dependencies": {
    "dotenv": "8.6.0",
    "dialogflow": "1.2.0",
    "express": "4.18.1",
    "firebase-admin": "8.13.0",
    "firebase-functions": "3.22.0",
    "grant-express": "5.4.8",
    "jose": "2.0.5",
    "node-fetch": "2.6.7",
    "oidc-provider": "6.31.1",
    "validator": "13.7.0"
  },
  "devDependencies": {
    "firebase-functions-test": "0.3.3"
  },
...

firebase.json

...
"hosting": {
  "rewrites": [
      {
        "source": "**",
        "function": "app"
      }
   ]
},
...

functions/lib/oidc.js

const Provider = require('oidc-provider');
const { JWKS } = require('jose');
const Account = require('../models/account');
const FirestoreAdapter = require('../adapters/firestore.js');
const admin = require('../services/admin.js');

const db = admin.firestore();

/**
 * @returns {Promise<Provider>}
 */
module.exports = async () => {
  const {
    interactionPolicy: { base: policy },
  } = Provider;

  const keystore = new JWKS.KeyStore();
  await Promise.all([
    keystore.generate('RSA', undefined, { use: 'sig' }),
    keystore.generate('EC', undefined, { key_ops: ['sign', 'verify'] }),
  ]);

  const querySnapshot = await db.collection('clients').get();

  const configuration = {
    get clients () {
      let clients = [];
      querySnapshot.forEach((doc) => {
        if (doc.id) {
          const data = doc.data();

          // насыщаем редирект текущим урлом сервера
          data.redirect_uris.unshift(process.env.SERVER_HOST + '/' + process.env.SERVER_PREFIX + '/oidcallback?client_id=tg'); // todo поменять client_id на любой ваш

          clients.push(data);
        }
      });
      return clients;
    },
    get jwks() {
      return keystore.toJWKS(true);
    },

    // This interface is required by oidc-provider
    findAccount: Account.findAccount,

    // hack https://github.com/panva/node-oidc-provider/blob/master/docs/README.md#id-token-does-not-include-claims-other-than-sub
    conformIdTokenClaims: false,
    claims: {
      email: ['email', 'email_verified'],
      profile: ['locale', 'client_id', 'name', 'updated_at', 'zoneinfo'],
    },
    features: {
      clientCredentials: { enabled: true },
      introspection: { enabled: true },
      revocation: { enabled: true },
      registration: { initialAccessToken: true },
      requestObjects: { request: true },
      encryption: { enabled: true },
      jwtIntrospection: { enabled: true },
      jwtResponseModes: { enabled: true },
      devInteractions: { enabled: false },
    },
    interactions: {
      get policy() {
        // copies the default policy, already has login and consent prompt policies
        const interactions = policy();
        return interactions;
      },
      url(context) {
        return '/' + process.env.SERVER_PREFIX + `/interaction/${context.oidc.uid}`;
      },
    },
    format: { default: 'opaque' },
    ttl: {
      AccessToken: 1 * 60 * 60, // 1 hour in seconds
      AuthorizationCode: 10 * 60, // 10 minutes in seconds
      IdToken: 1 * 60 * 60, // 1 hour in seconds
      RefreshToken: 1 * 24 * 60 * 60, // 1 day in seconds
    },
  };

  const provider = new Provider(process.env.SERVER_HOST, {
    adapter: FirestoreAdapter,
    ...configuration,
  });

  // if (IS_PRODUCTION) {
  //   provider.proxy = true;
  //   provider.keys = SECURE_KEY.split(',');
  // }
  return provider;
};

functions/adapters/firestore.js

/*
 * Setup firebase-admin for use in nodejs
 * 1- Add the firebase-admin npm package.
 * 2- Go to https://console.firebase.google.com/ and create a project if you do not have an existing one.
 * 3- Get the secret from https://console.firebase.google.com/u/0/project/{PROJECT_ID}/settings/serviceaccounts/adminsdk.
 * 4- Set GOOGLE_APPLICATION_CREDENTIALS environment variable to the path of the JSON file.
 * 5- import the library using`import * as admin from 'firebase-admin'` in the server file.
 * 6- Call `admin.initializeApp()` in the server file to establish the connection.
 *
 * Reach out to @hadyrashwan <https://github.com/hadyrashwan> for questions on this community example.
 */

const admin = require('../services/admin.js');

const undefinedFirestoreValue = 'custom.type.firestore'; // A work around as firestore does not support undefined.
const namePrefix = 'oidc_library';
const db = admin.firestore();
/**
 * Use the library with Google's Firestore database
 *
 * @class FirestoreAdapter
 */
class FirestoreAdapter {
  constructor(name) {
    this.name = `${namePrefix}_${name.split(' ').join('_')}`;
  }

  async upsert(id, payload, expiresIn) {
    let expiresAt;

    if (expiresIn) {
      expiresAt = new Date(Date.now() + expiresIn * 1000);
    }
    await db
      .collection(this.name)
      .doc(id)
      .set(
        {
          payload: this.updateNestedObject(payload, undefined, undefinedFirestoreValue),
          ...(expiresAt ? { expiresAt } : null),
        },
        { merge: true },
      );
  }

  async find(id) {
    const response = await db.collection(this.name).doc(id).get();
    if (!response.exists) {
      return undefined;
    }
    const data = response.data();
    return this.updateNestedObject(data.payload, undefinedFirestoreValue, undefined);
  }

  async findByUserCode(userCode) {
    const response = await db
    .collection(this.name)
    .where('payload.userCode', '==', userCode)
    .limit(1)
    .get();
    if (response.empty) {
      return undefined;
    }
    const data = response[0].data();
    return this.updateNestedObject(data.payload, undefinedFirestoreValue, undefined);
  }

  async findByUid(uid) {
    const response = await db.collection(this.name).where('payload.uid', '==', uid).limit(1).get();
    if (response.empty) {
      return undefined;
    }
    const data = response.docs[0].data();
    return this.updateNestedObject(data.payload, undefinedFirestoreValue, undefined);
  }

  async destroy(id) {
    await db.collection(this.name).doc(id).delete();
  }

  async revokeByGrantId(grantId) {
    const response = await db.collection(this.name).where('payload.grantId', '==', grantId).get();
    if (response.empty) {
      return;
    }
    const batch = db.batch();

    response.docs.forEach((doc) => batch.delete(db.collection(this.name).doc(doc.id).delete()));

    await batch.commit();
  }

  async consume(id) {
    const response = await db.collection(this.name).doc(id).get();
    if (!response.exists) {
      return;
    }
    const payload = response.data();
    payload.consumed = Math.floor(Date.now() / 1000);
    await db.collection(this.name).doc(id).update(payload);
  }

  /**
   * Replace a value in the object with another value
   *
   * @private
   * @param {object} internalObject
   * @param {(string | undefined)} value
   * @param {(string | undefined)} toReplaceValue
   * @returns
   * @memberof FirestoreAdapter
   */
  updateNestedObject(object, value, toReplaceValue) {
    const internalObject = Array.isArray(object) ? object : { ...object }; // avoid mutation
    const keys = Object.keys(internalObject);
    for (let index = 0; index < keys.length;) {
      const key = keys[index];
      if (Object.prototype.hasOwnProperty.call(internalObject, key)) {
        if (internalObject[key] === value) {
          internalObject[key] = toReplaceValue;
        }
        if (typeof internalObject[key] === 'object') {
          // Recursion
          internalObject[key] = this.updateNestedObject(internalObject[key], value, toReplaceValue);
        }
      }
      index += 1;
    }
    return internalObject;
  }
}

module.exports = FirestoreAdapter;

functions/index.js

const functions = require('firebase-functions');
const express = require('express');

const app = express();
const router = express.Router();

const provider = require('./lib/oidc')
const OidcController = require('./controllers/oidc')

// oidc
(async function() {
  const oidcProvider = await provider();
  const { constructor: { errors: { SessionNotFound } } } = oidcProvider;

  // OpenID Connect server
  const oidc = new OidcController(oidcProvider);
  router.get('/oidcallback', oidc.oidcallback.bind(oidc));
  router.get('/interaction/:uid', setNoCache, oidc.interactionUID.bind(oidc));
  router.post(
    '/interaction/:uid/login',
    setNoCache,
    body,
    oidc.interactionLogin.bind(oidc)
  );
  router.post(
    '/interaction/:uid/continue',
    setNoCache,
    body,
    oidc.interactionContinue.bind(oidc)
  );
  router.post(
    '/interaction/:uid/confirm',
    setNoCache,
    body,
    oidc.interactionConfirm.bind(oidc)
  );
  router.get(
    '/interaction/:uid/abort',
    setNoCache,
    oidc.interactionAbort.bind(oidc)
  );

  oidcRouter.use((err, req, res, next) => {
    if (err instanceof SessionNotFound) {
      // handle interaction expired / session not found error
    }
    next(err);
  });

  app.use('/oidc/', oidcRouter);
  app.use(router);
  app.use(oidcProvider.callback);
})();

exports.app = functions.https.onRequest(app);

functions/views/oidc/interaction.login.js

module.exports = ({ uid, clientId }) => {
  return `
      <h1>${clientId}</h1>
      <form autocomplete="off" action="/${process.env.SERVER_PREFIX}/interaction/${uid}/login" method="post">
        <input required type="email" name="email" placeholder="Enter email" autofocus="on">
        <input required type="password" name="password" placeholder="and password">
        <button type="submit">Sign-in</button>
      </form>
      <div>
        <a href="/${process.env.SERVER_PREFIX}/interaction/${uid}/abort">[ Abort ]</a>
      </div>
  `
}

functions/views/oidc/interaction.consept.js

module.exports = ({ uid, details }) => {
  return `
        <ul>
          ${details.scopes.new.map((scope) => {
            return `<li>${scope}</li>`;
          })}
        </ul>
        <form autocomplete="off" action="/${process.env.SERVER_PREFIX}/interaction/${uid}/confirm" method="post">
          <button autofocus type="submit">Continue</button>
        </form>
        <div>
          <a href="/${process.env.SERVER_PREFIX}/interaction/${uid}/abort">[ Abort ]</a>
        </div>
  `;
};

functions/models/account.js

const store = new Map();

class Account {
  constructor(id) {
    this.accountId = id;
    store.set(this.accountId, this);
  }
  // todo использовать scope и use для выборки
  // todo переделать под private API
  async claims(/* use, scope */) {
    // example
    return {
      sub: this.accountId, // it is essential to always return a sub claim
      email: 'xxx@xxx.xxx', // email
      email_verified: true, // email verified
      client_id: '3555d074-b52b-4703-a8c4-96ad9edf43e9', // example client_id GUID
      updated_at: '2020-04-16 11:46:25.689329', // time
    };
  }
  /**
   * Получение аккаунта и запись в стор
   *
   * @param {*} context - context
   * @param {*} id - id
   * @returns {Promise<any>}
   */
  static findAccount(context, id) {
    // find account
    return new Promise((resolve) => {
      // token is a reference to the token used for which a given account is being loaded,
      //  it is undefined in scenarios where account claims are returned from authorization endpoint
      if (!store.get(id)) {
        new Account(id);
      }
      const out = store.get(id);
      resolve(out);
    });
  }
}

module.exports = Account;

functions/controllers/oidc.js

const jose = require('jose');
const e = require('express');
const fetch = require('node-fetch');
const Account = require('../models/account')

class OIDC {
  constructor(provider) {
    this.oidc = provider;
  }
  /**
   * @description callback должен выполняться на ассистенте и записывать JWT в свою БД
   * @param {e.Request} request - request
   * @param {e.Response} response - response
   * @returns {Promise<void>}
   */
  async oidcallback(request, response) {
    try {
      if (request.error) {
        throw new Error(request.error);
      }
      if (request.query.error) {
        throw new Error(
          request.query.error + '\n' + request.query.error_description,
        );
      }
      const res = await fetch(process.env.SERVER_HOST + '/' + process.env.SERVER_PREFIX + '/token', {
        method: 'POST',
        body: new URLSearchParams({
            'client_id': request.query.client_id,
            'client_secret': 'foobar',
            'code': request.query.code,
            'grant_type': 'authorization_code',
            'redirect_uri': process.env.SERVER_HOST + '/' + process.env.SERVER_PREFIX + `/oidcallback?client_id=${request.query.client_id}`,
        }),
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          'Cache-Control': 'no-cache',
        },
      });
      const tokenResult = await res.json();
      const decoded = jose.JWT.decode(tokenResult.id_token);

      request.session.passportId = decoded.client_id;
      response.send(
        `Aud: ${decoded.aud} ; Email: ${decoded.email}.`,
      );
    } catch (error) {
      response.status(400).json({ error: error.message });
    }
  }
  /**
   * @see https://github.com/panva/node-oidc-provider/blob/master/example/routes/express.js
   * @param {e.Request} request - request
   * @param {e.Response} response - response
   * @returns {any}
   */
  async interactionUID(request, response) {
    try {
      const details = await this.oidc.interactionDetails(request, response);
      const { uid, prompt, params, session } = details;
      // eslint-disable-next-line unicorn/no-fn-reference-in-iterator
      const client = await this.oidc.Client.find(params.client_id);
      switch (prompt.name) {
        case 'select_account': {
          if (!session) {
            return this.oidc.interactionFinished(
              request,
              response,
              { select_account: {} },
              { mergeWithLastSubmission: false },
            );
          }

          const account = await Account.findAccount(
            undefined,
            session.accountId,
          );
          const { email } = await account.claims(
            'prompt',
            'email',
            { email: null },
            [],
          );

          return response.render('select_account', {
            client,
            uid,
            email,
            details: prompt.details,
            params,
            title: 'Sign-in',
            session: session ? session : undefined,
            dbg: {
              params: params,
              prompt: prompt,
            },
          });
        }
        case 'login': {
          const interactionLoginView = require('../views/oidc/interaction.login.js');
          response.send(interactionLoginView({
            uid,
            clientId: client.clientId
          }));
          break;
        }
        case 'consent': {
          const interactionConsentView = require('../views/oidc/interaction.consent.js');
          response.send(interactionConsentView({
            uid,
            details: prompt.details,
          }));
          break;
        }
        default: {
          return;
        }
      }
    } catch (error) {
      response.status(400).json({ error: error.message });
    }
  }
  /**
   * @description authenticate
   * @param {e.Request} request - request
   * @param {e.Response} response -> response
   */
  async interactionLogin(request, response) {
    try {
      const {
        prompt: { name },
      } = await this.oidc.interactionDetails(request, response);
      console.log('name: ' + name);
      const result = {
        select_account: {}, // make sure its skipped by the interaction policy since we just logged in
        login: {
          account: '3555d074-b52b-4703-a8c4-96ad9edf43e9',
        },
      };
      await this.oidc.interactionFinished(request, response, result, {
        mergeWithLastSubmission: false,
      });
    } catch (error) {
      response.status(400).json({ error: error.message });
    }
  }
  /**
   * @param {e.Request} request - request
   * @param {e.Response} response - response
   */
  async interactionContinue(request, response) {
    try {
      const interaction = await this.oidc.interactionDetails(request, response);
      if (request.body.switch) {
        if (interaction.params.prompt) {
          const prompts = new Set(interaction.params.prompt.split(' '));
          prompts.add('login');
          interaction.params.prompt = [...prompts].join(' ');
        } else {
          interaction.params.prompt = 'logout';
        }
        await interaction.save();
      }
      const result = { select_account: {} };
      await this.oidc.interactionFinished(request, response, result, {
        mergeWithLastSubmission: false,
      });
    } catch (error) {
      response.status(400).json({ error: error.message });
    }
  }
  /**
   * @param {e.Request} request - request
   * @param {e.Response} response - response
   */
  async interactionConfirm(request, response) {
    try {
      await this.oidc.interactionFinished(
        request,
        response,
        {
          consent: {
            rejectedScopes: [], // < uncomment and add rejections here
            rejectedClaims: [], // < uncomment and add rejections here
          },
        },
        {
          mergeWithLastSubmission: true,
        },
      );
    } catch (error) {
      response.status(400).json({ error: error.message });
    }
  }
  /**
   * @param {e.Request} request - request
   * @param {e.Response} response - response
   */
  async interactionAbort(request, response) {
    try {
      const result = {
        error: 'access_denied',
        error_description: 'End-User aborted interaction',
      };
      await this.oidc.interactionFinished(request, response, result, {
        mergeWithLastSubmission: false,
      });
    } catch (error) {
      response.status(400).json({ error: error.message });
    }
  }
}

module.exports = OIDC;

functions/middlewares/no-cache.js

const setNoCache = (request, response, next) => {
  response.set('Pragma', 'no-cache');
  response.set('Cache-Control', 'no-cache, no-store');
  next();
}

module.exports = setNoCache;

Денис Сергеевич Басковский

Философ, изобретатель и поэт.

Добавить комментарий Отменить ответ

http headers
Предыдущая статья

HTTP Headers которые вам понадобятся

Quasar App Extension
Следующая статья

Создание своего Quasar App Extension

Exit mobile version