Skip to content

Commit

Permalink
refactor(service): users/auth: handle state across external protocol …
Browse files Browse the repository at this point in the history
…flow requests
  • Loading branch information
restjohn committed Nov 14, 2024
1 parent efd7d65 commit 52aa863
Show file tree
Hide file tree
Showing 4 changed files with 161 additions and 108 deletions.
171 changes: 93 additions & 78 deletions service/src/ingress/ingress.adapters.controllers.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,31 @@ import { Authenticator } from 'passport'
import { Strategy as BearerStrategy } from 'passport-http-bearer'
import { defaultHashUtil } from '../utilities/password-hashing'
import { JWTService, Payload, TokenVerificationError, VerificationErrorReason, TokenAssertion } from './verification'
import { invalidInput, InvalidInputError, MageError } from '../app.api/app.api.errors'
import { IdentityProviderRepository } from './ingress.entities'
import { invalidInput, InvalidInputError, MageError, permissionDenied } from '../app.api/app.api.errors'
import { IdentityProvider, IdentityProviderRepository } from './ingress.entities'
import { AdmitFromIdentityProviderOperation, EnrollMyselfOperation, EnrollMyselfRequest } from './ingress.app.api'
import { IngressProtocolWebBinding } from './ingress.protocol.bindings'
import { IngressProtocolWebBinding, IngressResponseType } from './ingress.protocol.bindings'
import { createWebBinding as LocalBinding } from './ingress.protocol.local'
import { createWebBinding as OAuthBinding } from './ingress.protocol.oauth'

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

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

type IngressRequestContext = {
idp: IdentityProvider
idpBinding: IngressProtocolWebBinding
}

type LocalEnrollment =
type LocalEnrollmentContext =
| {
state: 'humanTokenVerified'
captchaTokenPayload: Payload
Expand All @@ -30,7 +38,7 @@ type LocalEnrollment =
subject: string
}

export type IngressOperations = {
export type IngressUseCases = {
enrollMyself: EnrollMyselfOperation
admitFromIdentityProvider: AdmitFromIdentityProviderOperation
}
Expand All @@ -40,78 +48,77 @@ export type IngressRoutes = {
idpAdmission: express.Router
}

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

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

const captchaBearer = new BearerStrategy((token, done) => {
const expectation = {
subject: null,
expiration: null,
assertion: TokenAssertion.IsHuman
const idpBindings = new Map<string, IngressProtocolWebBinding>()
async function bindingFor(idpName: string): Promise<IngressProtocolWebBinding | null> {
const idp = await idpRepo.findIdpByName(idpName)
if (!idp) {
return null
}
tokenService.verifyToken(token, expectation)
.then(payload => done(null, payload))
.catch(err => done(err))
})

// TODO: separate routers for /auth/idp/* and /api/users/signups/* for backward compatibility
if (idp.protocol === 'local') {
return LocalBinding(passport, )
}
throw new Error('unimplemented')
}

const routeToIdp = express.Router().all('/',
((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) {
console.error('identity provider authentication error:', err)
return res.status(500).send('unexpected authentication result')
}
if (req.user?.from !== 'identityProvider') {
console.error('unexpected authentication user type:', req.user?.from)
return res.status(500).send('unexpected authentication result')
}
const identityProviderName = req.ingress!.identityProviderService!.idp.name
const identityProviderUser = req.user.account
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}`
const routeToIdp = express.Router()
.all('/',
((req, res, next) => {
const idpService = req.ingress?.idpBinding
if (!idpService) {
return next(new Error(`no identity provider for ingress request: ${req.method} ${req.originalUrl}`))
}
res.redirect(uri);
} else {
res.render('authentication', { host: req.getRoot(), success: true, login: { token: req.token, user: req.user } });
}
}) as express.ErrorRequestHandler
)
if (req.path.endsWith('/signin')) {
const userAgentType: UserAgentType = req.params.state === 'mobile' ? UserAgentType.MobileApp : UserAgentType.WebApp
return idpService.beginIngressFlow(req, res, next, userAgentType)
}
idpService.handleIngressFlowRequest(req, res, next)
}) as express.RequestHandler,
(async (err, req, res, next) => {
if (err) {
console.error('identity provider authentication error:', err)
return res.status(500).send('unexpected authentication result')
}
if (!req.user?.admittingFromIdentityProvider) {
console.error('unexpected ingress user type:', req.user)
return res.status(500).send('unexpected authentication result')
}
const idpAdmission = req.user.admittingFromIdentityProvider
const { idpBinding, idp } = req.ingress!
const identityProviderUser = idpAdmission.account
const admission = await ingressApp.admitFromIdentityProvider({ identityProviderName: idp.name, identityProviderUser })
if (admission.error) {
return next(admission.error)
}
const { admissionToken, mageAccount } = admission.success
if (idpBinding.ingressResponseType === IngressResponseType.Direct) {
return res.json({ user: mageAccount, token: admissionToken })
}
if (idpAdmission.flowState === UserAgentType.MobileApp) {
if (mageAccount.active && mageAccount.enabled) {
return res.redirect(`mage://app/authentication?token=${req.token}`)
}
else {
return res.redirect(`mage://app/invalid_account?active=${mageAccount.active}&enabled=${mageAccount.enabled}`)
}
}
else if (idpAdmission.flowState === UserAgentType.WebApp) {
return res.render('authentication', { host: req.getRoot(), success: true, login: { token: admissionToken, user: mageAccount } })
}
return res.status(500).send('invalid authentication state')
}) as express.ErrorRequestHandler
)

