Move metamigo assets to metamigo-add
This commit is contained in:
parent
aab5b7f5d5
commit
28f7f0f47b
71 changed files with 3 additions and 2 deletions
35
apps/link/metamigo-add/_lib/absolute-url.ts
Normal file
35
apps/link/metamigo-add/_lib/absolute-url.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { IncomingMessage } from "node:http";
|
||||
|
||||
function absoluteUrl(
|
||||
req?: IncomingMessage,
|
||||
localhostAddress = "localhost:3000"
|
||||
) {
|
||||
let host =
|
||||
(req?.headers ? req.headers.host : window.location.host) ||
|
||||
localhostAddress;
|
||||
let protocol = /^localhost(:\d+)?$/.test(host) ? "http:" : "https:";
|
||||
|
||||
if (
|
||||
req &&
|
||||
req.headers["x-forwarded-host"] &&
|
||||
typeof req.headers["x-forwarded-host"] === "string"
|
||||
) {
|
||||
host = req.headers["x-forwarded-host"];
|
||||
}
|
||||
|
||||
if (
|
||||
req &&
|
||||
req.headers["x-forwarded-proto"] &&
|
||||
typeof req.headers["x-forwarded-proto"] === "string"
|
||||
) {
|
||||
protocol = `${req.headers["x-forwarded-proto"]}:`;
|
||||
}
|
||||
|
||||
return {
|
||||
protocol,
|
||||
host,
|
||||
origin: protocol + "//" + host,
|
||||
};
|
||||
}
|
||||
|
||||
export default absoluteUrl;
|
||||
40
apps/link/metamigo-add/_lib/apollo-client.ts
Normal file
40
apps/link/metamigo-add/_lib/apollo-client.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import {
|
||||
ApolloClient,
|
||||
InMemoryCache,
|
||||
ApolloLink,
|
||||
HttpLink,
|
||||
} from "@apollo/client";
|
||||
import { onError } from "@apollo/client/link/error";
|
||||
|
||||
const errorLink = onError(
|
||||
({ operation, graphQLErrors, networkError, forward }) => {
|
||||
console.log("ERROR LINK", operation);
|
||||
if (graphQLErrors)
|
||||
graphQLErrors.map(({ message, locations, path, ...rest }) =>
|
||||
console.log(
|
||||
`[GraphQL error]: Message: ${message}`,
|
||||
locations,
|
||||
path,
|
||||
rest
|
||||
)
|
||||
);
|
||||
if (networkError) console.log(`[Network error]: ${networkError}`);
|
||||
forward(operation);
|
||||
}
|
||||
);
|
||||
|
||||
export const apolloClient = new ApolloClient({
|
||||
link: ApolloLink.from([errorLink, new HttpLink({ uri: "/proxy/metamigo/graphql" })]),
|
||||
cache: new InMemoryCache(),
|
||||
/*
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
fetchPolicy: "no-cache",
|
||||
errorPolicy: "ignore",
|
||||
},
|
||||
query: {
|
||||
fetchPolicy: "no-cache",
|
||||
errorPolicy: "all",
|
||||
},
|
||||
}, */
|
||||
});
|
||||
213
apps/link/metamigo-add/_lib/cloudflare.ts
Normal file
213
apps/link/metamigo-add/_lib/cloudflare.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
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),
|
||||
});
|
||||
};
|
||||
12
apps/link/metamigo-add/_lib/dataprovider.ts
Normal file
12
apps/link/metamigo-add/_lib/dataprovider.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import pgDataProvider from "ra-postgraphile";
|
||||
import schema from "./graphql-schema.json";
|
||||
|
||||
export const metamigoDataProvider = async (client: any) => {
|
||||
const graphqlDataProvider: any = await pgDataProvider(
|
||||
client,
|
||||
// @ts-expect-error: Missing property
|
||||
{},
|
||||
{ introspection: { schema: schema.data.__schema } }
|
||||
);
|
||||
return graphqlDataProvider;
|
||||
};
|
||||
1
apps/link/metamigo-add/_lib/graphql-schema.json
Normal file
1
apps/link/metamigo-add/_lib/graphql-schema.json
Normal file
File diff suppressed because one or more lines are too long
230
apps/link/metamigo-add/_lib/nextauth-adapter.ts
Normal file
230
apps/link/metamigo-add/_lib/nextauth-adapter.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/* eslint-disable unicorn/no-null */
|
||||
import type {
|
||||
Adapter,
|
||||
AdapterAccount,
|
||||
AdapterSession,
|
||||
AdapterUser,
|
||||
} from "next-auth/adapters";
|
||||
import * as Wreck from "@hapi/wreck";
|
||||
import * as Boom from "@hapi/boom";
|
||||
|
||||
import type { IAppConfig } from "@digiresilience/metamigo-config";
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerified: string;
|
||||
userRole: string;
|
||||
avatar?: string;
|
||||
image?: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export type User = Profile & { id: string; createdAt: Date; updatedAt: Date };
|
||||
|
||||
export interface Session {
|
||||
userId: string;
|
||||
expires: Date;
|
||||
sessionToken: string;
|
||||
accessToken: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// from https://github.com/nextauthjs/next-auth/blob/main/src/lib/errors.js
|
||||
class UnknownError extends Error {
|
||||
constructor(message: any) {
|
||||
super(message);
|
||||
this.name = "UnknownError";
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
// stack: this.stack
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class CreateUserError extends UnknownError {
|
||||
constructor(message: any) {
|
||||
super(message);
|
||||
this.name = "CreateUserError";
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
const basicHeader = (secret: any) =>
|
||||
"Basic " + Buffer.from(secret + ":", "utf8").toString("base64");
|
||||
|
||||
export const MetamigoAdapter = (config: IAppConfig): Adapter => {
|
||||
if (!config) throw new Error("MetamigoAdapter: config is not defined.");
|
||||
const wreck = Wreck.defaults({
|
||||
headers: {
|
||||
authorization: basicHeader(config.nextAuth.secret),
|
||||
},
|
||||
baseUrl: `${config.frontend.apiUrl}/api/nextauth/`,
|
||||
maxBytes: 1024 * 1024,
|
||||
json: "force",
|
||||
});
|
||||
|
||||
function getAdapter(): Adapter {
|
||||
async function createUser(profile: Profile) {
|
||||
try {
|
||||
if (!profile.createdBy) profile = { ...profile, createdBy: "nextauth" };
|
||||
profile.avatar = profile.image;
|
||||
delete profile.image;
|
||||
const { payload } = await wreck.post("createUser", {
|
||||
payload: profile,
|
||||
});
|
||||
return payload;
|
||||
} catch {
|
||||
throw new CreateUserError("CREATE_USER_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function getUser(id: string) {
|
||||
try {
|
||||
const { payload } = await wreck.get(`getUser/${id}`);
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (Boom.isBoom(error, 404)) return null;
|
||||
throw new Error("GET_USER_BY_ID_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserByEmail(email: string) {
|
||||
try {
|
||||
const { payload } = await wreck.get(`getUserByEmail/${email}`);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (Boom.isBoom(error, 404)) return null;
|
||||
throw new Error("GET_USER_BY_EMAIL_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserByAccount({
|
||||
providerAccountId,
|
||||
provider,
|
||||
}: {
|
||||
providerAccountId: string;
|
||||
provider: string;
|
||||
}) {
|
||||
try {
|
||||
const { payload } = await wreck.get(
|
||||
`getUserByAccount/${provider}/${providerAccountId}`
|
||||
);
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (Boom.isBoom(error, 404)) return null;
|
||||
console.log(error);
|
||||
throw new Error("GET_USER_BY_ACCOUNT");
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser(user: User) {
|
||||
try {
|
||||
const { payload } = await wreck.put("updateUser", {
|
||||
payload: user,
|
||||
});
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
throw new Error("UPDATE_USER");
|
||||
}
|
||||
}
|
||||
|
||||
async function linkAccount(account: AdapterAccount) {
|
||||
try {
|
||||
await wreck.put("linkAccount", { payload: account } as any);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw new Error("LINK_ACCOUNT_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(user: User) {
|
||||
try {
|
||||
const { payload }: { payload: AdapterSession } = await wreck.post(
|
||||
"createSession",
|
||||
{
|
||||
payload: user,
|
||||
}
|
||||
);
|
||||
payload.expires = new Date(payload.expires);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw new Error("CREATE_SESSION_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function getSessionAndUser(sessionToken: string) {
|
||||
try {
|
||||
const { payload }: { payload: any } = await wreck.get(
|
||||
`getSessionAndUser/${sessionToken}`
|
||||
);
|
||||
const {
|
||||
session,
|
||||
user,
|
||||
}: { session: AdapterSession; user: AdapterUser } = payload;
|
||||
session.expires = new Date(session.expires);
|
||||
return { session, user };
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
if (Boom.isBoom(error, 404)) return null;
|
||||
throw new Error("GET_SESSION_AND_USER_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSession(session: Session, force: boolean) {
|
||||
try {
|
||||
const payload = {
|
||||
...session,
|
||||
expires: new Date(session.expires).getTime(),
|
||||
};
|
||||
const { payload: result } = await wreck.put(
|
||||
`updateSession?force=${Boolean(force)}`,
|
||||
{
|
||||
payload,
|
||||
}
|
||||
);
|
||||
return result;
|
||||
} catch {
|
||||
throw new Error("UPDATE_SESSION_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sessionToken: string) {
|
||||
try {
|
||||
await wreck.delete(`deleteSession/${sessionToken}`);
|
||||
} catch {
|
||||
throw new Error("DELETE_SESSION_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createUser,
|
||||
getUser,
|
||||
getUserByEmail,
|
||||
getUserByAccount,
|
||||
updateUser,
|
||||
// deleteUser,
|
||||
linkAccount,
|
||||
// unlinkAccount,
|
||||
createSession,
|
||||
getSessionAndUser,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
// @ts-expect-error: Type error
|
||||
} as AdapterInstance<Profile, User, Session, unknown>;
|
||||
}
|
||||
|
||||
return getAdapter();
|
||||
};
|
||||
30
apps/link/metamigo-add/_lib/phone-numbers.ts
Normal file
30
apps/link/metamigo-add/_lib/phone-numbers.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { regex } from "react-admin";
|
||||
|
||||
export const E164Regex = /^\+[1-9]\d{1,14}$/;
|
||||
/**
|
||||
* Returns true if the number is a valid E164 number
|
||||
*/
|
||||
export const isValidE164Number = (phoneNumber: string) =>
|
||||
E164Regex.test(phoneNumber);
|
||||
|
||||
/**
|
||||
* Given a phone number approximation, will clean out whitespace and punctuation.
|
||||
*/
|
||||
export const sanitizeE164Number = (phoneNumber: string) => {
|
||||
if (!phoneNumber) return "";
|
||||
if (!phoneNumber.trim()) return "";
|
||||
const sanitized = phoneNumber
|
||||
.replaceAll(/\s/g, "")
|
||||
.replaceAll(".", "")
|
||||
.replaceAll("-", "")
|
||||
.replaceAll("(", "")
|
||||
.replaceAll(")", "");
|
||||
|
||||
if (sanitized[0] !== "+") return `+${sanitized}`;
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
export const validateE164Number = regex(
|
||||
E164Regex,
|
||||
"Must start with a + and have no punctunation and no spaces."
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue