Files
speckle-server/packages/server/modules/auth/services/passportService.ts
T
Kristaps Fabians Geikins bde148f286 chore(server): migrating fully to ESM (#5042)
* wip

* some extra fixes

* stuff kinda works?

* need to figure out mocks

* need to figure out mocks

* fix db listener

* gqlgen fix

* minor gqlgen watch adjustment

* lint fixes

* delete old codegen file

* converting migrations to ESM

* getModuleDIrectory

* vitest sort of works

* added back ts-vitest

* resolve gql double load

* fixing test timeout configs

* TSC lint fix

* fix automate tests

* moar debugging

* debugging

* more debugging

* codegen update

* server works

* yargs migrated

* chore(server): getting rid of global mocks for Server ESM (#5046)

* got rid of email mock

* got rid of comment mocks

* got rid of multi region mocks

* got rid of stripe mock

* admin override mock updated

* removed final mock

* fixing import.meta.resolve calls

* another import.meta.resolve fix

* added requested test

* nyc ESM fix

* removed unneeded deps + linting

* yarn lock forgot to commit

* tryna fix flakyness

* email capture util fix

* sendEmail fix

* fix TSX check

* sender transporter fix + CR comments

* merge main fix

* test fixx

* circleci fix

* gqlgen bigint fix

* error formatter fix

* more error formatting improvements

* esmloader added to Dockerfile

* more dockerfile fixes

* bg jobs fix
2025-07-14 10:26:19 +03:00

232 lines
7.9 KiB
TypeScript

import type { Strategy, AuthenticateOptions } from 'passport'
import passport from 'passport'
import type { Request, Response, NextFunction, RequestHandler } from 'express'
import { ensureError, type Optional, throwUncoveredError } from '@speckle/shared'
import { get, isArray, isObjectLike, isString } from 'lodash-es'
import type { PassportAuthenticateHandlerBuilder } from '@/modules/auth/domain/operations'
import { ExpectedAuthFailure } from '@/modules/auth/domain/const'
import type { ResolveAuthRedirectPath } from '@/modules/serverinvites/services/operations'
const resolveInfoMessage = (
info?: Optional<string | Record<string, unknown> | Array<string | undefined>>
) => {
if (!info) return null
if (isString(info)) return info
if (isArray(info)) {
const arrayStrings = info.filter(isString)
if (arrayStrings.length) return arrayStrings.join(', ')
}
if (isObjectLike(info)) {
const message = get(info, 'message')
if (isString(message)) return message
}
return null
}
const resolveFailureType = (
info?: Optional<string | Record<string, unknown> | Array<string | undefined>>
) => {
if (!info) return null
if (isString(info)) return null
if (isArray(info)) return null
if (isObjectLike(info)) {
const failureType = get(info, 'failureType')
if (
isString(failureType) &&
Object.values(ExpectedAuthFailure).includes(failureType as ExpectedAuthFailure)
) {
return failureType as ExpectedAuthFailure
}
}
return null
}
const resolveEmail = (
info?: Optional<string | Record<string, unknown> | Array<string | undefined>>
) => {
if (!info) return ''
if (isString(info)) return ''
if (isArray(info)) return ''
if (isObjectLike(info)) {
const email = get(info, 'email')
if (isString(email)) {
return email
}
}
return ''
}
const defaultErrorPath = (message: string) => `/error?message=${message}`
const unverifiedEmailPath = (email: string) => `/error-email-verify?email=${email}`
const buildRedirectUrl = (params: {
resolveAuthRedirectPath: ResolveAuthRedirectPath
path: string
}) => new URL(params.path, params.resolveAuthRedirectPath()).toString()
export const passportAuthenticationCallbackFactory =
(context: {
strategy: Strategy | string
req: Request
res: Response
next: NextFunction
resolveAuthRedirectPath: ResolveAuthRedirectPath
}) =>
(
callbackError: unknown,
user: Optional<Express.User>,
info: Optional<string | Record<string, unknown> | Array<string | undefined>>
) => {
const { strategy, req, res, next, resolveAuthRedirectPath } = context
let e = callbackError
let failureType = resolveFailureType(info)
// WORKAROUND
// passportjs states that 'verify' method of the strategy should not pass in
// an error for user input problems.
// Unfortunately openid-client <6.0.0 does not provide a third 'info' parameter
// so we rely on user-input problems being passed to callback as errors.
// This is a workaround until we upgrade to openid-client >=6.0.0
if (e && strategy === 'oidc' && failureType === null) {
switch (e.constructor.name) {
case ExpectedAuthFailure.UserInputError:
case ExpectedAuthFailure.InviteNotFoundError:
case ExpectedAuthFailure.UnverifiedEmailSSOLoginError:
// the error was being overloaded with user input problem information
// so we need to extract it and set it as the info
// and set the error to null
failureType = e.constructor.name
e = null
break
default:
// what we have is an unexpected error, so nothing needs to change
break
}
}
if (e) {
const err = ensureError(
e,
'Unknown authentication error. Please contact server admins'
)
// google will throw a TokenError for various reasons, such as invalid_grant, and the Google strategy will not call the verify method
if (err.name === 'TokenError' && 'code' in err) {
switch (err.code) {
// invalid_grant is a common error from strategies such as Google and a number of reasons
// can cause it. Many user-related issues, so we will treat it as user-related.
// https://blog.timekit.io/google-oauth-invalid-grant-nightmare-and-how-to-fix-it-9f4efaf1da35
case 'invalid_grant':
req.log.warn(
{ err: e, strategy },
'Authentication error for strategy "{strategy}" encountered an Invalid Grant error'
)
res.redirect(
buildRedirectUrl({
resolveAuthRedirectPath,
path: defaultErrorPath(
'Failed to authenticate, please refer to your SSO provider.'
)
})
)
return
default:
req.log.info(
// log at info level, as the error logging will be handled below
{ err, strategy },
'Authentication error for strategy "{strategy}" encountered an unexpected TokenError of code "{err.code}"'
)
// fall through to the unknown error handler
}
}
// unknown and unexpected error
req.log.error({ err, strategy }, 'Authentication error for strategy "{strategy}"')
return next(err)
}
if (user && failureType === null) {
req.user = user
// user authenticated successfully
next()
return
}
// no user, but no error either. This is expected in some cases (e.g. user input error)
// in this case, we need to redirect the user to the correct page
const infoMsg = resolveInfoMessage(info)
switch (failureType) {
case ExpectedAuthFailure.UserInputError:
case ExpectedAuthFailure.InvalidGrantError:
res.redirect(
buildRedirectUrl({
resolveAuthRedirectPath,
path: defaultErrorPath(
infoMsg ||
'Failed to authenticate, please try again. Contact server admins if this is a persistent error.'
)
})
)
return
case ExpectedAuthFailure.InviteNotFoundError:
res.redirect(
buildRedirectUrl({
resolveAuthRedirectPath,
path: defaultErrorPath(
infoMsg ||
'This server is invite only. The invite link may have expired or the invite may have been revoked. Please authenticate yourself through a valid invite link.'
)
})
)
return
case ExpectedAuthFailure.UnverifiedEmailSSOLoginError:
const email = resolveEmail(info)
res.redirect(
buildRedirectUrl({
resolveAuthRedirectPath,
path: unverifiedEmailPath(email)
})
)
return
case null:
// unexpected error or missing info
req.log.error(
{ info, authStrategy: strategy },
"Authentication error for strategy '{authStrategy}' encountered an unexpected failure type or 'info' parameter is missing or invalid"
)
const message = infoMsg || 'Failed to authenticate, contact server admins'
res.redirect(
buildRedirectUrl({
resolveAuthRedirectPath,
path: defaultErrorPath(message)
})
)
return
default:
throwUncoveredError(failureType)
}
}
/**
* Wrapper for passport.authenticate that handles success & failure scenarios correctly
* (passport.authenticate() by default doesn't, so don't use it)
*/
export const passportAuthenticateHandlerBuilderFactory =
(deps: {
resolveAuthRedirectPath: ResolveAuthRedirectPath
}): PassportAuthenticateHandlerBuilder =>
(
strategy: Strategy | string,
options: Optional<AuthenticateOptions> = undefined
): RequestHandler => {
return (req, res, next) => {
passport.authenticate(
strategy,
options || {},
passportAuthenticationCallbackFactory({ ...deps, strategy, req, res, next })
)(req, res, next)
}
}