115 lines
3 KiB
TypeScript
115 lines
3 KiB
TypeScript
|
|
import * as Boom from "@hapi/boom";
|
||
|
|
import * as Hoek from "@hapi/hoek";
|
||
|
|
import * as Hapi from "@hapi/hapi";
|
||
|
|
import { promisify } from "util";
|
||
|
|
import jwt from "jsonwebtoken";
|
||
|
|
import jwksClient, { hapiJwt2KeyAsync } from "jwks-rsa";
|
||
|
|
import type { IAppConfig } from "../../config";
|
||
|
|
|
||
|
|
const CF_JWT_HEADER_NAME = "cf-access-jwt-assertion";
|
||
|
|
const CF_JWT_ALGOS = ["RS256"];
|
||
|
|
|
||
|
|
const verifyToken = (settings: any) => {
|
||
|
|
const { audience, issuer } = settings;
|
||
|
|
const client = jwksClient({
|
||
|
|
jwksUri: `${issuer}/cdn-cgi/access/certs`,
|
||
|
|
});
|
||
|
|
|
||
|
|
return async (token: any) => {
|
||
|
|
const getKey = (header: any, callback: any) => {
|
||
|
|
client.getSigningKey(header.kid, (err, key) => {
|
||
|
|
if (err)
|
||
|
|
throw Boom.serverUnavailable(
|
||
|
|
"failed to fetch cloudflare access jwks"
|
||
|
|
);
|
||
|
|
callback(undefined, key?.getPublicKey());
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const opts = {
|
||
|
|
algorithms: CF_JWT_ALGOS,
|
||
|
|
audience,
|
||
|
|
issuer,
|
||
|
|
};
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
return (promisify(jwt.verify) as any)(token, getKey, opts);
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleCfJwt = (verify: any) => async (
|
||
|
|
request: Hapi.Request,
|
||
|
|
h: Hapi.ResponseToolkit
|
||
|
|
) => {
|
||
|
|
const token = request.headers[CF_JWT_HEADER_NAME];
|
||
|
|
if (token) {
|
||
|
|
try {
|
||
|
|
await verify(token);
|
||
|
|
} catch (error) {
|
||
|
|
console.error(error);
|
||
|
|
return Boom.unauthorized("invalid cloudflare access token");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return h.continue;
|
||
|
|
};
|
||
|
|
|
||
|
|
const defaultOpts = {
|
||
|
|
issuer: undefined,
|
||
|
|
audience: undefined,
|
||
|
|
strategyName: "clouflareaccess",
|
||
|
|
validate: undefined,
|
||
|
|
};
|
||
|
|
|
||
|
|
const cfJwtRegister = async (server: Hapi.Server, options: any): Promise<void> => {
|
||
|
|
server.dependency(["hapi-auth-jwt2"]);
|
||
|
|
const settings = Hoek.applyToDefaults(defaultOpts, options);
|
||
|
|
const verify = verifyToken(settings);
|
||
|
|
|
||
|
|
const { validate, strategyName, audience, issuer } = settings;
|
||
|
|
server.ext("onPreAuth", handleCfJwt(verify));
|
||
|
|
|
||
|
|
server.auth.strategy(strategyName!, "jwt", {
|
||
|
|
key: hapiJwt2KeyAsync({
|
||
|
|
jwksUri: `${issuer}/cdn-cgi/access/certs`,
|
||
|
|
}),
|
||
|
|
cookieKey: false,
|
||
|
|
urlKey: false,
|
||
|
|
headerKey: CF_JWT_HEADER_NAME,
|
||
|
|
validate,
|
||
|
|
verifyOptions: {
|
||
|
|
audience,
|
||
|
|
issuer,
|
||
|
|
algorithms: ["RS256"],
|
||
|
|
},
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
export const registerCloudflareAccessJwt = async (
|
||
|
|
server: Hapi.Server,
|
||
|
|
config: IAppConfig
|
||
|
|
): Promise<void> => {
|
||
|
|
const { audience, domain } = config.cfaccess;
|
||
|
|
// only enable this plugin if cloudflare access config is configured
|
||
|
|
if (audience && domain) {
|
||
|
|
server.log(["auth"], "cloudflare access authorization enabled");
|
||
|
|
await server.register({
|
||
|
|
plugin: {
|
||
|
|
name: "cloudflare-jwt",
|
||
|
|
version: "0.0.1",
|
||
|
|
register: cfJwtRegister,
|
||
|
|
},
|
||
|
|
options: {
|
||
|
|
issuer: `https://${domain}`,
|
||
|
|
audience,
|
||
|
|
validate: (decoded: any, _request: any) => {
|
||
|
|
const { email, name } = decoded;
|
||
|
|
return {
|
||
|
|
isValid: true,
|
||
|
|
credentials: { user: { email, name } },
|
||
|
|
};
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|