diff --git a/packages/server/.env-example b/packages/server/.env-example index 5365d3404..00bc249dd 100644 --- a/packages/server/.env-example +++ b/packages/server/.env-example @@ -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 diff --git a/packages/server/modules/auth/strategies.js b/packages/server/modules/auth/strategies.js index 2a57bebf4..4195ec16e 100644 --- a/packages/server/modules/auth/strategies.js +++ b/packages/server/modules/auth/strategies.js @@ -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) { diff --git a/packages/server/modules/auth/strategies/oidc.js b/packages/server/modules/auth/strategies/oidc.js new file mode 100644 index 000000000..6354cac1b --- /dev/null +++ b/packages/server/modules/auth/strategies/oidc.js @@ -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() + } +} diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index eff94edb6..098c64f74 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -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 */ diff --git a/packages/server/package.json b/packages/server/package.json index bc9fc5799..08809e998 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/utils/helm/speckle-server/templates/server/deployment.yml b/utils/helm/speckle-server/templates/server/deployment.yml index e9acb7fdb..00354e874 100644 --- a/utils/helm/speckle-server/templates/server/deployment.yml +++ b/utils/helm/speckle-server/templates/server/deployment.yml @@ -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 }} diff --git a/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml b/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml index 540d2d22b..ab5d1791c 100644 --- a/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml +++ b/utils/helm/speckle-server/templates/server/networkpolicy.cilium.yml @@ -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 }} diff --git a/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml index 13e065299..c67820261 100644 --- a/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml +++ b/utils/helm/speckle-server/templates/server/networkpolicy.kubernetes.yml @@ -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 diff --git a/utils/helm/speckle-server/templates/server/serviceaccount.yml b/utils/helm/speckle-server/templates/server/serviceaccount.yml index e0dcaa875..52a8d3c2a 100644 --- a/utils/helm/speckle-server/templates/server/serviceaccount.yml +++ b/utils/helm/speckle-server/templates/server/serviceaccount.yml @@ -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 }} diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index e93ae7c3c..bb53a2a35 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -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" + } + } + } } } }, diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index 08f1de12a..6fb001488 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -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: diff --git a/yarn.lock b/yarn.lock index 552831db9..87d0c1390 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"