uberf-8195: support openid auth (#6654)

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev 2024-09-20 18:59:23 +04:00 committed by GitHub
parent 4165163e96
commit 9a35f013ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 196 additions and 13 deletions

View File

@ -1691,6 +1691,9 @@ dependencies:
openai: openai:
specifier: ^4.56.0 specifier: ^4.56.0
version: 4.56.0(zod@3.23.8) version: 4.56.0(zod@3.23.8)
openid-client:
specifier: ~5.7.0
version: 5.7.0
otp-generator: otp-generator:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@ -17193,6 +17196,10 @@ packages:
engines: {node: '>= 0.6.0'} engines: {node: '>= 0.6.0'}
dev: false dev: false
/jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
dev: false
/jose@5.6.3: /jose@5.6.3:
resolution: {integrity: sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==} resolution: {integrity: sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==}
dev: false dev: false
@ -19076,6 +19083,11 @@ packages:
resolution: {integrity: sha512-CsubGNxhIEChNY4cXYuA6KXafztzHqzLLZ/y3Kasf3A+sa3lL9thq3z+7o0pZqzEinjXT6lXDPAfVWI59dUyzQ==} resolution: {integrity: sha512-CsubGNxhIEChNY4cXYuA6KXafztzHqzLLZ/y3Kasf3A+sa3lL9thq3z+7o0pZqzEinjXT6lXDPAfVWI59dUyzQ==}
dev: false dev: false
/object-hash@2.2.0:
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
engines: {node: '>= 6'}
dev: false
/object-hash@3.0.0: /object-hash@3.0.0:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -19210,6 +19222,11 @@ packages:
resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==}
dev: false 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: /omggif@1.0.10:
resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==}
dev: false dev: false
@ -19298,6 +19315,15 @@ packages:
hasBin: true hasBin: true
dev: false 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: /option@0.2.4:
resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==}
dev: false dev: false
@ -25220,7 +25246,7 @@ packages:
dev: false dev: false
file:projects/auth-providers.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): 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 id: file:projects/auth-providers.tgz
name: '@rush-temp/auth-providers' name: '@rush-temp/auth-providers'
version: 0.0.0 version: 0.0.0
@ -25248,6 +25274,7 @@ packages:
koa-router: 12.0.1 koa-router: 12.0.1
koa-session: 6.4.0 koa-session: 6.4.0
mongodb: 6.9.0 mongodb: 6.9.0
openid-client: 5.7.0
passport-custom: 1.1.1 passport-custom: 1.1.1
passport-github2: 0.1.12 passport-github2: 0.1.12
passport-google-oauth20: 2.0.0 passport-google-oauth20: 2.0.0

View File

@ -7,6 +7,7 @@
import { getProviders } from '../utils' import { getProviders } from '../utils'
import Github from './providers/Github.svelte' import Github from './providers/Github.svelte'
import Google from './providers/Google.svelte' import Google from './providers/Google.svelte'
import OpenId from './providers/OpenId.svelte'
interface Provider { interface Provider {
name: string name: string
@ -21,6 +22,10 @@
{ {
name: 'github', name: 'github',
component: Github component: Github
},
{
name: 'openid',
component: OpenId
} }
] ]

View File

@ -0,0 +1,15 @@
<svg viewBox="0 0 512 512" width="1.5rem" height="1.5rem" xmlns="http://www.w3.org/2000/svg">
<rect
height="512"
rx="64"
ry="64"
style="fill:#f68423;fill-opacity:1;fill-rule:nonzero;stroke:none"
width="512"
x="0"
y="0"
/>
<path
d="m 416.99957,216.95084 c -39.2625,-22.18682 -78.9827,-34.64025 -117.0052,-39.52028 V 73.776502 l -60.66712,39.049158 v 63.02711 c -188.010218,14.68899 -295.068752,208.65924 0,262.37073 l 60.66712,-39.04916 V 218.28862 c 24.3468,4.22226 50.4759,12.17342 78.0094,24.69351 l -34.6758,21.70238 h 112.6719 v -73.76497 l -39.0003,26.0313 z m -177.67682,-1.66668 v 183.89018 c -182.841238,-23.72461 -140.809838,-171.94788 0,-183.89018 z"
style="fill:#ffffff;fill-opacity:1"
/>
</svg>

After

Width:  |  Height:  |  Size: 751 B

View File

@ -0,0 +1,10 @@
<script lang="ts">
import { Label } from '@hcengineering/ui'
import OpenId from '../icons/OpenId.svelte'
import login from '../../plugin'
</script>
<div class="flex-row-center flex-gap-2">
<OpenId />
<Label label={login.string.ContinueWith} params={{ provider: 'OpenId' }} />
</div>

View File

@ -52,6 +52,7 @@
"passport-custom": "~1.1.1", "passport-custom": "~1.1.1",
"passport-google-oauth20": "~2.0.0", "passport-google-oauth20": "~2.0.0",
"passport-github2": "~0.1.12", "passport-github2": "~0.1.12",
"openid-client": "~5.7.0",
"koa-passport": "^6.0.0", "koa-passport": "^6.0.0",
"koa": "^2.15.3", "koa": "^2.15.3",
"koa-router": "^12.0.1", "koa-router": "^12.0.1",

View File

