diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 7e335295fa..9a8645a5bf 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1691,6 +1691,9 @@ dependencies: openai: specifier: ^4.56.0 version: 4.56.0(zod@3.23.8) + openid-client: + specifier: ~5.7.0 + version: 5.7.0 otp-generator: specifier: ^4.0.1 version: 4.0.1 @@ -17193,6 +17196,10 @@ packages: engines: {node: '>= 0.6.0'} dev: false + /jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + dev: false + /jose@5.6.3: resolution: {integrity: sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==} dev: false @@ -19076,6 +19083,11 @@ packages: resolution: {integrity: sha512-CsubGNxhIEChNY4cXYuA6KXafztzHqzLLZ/y3Kasf3A+sa3lL9thq3z+7o0pZqzEinjXT6lXDPAfVWI59dUyzQ==} dev: false + /object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + dev: false + /object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -19210,6 +19222,11 @@ packages: resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} dev: false + /oidc-token-hash@5.0.3: + resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} + engines: {node: ^10.13.0 || >=12.0.0} + dev: false + /omggif@1.0.10: resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} dev: false @@ -19298,6 +19315,15 @@ packages: hasBin: true dev: false + /openid-client@5.7.0: + resolution: {integrity: sha512-4GCCGZt1i2kTHpwvaC/sCpTpQqDnBzDzuJcJMbH+y1Q5qI8U8RBvoSh28svarXszZHR5BAMXbJPX1PGPRE3VOA==} + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.0.3 + dev: false + /option@0.2.4: resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} dev: false @@ -25220,7 +25246,7 @@ packages: dev: false file:projects/auth-providers.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): - resolution: {integrity: sha512-RDD+zkNosRxJuY+bq2skRGbRPr4saOaAvlkKv3gvvIXT5RxnmOXnznS6haxIcXw4VA96oxv08U9Mu8+3PjgpwQ==, tarball: file:projects/auth-providers.tgz} + resolution: {integrity: sha512-0YbyLxnpaSZfdCfdlt5T9LI/TmahO7fbLohsHvXQVFb2J1InAIyu1WWzbS92CLYLHxQtJmSIOAbPVbRV1wPWbw==, tarball: file:projects/auth-providers.tgz} id: file:projects/auth-providers.tgz name: '@rush-temp/auth-providers' version: 0.0.0 @@ -25248,6 +25274,7 @@ packages: koa-router: 12.0.1 koa-session: 6.4.0 mongodb: 6.9.0 + openid-client: 5.7.0 passport-custom: 1.1.1 passport-github2: 0.1.12 passport-google-oauth20: 2.0.0 diff --git a/plugins/login-resources/src/components/Providers.svelte b/plugins/login-resources/src/components/Providers.svelte index 4e60845186..4753592a75 100644 --- a/plugins/login-resources/src/components/Providers.svelte +++ b/plugins/login-resources/src/components/Providers.svelte @@ -7,6 +7,7 @@ import { getProviders } from '../utils' import Github from './providers/Github.svelte' import Google from './providers/Google.svelte' + import OpenId from './providers/OpenId.svelte' interface Provider { name: string @@ -21,6 +22,10 @@ { name: 'github', component: Github + }, + { + name: 'openid', + component: OpenId } ] diff --git a/plugins/login-resources/src/components/icons/OpenId.svelte b/plugins/login-resources/src/components/icons/OpenId.svelte new file mode 100644 index 0000000000..c977eaecc5 --- /dev/null +++ b/plugins/login-resources/src/components/icons/OpenId.svelte @@ -0,0 +1,15 @@ + + + + diff --git a/plugins/login-resources/src/components/providers/OpenId.svelte b/plugins/login-resources/src/components/providers/OpenId.svelte new file mode 100644 index 0000000000..1948f535c8 --- /dev/null +++ b/plugins/login-resources/src/components/providers/OpenId.svelte @@ -0,0 +1,10 @@ + + +
+ +
diff --git a/pods/authProviders/package.json b/pods/authProviders/package.json index 4735797719..20e8c75f2b 100644 --- a/pods/authProviders/package.json +++ b/pods/authProviders/package.json @@ -52,6 +52,7 @@ "passport-custom": "~1.1.1", "passport-google-oauth20": "~2.0.0", "passport-github2": "~0.1.12", + "openid-client": "~5.7.0", "koa-passport": "^6.0.0", "koa": "^2.15.3", "koa-router": "^12.0.1", diff --git a/pods/authProviders/src/github.ts b/pods/authProviders/src/github.ts index 57ffae31db..6fc525cd5f 100644 --- a/pods/authProviders/src/github.ts +++ b/pods/authProviders/src/github.ts @@ -12,7 +12,7 @@ export function registerGithub ( passport: Passport, router: Router, accountsUrl: string, - db: Db, + dbPromise: Promise, frontUrl: string, brandings: BrandingMap ): string | undefined { @@ -69,6 +69,7 @@ export function registerGithub ( let loginInfo: LoginInfo const state = safeParseAuthState(ctx.query?.state) const branding = getBranding(brandings, state?.branding) + const db = await dbPromise if (state.inviteId != null && state.inviteId !== '') { loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any, { githubId: ctx.state.user.id diff --git a/pods/authProviders/src/google.ts b/pods/authProviders/src/google.ts index 6453165d58..be68fe269a 100644 --- a/pods/authProviders/src/google.ts +++ b/pods/authProviders/src/google.ts @@ -12,7 +12,7 @@ export function registerGoogle ( passport: Passport, router: Router, accountsUrl: string, - db: Db, + dbPromise: Promise, frontUrl: string, brandings: BrandingMap ): string | undefined { @@ -74,6 +74,7 @@ export function registerGoogle ( let loginInfo: LoginInfo const state = safeParseAuthState(ctx.query?.state) const branding = getBranding(brandings, state?.branding) + const db = await dbPromise if (state.inviteId != null && state.inviteId !== '') { loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any) } else { diff --git a/pods/authProviders/src/index.ts b/pods/authProviders/src/index.ts index 0ab49993c0..a812761f03 100644 --- a/pods/authProviders/src/index.ts +++ b/pods/authProviders/src/index.ts @@ -5,6 +5,7 @@ import session from 'koa-session' import { Db } from 'mongodb' import { registerGithub } from './github' import { registerGoogle } from './google' +import { registerOpenid } from './openid' import { registerToken } from './token' import { BrandingMap, MeasureContext } from '@hcengineering/core' @@ -15,7 +16,7 @@ export type AuthProvider = ( passport: Passport, router: Router, accountsUrl: string, - db: Db, + db: Promise, frontUrl: string, brandings: BrandingMap ) => string | undefined @@ -24,7 +25,7 @@ export function registerProviders ( ctx: MeasureContext, app: Koa, router: Router, - db: Db, + db: Promise, serverSecret: string, frontUrl: string | undefined, brandings: BrandingMap @@ -60,7 +61,7 @@ export function registerProviders ( registerToken(ctx, passport, router, accountsUrl, db, frontUrl, brandings) const res: string[] = [] - const providers: AuthProvider[] = [registerGoogle, registerGithub] + const providers: AuthProvider[] = [registerGoogle, registerGithub, registerOpenid] for (const provider of providers) { const value = provider(ctx, passport, router, accountsUrl, db, frontUrl, brandings) if (value !== undefined) res.push(value) diff --git a/pods/authProviders/src/openid.ts b/pods/authProviders/src/openid.ts new file mode 100644 index 0000000000..b517c28664 --- /dev/null +++ b/pods/authProviders/src/openid.ts @@ -0,0 +1,119 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the f. +// +import { joinWithProvider, loginWithProvider, type LoginInfo } from '@hcengineering/account' +import { BrandingMap, concatLink, MeasureContext, getBranding } from '@hcengineering/core' +import Router from 'koa-router' +import { Db } from 'mongodb' +import { Issuer, Strategy } from 'openid-client' +import qs from 'querystringify' + +import { Passport } from '.' +import { getHost, safeParseAuthState } from './utils' + +export function registerOpenid ( + measureCtx: MeasureContext, + passport: Passport, + router: Router, + accountsUrl: string, + dbPromise: Promise, + frontUrl: string, + brandings: BrandingMap +): string | undefined { + const openidClientId = process.env.OPENID_CLIENT_ID + const openidClientSecret = process.env.OPENID_CLIENT_SECRET + const issuer = process.env.OPENID_ISSUER + + const redirectURL = '/auth/openid/callback' + if (openidClientId === undefined || openidClientSecret === undefined || issuer === undefined) return + + void Issuer.discover(issuer).then((issuerObj) => { + const client = new issuerObj.Client({ + client_id: openidClientId, + client_secret: openidClientSecret, + redirect_uris: [concatLink(accountsUrl, redirectURL)], + response_types: ['code'] + }) + + passport.use( + 'oidc', + new Strategy({ client, passReqToCallback: true }, (req: any, tokenSet: any, userinfo: any, done: any) => { + return done(null, userinfo) + }) + ) + }) + + router.get('/auth/openid', async (ctx, next) => { + measureCtx.info('try auth via', { provider: 'openid' }) + const host = getHost(ctx.request.headers) + const brandingKey = host !== undefined ? brandings[host]?.key ?? undefined : undefined + const state = encodeURIComponent( + JSON.stringify({ + inviteId: ctx.query?.inviteId, + branding: brandingKey + }) + ) + + await passport.authenticate('oidc', { + scope: 'openid profile email', + state + })(ctx, next) + }) + + router.get( + redirectURL, + async (ctx, next) => { + const state = safeParseAuthState(ctx.query?.state) + const branding = getBranding(brandings, state?.branding) + + await passport.authenticate('oidc', { + failureRedirect: concatLink(branding?.front ?? frontUrl, '/login') + })(ctx, next) + }, + async (ctx, next) => { + try { + const email = ctx.state.user.email ?? `openid:${ctx.state.user.sub}` + const [first, last] = ctx.state.user.name?.split(' ') ?? [ctx.state.user.username, ''] + measureCtx.info('Provider auth handler', { email, type: 'openid' }) + if (email !== undefined) { + let loginInfo: LoginInfo + const state = safeParseAuthState(ctx.query?.state) + const branding = getBranding(brandings, state?.branding) + const db = await dbPromise + if (state.inviteId != null && state.inviteId !== '') { + loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any, { + openId: ctx.state.user.sub + }) + } else { + loginInfo = await loginWithProvider(measureCtx, db, null, email, first, last, { + openId: ctx.state.user.sub + }) + } + + const origin = concatLink(branding?.front ?? frontUrl, '/login/auth') + const query = encodeURIComponent(qs.stringify({ token: loginInfo.token })) + + measureCtx.info('Success auth, redirect', { email, type: 'openid', target: origin }) + // Successful authentication, redirect to your application + ctx.redirect(`${origin}?${query}`) + } + } catch (err: any) { + measureCtx.error('failed to auth', { err, type: 'openid', user: ctx.state?.user }) + } + await next() + } + ) + + return 'openid' +} diff --git a/pods/authProviders/src/token.ts b/pods/authProviders/src/token.ts index fa2ad0793f..8edbd7d32b 100644 --- a/pods/authProviders/src/token.ts +++ b/pods/authProviders/src/token.ts @@ -12,7 +12,7 @@ export function registerToken ( passport: Passport, router: Router, accountsUrl: string, - db: Db, + dbPromise: Promise, frontUrl: string, brandings: BrandingMap ): string | undefined { @@ -21,9 +21,11 @@ export function registerToken ( new CustomStrategy(function (req: any, done: any) { const token = req.body.token ?? req.query.token - getAccountInfoByToken(measureCtx, db, null, token) - .then((user: any) => done(null, user)) - .catch((err: any) => done(err)) + void dbPromise.then((db) => { + getAccountInfoByToken(measureCtx, db, null, token) + .then((user: any) => done(null, user)) + .catch((err: any) => done(err)) + }) }) ) diff --git a/server/account-service/src/index.ts b/server/account-service/src/index.ts index c57ccaf2a6..03257a6f06 100644 --- a/server/account-service/src/index.ts +++ b/server/account-service/src/index.ts @@ -103,10 +103,11 @@ export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap ) app.use(bodyParser()) - void client.getClient().then(async (p: MongoClient) => { - const db = p.db(ACCOUNT_DB) - registerProviders(measureCtx, app, router, db, serverSecret, frontURL, brandings) + const mongoClientPromise = client.getClient() + const dbPromise = mongoClientPromise.then((c) => c.db(ACCOUNT_DB)) + registerProviders(measureCtx, app, router, dbPromise, serverSecret, frontURL, brandings) + void dbPromise.then((db) => { setInterval( () => { void cleanExpiredOtp(db)