link-stack/metamigo-frontend/lib/cloudflare.ts
2023-02-13 12:41:30 +00:00

210 lines
6 KiB
TypeScript

import { promisify } from "util";
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
import * as Boom from "@hapi/boom";
import * as Wreck from "@hapi/wreck";
import Providers from "next-auth/providers";
import type { Adapter } from "next-auth/adapters";
import type { IncomingMessage } from "http";
const CF_JWT_HEADER_NAME = "cf-access-jwt-assertion";
const CF_JWT_ALGOS = ["RS256"];
export type VerifyFn = (token: string) => Promise<any>;
/**
* Returns a function that will accept a jwt and verify it against the cloudflare access details
*
* @param audience the cloudflare access audience id
* @param domain the cloudflare access domain
*/
export const cfVerifier = (audience: string, domain: string): VerifyFn => {
if (!audience || !domain)
throw Boom.badImplementation(
"Cloudflare configuration is missing. See project documentation."
);
const issuer = `https://${domain}`;
const client = jwksClient({
jwksUri: `${issuer}/cdn-cgi/access/certs`,
});
return async (token) => {
const getKey = (header, callback) => {
client.getSigningKey(header.kid, function (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,
};
// @ts-expect-error: Too many args
return promisify(jwt.verify)(token, getKey, opts);
};
};
/**
* Verifies the Cloudflare Access JWT and returns the decoded token's contents.
* Throws if the token is missing or invalid.
*
* @param verifier the verification function
* @param req the incoming http request to verify
* @return the original token and the decoded contents.
*/
export const verifyRequest = async (
verifier: VerifyFn,
req: IncomingMessage
): Promise<{ token: string; decoded: any }> => {
const token = req.headers[CF_JWT_HEADER_NAME] as string;
if (token) {
try {
const decoded = await verifier(token);
return { token, decoded };
} catch (error) {
console.error(error);
throw Boom.unauthorized("invalid cloudflare access token");
}
}
throw Boom.unauthorized("cloudflare access token missing");
};
/**
* Fetches user identity information from cloudflare.
*
* @param domain the cloudflare access domain
* @param token the encoded jwt token for the user
* @see https://developers.cloudflare.com/access/setting-up-access/json-web-token#groups-within-a-jwt
*/
export const getIdentity = async (
domain: string,
token: string
): Promise<any> => {
const { payload } = await Wreck.get(
`https://${domain}/cdn-cgi/access/get-identity`,
{
headers: {
Cookie: `CF_Authorization=${token}`,
},
json: true,
}
);
return payload;
};
const cloudflareAccountProvider = "cloudflare-access";
const cloudflareAuthorizeCallback = (
req: IncomingMessage,
domain: string,
verifier: VerifyFn,
adapter: Adapter
): (() => Promise<any>) => async () => {
/*
lots of little variables in here.
token: the encoded jwt from cloudflare access
decoded: the decoded jwt containing the content cloudflare gives us
identity: we call the cloudflare access identity endpoint to retrieve more user identity information
this data is identity provider specific, so the format is unknown
it would be possible to support specific identity providers and have roles/groups
profile: this is the accumulated user information we have that we will fetch/build the user record with
*/
const { token, decoded } = await verifyRequest(verifier, req);
const profile = {
email: undefined,
name: undefined,
avatar: undefined,
};
if (decoded.email) profile.email = decoded.email;
if (decoded.name) profile.name = decoded.name;
const identity = await getIdentity(domain, token);
if (identity.email) profile.email = identity.email;
if (identity.name) profile.name = identity.name;
if (!profile.email)
throw new Error("cloudflare access authorization: email not found");
const providerId = `cfaccess|${identity.idp.type}|${identity.idp.id}`;
const providerAccountId = identity.user_uuid;
if (!providerAccountId)
throw new Error(
"cloudflare access authorization: missing provider account id"
);
const {
getUserByProviderAccountId,
getUserByEmail,
createUser,
linkAccount,
} =
// @ts-expect-error: non-existent property
await adapter.getAdapter({} as any);
const userByProviderAccountId = await getUserByProviderAccountId(
providerId,
providerAccountId
);
if (userByProviderAccountId) {
return userByProviderAccountId;
}
const userByEmail = await getUserByEmail(profile.email);
if (userByEmail) {
// we will not explicitly link accounts
throw new Error(
"cloudflare access authorization: user exists for email address, but is not linked."
);
}
const user = await createUser(profile);
// between the previous line and the next line exists a transactional bug
// https://github.com/nextauthjs/next-auth/issues/876
// hopefully we don't experience it
await linkAccount(
user.id,
providerId,
cloudflareAccountProvider,
providerAccountId,
// the following are unused but are specified for completness
undefined,
undefined,
undefined
);
return user;
};
/**
* @param audience the cloudflare access audience id
* @param domain the cloudflare access domain (including the .cloudflareaccess.com bit)
* @param adapter the next-auth adapter used to talk to the backend
* @param req the incoming request object used to parse the jwt from
*/
export const CloudflareAccessProvider = (
audience: string,
domain: string,
adapter: Adapter,
req: IncomingMessage
) => {
const verifier = cfVerifier(audience, domain);
return Providers.Credentials({
id: cloudflareAccountProvider,
name: "Cloudflare Access",
credentials: {},
authorize: cloudflareAuthorizeCallback(req, domain, verifier, adapter),
});
};