Template
1
0

feat: add supertokens

This commit is contained in:
2025-09-24 01:20:09 +02:00
parent 0d70749670
commit 99111b69eb
92 changed files with 1613 additions and 1141 deletions

View File

@@ -0,0 +1,87 @@
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>;

View File

@@ -0,0 +1,17 @@
import { HTTP } from "@cerbos/http";
import { getEnvironmentVariable } from "@platform/config/environment.ts";
import z from "zod";
export const cerbos = new HTTP(
getEnvironmentVariable({
key: "CERBOS_URL",
type: z.string(),
fallback: "http://localhost:3592",
}),
{
adminCredentials: {
username: "cerbos",
password: "cerbosAdmin",
},
},
);

View File

@@ -0,0 +1,44 @@
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,
};

View File

@@ -0,0 +1,13 @@
{
"name": "@platform/supertoken",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@cerbos/http": "0.23.1",
"@platform/config": "workspace:*",
"cookie": "1.0.2",
"supertokens-node": "23.0.1",
"zod": "4.1.11"
}
}

View File

@@ -0,0 +1,46 @@
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>;

View File

@@ -0,0 +1,133 @@
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);
}
}

View File

@@ -0,0 +1,37 @@
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);
}

View File

@@ -0,0 +1,50 @@
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;
}
}

View File

@@ -0,0 +1,10 @@
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);
}