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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user