Skip to content

Commit 52aa863

Browse files
committed
refactor(service): users/auth: handle state across external protocol flow requests
1 parent efd7d65 commit 52aa863

File tree

4 files changed

+161
-108
lines changed

4 files changed

+161
-108
lines changed

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

Lines changed: 93 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,31 @@ import { Authenticator } from 'passport'
44
import { Strategy as BearerStrategy } from 'passport-http-bearer'
55
import { defaultHashUtil } from '../utilities/password-hashing'
66
import { JWTService, Payload, TokenVerificationError, VerificationErrorReason, TokenAssertion } from './verification'
7-
import { invalidInput, InvalidInputError, MageError } from '../app.api/app.api.errors'
8-
import { IdentityProviderRepository } from './ingress.entities'
7+
import { invalidInput, InvalidInputError, MageError, permissionDenied } from '../app.api/app.api.errors'
8+
import { IdentityProvider, IdentityProviderRepository } from './ingress.entities'
99
import { AdmitFromIdentityProviderOperation, EnrollMyselfOperation, EnrollMyselfRequest } from './ingress.app.api'
10-
import { IngressProtocolWebBinding } from './ingress.protocol.bindings'
10+
import { IngressProtocolWebBinding, IngressResponseType } from './ingress.protocol.bindings'
11+
import { createWebBinding as LocalBinding } from './ingress.protocol.local'
12+
import { createWebBinding as OAuthBinding } from './ingress.protocol.oauth'
1113

1214
declare module 'express-serve-static-core' {
1315
interface Request {
1416
ingress?: IngressRequestContext
17+
localEnrollment?: LocalEnrollmentContext
1518
}
1619
}
1720

18-
type IngressRequestContext = { identityProviderService: IngressProtocolWebBinding } & (
19-
| { state: 'init' }
20-
| { state: 'localEnrollment', localEnrollment: LocalEnrollment }
21-
)
21+
enum UserAgentType {
22+
MobileApp = 'MobileApp',
23+
WebApp = 'WebApp'
24+
}
25+
26+
type IngressRequestContext = {
27+
idp: IdentityProvider
28+
idpBinding: IngressProtocolWebBinding
29+
}
2230

23-
type LocalEnrollment =
31+
type LocalEnrollmentContext =
2432
| {
2533
state: 'humanTokenVerified'
2634
captchaTokenPayload: Payload
@@ -30,7 +38,7 @@ type LocalEnrollment =
3038
subject: string
3139
}
3240

