From 2e4edc6fe5ea8f8f1c41fb9cc8bd54d7fc253348 Mon Sep 17 00:00:00 2001 From: Dan G Date: Mon, 9 Sep 2024 11:32:10 +0100 Subject: [PATCH] [api] add Microsoft strategy to auth module (single sign-on) (#3453) --- .env.example | 4 ++ api.planx.uk/.env.test.example | 3 + api.planx.uk/modules/auth/controller.ts | 1 + api.planx.uk/modules/auth/middleware.ts | 60 ++++++++++++---- api.planx.uk/modules/auth/passport.ts | 39 +++++++++++ api.planx.uk/modules/auth/routes.ts | 30 +++++--- .../modules/auth/strategy/microsoft-oidc.ts | 69 +++++++++++++++++++ api.planx.uk/package.json | 1 + api.planx.uk/pnpm-lock.yaml | 37 ++++++++++ api.planx.uk/server.ts | 33 +++++---- api.planx.uk/tsconfig.json | 2 +- api.planx.uk/tsconfig.test.json | 7 -- docker-compose.yml | 4 +- editor.planx.uk/src/pages/Login.tsx | 8 ++- 14 files changed, 250 insertions(+), 48 deletions(-) create mode 100644 api.planx.uk/modules/auth/passport.ts create mode 100644 api.planx.uk/modules/auth/strategy/microsoft-oidc.ts delete mode 100644 api.planx.uk/tsconfig.test.json diff --git a/.env.example b/.env.example index 222a893a48..ee53588dc0 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/api.planx.uk/.env.test.example b/api.planx.uk/.env.test.example index 2a36ccfc56..664aa4e0b9 100644 --- a/api.planx.uk/.env.test.example +++ b/api.planx.uk/.env.test.example @@ -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=👻 diff --git a/api.planx.uk/modules/auth/controller.ts b/api.planx.uk/modules/auth/controller.ts index 458487ad6c..383c954a47 100644 --- a/api.planx.uk/modules/auth/controller.ts +++ b/api.planx.uk/modules/auth/controller.ts @@ -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 }); diff --git a/api.planx.uk/modules/auth/middleware.ts b/api.planx.uk/modules/auth/middleware.ts index 487443e589..85457f0bf6 100644 --- a/api.planx.uk/modules/auth/middleware.ts +++ b/api.planx.uk/modules/auth/middleware.ts @@ -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"; @@ -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; diff --git a/api.planx.uk/modules/auth/passport.ts b/api.planx.uk/modules/auth/passport.ts new file mode 100644 index 0000000000..b354c94fc3 --- /dev/null +++ b/api.planx.uk/modules/auth/passport.ts @@ -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 => { + // 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; +}; diff --git a/api.planx.uk/modules/auth/routes.ts b/api.planx.uk/modules/auth/routes.ts index f21fe81da8..d349f55d94 100644 --- a/api.planx.uk/modules/auth/routes.ts +++ b/api.planx.uk/modules/auth/routes.ts @@ -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; +}; diff --git a/api.planx.uk/modules/auth/strategy/microsoft-oidc.ts b/api.planx.uk/modules/auth/strategy/microsoft-oidc.ts new file mode 100644 index 0000000000..cc9c1dc015 --- /dev/null +++ b/api.planx.uk/modules/auth/strategy/microsoft-oidc.ts @@ -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 => { + 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 = async ( + req: Http.IncomingMessageWithSession, + tokenSet, + done, +): Promise => { + // 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 }); +}; diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index 297ada5bc9..581b6b7d8b 100644 --- a/api.planx.uk/package.json +++ b/api.planx.uk/package.json @@ -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", diff --git a/api.planx.uk/pnpm-lock.yaml b/api.planx.uk/pnpm-lock.yaml index e01b020420..663a4c9e72 100644 --- a/api.planx.uk/pnpm-lock.yaml +++ b/api.planx.uk/pnpm-lock.yaml @@ -115,6 +115,9 @@ dependencies: notifications-node-client: specifier: ^8.2.0 version: 8.2.0 + openid-client: + specifier: ^5.6.5 + version: 5.6.5 passport: specifier: ^0.7.0 version: 0.7.0 @@ -4081,6 +4084,10 @@ packages: engines: {node: '>= 0.6.0'} dev: false + /jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + dev: false + /joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -4386,6 +4393,13 @@ packages: yallist: 3.1.1 dev: true + /lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 + dev: false + /lru-queue@0.1.0: resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==} dependencies: @@ -4693,6 +4707,11 @@ packages: engines: {node: '>= 0.10.0'} dev: true + /object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + dev: false + /object-inspect@1.13.2: resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} engines: {node: '>= 0.4'} @@ -4710,6 +4729,11 @@ packages: engines: {node: '>= 0.4'} dev: true + /oidc-token-hash@5.0.3: + resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} + engines: {node: ^10.13.0 || >=12.0.0} + dev: false + /on-exit-leak-free@0.2.0: resolution: {integrity: sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==} dev: false @@ -4754,6 +4778,15 @@ packages: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} dev: false + /openid-client@5.6.5: + resolution: {integrity: sha512-5P4qO9nGJzB5PI0LFlhj4Dzg3m4odt0qsJTfyEtZyOlkgpILwEioOhVVJOrS1iVH494S4Ee5OCjjg6Bf5WOj3w==} + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.0.3 + dev: false + /optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -6298,6 +6331,10 @@ packages: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} dev: true + /yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + dev: false + /yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index fcb477c8a9..1c4b249984 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -4,21 +4,21 @@ import bodyParser from "body-parser"; import cookieParser from "cookie-parser"; import cookieSession from "cookie-session"; import cors, { CorsOptions } from "cors"; -import express, { ErrorRequestHandler } from "express"; +import express from "express"; +import type { ErrorRequestHandler, Request } from "express"; import "express-async-errors"; import pinoLogger from "express-pino-logger"; import helmet from "helmet"; -import { Server } from "http"; +import { Server, type IncomingMessage } from "http"; import "isomorphic-fetch"; -import passport from "passport"; import noir from "pino-noir"; import airbrake from "./airbrake.js"; import { useSwaggerDocs } from "./docs/index.js"; import { ServerError } from "./errors/index.js"; import adminRoutes from "./modules/admin/routes.js"; import analyticsRoutes from "./modules/analytics/routes.js"; -import authRoutes from "./modules/auth/routes.js"; -import { googleStrategy } from "./modules/auth/strategy/google.js"; +import getPassport from "./modules/auth/passport.js"; +import getAuthRoutes from "./modules/auth/routes.js"; import fileRoutes from "./modules/file/routes.js"; import flowRoutes from "./modules/flows/routes.js"; import gisRoutes from "./modules/gis/routes.js"; @@ -122,19 +122,16 @@ app.use( // register stubs after cookieSession middleware initialisation app.use(registerSessionStubs); -passport.use("google", googleStrategy); - -passport.serializeUser(function (user, cb) { - cb(null, user); -}); - -passport.deserializeUser(function (obj: Express.User, cb) { - cb(null, obj); -}); +// 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); @@ -200,4 +197,12 @@ declare global { }; } } + + namespace Http { + interface IncomingMessageWithSession extends IncomingMessage { + session?: { + nonce: string; + }; + } + } } diff --git a/api.planx.uk/tsconfig.json b/api.planx.uk/tsconfig.json index 63ed4456b5..91fa3cb1b1 100644 --- a/api.planx.uk/tsconfig.json +++ b/api.planx.uk/tsconfig.json @@ -15,7 +15,7 @@ "strict": true, "target": "esnext", "types": ["vitest/globals"], - // ensure the code is ready for transpilation by tsx/esbuild (used in dev) + // ensure the code is ready for per-file transpilation by tsx (used in dev mode) "isolatedModules": true, // TODO: implement "verbatimModuleSyntax" option (laborious) }, diff --git a/api.planx.uk/tsconfig.test.json b/api.planx.uk/tsconfig.test.json deleted file mode 100644 index ea5b7c77b4..0000000000 --- a/api.planx.uk/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "esnext", - "moduleResolution": "bundler" - } -} diff --git a/docker-compose.yml b/docker-compose.yml index 6d57522027..0f9affb694 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -122,7 +122,7 @@ services: AWS_S3_REGION: ${AWS_S3_REGION} AWS_SECRET_KEY: ${AWS_SECRET_KEY} BOPS_API_TOKEN: ${BOPS_API_TOKEN} - CORS_ALLOWLIST: ${EDITOR_URL_EXT}, ${API_URL_EXT} + CORS_ALLOWLIST: ${EDITOR_URL_EXT}, ${API_URL_EXT}, https://login.live.com, https://login.microsoftonline.com EDITOR_URL_EXT: ${EDITOR_URL_EXT} ENCRYPTION_KEY: ${ENCRYPTION_KEY} FILE_API_KEY_BARNET: ${FILE_API_KEY_BARNET} @@ -130,6 +130,8 @@ services: FILE_API_KEY: ${FILE_API_KEY} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID} + MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET} GOVUK_NOTIFY_API_KEY: ${GOVUK_NOTIFY_API_KEY} HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_GRAPHQL_ADMIN_SECRET} HASURA_GRAPHQL_URL: http://hasura-proxy:${HASURA_PROXY_PORT}/v1/graphql diff --git a/editor.planx.uk/src/pages/Login.tsx b/editor.planx.uk/src/pages/Login.tsx index 02c6c20d82..d4fe9e22c7 100644 --- a/editor.planx.uk/src/pages/Login.tsx +++ b/editor.planx.uk/src/pages/Login.tsx @@ -67,16 +67,18 @@ const Login: React.FC = () => { Continue with Microsoft - (coming soon) + (in development)