Feature: Add OpenID Connect Generic Authentication Strategy (#1283)

* feat(server): add OIDC auth strategy

Add an OpenID Connect Authentication Strategy for Speckle Server. Enables configuration of
authentication against an OIDC standard compliant identity provider endpoint.

closes specklesystems#1270

Co-authored-by: spencer.goad <spencer.goad@disney.com>
This commit is contained in:
spgoad
2023-01-09 05:41:50 -08:00
committed by GitHub
parent 4181217cbc
commit 38720cecdc
12 changed files with 333 additions and 1 deletions
+6
View File
@@ -88,6 +88,12 @@ STRATEGY_LOCAL=true
# AZURE_AD_ISSUER="-> FILL IN <-"
# AZURE_AD_CLIENT_SECRET="-> FILL IN <-"
# STRATEGY_OIDC=false
# OIDC_NAME="-> FILL IN (optional) <-"
# OIDC_DISCOVERY_URL="-> FILL IN <-"
# OIDC_CLIENT_ID="-> FILL IN <-"
# OIDC_CLIENT_SECRET="-> FILL IN <-"
############################################################
# Tracing & co.
# Note: all data is anonymous, and it helps us deliver
@@ -115,6 +115,17 @@ module.exports = async (app) => {
strategyCount++
}
if (process.env.STRATEGY_OIDC === 'true') {
const oidcStrategy = await require('./strategies/oidc')(
app,
session,
sessionStorage,
finalizeAuth
)
authStrategies.push(oidcStrategy)
strategyCount++
}
// Note: always leave the local strategy init for last so as to be able to
// force enable it in case no others are present.
if (process.env.STRATEGY_LOCAL === 'true' || strategyCount === 0) {
@@ -0,0 +1,144 @@
/* istanbul ignore file */
'use strict'
const passport = require('passport')
const { Issuer, Strategy } = require('openid-client')
const URL = require('url').URL
const { findOrCreateUser, getUserByEmail } = require('@/modules/core/services/users')
const { getServerInfo } = require('@/modules/core/services/generic')
const {
validateServerInvite,
finalizeInvitedServerRegistration,
resolveAuthRedirectPath
} = require('@/modules/serverinvites/services/inviteProcessingService')
const { logger } = require('@/logging/logging')
const {
getOidcDiscoveryUrl,
getBaseUrl,
getOidcClientId,
getOidcClientSecret,
getOidcName
} = require('@/modules/shared/helpers/envHelper')
module.exports = async (app, session, sessionStorage, finalizeAuth) => {
await Issuer.discover(getOidcDiscoveryUrl()).then(async function (oidcIssuer) {
const redirectUrl = new URL('/auth/oidc/callback', getBaseUrl()).toString()
const client = new oidcIssuer.Client({
// eslint-disable-next-line camelcase
client_id: getOidcClientId(),
// eslint-disable-next-line camelcase
client_secret: getOidcClientSecret(),
// eslint-disable-next-line camelcase
redirect_uris: [redirectUrl],
// eslint-disable-next-line camelcase
response_types: ['code']
})
passport.use(
'oidc',
new Strategy(
{ client, passReqToCallback: true },
async (req, tokenSet, userinfo, done) => {
req.session.tokenSet = tokenSet
req.session.userinfo = userinfo
const serverInfo = await getServerInfo()
try {
const email = userinfo['email']
const name = `${userinfo['given_name']} ${userinfo['family_name']}`
const user = { email, name }
const existingUser = await getUserByEmail({ email: user.email })
if (existingUser && !existingUser.verified) {
throw new Error(
'Email already in use by a user with unverified email. Verify the email on the existing user to be able to log in with ' +
getOidcName()
)
}
// if there is an existing user, go ahead and log them in (regardless of
// whether the server is invite only or not).
if (existingUser) {
const myUser = await findOrCreateUser({
user,
rawProfile: userinfo
})
return done(null, myUser)
}
// if the server is not invite only, go ahead and log the user in.
if (!serverInfo.inviteOnly) {
const myUser = await findOrCreateUser({
user,
rawProfile: userinfo
})
// process invites
if (myUser.isNewUser) {
await finalizeInvitedServerRegistration(user.email, myUser.id)
}
return done(null, myUser)
}
// if the server is invite only and we have no invite id, throw.
if (serverInfo.inviteOnly && !req.session.inviteId) {
throw new Error(
'This server is invite only. Please provide an invite id.'
)
}
// validate the invite
const validInvite = await validateServerInvite(
user.email,
req.session.inviteId
)
// create the user
const myUser = await findOrCreateUser({ user, rawProfile: userinfo })
await finalizeInvitedServerRegistration(user.email, myUser.id)
// Resolve redirect path
req.authRedirectPath = resolveAuthRedirectPath(validInvite)
// return to the auth flow
return done(null, myUser)
} catch (err) {
logger.error(err)
return done(null, false, { message: err.message })
}
}
)
)
})
app.get(
'/auth/oidc',
session,
sessionStorage,
passport.authenticate('oidc', {
scope: 'openid profile email'
})
)
app.get(
'/auth/oidc/callback',
session,
passport.authenticate('oidc', {
failureRedirect: '/error?message=Failed to authenticate.'
}),
finalizeAuth
)
return {
id: 'oidc',
name: getOidcName(),
icon: 'mdi-badge-account-horizontal-outline',
color: 'blue darken-3',
url: '/auth/oidc',
callbackUrl: new URL('/auth/oidc/callback', getBaseUrl()).toString()
}
}
@@ -41,6 +41,38 @@ export function getRedisUrl() {
return process.env.REDIS_URL
}
export function getOidcDiscoveryUrl() {
if (!process.env.OIDC_DISCOVERY_URL) {
throw new MisconfiguredEnvironmentError('OIDC_DISCOVERY_URL env var not configured')
}
return process.env.OIDC_DISCOVERY_URL
}
export function getOidcClientId() {
if (!process.env.OIDC_CLIENT_ID) {
throw new MisconfiguredEnvironmentError('OIDC_CLIENT_ID env var not configured')
}
return process.env.OIDC_CLIENT_ID
}
export function getOidcClientSecret() {
if (!process.env.OIDC_CLIENT_SECRET) {
throw new MisconfiguredEnvironmentError('OIDC_CLIENT_SECRET env var not configured')
}
return process.env.OIDC_CLIENT_SECRET
}
export function getOidcName() {
if (!process.env.OIDC_NAME) {
throw new MisconfiguredEnvironmentError('OIDC_NAME env var not configured')
}
return process.env.OIDC_NAME
}
/**
* Get app base url / canonical url / origin
*/
+1
View File
@@ -68,6 +68,7 @@
"node-cron": "^3.0.2",
"node-machine-id": "^1.1.12",
"nodemailer": "^6.5.0",
"openid-client": "^5.1.7",
"passport": "^0.4.1",
"passport-azure-ad": "^4.3.0",
"passport-github2": "^0.1.12",
@@ -208,6 +208,24 @@ spec:
{{- end }}
# OpenID Connect Auth
{{- if .Values.server.auth.oidc.enabled }}
- name: STRATEGY_OIDC
value: "true"
- name: OIDC_NAME
value: {{ .Values.server.auth.oidc.name }}
- name: OIDC_DISCOVERY_URL
value: {{ .Values.server.auth.oidc.discovery_url }}
- name: OIDC_CLIENT_ID
value: {{ .Values.server.auth.oidc.client_id }}
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ default .Values.secretName .Values.server.auth.oidc.clientSecret.secretName }}
key: {{ default "oidc_client_secret" .Values.server.auth.oidc.clientSecret.secretKey }}
{{- end }}
# *** Email ***
{{- if .Values.server.email.enabled }}
@@ -86,6 +86,10 @@ spec:
- matchName: 'login.windows.net'
{{ include "speckle.renderTpl" (dict "value" .Values.server.auth.azure_ad.networkPolicy.domains "context" $ ) | indent 14 }}
{{- end }}
{{- if .Values.server.auth.oidc.enabled }}
# oidc auth
{{ include "speckle.renderTpl" (dict "value" .Values.server.auth.oidc.domains "context" $ ) | indent 14 }}
{{- end }}
{{ include "speckle.networkpolicy.dns.postgres.cilium" $ | indent 14 }}
{{ include "speckle.networkpolicy.dns.redis.cilium" $ | indent 14 }}
{{ include "speckle.networkpolicy.dns.blob_storage.cilium" $ | indent 14 }}
@@ -150,6 +154,14 @@ spec:
toPorts:
- port: {{ default 443 .Values.server.auth.azure_ad.port | quote }}
protocol: TCP
{{- end }}
{{- if .Values.server.auth.oidc.enabled }}
# azure ad auth
- toFQDNs:
{{ include "speckle.renderTpl" (dict "value" .Values.server.auth.oidc.domains "context" $ ) | indent 8 }}
toPorts:
- port: "443"
protocol: TCP
{{- end }}
# postgres
{{ include "speckle.networkpolicy.egress.postgres.cilium" $ | indent 4 }}
@@ -30,7 +30,7 @@ spec:
ports:
- port: 443
{{- end }}
{{- if ( or .Values.server.auth.google.enabled .Values.server.auth.github.enabled .Values.server.auth.azure_ad.enabled ) }}
{{- if ( or .Values.server.auth.google.enabled .Values.server.auth.github.enabled .Values.server.auth.azure_ad.enabled .Values.server.auth.oidc.enabled ) }}
- to:
- ipBlock:
cidr: 0.0.0.0/0
@@ -23,6 +23,9 @@ secrets:
{{- if .Values.server.auth.azure_ad.enabled }}
- name: {{ default .Values.secretName .Values.server.auth.azure_ad.clientSecret.secretName }}
{{- end }}
{{- if .Values.server.auth.oidc.enabled }}
- name: {{ default .Values.secretName .Values.server.auth.oidc.clientSecret.secretName }}
{{- end }}
{{- if .Values.server.email.enabled }}
- name: {{ default .Values.secretName .Values.server.email.password.secretName }}
{{- end }}
@@ -583,6 +583,54 @@
"default": 443
}
}
},
"oidc": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "If enabled, users can authenticate via OpenID Connect.",
"default": false
},
"name": {
"type": "string",
"description": "This is the name that you want displayed on the login button",
"default": ""
},
"discovery_url": {
"type": "string",
"description": "This is the OIDC discovery URL for the identity provider you want to use",
"default": ""
},
"client_id": {
"type": "string",
"description": "This is the ID for Speckle that you have registered with the OIDC identity provider",
"default": ""
},
"clientSecret": {
"type": "object",
"properties": {
"secretName": {
"type": "string",
"description": "The name of the Kubernetes Secret containing the OIDC client secret. This is expected to be provided within the Kubernetes cluster as an opaque Kubernetes Secret. Ref: https://kubernetes.io/docs/concepts/configuration/secret/#opaque-secrets",
"default": ""
},
"secretKey": {
"type": "string",
"description": "The key within the Kubernetes Secret holding the OIDC client secret as its value.",
"default": ""
}
}
},
"domains": {
"type": "array",
"description": "List of `matchName` or `matchPattern` maps for domains that should be allow-listed for egress in Network Policy.",
"default": [],
"items": {
"type": "object"
}
}
}
}
}
},
+23
View File
@@ -436,6 +436,29 @@ server:
## @param server.auth.azure_ad.port Port on server to connect to. Used to allow egress in Network Policy. Defaults to 443
##
port: 443
oidc:
## @param server.auth.oidc.enabled If enabled, users can authenticate via OpenID Connect identity provider
##
enabled: false
## @param server.auth.oidc.name This is the name that you want displayed on the login button
##
name: ''
## @param server.auth.oidc.discovery_url This is the OIDC discovery URL for the identity provider you want to use
##
discovery_url: ''
## @param server.auth.oidc.client_id This is the ID for Speckle that you have registered with the OIDC identity provider
##
client_id: ''
clientSecret:
## @param server.auth.oidc.clientSecret.secretName The name of the Kubernetes Secret containing the OIDC client secret. This is expected to be provided within the Kubernetes cluster as an opaque Kubernetes Secret. Ref: https://kubernetes.io/docs/concepts/configuration/secret/#opaque-secrets
##
secretName: ''
## @param server.auth.oidc.clientSecret.secretKey The key within the Kubernetes Secret holding the OIDC client secret as its value.
##
secretKey: ''
## @param server.auth.oidc.domains List of `matchName` or `matchPattern` maps for domains that should be allow-listed for egress in Network Policy.
##
domains: []
## @extra server.email Speckle can communicate with users via email, providing account verification and notification.
##
email:
+34
View File
@@ -5076,6 +5076,7 @@ __metadata:
nodemailer: ^6.5.0
nodemon: ^2.0.6
nyc: ^15.0.1
openid-client: ^5.1.7
passport: ^0.4.1
passport-azure-ad: ^4.3.0
passport-github2: ^0.1.12
@@ -13856,6 +13857,13 @@ __metadata:
languageName: node
linkType: hard
"jose@npm:^4.10.0":
version: 4.11.1
resolution: "jose@npm:4.11.1"
checksum: cd15cba258d0fd20f6168631ce2e94fda8442df80e43c1033c523915cecdf390a1cc8efe0eab0c2d65935ca973d791c668aea80724d2aa9c2879d4e70f3081d7
languageName: node
linkType: hard
"joycon@npm:^3.1.1":
version: 3.1.1
resolution: "joycon@npm:3.1.1"
@@ -16160,6 +16168,13 @@ __metadata:
languageName: node
linkType: hard
"object-hash@npm:^2.0.1":
version: 2.2.0
resolution: "object-hash@npm:2.2.0"
checksum: 55ba841e3adce9c4f1b9b46b41983eda40f854e0d01af2802d3ae18a7085a17168d6b81731d43fdf1d6bcbb3c9f9c56d22c8fea992203ad90a38d7d919bc28f1
languageName: node
linkType: hard
"object-inspect@npm:^1.12.0, object-inspect@npm:^1.9.0":
version: 1.12.0
resolution: "object-inspect@npm:1.12.0"
@@ -16193,6 +16208,13 @@ __metadata:
languageName: node
linkType: hard
"oidc-token-hash@npm:^5.0.1":
version: 5.0.1
resolution: "oidc-token-hash@npm:5.0.1"
checksum: d62aa8c665f1a0133a3694785e4cd75f471364e6a2f4fbf7997e193a49ccf8f8de15596f475c7fdf8510d021c3b72f7ff6ab8bc5b07bff4cf21d490177f164a5
languageName: node
linkType: hard
"on-exit-leak-free@npm:^2.1.0":
version: 2.1.0
resolution: "on-exit-leak-free@npm:2.1.0"
@@ -16272,6 +16294,18 @@ __metadata:
languageName: node
linkType: hard
"openid-client@npm:^5.1.7":
version: 5.3.1
resolution: "openid-client@npm:5.3.1"
dependencies:
jose: ^4.10.0
lru-cache: ^6.0.0
object-hash: ^2.0.1
oidc-token-hash: ^5.0.1
checksum: e8993cfe6ac55942fb7a5fc4db8ab88ab36bbcf2a6519993a9a2e288bfc248fcfb07804b6785eb7852a9eea0eec27f80ae57a29193c0b835f31f611b11b9bda1
languageName: node
linkType: hard
"optimism@npm:^0.16.1":
version: 0.16.1
resolution: "optimism@npm:0.16.1"