Skip to content

Commit

Permalink
Merge 8416f26 into 6f0f2e3
Browse files Browse the repository at this point in the history
  • Loading branch information
freemvmt authored Jul 30, 2024
2 parents 6f0f2e3 + 8416f26 commit 2ad0aba
Show file tree
Hide file tree
Showing 13 changed files with 215 additions and 29 deletions.
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
2 changes: 1 addition & 1 deletion api.planx.uk/jest.setup.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import dotenv from "dotenv";
import { queryMock } from "./tests/graphqlQueryMock";

dotenv.config({ path: "./.env.test" });
dotenv.config({ override: true, path: "./.env.test" });

beforeEach(() => {
queryMock.setup(process.env.HASURA_GRAPHQL_URL);
Expand Down
4 changes: 4 additions & 0 deletions api.planx.uk/modules/auth/controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { CookieOptions, RequestHandler, Response } from "express";
import { Request } from "express-jwt";

import { microsoftOidcClient } from "./passport";

export const failedLogin: RequestHandler = (_req, _res, next) =>
next({
status: 401,
Expand All @@ -11,6 +13,8 @@ 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
38 changes: 30 additions & 8 deletions api.planx.uk/modules/auth/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import crypto from "crypto";
import assert from "assert";
import { ServerError } from "../../errors";
import { Template } from "../../lib/notify";
import { expressjwt } from "express-jwt";

import passport from "passport";

import { RequestHandler } from "http-proxy-middleware";
import { Role } from "@opensystemslab/planx-core/types";
import { AsyncLocalStorage } from "async_hooks";
import { Request } from "express";
import { generators } from "openid-client";

import { Role } from "@opensystemslab/planx-core/types";

import { ServerError } from "../../errors";
import { Template } from "../../lib/notify";
import { passportWithStrategies } from "./passport";

export const userContext = new AsyncLocalStorage<{ user: Express.User }>();

Expand Down Expand Up @@ -112,13 +113,34 @@ export const useJWT = expressjwt({

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

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

export const useMicrosoftAuth: RequestHandler = (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 passportWithStrategies.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);
};
Expand Down
48 changes: 48 additions & 0 deletions api.planx.uk/modules/auth/passport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { custom, Issuer } from "openid-client";
import passport from "passport";

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

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();

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

// do any other aspects of passport setup which can be handled here
passportWithStrategies.use("google", googleStrategy);
passportWithStrategies.serializeUser((user: any, done) => {
done(null, user);
});
passportWithStrategies.deserializeUser((obj: any, done) => {
done(null, obj);
});

return { passportWithStrategies, microsoftOidcClient };
};

// instantiate and export the new passport class and Microsoft client as early as possible
const { passportWithStrategies, microsoftOidcClient } = setupPassport();
export { passportWithStrategies, microsoftOidcClient };
6 changes: 6 additions & 0 deletions api.planx.uk/modules/auth/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,11 @@ router.get(
Middleware.useGoogleCallbackAuth,
Controller.handleSuccess,
);
router.get("/auth/microsoft", Middleware.useMicrosoftAuth);
router.post(
"/auth/microsoft/callback",
Middleware.useMicrosoftCallbackAuth,
Controller.handleSuccess,
);

export default router;
4 changes: 3 additions & 1 deletion api.planx.uk/modules/auth/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ export const buildJWT = async (email: string): Promise<string | undefined> => {
const user = await $api.user.getByEmail(email);
if (!user) return;

const claims = generateHasuraClaimsForUser(user);

const data = {
sub: user.id.toString(),
email,
"https://hasura.io/jwt/claims": generateHasuraClaimsForUser(user),
"https://hasura.io/jwt/claims": claims,
};

const jwt = sign(data, process.env.JWT_SECRET!);
Expand Down
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 { Strategy, TokenSet, Client, ClientMetadata } from "openid-client";
import { buildJWT } from "../service";

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,
},
async (req: any, tokenSet: TokenSet, done: any): Promise<void> => {
// TODO: use state to pass the redirectTo query param through the auth flow
const state = tokenSet.state;

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(
new Error("Returned nonce does not match session nonce"),
null,
);
}

if (!email) {
return done(new Error("Unable to authenticate without email"), null);
}

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,
null,
);
}

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 @@ -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",
Expand Down
38 changes: 38 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.

15 changes: 3 additions & 12 deletions api.planx.uk/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@ import express, { ErrorRequestHandler } from "express";
import noir from "pino-noir";
import pinoLogger from "express-pino-logger";
import { Server } from "http";
import passport from "passport";
import helmet from "helmet";
import { ServerError } from "./errors";
import airbrake from "./airbrake";
import { apiLimiter } from "./rateLimit";
import { googleStrategy } from "./modules/auth/strategy/google";
import { passportWithStrategies } from "./modules/auth/passport";
import authRoutes from "./modules/auth/routes";
import teamRoutes from "./modules/team/routes";
import miscRoutes from "./modules/misc/routes";
Expand Down Expand Up @@ -116,17 +115,9 @@ app.use(
}),
);

passport.use("google", googleStrategy);
app.use(passportWithStrategies.initialize());
app.use(passportWithStrategies.session());

passport.serializeUser(function (user, cb) {
cb(null, user);
});

passport.deserializeUser(function (obj: Express.User, cb) {
cb(null, obj);
});
app.use(passport.initialize());
app.use(passport.session());
app.use(urlencoded({ extended: true }));

// Setup API routes
Expand Down
5 changes: 4 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ services:
SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL}
ORDNANCE_SURVEY_API_KEY: ${ORDNANCE_SURVEY_API_KEY}
MINIO_PORT: ${MINIO_PORT}
CORS_ALLOWLIST: ${EDITOR_URL_EXT}, ${API_URL_EXT}
CORS_ALLOWLIST: ${EDITOR_URL_EXT}, ${API_URL_EXT}, https://login.live.com, https://login.microsoftonline.com
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
# Local authority config
# Lambeth
Expand All @@ -153,6 +153,9 @@ services:
UNIFORM_CLIENT_AYLESBURY_VALE: ${UNIFORM_CLIENT_AYLESBURY_VALE}
UNIFORM_CLIENT_CHILTERN: ${UNIFORM_CLIENT_CHILTERN}
UNIFORM_CLIENT_WYCOMBE: ${UNIFORM_CLIENT_WYCOMBE}
# microsoft-oidc strategy testing
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID}
MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET}

sharedb:
restart: unless-stopped
Expand Down
Loading

0 comments on commit 2ad0aba

Please sign in to comment.