Skip to content

Commit

Permalink
refactor(service): users/auth: cleanup local enrollment web flow type…
Browse files Browse the repository at this point in the history
…s and separate routers for local enrollment and idp admission
  • Loading branch information
restjohn committed Oct 25, 2024
1 parent 7d6f46f commit 87b7efb
Showing 1 changed file with 78 additions and 30 deletions.
108 changes: 78 additions & 30 deletions service/src/ingress/ingress.adapters.controllers.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,49 +3,69 @@ import svgCaptcha from 'svg-captcha'
import { Authenticator } from 'passport'
import { Strategy as BearerStrategy } from 'passport-http-bearer'
import { defaultHashUtil } from '../utilities/password-hashing'
import { JWTService, Payload, TokenVerificationError, VerificationErrorReason } from './verification'
import { JWTService, Payload, TokenVerificationError, VerificationErrorReason, TokenAssertion } from './verification'
import { invalidInput, InvalidInputError, MageError } from '../app.api/app.api.errors'
import { IdentityProviderRepository } from './ingress.entities'
import { AdmitFromIdentityProviderOperation, EnrollMyselfOperation, EnrollMyselfRequest } from './ingress.app.api'
import { IngressProtocolWebBinding } from './ingress.protocol.bindings'

declare module 'express-serve-static-core' {
interface Request {
identityProviderService?: IngressProtocolWebBinding
ingress?: IngressRequestContext
}
}

type IngressRequestContext = { identityProviderService: IngressProtocolWebBinding } & (
| { state: 'init' }
| { state: 'localEnrollment', localEnrollment: LocalEnrollment }
)

type LocalEnrollment =
| {
state: 'humanTokenVerified'
captchaTokenPayload: Payload
}
| {
state: 'humanVerified'
subject: string
}

export type IngressOperations = {
enrollMyself: EnrollMyselfOperation
admitFromIdentityProvider: AdmitFromIdentityProviderOperation
}

export type IngressRoutes = {
localEnrollment: express.Router
idpAdmission: express.Router
}

function bindingFor(idpName: string): IngressProtocolWebBinding {
throw new Error('unimplemented')
}

