diff --git a/service/src/ingress/ingress.adapters.controllers.web.ts b/service/src/ingress/ingress.adapters.controllers.web.ts index 6869c53af..bcdf9ae37 100644 --- a/service/src/ingress/ingress.adapters.controllers.web.ts +++ b/service/src/ingress/ingress.adapters.controllers.web.ts @@ -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 @@ -30,7 +38,7 @@ type LocalEnrollment = subject: string } -export type IngressOperations = { +export type IngressUseCases = { enrollMyself: EnrollMyselfOperation admitFromIdentityProvider: AdmitFromIdentityProviderOperation } @@ -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() + async function bindingFor(idpName: string): Promise { + 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`) @@ -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( @@ -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) diff --git a/service/src/ingress/ingress.protocol.bindings.ts b/service/src/ingress/ingress.protocol.bindings.ts index aa2bfe699..52dcf875f 100644 --- a/service/src/ingress/ingress.protocol.bindings.ts +++ b/service/src/ingress/ingress.protocol.bindings.ts @@ -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 { @@ -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 } diff --git a/service/src/ingress/ingress.protocol.local.ts b/service/src/ingress/ingress.protocol.local.ts index f61da471e..05f6ab5b5 100644 --- a/service/src/ingress/ingress.protocol.local.ts +++ b/service/src/ingress/ingress.protocol.local.ts @@ -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 { @@ -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) } @@ -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') + } + } } diff --git a/service/src/ingress/ingress.protocol.oauth.ts b/service/src/ingress/ingress.protocol.oauth.ts index 3869c2b50..9946c2271 100644 --- a/service/src/ingress/ingress.protocol.oauth.ts +++ b/service/src/ingress/ingress.protocol.oauth.ts @@ -3,6 +3,7 @@ import { InternalOAuthError, Strategy as OAuth2Strategy, StrategyOptions as OAut import base64 from 'base-64' import { IdentityProvider, IdentityProviderUser } from './ingress.entities' import { Authenticator } from 'passport' +import { IngressProtocolWebBinding, IngressResponseType } from './ingress.protocol.bindings' export type OAuth2ProtocolSettings = Pick { + passport.authenticate(oauth2Strategy, + (err: Error | null | undefined, account: IdentityProviderUser, info: OAuth2Info) => { + if (err) { + return next(err) + } + req.user = { admittingFromIdentityProvider: { idpName: idp.name, account, flowState: info.state }} + })(req, res, next) + }) + return { + ingressResponseType: IngressResponseType.Redirect, + beginIngressFlow(req, res, next, flowState): any { + passport.authenticate(oauth2Strategy, { state: flowState })(req, res, next) + }, + handleIngressFlowRequest + } } \ No newline at end of file