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;