diff --git a/api.planx.uk/modules/auth/middleware.ts b/api.planx.uk/modules/auth/middleware.ts index 6d597a574c..a7084a0fca 100644 --- a/api.planx.uk/modules/auth/middleware.ts +++ b/api.planx.uk/modules/auth/middleware.ts @@ -123,6 +123,19 @@ export const useGoogleCallbackAuth: RequestHandler = (req, res, next) => { })(req, res, next); }; +export const useMicrosoftAuth: RequestHandler = (req, res, next) => { + req.session!.returnTo = req.get("Referrer"); + return passport.authenticate("microsoft-oidc", { + prompt: "select_account", + })(req, res, next); +}; + +export const useMicrosoftCallbackAuth: RequestHandler = (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/routes.ts b/api.planx.uk/modules/auth/routes.ts index b041ca1fb8..8570bcadc2 100644 --- a/api.planx.uk/modules/auth/routes.ts +++ b/api.planx.uk/modules/auth/routes.ts @@ -12,5 +12,11 @@ router.get( Middleware.useGoogleCallbackAuth, Controller.handleSuccess, ); +router.get("/auth/microsoft", Middleware.useMicrosoftAuth) +router.get( + "/auth/microsoft/callback", + Middleware.useMicrosoftCallbackAuth, + Controller.handleSuccess, +); export default 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..99292af329 --- /dev/null +++ b/api.planx.uk/modules/auth/strategy/microsoft-oidc.ts @@ -0,0 +1,72 @@ +import { Strategy, TokenSet, Issuer, generators, Client } from "openid-client"; +import { buildJWT } from "../service"; + +const MICROSOFT_OAUTH_BASE_URL = + "https://login.microsoftonline.com/common/v2.0"; +const OPENID_METADATA_DOCUMENT_ENDPOINT = "/.well-known/openid-configuration"; + +export const getMicrosoftIssuer = async (): Promise => { + const microsoftIssuer = await Issuer.discover( + MICROSOFT_OAUTH_BASE_URL + OPENID_METADATA_DOCUMENT_ENDPOINT, + ); + console.log( + "Discovered issuer %s %O", + microsoftIssuer.issuer, + microsoftIssuer.metadata, + ); + return microsoftIssuer; +}; + +export const getMicrosoftOidcStrategy = ( + microsoftIssuer: Issuer, +): Strategy => { + console.log("redirect uri domain:"); + console.log(process.env.API_URL_EXT); + + const microsoftClient = new microsoftIssuer.Client({ + client_id: process.env.MICROSOFT_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.API_URL_EXT}/logout`], + response_types: ["id_token"], + }); + + const nonce = generators.nonce(); + console.log(`Generated a nonce: ${nonce}`); + // TODO: store nonce (encrypted and httpOnly) in session + + microsoftClient.authorizationUrl({ + scope: "openid email profile", + response_mode: "form_post", // could also be 'query' or 'fragment' + nonce, + }); + + console.log("Built Microsoft client:"); + console.log(microsoftClient.metadata); + + // oidc = Open ID Connect + return new Strategy( + { client: microsoftClient }, + async (tokenset: TokenSet, userInfo: any, done: any): Promise => { + console.log("USER INFO:"); + console.log(userInfo); + + console.log("TOKEN SET:"); + console.log(tokenset); + + const email = "xxx"; + if (!email) throw 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?`, + } as any); + } + + done(null, { jwt }); + }, + ); +}; diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index 037e410828..2e9a32d905 100644 --- a/api.planx.uk/package.json +++ b/api.planx.uk/package.json @@ -39,6 +39,7 @@ "multer": "^1.4.5-lts.1", "nanoid": "^3.3.7", "notifications-node-client": "^8.2.0", + "openid-client": "^5.6.5", "passport": "^0.5.3", "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 e40f8c91b2..c2b5880a62 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.5.3 version: 0.5.3 @@ -5535,6 +5538,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'} @@ -5877,6 +5884,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: @@ -6293,6 +6307,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'} @@ -6324,6 +6343,11 @@ packages: isobject: 3.0.1 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 @@ -6368,6 +6392,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'} @@ -8145,6 +8178,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 c9f99f93c0..d045475953 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -15,6 +15,11 @@ import { ServerError } from "./errors"; import airbrake from "./airbrake"; import { apiLimiter } from "./rateLimit"; import { googleStrategy } from "./modules/auth/strategy/google"; +import { + getMicrosoftIssuer, + getMicrosoftOidcStrategy, +} from "./modules/auth/strategy/microsoft-oidc"; +import { Issuer } from "openid-client"; import authRoutes from "./modules/auth/routes"; import teamRoutes from "./modules/team/routes"; import miscRoutes from "./modules/misc/routes"; @@ -116,6 +121,11 @@ app.use( }), ); +// we have to fetch the Microsoft OpenID issuer to pass to our strategy constructor +// TODO: handle failure to fetch issuer +getMicrosoftIssuer().then((microsoftIssuer: Issuer) => { + passport.use("microsoft-oidc", getMicrosoftOidcStrategy(microsoftIssuer)); +}); passport.use("google", googleStrategy); passport.serializeUser(function (user, cb) { diff --git a/editor.planx.uk/src/pages/Login.tsx b/editor.planx.uk/src/pages/Login.tsx index 00f7354d0a..99aedac2dc 100644 --- a/editor.planx.uk/src/pages/Login.tsx +++ b/editor.planx.uk/src/pages/Login.tsx @@ -45,6 +45,16 @@ const Login: React.FC = () => { > Login with Google + + Login with Microsoft + );