Skip to content

Commit 87b7efb

Browse files
committed
refactor(service): users/auth: cleanup local enrollment web flow types and separate routers for local enrollment and idp admission
1 parent 7d6f46f commit 87b7efb

File tree

1 file changed

+78
-30
lines changed

1 file changed

+78
-30
lines changed

service/src/ingress/ingress.adapters.controllers.web.ts

Lines changed: 78 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,49 +3,69 @@ import svgCaptcha from 'svg-captcha'
33
import { Authenticator } from 'passport'
44
import { Strategy as BearerStrategy } from 'passport-http-bearer'
55
import { defaultHashUtil } from '../utilities/password-hashing'
6-
import { JWTService, Payload, TokenVerificationError, VerificationErrorReason } from './verification'
6+
import { JWTService, Payload, TokenVerificationError, VerificationErrorReason, TokenAssertion } from './verification'
77
import { invalidInput, InvalidInputError, MageError } from '../app.api/app.api.errors'
88
import { IdentityProviderRepository } from './ingress.entities'
99
import { AdmitFromIdentityProviderOperation, EnrollMyselfOperation, EnrollMyselfRequest } from './ingress.app.api'
1010
import { IngressProtocolWebBinding } from './ingress.protocol.bindings'
1111

1212
declare module 'express-serve-static-core' {
1313
interface Request {
14-
identityProviderService?: IngressProtocolWebBinding
14+
ingress?: IngressRequestContext
1515
}
1616
}
1717

18+
type IngressRequestContext = { identityProviderService: IngressProtocolWebBinding } & (
19+
| { state: 'init' }
20+
| { state: 'localEnrollment', localEnrollment: LocalEnrollment }
21+
)
22+
23+
type LocalEnrollment =
24+
| {
25+
state: 'humanTokenVerified'
26+
captchaTokenPayload: Payload
27+
}
28+
| {
29+
state: 'humanVerified'
30+
subject: string
31+
}
32+
1833
export type IngressOperations = {
1934
enrollMyself: EnrollMyselfOperation
2035
admitFromIdentityProvider: AdmitFromIdentityProviderOperation
2136
}
2237

38+
export type IngressRoutes = {
39+
localEnrollment: express.Router
40+
idpAdmission: express.Router
41+
}
42+
2343
function bindingFor(idpName: string): IngressProtocolWebBinding {
2444
throw new Error('unimplemented')
2545
}
2646

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

2949
const captchaBearer = new BearerStrategy((token, done) => {
3050
const expectation = {
3151
subject: null,
3252
expiration: null,
33-
assertion: TokenAssertion.Captcha
53+
assertion: TokenAssertion.IsHuman
3454
}
3555
tokenService.verifyToken(token, expectation)
3656
.then(payload => done(null, payload))
3757
.catch(err => done(err))
3858
})
3959

40-
const routes = express.Router()
41-
42-
// TODO: signup
43-
// TODO: signin
60+
// TODO: separate routers for /auth/idp/* and /api/users/signups/* for backward compatibility
4461