export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: IdentityProviderRepository, tokenService: JWTService, passport: Authenticator): express.Router {
export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: IdentityProviderRepository, tokenService: JWTService, passport: Authenticator): IngressRoutes {

const captchaBearer = new BearerStrategy((token, done) => {
const expectation = {
subject: null,
expiration: null,
assertion: TokenAssertion.Captcha
assertion: TokenAssertion.IsHuman
}
tokenService.verifyToken(token, expectation)
.then(payload => done(null, payload))
.catch(err => done(err))
})

const routes = express.Router()

// TODO: signup
// TODO: signin
// TODO: separate routers for /auth/idp/* and /api/users/signups/* for backward compatibility

const routeToIdp = express.Router().all('/',
((req, res, next) => {
const idpService = req.identityProviderService!
idpService.handleRequest(req, res, next)
const idpService = req.ingress?.identityProviderService
if (idpService) {
return idpService.handleRequest(req, res, next)
}
next(new Error(`no identity provider for ingress request: ${req.method} ${req.originalUrl}`))
}) as express.RequestHandler,
(async (err, req, res, next) => {
if (err) {
Expand All @@ -56,34 +76,53 @@ export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: Iden
console.error('unexpected authentication user type:', req.user?.from)
return res.status(500).send('unexpected authentication result')
}
const identityProviderName = req.identityProviderService!.idp.name
const identityProviderName = req.ingress!.identityProviderService!.idp.name
const identityProviderUser = req.user.account
const ingressResult = await ingressApp.admitFromIdentityProvider({ identityProviderName, identityProviderUser })
if (ingressResult.error) {
next(ingressResult.error)
const admission = await ingressApp.admitFromIdentityProvider({ identityProviderName, identityProviderUser })
if (admission.error) {
return next(admission.error)
}
const { admissionToken, mageAccount } = admission.success
/*
TODO: copied from redirecting protocols - cleanup and adapt here
local/ldap use direct json response
saml uses RelayState body property
oauth/oidc use state query parameter
can all use direct json response and handle redirect windows client side?
*/
if (req.query.state === 'mobile') {
let uri;
if (!mageAccount.active || !mageAccount.enabled) {
uri = `mage://app/invalid_account?active=${mageAccount.active}&enabled=${mageAccount.enabled}`;
} else {
uri = `mage://app/authentication?token=${req.token}`
}
res.redirect(uri);
} else {
res.render('authentication', { host: req.getRoot(), success: true, login: { token: req.token, user: req.user } });
}
// if user active and enabled, send authenticated JWT and proceed to verification
// else
const account = ingressResult.success!

}) as express.ErrorRequestHandler
)

routes.use('/:identityProviderName',
// TODO: mount to /auth
const idpAdmission = express.Router()
idpAdmission.use('/:identityProviderName',
(req, res, next) => {
const idpName = req.params.identityProviderName
const idpService = bindingFor(idpName)
if (idpService) {
req.identityProviderService = idpService
req.ingress = { state: 'init', identityProviderService: idpService }
return next()
}
res.status(404).send(`${idpName} not found`)
},
// use a sub-router so express implicitly strips the base url /auth/:identityProviderName before routing idp handler
routeToIdp
)

// TODO: mount to /api/users/signups
routes.route('/signups')
const localEnrollment = express.Router()
localEnrollment.route('/signups')
.post(async (req, res, next) => {
try {
const username = typeof req.body.username === 'string' ? req.body.username.trim() : null
Expand All @@ -99,7 +138,7 @@ export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: Iden
})
const captchaHash = await defaultHashUtil.hashPassword(captcha.text)
const claims = { captcha: captchaHash }
const verificationToken = await tokenService.generateToken(username, TokenAssertion.Captcha, 60 * 3, claims)
const verificationToken = await tokenService.generateToken(username, TokenAssertion.IsHuman, 60 * 3, claims)
res.json({
token: verificationToken,
captcha: `data:image/svg+xml;base64,${Buffer.from(captcha.data).toString('base64')}`
Expand All @@ -110,7 +149,8 @@ export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: Iden
}
})

routes.route('/signups/verifications')
// TODO: mount to /api/users/signups/verifications
localEnrollment.route('/signups/verifications')
.post(
async (req, res, next) => {
passport.authenticate(captchaBearer, (err: TokenVerificationError, captchaTokenPayload: Payload) => {
Expand All @@ -123,18 +163,26 @@ export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: Iden
if (!captchaTokenPayload) {
return res.status(400).send('Missing captcha token')
}
req.user = captchaTokenPayload
req.ingress = {
...req.ingress!,
state: 'localEnrollment',
localEnrollment: { state: 'humanTokenVerified', captchaTokenPayload } }
next()
})(req, res, next)
},
async (req, res, next) => {
try {
const isHuman = await defaultHashUtil.validPassword(req.body.captchaText, req.user.captcha)
if (req.ingress?.state !== 'localEnrollment' || req.ingress.localEnrollment.state !== 'humanTokenVerified') {
return res.status(500).send('invalid ingress state')
}
const tokenPayload = req.ingress.localEnrollment.captchaTokenPayload
const hashedCaptchaText = tokenPayload.captcha as string
const userCaptchaText = req.body.captchaText
const isHuman = await defaultHashUtil.validPassword(userCaptchaText, hashedCaptchaText)
if (!isHuman) {
return res.status(403).send('Invalid captcha. Please try again.')
}
const payload = req.user as Payload
const username = payload.subject!
const username = tokenPayload.subject!
const parsedEnrollment = validateEnrollment(req.body)
if (parsedEnrollment instanceof MageError) {
return next(parsedEnrollment)
Expand All @@ -155,15 +203,15 @@ export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: Iden
}
)

return routes
return { localEnrollment, idpAdmission }
}

function validateEnrollment(input: any): Omit<EnrollMyselfRequest, 'username'> | InvalidInputError {
const { displayName, email, password, phone } = input
if (!displayName) {
if (typeof displayName !== 'string') {
return invalidInput('displayName is required')
}
if (!password) {
if (typeof password !== 'string') {
return invalidInput('password is required')
}
const enrollment: Omit<EnrollMyselfRequest, 'username'> = { displayName, password }
Expand Down

0 comments on commit 87b7efb

Please sign in to comment.