@ -12,7 +12,7 @@ export function registerGithub (
passport: Passport, passport: Passport,
router: Router<any, any>, router: Router<any, any>,
accountsUrl: string, accountsUrl: string,
db: Db, dbPromise: Promise<Db>,
frontUrl: string, frontUrl: string,
brandings: BrandingMap brandings: BrandingMap
): string | undefined { ): string | undefined {
@ -69,6 +69,7 @@ export function registerGithub (
let loginInfo: LoginInfo let loginInfo: LoginInfo
const state = safeParseAuthState(ctx.query?.state) const state = safeParseAuthState(ctx.query?.state)
const branding = getBranding(brandings, state?.branding) const branding = getBranding(brandings, state?.branding)
const db = await dbPromise
if (state.inviteId != null && state.inviteId !== '') { if (state.inviteId != null && state.inviteId !== '') {
loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any, { loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any, {
githubId: ctx.state.user.id githubId: ctx.state.user.id

View File

@ -12,7 +12,7 @@ export function registerGoogle (
passport: Passport, passport: Passport,
router: Router<any, any>, router: Router<any, any>,
accountsUrl: string, accountsUrl: string,
db: Db, dbPromise: Promise<Db>,
frontUrl: string, frontUrl: string,
brandings: BrandingMap brandings: BrandingMap
): string | undefined { ): string | undefined {
@ -74,6 +74,7 @@ export function registerGoogle (
let loginInfo: LoginInfo let loginInfo: LoginInfo
const state = safeParseAuthState(ctx.query?.state) const state = safeParseAuthState(ctx.query?.state)
const branding = getBranding(brandings, state?.branding) const branding = getBranding(brandings, state?.branding)
const db = await dbPromise
if (state.inviteId != null && state.inviteId !== '') { if (state.inviteId != null && state.inviteId !== '') {
loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any) loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any)
} else { } else {

View File

@ -5,6 +5,7 @@ import session from 'koa-session'
import { Db } from 'mongodb' import { Db } from 'mongodb'
import { registerGithub } from './github' import { registerGithub } from './github'
import { registerGoogle } from './google' import { registerGoogle } from './google'
import { registerOpenid } from './openid'
import { registerToken } from './token' import { registerToken } from './token'
import { BrandingMap, MeasureContext } from '@hcengineering/core' import { BrandingMap, MeasureContext } from '@hcengineering/core'
@ -15,7 +16,7 @@ export type AuthProvider = (
passport: Passport, passport: Passport,
router: Router<any, any>, router: Router<any, any>,
accountsUrl: string, accountsUrl: string,
db: Db, db: Promise<Db>,
frontUrl: string, frontUrl: string,
brandings: BrandingMap brandings: BrandingMap
) => string | undefined ) => string | undefined
@ -24,7 +25,7 @@ export function registerProviders (
ctx: MeasureContext, ctx: MeasureContext,
app: Koa<Koa.DefaultState, Koa.DefaultContext>, app: Koa<Koa.DefaultState, Koa.DefaultContext>,
router: Router<any, any>, router: Router<any, any>,
db: Db, db: Promise<Db>,
serverSecret: string, serverSecret: string,
frontUrl: string | undefined, frontUrl: string | undefined,
brandings: BrandingMap brandings: BrandingMap
@ -60,7 +61,7 @@ export function registerProviders (
registerToken(ctx, passport, router, accountsUrl, db, frontUrl, brandings) registerToken(ctx, passport, router, accountsUrl, db, frontUrl, brandings)
const res: string[] = [] const res: string[] = []
const providers: AuthProvider[] = [registerGoogle, registerGithub] const providers: AuthProvider[] = [registerGoogle, registerGithub, registerOpenid]
for (const provider of providers) { for (const provider of providers) {
const value = provider(ctx, passport, router, accountsUrl, db, frontUrl, brandings) const value = provider(ctx, passport, router, accountsUrl, db, frontUrl, brandings)
if (value !== undefined) res.push(value) if (value !== undefined) res.push(value)

View File

@ -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<any, any>,
accountsUrl: string,
dbPromise: Promise<Db>,
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'
}

View File

@ -12,7 +12,7 @@ export function registerToken (
passport: Passport, passport: Passport,
router: Router<any, any>, router: Router<any, any>,
accountsUrl: string, accountsUrl: string,
db: Db, dbPromise: Promise<Db>,
frontUrl: string, frontUrl: string,
brandings: BrandingMap brandings: BrandingMap
): string | undefined { ): string | undefined {
@ -21,9 +21,11 @@ export function registerToken (
new CustomStrategy(function (req: any, done: any) { new CustomStrategy(function (req: any, done: any) {
const token = req.body.token ?? req.query.token const token = req.body.token ?? req.query.token
getAccountInfoByToken(measureCtx, db, null, token) void dbPromise.then((db) => {
.then((user: any) => done(null, user)) getAccountInfoByToken(measureCtx, db, null, token)
.catch((err: any) => done(err)) .then((user: any) => done(null, user))
.catch((err: any) => done(err))
})
}) })
) )

View File

@ -103,10 +103,11 @@ export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap
) )
app.use(bodyParser()) app.use(bodyParser())
void client.getClient().then(async (p: MongoClient) => { const mongoClientPromise = client.getClient()
const db = p.db(ACCOUNT_DB) const dbPromise = mongoClientPromise.then((c) => c.db(ACCOUNT_DB))
registerProviders(measureCtx, app, router, db, serverSecret, frontURL, brandings) registerProviders(measureCtx, app, router, dbPromise, serverSecret, frontURL, brandings)
void dbPromise.then((db) => {
setInterval( setInterval(
() => { () => {
void cleanExpiredOtp(db) void cleanExpiredOtp(db)