4562
const routeToIdp = express.Router().all('/',
4663
((req, res, next) => {
47-
const idpService = req.identityProviderService!
48-
idpService.handleRequest(req, res, next)
64+
const idpService = req.ingress?.identityProviderService
65+
if (idpService) {
66+
return idpService.handleRequest(req, res, next)
67+
}
68+
next(new Error(`no identity provider for ingress request: ${req.method} ${req.originalUrl}`))
4969
}) as express.RequestHandler,
5070
(async (err, req, res, next) => {
5171
if (err) {
@@ -56,34 +76,53 @@ export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: Iden
5676
console.error('unexpected authentication user type:', req.user?.from)
5777
return res.status(500).send('unexpected authentication result')
5878
}
59-
const identityProviderName = req.identityProviderService!.idp.name
79+
const identityProviderName = req.ingress!.identityProviderService!.idp.name
6080
const identityProviderUser = req.user.account
61-
const ingressResult = await ingressApp.admitFromIdentityProvider({ identityProviderName, identityProviderUser })
62-
if (ingressResult.error) {
63-
next(ingressResult.error)
81+
const admission = await ingressApp.admitFromIdentityProvider({ identityProviderName, identityProviderUser })
82+
if (admission.error) {
83+
return next(admission.error)
84+
}
85+
const { admissionToken, mageAccount } = admission.success
86+
/*
87+
TODO: copied from redirecting protocols - cleanup and adapt here
88+
local/ldap use direct json response
89+
saml uses RelayState body property
90+
oauth/oidc use state query parameter
91+
can all use direct json response and handle redirect windows client side?
92+
*/
93+
if (req.query.state === 'mobile') {
94+
let uri;
95+
if (!mageAccount.active || !mageAccount.enabled) {
96+
uri = `mage://app/invalid_account?active=${mageAccount.active}&enabled=${mageAccount.enabled}`;
97+
} else {
98+
uri = `mage://app/authentication?token=${req.token}`
99+
}
100+
res.redirect(uri);
101+
} else {
102+
res.render('authentication', { host: req.getRoot(), success: true, login: { token: req.token, user: req.user } });
64103
}
65-
// if user active and enabled, send authenticated JWT and proceed to verification
66-
// else
67-
const account = ingressResult.success!
68-
69104
}) as express.ErrorRequestHandler
70105
)
71106

72-
routes.use('/:identityProviderName',
107+
// TODO: mount to /auth
108+
const idpAdmission = express.Router()
109+
idpAdmission.use('/:identityProviderName',
73110
(req, res, next) => {
74111
const idpName = req.params.identityProviderName
75112
const idpService = bindingFor(idpName)
76113
if (idpService) {
77-
req.identityProviderService = idpService
114+
req.ingress = { state: 'init', identityProviderService: idpService }
78115
return next()
79116
}
80117
res.status(404).send(`${idpName} not found`)
81118
},
119+
// use a sub-router so express implicitly strips the base url /auth/:identityProviderName before routing idp handler
82120
routeToIdp
83121
)
84122

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

113-
routes.route('/signups/verifications')
152+
// TODO: mount to /api/users/signups/verifications
153+
localEnrollment.route('/signups/verifications')
114154
.post(
115155
async (req, res, next) => {
116156
passport.authenticate(captchaBearer, (err: TokenVerificationError, captchaTokenPayload: Payload) => {
@@ -123,18 +163,26 @@ export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: Iden
123163
if (!captchaTokenPayload) {
124164
return res.status(400).send('Missing captcha token')
125165
}
126-
req.user = captchaTokenPayload
166+
req.ingress = {
167+
...req.ingress!,
168+
state: 'localEnrollment',
169+
localEnrollment: { state: 'humanTokenVerified', captchaTokenPayload } }
127170
next()
128171
})(req, res, next)
129172
},
130173
async (req, res, next) => {
131174
try {
132-
const isHuman = await defaultHashUtil.validPassword(req.body.captchaText, req.user.captcha)
175+
if (req.ingress?.state !== 'localEnrollment' || req.ingress.localEnrollment.state !== 'humanTokenVerified') {
176+
return res.status(500).send('invalid ingress state')
177+
}
178+
const tokenPayload = req.ingress.localEnrollment.captchaTokenPayload
179+
const hashedCaptchaText = tokenPayload.captcha as string
180+
const userCaptchaText = req.body.captchaText
181+
const isHuman = await defaultHashUtil.validPassword(userCaptchaText, hashedCaptchaText)
133182
if (!isHuman) {
134183
return res.status(403).send('Invalid captcha. Please try again.')
135184
}
136-
const payload = req.user as Payload
137-
const username = payload.subject!
185+
const username = tokenPayload.subject!
138186
const parsedEnrollment = validateEnrollment(req.body)
139187
if (parsedEnrollment instanceof MageError) {
140188
return next(parsedEnrollment)
@@ -155,15 +203,15 @@ export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: Iden
155203
}
156204
)
157205

158-
return routes
206+
return { localEnrollment, idpAdmission }
159207
}
160208

161209
function validateEnrollment(input: any): Omit<EnrollMyselfRequest, 'username'> | InvalidInputError {
162210
const { displayName, email, password, phone } = input
163-
if (!displayName) {
211+
if (typeof displayName !== 'string') {
164212
return invalidInput('displayName is required')
165213
}
166-
if (!password) {
214+
if (typeof password !== 'string') {
167215
return invalidInput('password is required')
168216
}
169217
const enrollment: Omit<EnrollMyselfRequest, 'username'> = { displayName, password }

0 commit comments

Comments
 (0)