Skip to content

Commit

Permalink
[api] add Microsoft strategy to auth module (single sign-on) (#3453)
Browse files Browse the repository at this point in the history
  • Loading branch information
freemvmt authored Sep 9, 2024
1 parent e1bcd7a commit 2e4edc6
Show file tree
Hide file tree
Showing 14 changed files with 250 additions and 48 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ SESSION_SECRET=👻
GOOGLE_CLIENT_ID=👻
GOOGLE_CLIENT_SECRET=👻

# Microsoft Azure OIDC credentials
MICROSOFT_CLIENT_ID=👻
MICROSOFT_CLIENT_SECRET=👻

# AWS credentials for uploading user files from local and pull request environments to a staging S3 bucket
AWS_S3_REGION=eu-west-2
AWS_S3_ACL=public-read
Expand Down
3 changes: 3 additions & 0 deletions api.planx.uk/.env.test.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ SESSION_SECRET=👻
GOOGLE_CLIENT_ID=👻
GOOGLE_CLIENT_SECRET=👻

MICROSOFT_CLIENT_ID=👻
MICROSOFT_CLIENT_SECRET=👻

# AWS infrastructure
AWS_S3_REGION=eu-west-2
AWS_S3_BUCKET=👻
Expand Down
1 change: 1 addition & 0 deletions api.planx.uk/modules/auth/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const failedLogin: RequestHandler = (_req, _res, next) =>
});

export const logout: RequestHandler = (req, res) => {
// TODO: implement dual purpose as Microsoft frontend logout channel
req.logout(() => {
// do nothing
});
Expand Down
60 changes: 48 additions & 12 deletions api.planx.uk/modules/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import assert from "assert";
import { ServerError } from "../../errors/index.js";
import { Template } from "../../lib/notify/index.js";
import { expressjwt } from "express-jwt";

import passport from "passport";

import { generators } from "openid-client";
import { Authenticator } from "passport";
import { RequestHandler } from "http-proxy-middleware";
import { Role } from "@opensystemslab/planx-core/types";
import { AsyncLocalStorage } from "async_hooks";
Expand Down Expand Up @@ -110,17 +109,54 @@ export const useJWT = expressjwt({
getToken: getToken,
});

export const useGoogleAuth: RequestHandler = (req, res, next) => {
req.session!.returnTo = req.get("Referrer");
return passport.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 getGoogleCallbackAuthHandler = (
passport: Authenticator,
): RequestHandler => {
return (req, res, next) => {
return passport.authenticate("google", {
failureRedirect: "/auth/login/failed",
})(req, res, next);
};
};

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;

// @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 useGoogleCallbackAuth: RequestHandler = (req, res, next) => {
return passport.authenticate("google", {
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
39 changes: 39 additions & 0 deletions api.planx.uk/modules/auth/passport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Issuer } from "openid-client";
import passport, { type Authenticator } from "passport";

import { googleStrategy } from "./strategy/google.js";
import {
getMicrosoftOidcStrategy,
getMicrosoftClientConfig,
MICROSOFT_OPENID_CONFIG_URL,
} from "./strategy/microsoft-oidc.js";

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

// 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),
);

// note that we don't serialize the user in any meaningful way - we just store the entire jwt in session
// i.e. req.session.passport.user == { jwt: "..." }
customPassport.use("google", googleStrategy);
customPassport.serializeUser((user: Express.User, done) => {
done(null, user);
});
customPassport.deserializeUser((user: Express.User, done) => {
done(null, user);
});

// tsc dislikes the use of 'this' in the passportjs codebase, so we cast explicitly
return customPassport as Authenticator;
};
30 changes: 20 additions & 10 deletions api.planx.uk/modules/auth/routes.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
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("/logout", Controller.logout);
// router.get("/auth/frontchannel-logout", Controller.frontChannelLogout)
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;
};
69 changes: 69 additions & 0 deletions api.planx.uk/modules/auth/strategy/microsoft-oidc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type {
Client,
ClientMetadata,
IdTokenClaims,
StrategyVerifyCallbackReq,
} from "openid-client";
import { Strategy } from "openid-client";
import { buildJWT } from "../service.js";

export const MICROSOFT_OPENID_CONFIG_URL =
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration";

export const getMicrosoftClientConfig = (): ClientMetadata => {
const client_id = process.env.MICROSOFT_CLIENT_ID!;
if (typeof client_id !== "string") {
throw new Error("No MICROSOFT_CLIENT_ID in the environment");
}
return {
client_id,
client_secret: process.env.MICROSOFT_CLIENT_SECRET!,
redirect_uris: [`${process.env.API_URL_EXT}/auth/microsoft/callback`],
post_logout_redirect_uris: [process.env.EDITOR_URL_EXT!],
response_types: ["id_token"],
};
};

// oidc = OpenID Connect, an auth standard built on top of OAuth 2.0
export const getMicrosoftOidcStrategy = (client: Client): Strategy<Client> => {
return new Strategy(
{
client: client,
params: {
scope: "openid email profile",
response_mode: "form_post",
},
// need the request in the verify callback to validate the returned nonce
passReqToCallback: true,
},
verifyCallback,
);
};

const verifyCallback: StrategyVerifyCallbackReq<Express.User> = async (
req: Http.IncomingMessageWithSession,
tokenSet,
done,
): Promise<void> => {
// TODO: use tokenSet.state to pass the redirectTo query param through the auth flow
const claims: IdTokenClaims = tokenSet.claims();
const email = claims.email;
const returned_nonce = claims.nonce;

if (returned_nonce != req.session?.nonce) {
return done(new Error("Returned nonce does not match session nonce"));
}
if (!email) {
return done(new Error("Unable to authenticate without email"));
}

const jwt = await buildJWT(email);
if (!jwt) {
return done({
status: 404,
message: `User (${email}) not found. Do you need to log in to a different Microsoft Account?`,
});
}

return done(null, { jwt });
};
1 change: 1 addition & 0 deletions api.planx.uk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"multer": "^1.4.5-lts.1",
"nanoid": "^3.3.7",
"notifications-node-client": "^8.2.0",
"openid-client": "^5.6.5",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"pino-noir": "^2.2.1",
Expand Down
37 changes: 37 additions & 0 deletions api.planx.uk/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 2e4edc6

Please sign in to comment.