feat: encapsulate identity with better-auth
This commit is contained in:
1
platform/cerbos/mod.ts
Normal file
1
platform/cerbos/mod.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./cerbos.ts";
|
||||
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "@platform/supertoken",
|
||||
"name": "@platform/auth",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./mod.ts",
|
||||
"exports": {
|
||||
".": "./mod.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cerbos/http": "0.23.1",
|
||||
"@platform/config": "workspace:*",
|
||||
"cookie": "1.0.2",
|
||||
"supertokens-node": "23.0.1",
|
||||
"@platform/logger": "workspace:*",
|
||||
"zod": "4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Collection, type CollectionOptions, type Db, type Document, type MongoClient } from "mongodb";
|
||||
|
||||
import { container } from "./container.ts";
|
||||
import { mongo } from "./client.ts";
|
||||
|
||||
export function getDatabaseAccessor<TSchemas extends Record<string, Document>>(
|
||||
database: string,
|
||||
@@ -14,7 +14,7 @@ export function getDatabaseAccessor<TSchemas extends Record<string, Document>>(
|
||||
return instance;
|
||||
},
|
||||
get client(): MongoClient {
|
||||
return container.get("mongo");
|
||||
return mongo;
|
||||
},
|
||||
collection<TSchema extends keyof TSchemas>(
|
||||
name: TSchema,
|
||||
|
||||
4
platform/database/client.ts
Normal file
4
platform/database/client.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { config } from "./config.ts";
|
||||
import { getMongoClient } from "./connection.ts";
|
||||
|
||||
export const mongo = getMongoClient(config.mongo);
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Container } from "@valkyr/inverse";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
export const container = new Container<{
|
||||
mongo: MongoClient;
|
||||
}>("@platform/database");
|
||||
@@ -1,9 +0,0 @@
|
||||
import { config } from "./config.ts";
|
||||
import { getMongoClient } from "./connection.ts";
|
||||
import { container } from "./container.ts";
|
||||
|
||||
export default {
|
||||
bootstrap: async (): Promise<void> => {
|
||||
container.set("mongo", getMongoClient(config.mongo));
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
import "@platform/supertoken/types.ts";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface ServerContext {}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import "./types.d.ts";
|
||||
import "./types.ts";
|
||||
|
||||
import { context } from "@platform/relay";
|
||||
import { InternalServerError } from "@platform/relay";
|
||||
|
||||
@@ -30,7 +30,6 @@ declare module "@platform/storage" {
|
||||
|
||||
declare module "@platform/relay" {
|
||||
interface ServerContext {
|
||||
isAuthenticated: boolean;
|
||||
request: {
|
||||
headers: Headers;
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
import { cerbos } from "./cerbos.ts";
|
||||
import type { Principal } from "./principal.ts";
|
||||
|
||||
export function getAccessControlMethods(principal: Principal) {
|
||||
return {
|
||||
/**
|
||||
* Check if a principal is allowed to perform an action on a resource.
|
||||
*
|
||||
* @param resource - Resource which we are validating.
|
||||
* @param action - Action which we are validating.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* await access.isAllowed(
|
||||
* {
|
||||
* kind: "document",
|
||||
* id: "1",
|
||||
* attr: { owner: "user@example.com" },
|
||||
* },
|
||||
* "view"
|
||||
* ); // => true
|
||||
*/
|
||||
isAllowed(resource: any, action: string) {
|
||||
return cerbos.isAllowed({ principal, resource, action });
|
||||
},
|
||||
|
||||
/**
|
||||
* Check a principal's permissions on a resource.
|
||||
*
|
||||
* @param resource - Resource which we are validating.
|
||||
* @param actions - Actions which we are validating.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* const decision = await access.checkResource(
|
||||
* {
|
||||
* kind: "document",
|
||||
* id: "1",
|
||||
* attr: { owner: "user@example.com" },
|
||||
* },
|
||||
* ["view", "edit"],
|
||||
* );
|
||||
*
|
||||
* decision.isAllowed("view"); // => true
|
||||
*/
|
||||
checkResource(resource: any, actions: string[]) {
|
||||
return cerbos.checkResource({ principal, resource, actions });
|
||||
},
|
||||
|
||||
/**
|
||||
* Check a principal's permissions on a set of resources.
|
||||
*
|
||||
* @param resources - Resources which we are validating.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* const decision = await access.checkResources([
|
||||
* {
|
||||
* resource: {
|
||||
* kind: "document",
|
||||
* id: "1",
|
||||
* attr: { owner: "user@example.com" },
|
||||
* },
|
||||
* actions: ["view", "edit"],
|
||||
* },
|
||||
* {
|
||||
* resource: {
|
||||
* kind: "image",
|
||||
* id: "1",
|
||||
* attr: { owner: "user@example.com" },
|
||||
* },
|
||||
* actions: ["delete"],
|
||||
* },
|
||||
* ]);
|
||||
*
|
||||
* decision.isAllowed({
|
||||
* resource: { kind: "document", id: "1" },
|
||||
* action: "view",
|
||||
* }); // => true
|
||||
*/
|
||||
checkResources(resources: { resource: any; actions: string[] }[]) {
|
||||
return cerbos.checkResources({ principal, resources });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type AccessControlMethods = ReturnType<typeof getAccessControlMethods>;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { getEnvironmentVariable } from "@platform/config/environment.ts";
|
||||
import type { SerializeOptions } from "cookie";
|
||||
import z from "zod";
|
||||
|
||||
export const config = {
|
||||
supertokens: {
|
||||
connectionURI: getEnvironmentVariable({
|
||||
key: "SUPERTOKEN_URI",
|
||||
type: z.string(),
|
||||
fallback: "http://localhost:3567",
|
||||
}),
|
||||
},
|
||||
appInfo: {
|
||||
appName: getEnvironmentVariable({
|
||||
key: "PROJECT_NAME",
|
||||
type: z.string(),
|
||||
fallback: "Boilerplate",
|
||||
}),
|
||||
apiDomain: getEnvironmentVariable({
|
||||
key: "API_DOMAIN",
|
||||
type: z.string(),
|
||||
fallback: "http://localhost:8370",
|
||||
}),
|
||||
websiteDomain: getEnvironmentVariable({
|
||||
key: "APP_DOMAIN",
|
||||
type: z.string(),
|
||||
fallback: "http://localhost:3000",
|
||||
}),
|
||||
apiBasePath: "/api/v1/identity",
|
||||
websiteBasePath: "/auth",
|
||||
},
|
||||
cookie: (maxAge: number) =>
|
||||
({
|
||||
httpOnly: true,
|
||||
secure: getEnvironmentVariable({
|
||||
key: "AUTH_COOKIE_SECURE",
|
||||
type: z.coerce.boolean(),
|
||||
fallback: "false",
|
||||
}), // Set to true for HTTPS in production
|
||||
maxAge,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
}) satisfies SerializeOptions,
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import UserMetadata from "supertokens-node/recipe/usermetadata";
|
||||
import z from "zod";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Schema
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const PrincipalSchema = z.object({
|
||||
id: z.string(),
|
||||
roles: z.array(z.string()),
|
||||
attr: z.record(z.string(), z.any()),
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get principal roles from the provided userId.
|
||||
*
|
||||
* @param userId - User to get principal roles from.
|
||||
*/
|
||||
export async function getPrincipalRoles(userId: string): Promise<string[]> {
|
||||
return (await UserMetadata.getUserMetadata(userId)).metadata?.roles ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get principal attributes from the provided userId.
|
||||
*
|
||||
* @param userId - User to get principal attributes from.
|
||||
*/
|
||||
export async function getPrincipalAttributes(userId: string): Promise<Record<string, any>> {
|
||||
return (await UserMetadata.getUserMetadata(userId)).metadata?.attr ?? {};
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type Principal = z.infer<typeof PrincipalSchema>;
|
||||
@@ -1,133 +0,0 @@
|
||||
import "./types.ts";
|
||||
|
||||
import { UnauthorizedError } from "@platform/relay";
|
||||
import { context } from "@platform/relay";
|
||||
import { storage } from "@platform/storage";
|
||||
import cookie from "cookie";
|
||||
import supertokens from "supertokens-node";
|
||||
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||
import Session from "supertokens-node/recipe/session";
|
||||
|
||||
import { getAccessControlMethods } from "./access.ts";
|
||||
import { config } from "./config.ts";
|
||||
import { getPrincipalAttributes, getPrincipalRoles, Principal } from "./principal.ts";
|
||||
import { getSessionByAccessToken } from "./session.ts";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Server Module
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export default {
|
||||
bootsrap: async () => {
|
||||
bootstrapSuperTokens();
|
||||
bootstrapStorageContext();
|
||||
},
|
||||
resolve: async (request: Request): Promise<void> => {
|
||||
await resolveSession(request);
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Bootstrap Methods
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function bootstrapSuperTokens() {
|
||||
supertokens.init({
|
||||
framework: "custom",
|
||||
supertokens: config.supertokens,
|
||||
appInfo: config.appInfo,
|
||||
recipeList: [
|
||||
Passwordless.init({
|
||||
flowType: "USER_INPUT_CODE",
|
||||
contactMethod: "EMAIL",
|
||||
}),
|
||||
Session.init({
|
||||
getTokenTransferMethod: () => "cookie",
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function bootstrapStorageContext() {
|
||||
Object.defineProperties(context, {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
isAuthenticated: {
|
||||
get() {
|
||||
return storage.getStore()?.principal !== undefined;
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
session: {
|
||||
get() {
|
||||
const session = storage.getStore()?.session;
|
||||
if (session === undefined) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
principal: {
|
||||
get() {
|
||||
const principal = storage.getStore()?.principal;
|
||||
if (principal === undefined) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
return principal;
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
access: {
|
||||
get() {
|
||||
const access = storage.getStore()?.access;
|
||||
if (access === undefined) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
return access;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Request Middleware
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async function resolveSession(request: Request): Promise<void> {
|
||||
const accessToken = cookie.parse(request.headers.get("cookie") ?? "").sAccessToken;
|
||||
if (accessToken !== undefined) {
|
||||
const session = await getSessionByAccessToken(accessToken);
|
||||
|
||||
const store = storage.getStore();
|
||||
if (store === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const principal: Principal = {
|
||||
id: session.getUserId(),
|
||||
roles: await getPrincipalRoles(session.getUserId()),
|
||||
attr: await getPrincipalAttributes(session.getUserId()),
|
||||
};
|
||||
|
||||
store.session = session;
|
||||
store.principal = principal;
|
||||
store.access = getAccessControlMethods(principal);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import cookie from "cookie";
|
||||
import type { RecipeUserId } from "supertokens-node/index.js";
|
||||
import Session from "supertokens-node/recipe/session";
|
||||
|
||||
import { config } from "./config.ts";
|
||||
|
||||
/**
|
||||
* Get session headers which can be applied on a Response object to apply
|
||||
* an authenticted session to the respondant.
|
||||
*
|
||||
* @param tenantId - Tenant scope the session belongs to.
|
||||
* @param recipeUserId - User recipe to apply to the session.
|
||||
*/
|
||||
export async function getSessionHeaders(tenantId: string, recipeUserId: RecipeUserId): Promise<Headers> {
|
||||
const session = await Session.createNewSessionWithoutRequestResponse(tenantId, recipeUserId);
|
||||
const tokens = session.getAllSessionTokensDangerously();
|
||||
const options = config.cookie(await session.getExpiry());
|
||||
|
||||
const headers = new Headers({ "set-cookie": cookie.serialize("sAccessToken", tokens.accessToken, options) });
|
||||
if (tokens.refreshToken !== undefined) {
|
||||
headers.append("set-cookie", cookie.serialize("sRefreshToken", tokens.refreshToken, options));
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session container from access token.
|
||||
*
|
||||
* @param accessToken - Access token to resolve session from.
|
||||
* @param antiCsrfToken - Optional CSRF token.
|
||||
*/
|
||||
export async function getSessionByAccessToken(
|
||||
accessToken: string,
|
||||
antiCsrfToken?: string,
|
||||
): Promise<Session.SessionContainer> {
|
||||
return Session.getSessionWithoutRequestResponse(accessToken, antiCsrfToken);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import "@platform/relay";
|
||||
import "@platform/storage";
|
||||
|
||||
import type Session from "supertokens-node/recipe/session";
|
||||
|
||||
import type { AccessControlMethods } from "./access.ts";
|
||||
import type { Principal } from "./principal.ts";
|
||||
|
||||
declare module "@platform/storage" {
|
||||
interface StorageContext {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
session?: Session.SessionContainer;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
principal?: Principal;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
access?: AccessControlMethods;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@platform/relay" {
|
||||
interface ServerContext {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
isAuthenticated: boolean;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
session: Session.SessionContainer;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
principal: Principal;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
access: AccessControlMethods;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import supertokens, { type User } from "supertokens-node";
|
||||
|
||||
/**
|
||||
* Get a user by provided user id.
|
||||
*
|
||||
* @param userId - User id to retrieve.
|
||||
*/
|
||||
export async function getUserById(userId: string): Promise<User | undefined> {
|
||||
return supertokens.getUser(userId);
|
||||
}
|
||||
Reference in New Issue
Block a user