feat: add supertokens
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
@@ -1,3 +1,5 @@
|
||||
import "@platform/supertoken/types.ts";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface ServerContext {}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
87
platform/supertoken/access.ts
Normal file
87
platform/supertoken/access.ts
Normal 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>;
|
||||
44
platform/supertoken/config.ts
Normal file
44
platform/supertoken/config.ts
Normal 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,
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
46
platform/supertoken/principal.ts
Normal file
46
platform/supertoken/principal.ts
Normal 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>;
|
||||
133
platform/supertoken/server.ts
Normal file
133
platform/supertoken/server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
37
platform/supertoken/session.ts
Normal file
37
platform/supertoken/session.ts
Normal 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);
|
||||
}
|
||||
50
platform/supertoken/types.ts
Normal file
50
platform/supertoken/types.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
platform/supertoken/users.ts
Normal file
10
platform/supertoken/users.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user