Skip to content

Commit

Permalink
[api] introduce top-level await for getting passport in server.ts
Browse files Browse the repository at this point in the history
  • Loading branch information
freemvmt committed Aug 16, 2024
1 parent cb1d7da commit d9f76ad
Show file tree
Hide file tree
Showing 6 changed files with 96 additions and 84 deletions.
4 changes: 0 additions & 4 deletions api.planx.uk/modules/auth/controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import type { CookieOptions, RequestHandler, Response } from "express";
import type { Request } from "express-jwt";

import { microsoftOidcClient } from "./passport.js";

export const failedLogin: RequestHandler = (_req, _res, next) =>
next({
status: 401,
Expand All @@ -13,8 +11,6 @@ export const logout: RequestHandler = (req, res) => {
req.logout(() => {
// do nothing
});
// TODO: implement logout for Microsoft strategy (redirect to logout URL with hint)
// logout_url = microsoftOidcClient.endSessionUrl({ id_token_hint });
res.redirect(process.env.EDITOR_URL_EXT!);
};

Expand Down
66 changes: 41 additions & 25 deletions api.planx.uk/modules/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ServerError } from "../../errors/index.js";
import type { Template } from "../../lib/notify/index.js";
import { expressjwt } from "express-jwt";
import { generators } from "openid-client";
import { passportWithStrategies } from "./passport.js";
import type { Authenticator } from "passport";
import type { RequestHandler } from "http-proxy-middleware";
import type { Role } from "@opensystemslab/planx-core/types";
import { AsyncLocalStorage } from "async_hooks";
Expand Down Expand Up @@ -109,38 +109,54 @@ export const useJWT = expressjwt({
getToken: getToken,
});

export const useGoogleAuth: RequestHandler = (req, res, next) => {
req.session!.returnTo = req.get("Referrer");
return passportWithStrategies.authenticate("google", {
scope: ["profile", "email"],
})(req, res, next);
export const getGoogleAuthHandler = (
passport: Authenticator,
): RequestHandler => {
return (req, res, next) => {
req.session!.returnTo = req.get("Referrer");
return passport.authenticate("google", {
scope: ["profile", "email"],
})(req, res, next);
};
};

export const useGoogleCallbackAuth: RequestHandler = (req, res, next) => {
return passportWithStrategies.authenticate("google", {
failureRedirect: "/auth/login/failed",
})(req, res, next);
export const getGoogleCallbackAuthHandler = (
passport: Authenticator,
): RequestHandler => {
return (req, res, next) => {
return passport.authenticate("google", {
failureRedirect: "/auth/login/failed",
})(req, res, next);
};
};

export const useMicrosoftAuth: RequestHandler = (req, res, next) => {
req.session!.returnTo = req.get("Referrer");
export const getMicrosoftAuthHandler = (
passport: Authenticator,
): RequestHandler => {
return (req, res, next) => {
req.session!.returnTo = req.get("Referrer");

// generate a nonce to enable us to validate the response from OP
const nonce = generators.nonce();
console.debug(`Generated a nonce: %s`, nonce);
req.session!.nonce = nonce;
// generate a nonce to enable us to validate the response from OP
const nonce = generators.nonce();
console.debug(`Generated a nonce: %s`, nonce);
req.session!.nonce = nonce;

// @ts-expect-error (method not typed to accept nonce, but it does pass it to the strategy)
return passportWithStrategies.authenticate("microsoft-oidc", {
prompt: "select_account",
nonce,
})(req, res, next);
// @ts-expect-error (method not typed to accept nonce, but it does pass it to the strategy)
return passport.authenticate("microsoft-oidc", {
prompt: "select_account",
nonce,
})(req, res, next);
};
};

export const useMicrosoftCallbackAuth: RequestHandler = (req, res, next) => {
return passportWithStrategies.authenticate("microsoft-oidc", {
failureRedirect: "/auth/login/failed",
})(req, res, next);
export const getMicrosoftCallbackAuthHandler = (
passport: Authenticator,
): RequestHandler => {
return (req, res, next) => {
return passport.authenticate("microsoft-oidc", {
failureRedirect: "/auth/login/failed",
})(req, res, next);
};
};

type UseRoleAuth = (authRoles: Role[]) => RequestHandler;
Expand Down
54 changes: 23 additions & 31 deletions api.planx.uk/modules/auth/passport.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { custom, Issuer } from "openid-client";
import { Issuer } from "openid-client";
import type { Authenticator } from "passport";
import passport from "passport";

import { googleStrategy } from "./strategy/google.js";
Expand All @@ -8,41 +9,32 @@ import {
MICROSOFT_OPENID_CONFIG_URL,
} from "./strategy/microsoft-oidc.js";

const setupPassport = () => {
// TODO: remove below config (timeout extended for local testing with poor connection)
custom.setHttpOptionsDefaults({
timeout: 10000,
});

// explicitly instantiate new passport for clarity
const passportWithStrategies = new passport.Passport();
export default async (): Promise<Authenticator> => {
// explicitly instantiate new passport class for clarity
const customPassport = new passport.Passport();

// build Microsoft OIDC client, and use it to build the related strategy
let microsoftOidcClient;
// TODO: need to block on fetch of issuer, but can't use top level await...
Issuer.discover(MICROSOFT_OPENID_CONFIG_URL).then((microsoftIssuer) => {
console.debug("Discovered issuer %s", microsoftIssuer.issuer);
const microsoftClientConfig = getMicrosoftClientConfig();
microsoftOidcClient = new microsoftIssuer.Client(microsoftClientConfig);
console.debug("Built Microsoft client: %O", microsoftOidcClient);
passportWithStrategies.use(
"microsoft-oidc",
getMicrosoftOidcStrategy(microsoftOidcClient),
);
});
// instantiate Microsoft OIDC client, and use it to build the related strategy
const microsoftIssuer = await Issuer.discover(MICROSOFT_OPENID_CONFIG_URL);
console.debug("Discovered issuer %s", microsoftIssuer.issuer);
const microsoftOidcClient = new microsoftIssuer.Client(
getMicrosoftClientConfig(),
);
console.debug("Built Microsoft client: %O", microsoftOidcClient);
customPassport.use(
"microsoft-oidc",
getMicrosoftOidcStrategy(microsoftOidcClient),
);

// do any other aspects of passport setup which can be handled here
passportWithStrategies.use("google", googleStrategy);
passportWithStrategies.serializeUser((user: any, done) => {
// do other aspects of passport setup which can be handled here
// TODO: replace types here (e.g. user: Express.User - but verify this first)
customPassport.use("google", googleStrategy);
customPassport.serializeUser((user: any, done) => {

Check warning on line 31 in api.planx.uk/modules/auth/passport.ts

View workflow job for this annotation

GitHub Actions / Run API Tests

Unexpected any. Specify a different type
done(null, user);
});
passportWithStrategies.deserializeUser((obj: any, done) => {
customPassport.deserializeUser((obj: any, done) => {

Check warning on line 34 in api.planx.uk/modules/auth/passport.ts

View workflow job for this annotation

GitHub Actions / Run API Tests

Unexpected any. Specify a different type
done(null, obj);
});

return { passportWithStrategies, microsoftOidcClient };
// tsc dislikes the use of 'this' in the passportjs codebase, so we cast explicitly
return customPassport as Authenticator;
};

// instantiate and export the new passport class and Microsoft client as early as possible
const { passportWithStrategies, microsoftOidcClient } = setupPassport();
export { passportWithStrategies, microsoftOidcClient };
35 changes: 19 additions & 16 deletions api.planx.uk/modules/auth/routes.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import { Router } from "express";
import type { Authenticator } from "passport";
import * as Middleware from "./middleware.js";
import * as Controller from "./controller.js";

const router = Router();
export default (passport: Authenticator): Router => {
const router = Router();

router.get("/logout", Controller.logout);
router.get("/auth/login/failed", Controller.failedLogin);
router.get("/auth/google", Middleware.useGoogleAuth);
router.get(
"/auth/google/callback",
Middleware.useGoogleCallbackAuth,
Controller.handleSuccess,
);
router.get("/auth/microsoft", Middleware.useMicrosoftAuth);
router.post(
"/auth/microsoft/callback",
Middleware.useMicrosoftCallbackAuth,
Controller.handleSuccess,
);
router.get("/logout", Controller.logout);
router.get("/auth/login/failed", Controller.failedLogin);
router.get("/auth/google", Middleware.getGoogleAuthHandler(passport));
router.get(
"/auth/google/callback",
Middleware.getGoogleCallbackAuthHandler(passport),
Controller.handleSuccess,
);
router.get("/auth/microsoft", Middleware.getMicrosoftAuthHandler(passport));
router.post(
"/auth/microsoft/callback",
Middleware.getMicrosoftCallbackAuthHandler(passport),
Controller.handleSuccess,
);

export default router;
return router;
};
6 changes: 2 additions & 4 deletions api.planx.uk/modules/auth/strategy/microsoft-oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,18 @@ export const getMicrosoftOidcStrategy = (client: Client): Strategy<Client> => {
client: client,
params: {
scope: "openid email profile",
// TODO: verify that this is the appropriate response_mode
response_mode: "form_post",
},
// need the request in the verify callback to validate the returned nonce
passReqToCallback: true,
},
async (req: any, tokenSet: TokenSet, done: any): Promise<void> => {

Check warning on line 35 in api.planx.uk/modules/auth/strategy/microsoft-oidc.ts

View workflow job for this annotation

GitHub Actions / Run API Tests

Unexpected any. Specify a different type

Check warning on line 35 in api.planx.uk/modules/auth/strategy/microsoft-oidc.ts

View workflow job for this annotation

GitHub Actions / Run API Tests

Unexpected any. Specify a different type
// TODO: use state to pass the redirectTo query param through the auth flow
const state = tokenSet.state;
// TODO: use tokenSet.state to pass the redirectTo query param through the auth flow

const claims = tokenSet.claims();
const email = claims.email;
const returned_nonce = claims.nonce;
// we grab login_hint to provide to the logout endpoint later (as per OpenID spec)
const login_hint = claims.login_hint;

if (returned_nonce != req.session.nonce) {
return done(
Expand Down
15 changes: 11 additions & 4 deletions api.planx.uk/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { ServerError } from "./errors/index.js";
import airbrake from "./airbrake.js";
import { apiLimiter } from "./rateLimit.js";
import { registerSessionStubs } from "./session.js";
import { passportWithStrategies } from "./modules/auth/passport.js";
import authRoutes from "./modules/auth/routes.js";
import getPassport from "./modules/auth/passport.js";
import getAuthRoutes from "./modules/auth/routes.js";
import teamRoutes from "./modules/team/routes.js";
import miscRoutes from "./modules/misc/routes.js";
import userRoutes from "./modules/user/routes.js";
Expand Down Expand Up @@ -121,10 +121,17 @@ app.use(

// register stubs after cookieSession middleware initialisation
app.use(registerSessionStubs);
app.use(passportWithStrategies.initialize());
app.use(passportWithStrategies.session());

// equip passport with auth strategies early on, so we can pass it to route handlers
const passport = await getPassport();
app.use(passport.initialize());
app.use(passport.session());

app.use(bodyParser.urlencoded({ extended: true }));

// auth routes rely on the passport class we've just initialised
const authRoutes = await getAuthRoutes(passport);

// Setup API routes
app.use(adminRoutes);
app.use(analyticsRoutes);
Expand Down

0 comments on commit d9f76ad

Please sign in to comment.