// TODO: mount to /auth
const idpAdmission = express.Router()
idpAdmission.use('/:identityProviderName',
(req, res, next) => {
async (req, res, next) => {
const idpName = req.params.identityProviderName
const idpService = bindingFor(idpName)
if (idpService) {
req.ingress = { state: 'init', identityProviderService: idpService }
const idp = await idpRepo.findIdpByName(idpName)
const idpBinding = await bindingFor(idpName)
if (idpBinding && idp) {
req.ingress = { idpBinding, idp }
return next()
}
res.status(404).send(`${idpName} not found`)
Expand Down Expand Up @@ -149,6 +156,17 @@ export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: Iden
}
})

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

// TODO: mount to /api/users/signups/verifications
localEnrollment.route('/signups/verifications')
.post(
Expand All @@ -163,19 +181,16 @@ export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: Iden
if (!captchaTokenPayload) {
return res.status(400).send('Missing captcha token')
}
req.ingress = {
...req.ingress!,
state: 'localEnrollment',
localEnrollment: { state: 'humanTokenVerified', captchaTokenPayload } }
req.localEnrollment = { state: 'humanTokenVerified', captchaTokenPayload }
next()
})(req, res, next)
},
async (req, res, next) => {
try {
if (req.ingress?.state !== 'localEnrollment' || req.ingress.localEnrollment.state !== 'humanTokenVerified') {
if (req.localEnrollment?.state !== 'humanTokenVerified') {
return res.status(500).send('invalid ingress state')
}
const tokenPayload = req.ingress.localEnrollment.captchaTokenPayload
const tokenPayload = req.localEnrollment.captchaTokenPayload
const hashedCaptchaText = tokenPayload.captcha as string
const userCaptchaText = req.body.captchaText
const isHuman = await defaultHashUtil.validPassword(userCaptchaText, hashedCaptchaText)
Expand Down
27 changes: 23 additions & 4 deletions service/src/ingress/ingress.protocol.bindings.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import express from 'express'
import { IdentityProvider, IdentityProviderUser } from './ingress.entities'
import { IdentityProviderUser } from './ingress.entities'

export type IdentityProviderAdmissionWebUser = {
idpName: string
account: IdentityProviderUser
account: IdentityProviderUser | undefined
flowState?: string | undefined
}

declare global {
Expand All @@ -23,13 +24,31 @@ declare global {
}
}

export enum IngressResponseType {
Direct = 'Direct',
Redirect = 'Redirect'
}

/**
* `IngressProtocolWebBinding` is the binding of an authentication protocol's HTTP requests to an identity provider.
* The protocol uses the identity provider settings to determine the identity provider's endpoints and orchestrate the
* flow of HTTP messages between the Mage client, Mage server, and the identity provider's endpoints.
*/
export interface IngressProtocolWebBinding {
idp: IdentityProvider
handleRequest: express.RequestHandler
ingressResponseType: IngressResponseType
/**
* This function initiates the protocol's ingress process, which starts with a request to the `/signin` path of the
* IDP's context, e.g., `GET /auth/google-oidc/signin`.
*
* The `flowState` parameter is a URL-safe, percent-encoded string value which holds any state information the app
* needs to persist across multiple ingress protocol requests.
* This is primarily for saving information about how Mage delivers the final ingress result to the client, such as
* a direct response, or a redirect URL suitable for the modile or web apps.
* Different protocols have different ways of persisting state across requests, such as the OAuth/OpenID Connect
* `state` parameter and the SAML `RelayState` body attribute. The protocol must store this value and return the
* value in the {@link IdentityProviderAdmissionWebUser#flowState admission result}.
*/
beginIngressFlow(req: express.Request, res: express.Response, next: express.NextFunction, flowState: string | undefined): any
handleIngressFlowRequest: express.RequestHandler
}

31 changes: 20 additions & 11 deletions service/src/ingress/ingress.protocol.local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Strategy as LocalStrategy, VerifyFunction as LocalStrategyVerifyFunctio
import { LocalIdpAccount } from './local-idp.entities'
import { IdentityProviderUser } from './ingress.entities'
import { LocalIdpAuthenticateOperation } from './local-idp.app.api'
import { IngressProtocolWebBinding, IngressResponseType } from './ingress.protocol.bindings'


function userForLocalIdpAccount(account: LocalIdpAccount): IdentityProviderUser {
Expand All @@ -14,13 +15,13 @@ function userForLocalIdpAccount(account: LocalIdpAccount): IdentityProviderUser
}
}

function createAuthenticationMiddleware(localIdpAuthenticate: LocalIdpAuthenticateOperation): passport.Strategy {
function createLocalStrategy(localIdpAuthenticate: LocalIdpAuthenticateOperation, flowState: string | undefined): passport.Strategy {
const verify: LocalStrategyVerifyFunction = async function LocalIngressProtocolVerify(username, password, done) {
const authResult = await localIdpAuthenticate({ username, password })
if (authResult.success) {
const localAccount = authResult.success
const localIdpUser = userForLocalIdpAccount(localAccount)
return done(null, { admittingFromIdentityProvider: { idpName: 'local', account: localIdpUser } })
return done(null, { admittingFromIdentityProvider: { idpName: 'local', account: localIdpUser, flowState } })
}
return done(authResult.error)
}
Expand All @@ -39,13 +40,21 @@ const validateSigninRequest: express.RequestHandler = function LocalProtocolIngr
next()
}

export function createWebBinding(passport: passport.Authenticator, localIdpAuthenticate: LocalIdpAuthenticateOperation): express.RequestHandler {
const authStrategy = createAuthenticationMiddleware(localIdpAuthenticate)
const handleRequest = express.Router()
.post('/signin',
express.urlencoded(),
validateSigninRequest,
passport.authenticate(authStrategy)
)
return handleRequest
export function createWebBinding(passport: passport.Authenticator, localIdpAuthenticate: LocalIdpAuthenticateOperation): IngressProtocolWebBinding {
return {
ingressResponseType: IngressResponseType.Direct,
beginIngressFlow: (req, res, next, flowState): any => {
const authStrategy = createLocalStrategy(localIdpAuthenticate, flowState)
const applyLocalProtocol = express.Router()
.post('/*',
express.urlencoded(),
validateSigninRequest,
passport.authenticate(authStrategy)
)
applyLocalProtocol(req, res, next)
},
handleIngressFlowRequest(req, res): any {
return res.status(400).send('invalid local ingress request')
}
}
}
Loading

0 comments on commit 52aa863

Please sign in to comment.