33-
export type IngressOperations = {
41+
export type IngressUseCases = {
3442
enrollMyself: EnrollMyselfOperation
3543
admitFromIdentityProvider: AdmitFromIdentityProviderOperation
3644
}
@@ -40,78 +48,77 @@ export type IngressRoutes = {
4048
idpAdmission: express.Router
4149
}
4250

43-
function bindingFor(idpName: string): IngressProtocolWebBinding {
44-
throw new Error('unimplemented')
45-
}
46-
47-
export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: IdentityProviderRepository, tokenService: JWTService, passport: Authenticator): IngressRoutes {
51+
export function CreateIngressRoutes(ingressApp: IngressUseCases, idpRepo: IdentityProviderRepository, tokenService: JWTService, passport: Authenticator): IngressRoutes {
4852

49-
const captchaBearer = new BearerStrategy((token, done) => {
50-
const expectation = {
51-
subject: null,
52-
expiration: null,
53-
assertion: TokenAssertion.IsHuman
53+
const idpBindings = new Map<string, IngressProtocolWebBinding>()
54+
async function bindingFor(idpName: string): Promise<IngressProtocolWebBinding | null> {
55+
const idp = await idpRepo.findIdpByName(idpName)
56+
if (!idp) {
57+
return null
5458
}
55-
tokenService.verifyToken(token, expectation)
56-
.then(payload => done(null, payload))
57-
.catch(err => done(err))
58-
})
59-
60-
// TODO: separate routers for /auth/idp/* and /api/users/signups/* for backward compatibility
59+
if (idp.protocol === 'local') {
60+
return LocalBinding(passport, )
61+
}
62+
throw new Error('unimplemented')
63+
}
6164

62-
const routeToIdp = express.Router().all('/',
63-
((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}`))
69-
}) as express.RequestHandler,
70-
(async (err, req, res, next) => {
71-
if (err) {
72-
console.error('identity provider authentication error:', err)
73-
return res.status(500).send('unexpected authentication result')
74-
}
75-
if (req.user?.from !== 'identityProvider') {
76-
console.error('unexpected authentication user type:', req.user?.from)
77-
return res.status(500).send('unexpected authentication result')
78-
}
79-
const identityProviderName = req.ingress!.identityProviderService!.idp.name
80-
const identityProviderUser = req.user.account
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}`
65+
const routeToIdp = express.Router()
66+
.all('/',
67+
((req, res, next) => {
68+
const idpService = req.ingress?.idpBinding
69+
if (!idpService) {
70+
return next(new Error(`no identity provider for ingress request: ${req.method} ${req.originalUrl}`))
9971
}
100-
res.redirect(uri);
101-
} else {
102-
res.render('authentication', { host: req.getRoot(), success: true, login: { token: req.token, user: req.user } });
103-
}
104-
}) as express.ErrorRequestHandler
105-
)
72+
if (req.path.endsWith('/signin')) {
73+
const userAgentType: UserAgentType = req.params.state === 'mobile' ? UserAgentType.MobileApp : UserAgentType.WebApp
74+
return idpService.beginIngressFlow(req, res, next, userAgentType)
75+
}
76+
idpService.handleIngressFlowRequest(req, res, next)
77+
}) as express.RequestHandler,
78+
(async (err, req, res, next) => {
79+
if (err) {
80+
console.error('identity provider authentication error:', err)
81+
return res.status(500).send('unexpected authentication result')
82+
}
83+
if (!req.user?.admittingFromIdentityProvider) {
84+
console.error('unexpected ingress user type:', req.user)
85+
return res.status(500).send('unexpected authentication result')
86+
}
87+
const idpAdmission = req.user.admittingFromIdentityProvider
88+
const { idpBinding, idp } = req.ingress!
89+
const identityProviderUser = idpAdmission.account
90+
const admission = await ingressApp.admitFromIdentityProvider({ identityProviderName: idp.name, identityProviderUser })
91+
if (admission.error) {
92+
return next(admission.error)
93+
}
94+
const { admissionToken, mageAccount } = admission.success
95+
if (idpBinding.ingressResponseType === IngressResponseType.Direct) {
96+
return res.json({ user: mageAccount, token: admissionToken })
97+
}
98+
if (idpAdmission.flowState === UserAgentType.MobileApp) {
99+
if (mageAccount.active && mageAccount.enabled) {
100+
return res.redirect(`mage://app/authentication?token=${req.token}`)
101+
}
102+
else {
103+
return res.redirect(`mage://app/invalid_account?active=${mageAccount.active}&enabled=${mageAccount.enabled}`)
104+
}
105+
}
106+
else if (idpAdmission.flowState === UserAgentType.WebApp) {
107+
return res.render('authentication', { host: req.getRoot(), success: true, login: { token: admissionToken, user: mageAccount } })
108+
}
109+
return res.status(500).send('invalid authentication state')
110+
}) as express.ErrorRequestHandler
111+
)
106112

107113
// TODO: mount to /auth
108114
const idpAdmission = express.Router()
109115
idpAdmission.use('/:identityProviderName',
110-
(req, res, next) => {
116+
async (req, res, next) => {
111117
const idpName = req.params.identityProviderName
112-
const idpService = bindingFor(idpName)
113-
if (idpService) {
114-
req.ingress = { state: 'init', identityProviderService: idpService }
118+
const idp = await idpRepo.findIdpByName(idpName)
119+
const idpBinding = await bindingFor(idpName)
120+
if (idpBinding && idp) {
121+
req.ingress = { idpBinding, idp }
115122
return next()
116123
}
117124
res.status(404).send(`${idpName} not found`)
@@ -149,6 +156,17 @@ export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: Iden
149156
}
150157
})
151158

159+
const captchaBearer = new BearerStrategy((token, done) => {
160+
const expectation = {
161+
subject: null,
162+
expiration: null,
163+
assertion: TokenAssertion.IsHuman
164+
}
165+
tokenService.verifyToken(token, expectation)
166+
.then(payload => done(null, payload))
167+
.catch(err => done(err))
168+
})
169+
152170
// TODO: mount to /api/users/signups/verifications
153171
localEnrollment.route('/signups/verifications')
154172
.post(
@@ -163,19 +181,16 @@ export function CreateIngressRoutes(ingressApp: IngressOperations, idpRepo: Iden
163181
if (!captchaTokenPayload) {
164182
return res.status(400).send('Missing captcha token')
165183
}
166-
req.ingress = {
167-
...req.ingress!,
168-
state: 'localEnrollment',
169-
localEnrollment: { state: 'humanTokenVerified', captchaTokenPayload } }
184+
req.localEnrollment = { state: 'humanTokenVerified', captchaTokenPayload }
170185
next()
171186
})(req, res, next)
172187
},
173188
async (req, res, next) => {
174189
try {
175-
if (req.ingress?.state !== 'localEnrollment' || req.ingress.localEnrollment.state !== 'humanTokenVerified') {
190+
if (req.localEnrollment?.state !== 'humanTokenVerified') {
176191
return res.status(500).send('invalid ingress state')
177192
}
178-
const tokenPayload = req.ingress.localEnrollment.captchaTokenPayload
193+
const tokenPayload = req.localEnrollment.captchaTokenPayload
179194
const hashedCaptchaText = tokenPayload.captcha as string
180195
const userCaptchaText = req.body.captchaText
181196
const isHuman = await defaultHashUtil.validPassword(userCaptchaText, hashedCaptchaText)

service/src/ingress/ingress.protocol.bindings.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import express from 'express'
2-
import { IdentityProvider, IdentityProviderUser } from './ingress.entities'
2+
import { IdentityProviderUser } from './ingress.entities'
33

44
export type IdentityProviderAdmissionWebUser = {
55
idpName: string
6-
account: IdentityProviderUser
6+
account: IdentityProviderUser | undefined
7+
flowState?: string | undefined
78
}
89

910
declare global {
@@ -23,13 +24,31 @@ declare global {
2324
}
2425
}
2526

27+
export enum IngressResponseType {
28+
Direct = 'Direct',
29+
Redirect = 'Redirect'
30+
}
31+
2632
/**
2733
* `IngressProtocolWebBinding` is the binding of an authentication protocol's HTTP requests to an identity provider.
2834
* The protocol uses the identity provider settings to determine the identity provider's endpoints and orchestrate the
2935
* flow of HTTP messages between the Mage client, Mage server, and the identity provider's endpoints.
3036
*/
3137
export interface IngressProtocolWebBinding {
32-
idp: IdentityProvider
33-
handleRequest: express.RequestHandler
38+
ingressResponseType: IngressResponseType
39+
/**
40+
* This function initiates the protocol's ingress process, which starts with a request to the `/signin` path of the
41+
* IDP's context, e.g., `GET /auth/google-oidc/signin`.
42+
*
43+
* The `flowState` parameter is a URL-safe, percent-encoded string value which holds any state information the app
44+
* needs to persist across multiple ingress protocol requests.
45+
* This is primarily for saving information about how Mage delivers the final ingress result to the client, such as
46+
* a direct response, or a redirect URL suitable for the modile or web apps.
47+
* Different protocols have different ways of persisting state across requests, such as the OAuth/OpenID Connect
48+
* `state` parameter and the SAML `RelayState` body attribute. The protocol must store this value and return the
49+
* value in the {@link IdentityProviderAdmissionWebUser#flowState admission result}.
50+
*/
51+
beginIngressFlow(req: express.Request, res: express.Response, next: express.NextFunction, flowState: string | undefined): any
52+
handleIngressFlowRequest: express.RequestHandler
3453
}
3554

service/src/ingress/ingress.protocol.local.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Strategy as LocalStrategy, VerifyFunction as LocalStrategyVerifyFunctio
44
import { LocalIdpAccount } from './local-idp.entities'
55
import { IdentityProviderUser } from './ingress.entities'
66
import { LocalIdpAuthenticateOperation } from './local-idp.app.api'
7+
import { IngressProtocolWebBinding, IngressResponseType } from './ingress.protocol.bindings'
78

89

910
function userForLocalIdpAccount(account: LocalIdpAccount): IdentityProviderUser {
@@ -14,13 +15,13 @@ function userForLocalIdpAccount(account: LocalIdpAccount): IdentityProviderUser
1415
}
1516
}
1617

17-
function createAuthenticationMiddleware(localIdpAuthenticate: LocalIdpAuthenticateOperation): passport.Strategy {
18+
function createLocalStrategy(localIdpAuthenticate: LocalIdpAuthenticateOperation, flowState: string | undefined): passport.Strategy {
1819
const verify: LocalStrategyVerifyFunction = async function LocalIngressProtocolVerify(username, password, done) {
1920
const authResult = await localIdpAuthenticate({ username, password })
2021
if (authResult.success) {
2122
const localAccount = authResult.success
2223
const localIdpUser = userForLocalIdpAccount(localAccount)
23-
return done(null, { admittingFromIdentityProvider: { idpName: 'local', account: localIdpUser } })
24+
return done(null, { admittingFromIdentityProvider: { idpName: 'local', account: localIdpUser, flowState } })
2425
}
2526
return done(authResult.error)
2627
}
@@ -39,13 +40,21 @@ const validateSigninRequest: express.RequestHandler = function LocalProtocolIngr
3940
next()
4041
}
4142

42-
export function createWebBinding(passport: passport.Authenticator, localIdpAuthenticate: LocalIdpAuthenticateOperation): express.RequestHandler {
43-
const authStrategy = createAuthenticationMiddleware(localIdpAuthenticate)
44-
const handleRequest = express.Router()
45-
.post('/signin',
46-
express.urlencoded(),
47-
validateSigninRequest,
48-
passport.authenticate(authStrategy)
49-
)
50-
return handleRequest
43+
export function createWebBinding(passport: passport.Authenticator, localIdpAuthenticate: LocalIdpAuthenticateOperation): IngressProtocolWebBinding {
44+
return {
45+
ingressResponseType: IngressResponseType.Direct,
46+
beginIngressFlow: (req, res, next, flowState): any => {
47+
const authStrategy = createLocalStrategy(localIdpAuthenticate, flowState)
48+
const applyLocalProtocol = express.Router()
49+
.post('/*',
50+
express.urlencoded(),
51+
validateSigninRequest,
52+
passport.authenticate(authStrategy)
53+
)
54+
applyLocalProtocol(req, res, next)
55+
},
56+
handleIngressFlowRequest(req, res): any {
57+
return res.status(400).send('invalid local ingress request')
58+
}
59+
}
5160
}

0 commit comments

Comments
 (0)