Last active
March 11, 2022 04:13
-
-
Save tegument/f60ef0bafac80faf7522f3a29fa04435 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const functions = require('firebase-functions') | |
const admin = require('firebase-admin') | |
const express = require('express') | |
const app = express() | |
const auth = admin.auth() | |
const cors = require('cors') | |
const cookieParser = require('cookie-parser') | |
const DiscourseSSO = require('discourse-sso') | |
const env = functions.config() | |
const __DEBUG__ = false | |
/* | |
-- Set firebase function environment specific variables via commandline (or hard code them here): | |
firebase functions:config:set discourse.sso_secret='your-super-secret-secret' | |
{ | |
"sso_secret": "your-super-secret-secret", | |
"api_url": "https://forum.domain.com/", | |
"sso_domain": "domain.com", | |
"api_key": "--your-api-key-here--", // not used for SSO, used elsewhere | |
"api_username": "admin" // not used for SSO, used elsewhere | |
} | |
*/ | |
const sso = new DiscourseSSO(env.discourse.sso_secret) | |
const domain = env.discourse.sso_domain | |
const siteUrl = `https://${domain}/` | |
const forumUrl = `https://forum.${domain}/` | |
const loginUrl = `${siteUrl}login` | |
const whitelist = [ | |
`http://localhost:3000`, | |
`https://${domain}`, | |
`https://forum.${domain}` | |
] | |
var corsOptionsDelegate = function (req, callback) { | |
var corsOptions | |
if (whitelist.indexOf(req.header('Origin')) !== -1) { | |
corsOptions = { | |
origin: true, | |
credentials: true // Must be true so that session cookies can be set. | |
} // reflect (enable) the requested origin in the CORS response | |
} else { | |
corsOptions = { origin: false } // disable CORS for this request | |
} | |
callback(null, corsOptions) // callback expects two parameters: error and options | |
} | |
/* | |
Example of client login using Firebase Auth and posting to /session endpoint: | |
After using Google Firebase Login you post the idToken to the /session endpoint: | |
Example: | |
function postData (url = ``, data = {}) { | |
// Default options are marked with * | |
return fetch(url, { | |
method: "POST", | |
mode: "cors", | |
cache: "no-cache", | |
credentials: "include", // SUPER IMPORTANT, requires cors config (included) | |
headers: { | |
"Content-Type": "application/json" | |
}, | |
redirect: "follow", | |
body: JSON.stringify(data), | |
}).then(response => response.json()); | |
} | |
firebase.auth().setPersistence(firebase.auth.Auth.Persistence.SESSION) | |
.then(function () { | |
return firebase | |
.login({ provider: 'google', type: 'popup' }) | |
.then(credential => { | |
credential.user.getIdToken(true).then(function (token) { | |
// var formData = new FormData(); | |
// formData.append("idToken", token); | |
postData('https://us-central1-FIREBASEPROJECTNAME.cloudfunctions.net/sso/session', { idToken: token }) | |
.then(data => console.log(JSON.stringify(data))) // JSON-string from `response.json()` call | |
.catch(error => console.error(error)) | |
}) | |
}) | |
.catch(err => showError(err.message)) | |
}) | |
.catch(function (error) { | |
// Handle Errors here. | |
var errorCode = error.code; | |
var errorMessage = error.message; | |
}) | |
*/ | |
// This endpoint receives idToken after logging in using firebase (above example) | |
// https://us-central1-FIREBASEPROJECTNAME.cloudfunctions.net/sso/session | |
app.post('/session', cors(corsOptionsDelegate), function (req, res) { | |
if (__DEBUG__) console.log(env.discourse) | |
// Get the ID token passed | |
var idToken = req.body.idToken | |
// Set session expiration to 5 days. | |
var expiresIn = 60 * 60 * 24 * 5 * 1000 | |
// Create the session cookie. This will also verify the ID token in the process. | |
// The session cookie will have the same claims as the ID token. | |
// We could also choose to enforce that the ID token auth_time is recent. | |
if (__DEBUG__) console.log(`/sso/session - idToken: ${idToken}`) | |
auth.verifyIdToken(idToken) | |
.then(function (decodedClaims) { | |
// In this case, we are enforcing that the user signed in in the last 5 minutes. | |
if (new Date().getTime() / 1000 - decodedClaims.auth_time < 5 * 60) { | |
return auth.createSessionCookie(idToken, { expiresIn: expiresIn }) | |
} | |
throw new Error('UNAUTHORIZED REQUEST!') | |
}) | |
.then(function (sessionCookie) { | |
if (__DEBUG__) console.log(`sessionCookie: ${sessionCookie}`) | |
// Note httpOnly cookie will not be accessible from javascript. | |
// secure flag should be set to true in production. | |
res.cookie('session', sessionCookie, { maxAge: expiresIn, httpOnly: true, secure: true }) // set secure: false to test localhost | |
res.end(JSON.stringify({ status: 'success' })) | |
}) | |
.catch(function (error) { | |
if (__DEBUG__) console.log(error) | |
res.status(401).send('UNAUTHORIZED REQUEST!') | |
}) | |
}) | |
// This endpoint is what you use in the discourse admin for: "sso url" | |
// https://us-central1-FIREBASEPROJECTNAME.cloudfunctions.net/sso/discourse | |
// IMPORTANT: Updated URL to match the url of your firebae project. | |
app.get('/discourse', cookieParser(), function (req, res) { | |
const sessionCookie = req.cookies.session || '' | |
// Verify the session cookie. In this case an additional check is added to detect | |
// if the user's Firebase session was revoked, user deleted/disabled, etc. | |
if (__DEBUG__) console.log(`/sso/discourse - sessionCookie: ${sessionCookie}`) | |
if (__DEBUG__) console.log(`auth.verifySessionCookie`) | |
auth.verifySessionCookie(sessionCookie, true /** checkRevoked */) | |
.then((decodedClaims) => { | |
// once we are here the user cookie is known to be valid and we can extract the uid | |
var uid = decodedClaims.uid | |
var payload = req.query.sso // fetch from incoming request | |
var sig = req.query.sig // fetch from incoming request | |
if (__DEBUG__) console.log(`/sso/discourse -uid: ${uid}`) | |
if (__DEBUG__) console.log(`/sso/discourse -payload: ${payload}`) | |
if (__DEBUG__) console.log(`/sso/discourse -sig: ${sig}`) | |
if (sso.validate(payload, sig)) { | |
// valid sso, make sure the user is valid | |
auth.getUser(uid) | |
.then(function (userRecord) { | |
// Successfully fetched user data | |
var nonce = sso.getNonce(payload) | |
var userparams = { | |
// Required, will throw exception otherwise | |
'nonce': nonce, | |
'external_id': uid, | |
'email': userRecord.email, | |
// Optional - could pull these from DB values | |
'username': userRecord.displayName, | |
'name': userRecord.displayName | |
} | |
var q = sso.buildLoginString(userparams) | |
if (__DEBUG__) console.log('user authed and signed in through SSO, redirecting') | |
res.redirect(`${forumUrl}session/sso_login?${q}`) | |
}) | |
.catch(function (error) { | |
if (__DEBUG__) console.log('Error fetching user data:', error) | |
if (__DEBUG__) console.log('redirecting to login because of invalid user data') | |
res.redirect(loginUrl) | |
}) | |
} else { | |
if (__DEBUG__) console.log('unable to validate discourse payload, redirecting to login') | |
res.redirect(loginUrl) | |
} | |
}).catch(error => { | |
// Session cookie is unavailable or invalid. Force user to login. | |
res.redirect(loginUrl) | |
if (__DEBUG__) console.log('invalied session cookie, redirecting to login') | |
if (__DEBUG__) console.log(error) | |
}) | |
}) | |
app.options('*', cors(corsOptionsDelegate)) | |
export default functions.https.onRequest(app) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
See:
https://meta.discourse.org/t/sso-with-firebase/56524/10
https://meta.discourse.org/t/official-single-sign-on-for-discourse-sso/13045
Thanks to depthkit for getting this going originally.