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

@@ -1,14 +0,0 @@
server:
adminAPI:
enabled: true
adminCredentials:
username: cerbos
passwordHash: JDJ5JDEwJDc5VzBkQ0NUWHFTT3N1OW9xZkx5ZC43M0tuM0JBSTU0dVRsMVBkOEtuYVBCaWFzVXk5d0phCgo=
httpListenAddr: ":3592"
grpcListenAddr: ":3593"
storage:
driver: disk
disk:
directory: /data/policies
watchForChanges: true

View File

@@ -1,47 +0,0 @@
# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json
# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies
apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: identity
version: default
rules:
### Read
- actions:
- read
effect: EFFECT_ALLOW
roles:
- admin
- actions:
- read
effect: EFFECT_ALLOW
roles:
- user
condition:
match:
expr: request.resource.id == request.principal.id
### Update
- actions:
- update
effect: EFFECT_ALLOW
roles:
- user
condition:
match:
expr: request.resource.id == request.principal.id
### Delete
- actions:
- delete
effect: EFFECT_ALLOW
roles:
- user
condition:
match:
expr: request.resource.id == request.principal.id

View File

@@ -1,42 +0,0 @@
# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json
# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies
apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: workspace
version: default
rules:
### Read
- actions:
- read
effect: EFFECT_ALLOW
roles:
- admin
- user
condition:
match:
expr: request.principal.workspaceIds.includes(request.resource.id)
### Update
- actions:
- update
effect: EFFECT_ALLOW
roles:
- admin
condition:
match:
expr: request.principal.workspaceIds.includes(request.resource.id)
### Delete
- actions:
- delete
effect: EFFECT_ALLOW
roles:
- admin
condition:
match:
expr: request.principal.workspaceIds.includes(request.resource.id)

View File

@@ -1,14 +0,0 @@
import { ResourceRegistry } from "@valkyr/auth";
export const resources = new ResourceRegistry([
{
kind: "identity",
attr: {},
},
{
kind: "workspace",
attr: {},
},
] as const);
export type Resource = typeof resources.$resource;

View File

@@ -1,3 +1,5 @@
import "@platform/supertoken/types.ts";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ServerContext {}

View File

@@ -286,7 +286,7 @@ export class UnprocessableContentError<TData = unknown> extends ServerError<TDat
}
}
export class ValidationError extends ServerError<ValidationErrorData> {
export class ValidationError<TData = unknown> extends ServerError<TData> {
readonly code = "VALIDATION";
/**
@@ -298,7 +298,7 @@ export class ValidationError extends ServerError<ValidationErrorData> {
* @param message - Optional message to send with the error. Default: "Validation Failed".
* @param data - Data with validation failure details.
*/
constructor(message = "Validation Failed", data: ValidationErrorData) {
constructor(message = "Validation Failed", data: TData) {
super(message, 422, data);
}
@@ -322,7 +322,7 @@ export class ValidationError extends ServerError<ValidationErrorData> {
message: issue.message,
};
}),
});
} satisfies ValidationErrorData);
}
}

View File

@@ -10,8 +10,8 @@
"dependencies": {
"@platform/auth": "workspace:*",
"@platform/socket": "workspace:*",
"@platform/supertokens": "workspace:*",
"@platform/vault": "workspace:*",
"@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.4",
"path-to-regexp": "8",
"zod": "4.1.11"
}

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,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

@@ -1,12 +1,13 @@
{
"name": "@platform/cerbos",
"name": "@platform/supertoken",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@cerbos/http": "0.23.1",
"@platform/config": "workspace:*",
"@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.4",
"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);
}