213 lines
6.2 KiB
TypeScript
213 lines
6.2 KiB
TypeScript
import { promisify } from "node:util";
|
|
import jwt from "jsonwebtoken";
|
|
import jwksClient from "jwks-rsa";
|
|
import * as Boom from "@hapi/boom";
|
|
import * as Wreck from "@hapi/wreck";
|
|
import Credentials from "next-auth/providers/credentials";
|
|
import type { Adapter } from "next-auth/adapters";
|
|
import type { IncomingMessage } from "node: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: any, callback: any) => {
|
|
client.getSigningKey(header.kid, (err: any, key: any) => {
|
|
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 Credentials({
|
|
id: cloudflareAccountProvider,
|
|
name: "Cloudflare Access",
|
|
credentials: {},
|
|
authorize: cloudflareAuthorizeCallback(req, domain, verifier, adapter),
|
|
});
|
|
};
|