refactor: identity -> iam
This commit is contained in:
17
modules/iam/cerbos/client.ts
Normal file
17
modules/iam/cerbos/client.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
);
|
||||
23
modules/iam/cerbos/policies/identity.yaml
Normal file
23
modules/iam/cerbos/policies/identity.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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:
|
||||
|
||||
# Admins can read any identity with limited fields
|
||||
|
||||
- actions: ["read", "update"]
|
||||
effect: EFFECT_ALLOW
|
||||
roles: ["admin"]
|
||||
|
||||
# Users can fully read, update, or delete their own identity
|
||||
|
||||
- actions: ["read", "update", "delete"]
|
||||
effect: EFFECT_ALLOW
|
||||
roles: ["user"]
|
||||
condition:
|
||||
match:
|
||||
expr: request.resource.id == request.principal.id
|
||||
14
modules/iam/cerbos/policies/role.yaml
Normal file
14
modules/iam/cerbos/policies/role.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
# 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: role
|
||||
version: default
|
||||
rules:
|
||||
|
||||
# Admin can manage roles
|
||||
|
||||
- actions: ["manage"]
|
||||
effect: EFFECT_ALLOW
|
||||
roles: ["super"]
|
||||
11
modules/iam/cerbos/resources.ts
Normal file
11
modules/iam/cerbos/resources.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
export const resources = new ResourceRegistry([
|
||||
{
|
||||
kind: "identity",
|
||||
actions: ["read", "update", "delete"],
|
||||
attr: {},
|
||||
},
|
||||
] as const);
|
||||
|
||||
export type Resource = typeof resources.$resource;
|
||||
*/
|
||||
163
modules/iam/client.ts
Normal file
163
modules/iam/client.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { CheckResourcesResponse } from "@cerbos/core";
|
||||
import { HttpAdapter, makeClient } from "@platform/relay";
|
||||
|
||||
import { config } from "./config.ts";
|
||||
import checkResource from "./routes/access/check-resource/spec.ts";
|
||||
import checkResources from "./routes/access/check-resources/spec.ts";
|
||||
import isAllowed from "./routes/access/is-allowed/spec.ts";
|
||||
import getById from "./routes/identities/get/spec.ts";
|
||||
import loginByPassword from "./routes/login/code/spec.ts";
|
||||
import loginByEmail from "./routes/login/email/spec.ts";
|
||||
import loginByCode from "./routes/login/password/spec.ts";
|
||||
import me from "./routes/me/spec.ts";
|
||||
|
||||
const adapter = new HttpAdapter({
|
||||
url: config.url,
|
||||
});
|
||||
|
||||
const access = makeClient(
|
||||
{
|
||||
adapter,
|
||||
},
|
||||
{
|
||||
isAllowed,
|
||||
checkResource,
|
||||
checkResources,
|
||||
},
|
||||
);
|
||||
|
||||
export const identity = makeClient(
|
||||
{
|
||||
adapter,
|
||||
},
|
||||
{
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
getById,
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
me,
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
login: {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
email: loginByEmail,
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
password: loginByPassword,
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
code: loginByCode,
|
||||
},
|
||||
|
||||
access: {
|
||||
/**
|
||||
* 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: async (resource: Resource, action: string) => {
|
||||
const response = await access.isAllowed({ body: { resource, action } });
|
||||
if ("error" in response) {
|
||||
throw response.error;
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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: async (resource: Resource, actions: string[]) => {
|
||||
const response = await access.checkResource({ body: { resource, actions } });
|
||||
if ("error" in response) {
|
||||
throw response.error;
|
||||
}
|
||||
return new CheckResourcesResponse(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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: async (resources: { resource: Resource; actions: string[] }[]) => {
|
||||
const response = await access.checkResources({ body: resources });
|
||||
if ("error" in response) {
|
||||
throw response.error;
|
||||
}
|
||||
return new CheckResourcesResponse(response.data);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type Resource = {
|
||||
kind: string;
|
||||
id: string;
|
||||
attr: Record<string, any>;
|
||||
};
|
||||
44
modules/iam/config.ts
Normal file
44
modules/iam/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 = {
|
||||
url: getEnvironmentVariable({
|
||||
key: "IDENTITY_SERVICE_URL",
|
||||
type: z.url(),
|
||||
fallback: "http://localhost:8370",
|
||||
}),
|
||||
internal: {
|
||||
privateKey: getEnvironmentVariable({
|
||||
key: "IDENTITY_PRIVATE_KEY",
|
||||
type: z.string(),
|
||||
fallback:
|
||||
"-----BEGIN PRIVATE KEY-----\n" +
|
||||
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2WYKMJZUWff5XOWC\n" +
|
||||
"XGuU+wmsRzhQGEIzfUoL6rrGoaehRANCAATCpiGiFQxTA76EIVG0cBbj+AFt6BuJ\n" +
|
||||
"t4q+zoInPUzkChCdwI+XfAYokrZwBjcyRGluC02HaN3cptrmjYSGSMSx\n" +
|
||||
"-----END PRIVATE KEY-----",
|
||||
}),
|
||||
publicKey: getEnvironmentVariable({
|
||||
key: "IDENTITY_PUBLIC_KEY",
|
||||
type: z.string(),
|
||||
fallback:
|
||||
"-----BEGIN PUBLIC KEY-----\n" +
|
||||
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwqYhohUMUwO+hCFRtHAW4/gBbegb\n" +
|
||||
"ibeKvs6CJz1M5AoQncCPl3wGKJK2cAY3MkRpbgtNh2jd3Kba5o2EhkjEsQ==\n" +
|
||||
"-----END PUBLIC KEY-----",
|
||||
}),
|
||||
},
|
||||
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,
|
||||
};
|
||||
46
modules/iam/models/principal.ts
Normal file
46
modules/iam/models/principal.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { makeDocumentParser } from "@platform/database/utilities.ts";
|
||||
import z from "zod";
|
||||
|
||||
export enum PrincipalTypeId {
|
||||
User = 1,
|
||||
Group = 2,
|
||||
Other = 99,
|
||||
}
|
||||
|
||||
export const PRINCIPAL_TYPE_NAMES = {
|
||||
[PrincipalTypeId.User]: "User",
|
||||
[PrincipalTypeId.Group]: "Group",
|
||||
[PrincipalTypeId.Other]: "Other",
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Schema
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const PrincipalSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.strictObject({
|
||||
id: z.enum(PrincipalTypeId),
|
||||
name: z.string(),
|
||||
}),
|
||||
roles: z.array(z.string()),
|
||||
attr: z.record(z.string(), z.any()),
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Parsers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const parsePrincipal = makeDocumentParser(PrincipalSchema);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type Principal = z.infer<typeof PrincipalSchema>;
|
||||
26
modules/iam/models/session.ts
Normal file
26
modules/iam/models/session.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import z from "zod";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Schema
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const SessionSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
token: z.string(),
|
||||
ipAddress: z.string().nullable().optional(),
|
||||
userAgent: z.string().nullable().optional(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
expiresAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type Session = z.infer<typeof SessionSchema>;
|
||||
23
modules/iam/package.json
Normal file
23
modules/iam/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@modules/identity",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./client.ts": "./client.ts",
|
||||
"./server.ts": "./server.ts",
|
||||
"./types.ts": "./types.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cerbos/core": "0.24.1",
|
||||
"@cerbos/http": "0.23.1",
|
||||
"@platform/config": "workspace:*",
|
||||
"@platform/logger": "workspace:*",
|
||||
"@platform/relay": "workspace:*",
|
||||
"@platform/storage": "workspace:*",
|
||||
"@platform/vault": "workspace:*",
|
||||
"better-auth": "1.3.16",
|
||||
"cookie": "1.0.2",
|
||||
"zod": "4.1.11"
|
||||
}
|
||||
}
|
||||
6
modules/iam/routes/access/check-resource/handle.ts
Normal file
6
modules/iam/routes/access/check-resource/handle.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { cerbos } from "../../../cerbos/client.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ body: { resource, actions } }, { principal }) => {
|
||||
return cerbos.checkResource({ principal, resource, actions });
|
||||
});
|
||||
16
modules/iam/routes/access/check-resource/spec.ts
Normal file
16
modules/iam/routes/access/check-resource/spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route
|
||||
.post("/api/v1/identity/access/check-resource")
|
||||
.body(
|
||||
z.strictObject({
|
||||
resource: z.strictObject({
|
||||
kind: z.string(),
|
||||
id: z.string(),
|
||||
attr: z.record(z.string(), z.any()),
|
||||
}),
|
||||
actions: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.response(z.any());
|
||||
6
modules/iam/routes/access/check-resources/handle.ts
Normal file
6
modules/iam/routes/access/check-resources/handle.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { cerbos } from "../../../cerbos/client.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ body: resources }, { principal }) => {
|
||||
return cerbos.checkResources({ principal, resources });
|
||||
});
|
||||
18
modules/iam/routes/access/check-resources/spec.ts
Normal file
18
modules/iam/routes/access/check-resources/spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route
|
||||
.post("/api/v1/identity/access/check-resources")
|
||||
.body(
|
||||
z.array(
|
||||
z.strictObject({
|
||||
resource: z.strictObject({
|
||||
kind: z.string(),
|
||||
id: z.string(),
|
||||
attr: z.record(z.string(), z.any()),
|
||||
}),
|
||||
actions: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.response(z.any());
|
||||
6
modules/iam/routes/access/is-allowed/handle.ts
Normal file
6
modules/iam/routes/access/is-allowed/handle.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { cerbos } from "../../../cerbos/client.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ body: { resource, action } }, { principal }) => {
|
||||
return cerbos.isAllowed({ principal, resource, action });
|
||||
});
|
||||
16
modules/iam/routes/access/is-allowed/spec.ts
Normal file
16
modules/iam/routes/access/is-allowed/spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route
|
||||
.post("/api/v1/identity/access/is-allowed")
|
||||
.body(
|
||||
z.strictObject({
|
||||
resource: z.strictObject({
|
||||
kind: z.string(),
|
||||
id: z.string(),
|
||||
attr: z.record(z.string(), z.any()),
|
||||
}),
|
||||
action: z.string(),
|
||||
}),
|
||||
)
|
||||
.response(z.boolean());
|
||||
16
modules/iam/routes/identities/get/handle.ts
Normal file
16
modules/iam/routes/identities/get/handle.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ForbiddenError, NotFoundError } from "@platform/relay";
|
||||
|
||||
import { getPrincipalById } from "../../../services/database.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ params: { id } }, { access }) => {
|
||||
const principal = await getPrincipalById(id);
|
||||
if (principal === undefined) {
|
||||
return new NotFoundError("Identity does not exist, or has been removed.");
|
||||
}
|
||||
const decision = await access.isAllowed({ kind: "identity", id, attr: {} }, "read");
|
||||
if (decision === false) {
|
||||
return new ForbiddenError("You do not have permission to view this identity.");
|
||||
}
|
||||
return principal;
|
||||
});
|
||||
10
modules/iam/routes/identities/get/spec.ts
Normal file
10
modules/iam/routes/identities/get/spec.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route
|
||||
.get("/api/v1/identity/:id")
|
||||
.params({
|
||||
id: z.string(),
|
||||
})
|
||||
.errors([UnauthorizedError, ForbiddenError, NotFoundError])
|
||||
.response(z.any());
|
||||
43
modules/iam/routes/identities/update/handle.ts
Normal file
43
modules/iam/routes/identities/update/handle.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ForbiddenError, NotFoundError } from "@platform/relay";
|
||||
|
||||
import { getPrincipalById, setPrincipalAttributesById } from "../../../services/database.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ params: { id }, body: ops }, { access }) => {
|
||||
const principal = await getPrincipalById(id);
|
||||
if (principal === undefined) {
|
||||
return new NotFoundError();
|
||||
}
|
||||
const decision = await access.isAllowed({ kind: "identity", id: principal.id, attr: principal.attr }, "update");
|
||||
if (decision === false) {
|
||||
return new ForbiddenError("You do not have permission to update this identity.");
|
||||
}
|
||||
const attr = principal.attr;
|
||||
for (const op of ops) {
|
||||
switch (op.type) {
|
||||
case "add": {
|
||||
attr[op.key] = op.value;
|
||||
break;
|
||||
}
|
||||
case "push": {
|
||||
if (attr[op.key] === undefined) {
|
||||
attr[op.key] = op.values;
|
||||
} else {
|
||||
attr[op.key] = [...attr[op.key], ...op.values];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "pop": {
|
||||
if (Array.isArray(attr[op.key])) {
|
||||
attr[op.key] = attr[op.key].filter((value: any) => op.values.includes(value) === false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "remove": {
|
||||
delete attr[op.key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
await setPrincipalAttributesById(id, attr);
|
||||
});
|
||||
29
modules/iam/routes/identities/update/spec.ts
Normal file
29
modules/iam/routes/identities/update/spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route
|
||||
.put("/api/v1/identity/:id")
|
||||
.params({
|
||||
id: z.string(),
|
||||
})
|
||||
.body(
|
||||
z.array(
|
||||
z.union([
|
||||
z.strictObject({
|
||||
type: z.union([z.literal("add")]),
|
||||
key: z.string(),
|
||||
value: z.any(),
|
||||
}),
|
||||
z.strictObject({
|
||||
type: z.union([z.literal("push"), z.literal("pop")]),
|
||||
key: z.string(),
|
||||
values: z.array(z.any()),
|
||||
}),
|
||||
z.strictObject({
|
||||
type: z.union([z.literal("remove")]),
|
||||
key: z.string(),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.errors([UnauthorizedError, ForbiddenError, NotFoundError]);
|
||||
16
modules/iam/routes/login/code/handle.ts
Normal file
16
modules/iam/routes/login/code/handle.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NotFoundError } from "@platform/relay";
|
||||
|
||||
import { auth } from "../../../services/auth.ts";
|
||||
import { logger } from "../../../services/logger.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("public").handle(async ({ body: { email, otp } }) => {
|
||||
const response = await auth.api.signInEmailOTP({ body: { email, otp }, asResponse: true, returnHeaders: true });
|
||||
if (response.status !== 200) {
|
||||
logger.error("OTP Signin Failed", await response.json());
|
||||
return new NotFoundError();
|
||||
}
|
||||
return new Response(null, {
|
||||
headers: response.headers,
|
||||
});
|
||||
});
|
||||
14
modules/iam/routes/login/code/spec.ts
Normal file
14
modules/iam/routes/login/code/spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route
|
||||
.post("/api/v1/identity/login/code")
|
||||
.body(
|
||||
z.strictObject({
|
||||
email: z.string(),
|
||||
otp: z.string(),
|
||||
}),
|
||||
)
|
||||
.query({
|
||||
next: z.string().optional(),
|
||||
});
|
||||
14
modules/iam/routes/login/email/handle.ts
Normal file
14
modules/iam/routes/login/email/handle.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { auth } from "../../../services/auth.ts";
|
||||
import { logger } from "../../../services/logger.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("public").handle(async ({ body: { email } }) => {
|
||||
const response = await auth.api.sendVerificationOTP({ body: { email, type: "sign-in" } });
|
||||
if (response.success === false) {
|
||||
logger.info({
|
||||
type: "auth:passwordless",
|
||||
message: "OTP Email verification failed.",
|
||||
received: email,
|
||||
});
|
||||
}
|
||||
});
|
||||
8
modules/iam/routes/login/email/spec.ts
Normal file
8
modules/iam/routes/login/email/spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route.post("/api/v1/identity/login/email").body(
|
||||
z.object({
|
||||
email: z.email(),
|
||||
}),
|
||||
);
|
||||
36
modules/iam/routes/login/password/handle.ts
Normal file
36
modules/iam/routes/login/password/handle.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { logger } from "@platform/logger";
|
||||
import { BadRequestError } from "@platform/relay";
|
||||
import cookie from "cookie";
|
||||
|
||||
import { auth } from "../../../auth.ts";
|
||||
import { config } from "../../../config.ts";
|
||||
import { password } from "../../../crypto/password.ts";
|
||||
import { getPasswordStrategyByAlias } from "../../../database.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("public").handle(async ({ body: { alias, password: userPassword } }) => {
|
||||
const strategy = await getPasswordStrategyByAlias(alias);
|
||||
if (strategy === undefined) {
|
||||
return logger.info({
|
||||
type: "auth:password",
|
||||
message: "Failed to get account with 'password' strategy.",
|
||||
alias,
|
||||
});
|
||||
}
|
||||
|
||||
const isValidPassword = await password.verify(userPassword, strategy.password);
|
||||
if (isValidPassword === false) {
|
||||
return new BadRequestError("Invalid email/password provided.");
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"set-cookie": cookie.serialize(
|
||||
"token",
|
||||
await auth.generate({ id: strategy.accountId }, "1 week"),
|
||||
config.cookie(1000 * 60 * 60 * 24 * 7),
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
9
modules/iam/routes/login/password/spec.ts
Normal file
9
modules/iam/routes/login/password/spec.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route.post("/api/v1/identities/login/password").body(
|
||||
z.object({
|
||||
alias: z.string(),
|
||||
password: z.string(),
|
||||
}),
|
||||
);
|
||||
39
modules/iam/routes/login/sudo/handle.ts
Normal file
39
modules/iam/routes/login/sudo/handle.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("public").handle(async () => {
|
||||
// const code = await Passwordless.createCode({ tenantId: "public", email });
|
||||
// if (code.status !== "OK") {
|
||||
// return logger.info({
|
||||
// type: "auth:passwordless",
|
||||
// message: "Create code failed.",
|
||||
// received: email,
|
||||
// });
|
||||
// }
|
||||
// logger.info({
|
||||
// type: "auth:passwordless",
|
||||
// data: {
|
||||
// deviceId: code.deviceId,
|
||||
// preAuthSessionId: code.preAuthSessionId,
|
||||
// userInputCode: code.userInputCode,
|
||||
// },
|
||||
// });
|
||||
// const response = await Passwordless.consumeCode({
|
||||
// tenantId: "public",
|
||||
// preAuthSessionId: code.preAuthSessionId,
|
||||
// deviceId: code.deviceId,
|
||||
// userInputCode: code.userInputCode,
|
||||
// });
|
||||
// if (response.status !== "OK") {
|
||||
// return new NotFoundError();
|
||||
// }
|
||||
// logger.info({
|
||||
// type: "code:claimed",
|
||||
// session: true,
|
||||
// message: "Identity resolved",
|
||||
// user: response.user.toJson(),
|
||||
// });
|
||||
// return new Response(null, {
|
||||
// status: 200,
|
||||
// headers: await getSessionHeaders("public", response.recipeUserId),
|
||||
// });
|
||||
});
|
||||
8
modules/iam/routes/login/sudo/spec.ts
Normal file
8
modules/iam/routes/login/sudo/spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route.post("/api/v1/identities/login/sudo").body(
|
||||
z.object({
|
||||
email: z.email(),
|
||||
}),
|
||||
);
|
||||
5
modules/iam/routes/me/handle.ts
Normal file
5
modules/iam/routes/me/handle.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ principal }) => {
|
||||
return principal;
|
||||
});
|
||||
4
modules/iam/routes/me/spec.ts
Normal file
4
modules/iam/routes/me/spec.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { NotFoundError, route, UnauthorizedError } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route.get("/api/v1/identity/me").errors([UnauthorizedError, NotFoundError]).response(z.any());
|
||||
33
modules/iam/routes/roles/handle.ts
Normal file
33
modules/iam/routes/roles/handle.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ForbiddenError, NotFoundError } from "@platform/relay";
|
||||
|
||||
import { getPrincipalById, setPrincipalRolesById } from "../../services/database.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ params: { id }, body: ops }, { access }) => {
|
||||
const principal = await getPrincipalById(id);
|
||||
if (principal === undefined) {
|
||||
return new NotFoundError();
|
||||
}
|
||||
const decision = await access.isAllowed({ kind: "role", id: principal.id, attr: principal.attr }, "manage");
|
||||
if (decision === false) {
|
||||
return new ForbiddenError("You do not have permission to modify roles for this identity.");
|
||||
}
|
||||
const roles: Set<string> = new Set(principal.roles);
|
||||
for (const op of ops) {
|
||||
switch (op.type) {
|
||||
case "add": {
|
||||
for (const role of op.roles) {
|
||||
roles.add(role);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "remove": {
|
||||
for (const role of op.roles) {
|
||||
roles.delete(role);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
await setPrincipalRolesById(id, Array.from(roles));
|
||||
});
|
||||
19
modules/iam/routes/roles/spec.ts
Normal file
19
modules/iam/routes/roles/spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route
|
||||
.put("/api/v1/identity/:id/roles")
|
||||
.params({
|
||||
id: z.string(),
|
||||
})
|
||||
.body(
|
||||
z.array(
|
||||
z.union([
|
||||
z.strictObject({
|
||||
type: z.union([z.literal("add"), z.literal("remove")]),
|
||||
roles: z.array(z.any()),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.errors([UnauthorizedError, ForbiddenError, NotFoundError]);
|
||||
17
modules/iam/routes/session/resolve/handle.ts
Normal file
17
modules/iam/routes/session/resolve/handle.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NotFoundError } from "@platform/relay";
|
||||
|
||||
import { config } from "../../../config.ts";
|
||||
import { getPrincipalByUserId } from "../../../services/database.ts";
|
||||
import { getSessionByRequestHeader } from "../../../services/session.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access(["internal:public", config.internal.privateKey]).handle(async ({ request }) => {
|
||||
const session = await getSessionByRequestHeader(request.headers);
|
||||
if (session === undefined) {
|
||||
return new NotFoundError();
|
||||
}
|
||||
return {
|
||||
session,
|
||||
principal: await getPrincipalByUserId(session.userId),
|
||||
};
|
||||
});
|
||||
12
modules/iam/routes/session/resolve/spec.ts
Normal file
12
modules/iam/routes/session/resolve/spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
import { PrincipalSchema } from "../../../models/principal.ts";
|
||||
import { SessionSchema } from "../../../models/session.ts";
|
||||
|
||||
export default route.get("/api/v1/identity/session").response(
|
||||
z.object({
|
||||
session: SessionSchema,
|
||||
principal: PrincipalSchema,
|
||||
}),
|
||||
);
|
||||
62
modules/iam/server.ts
Normal file
62
modules/iam/server.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { HttpAdapter, makeClient } from "@platform/relay";
|
||||
|
||||
import { config } from "./config.ts";
|
||||
import resolve from "./routes/session/resolve/spec.ts";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Internal Session Resolver
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const identity = makeClient(
|
||||
{
|
||||
adapter: new HttpAdapter({
|
||||
url: config.url,
|
||||
}),
|
||||
},
|
||||
{
|
||||
resolve: resolve.crypto({
|
||||
publicKey: config.internal.publicKey,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
export async function getPrincipalSession(payload: { headers: Headers }) {
|
||||
const response = await identity.resolve(payload);
|
||||
if ("data" in response) {
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Server Exports
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export * from "./services/session.ts";
|
||||
export * from "./types.ts";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Module Server
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
(await import("./routes/identities/get/handle.ts")).default,
|
||||
(await import("./routes/identities/update/handle.ts")).default,
|
||||
(await import("./routes/login/code/handle.ts")).default,
|
||||
(await import("./routes/login/email/handle.ts")).default,
|
||||
// (await import("./routes/login/password/handle.ts")).default,
|
||||
(await import("./routes/login/sudo/handle.ts")).default,
|
||||
(await import("./routes/me/handle.ts")).default,
|
||||
(await import("./routes/roles/handle.ts")).default,
|
||||
(await import("./routes/session/resolve/handle.ts")).default,
|
||||
(await import("./routes/access/is-allowed/handle.ts")).default,
|
||||
(await import("./routes/access/check-resource/handle.ts")).default,
|
||||
(await import("./routes/access/check-resources/handle.ts")).default,
|
||||
],
|
||||
};
|
||||
29
modules/iam/services/auth.ts
Normal file
29
modules/iam/services/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { logger } from "@platform/logger";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { mongodbAdapter } from "better-auth/adapters/mongodb";
|
||||
import { emailOTP } from "better-auth/plugins";
|
||||
|
||||
import { db } from "./database.ts";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: mongodbAdapter(db.db),
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // Cache duration in seconds
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
emailOTP({
|
||||
async sendVerificationOTP({ email, otp, type }) {
|
||||
if (type === "sign-in") {
|
||||
logger.info({ email, otp, type });
|
||||
} else if (type === "email-verification") {
|
||||
// Send the OTP for email verification
|
||||
} else {
|
||||
// Send the OTP for password reset
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
61
modules/iam/services/database.ts
Normal file
61
modules/iam/services/database.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { getDatabaseAccessor } from "@platform/database/accessor.ts";
|
||||
|
||||
import {
|
||||
PRINCIPAL_TYPE_NAMES,
|
||||
type Principal,
|
||||
PrincipalSchema,
|
||||
PrincipalTypeId,
|
||||
parsePrincipal,
|
||||
} from "../models/principal.ts";
|
||||
|
||||
export const db = getDatabaseAccessor<{
|
||||
principal: Principal;
|
||||
}>("auth");
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Methods
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export async function getPrincipalById(id: string): Promise<Principal | undefined> {
|
||||
return db
|
||||
.collection("principal")
|
||||
.findOne({ id })
|
||||
.then((value) => parsePrincipal(value));
|
||||
}
|
||||
|
||||
export async function setPrincipalRolesById(id: string, roles: string[]): Promise<void> {
|
||||
await db.collection("principal").updateOne({ id }, { $set: { roles } });
|
||||
}
|
||||
|
||||
export async function setPrincipalAttributesById(id: string, attr: Record<string, any>): Promise<void> {
|
||||
await db.collection("principal").updateOne({ id }, { $set: { attr } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a principal for a better-auth user.
|
||||
*
|
||||
* @param userId - User id from better-auth user list.
|
||||
*/
|
||||
export async function getPrincipalByUserId(userId: string): Promise<Principal> {
|
||||
const principal = await db.collection("principal").findOneAndUpdate(
|
||||
{ id: userId },
|
||||
{
|
||||
$setOnInsert: {
|
||||
id: userId,
|
||||
type: {
|
||||
id: PrincipalTypeId.User,
|
||||
name: PRINCIPAL_TYPE_NAMES[PrincipalTypeId.User],
|
||||
},
|
||||
roles: ["user"],
|
||||
attr: {},
|
||||
},
|
||||
},
|
||||
{ upsert: true, returnDocument: "after" },
|
||||
);
|
||||
if (principal === null) {
|
||||
throw new Error("Failed to resolve Principal");
|
||||
}
|
||||
return PrincipalSchema.parse(principal);
|
||||
}
|
||||
3
modules/iam/services/logger.ts
Normal file
3
modules/iam/services/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { logger as platformLogger } from "@platform/logger";
|
||||
|
||||
export const logger = platformLogger.prefix("Modules/Identity");
|
||||
34
modules/iam/services/session.ts
Normal file
34
modules/iam/services/session.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import cookie from "cookie";
|
||||
|
||||
import { config } from "../config.ts";
|
||||
import { auth } from "./auth.ts";
|
||||
|
||||
/**
|
||||
* Get session headers which can be applied on a Response object to apply
|
||||
* an authenticated session to the respondent.
|
||||
*
|
||||
* @param accessToken - Token to apply to the cookie.
|
||||
* @param maxAge - Max age of the token.
|
||||
*/
|
||||
export async function getSessionHeaders(accessToken: string, maxAge: number): Promise<Headers> {
|
||||
return new Headers({
|
||||
"set-cookie": cookie.serialize(
|
||||
"better-auth.session_token",
|
||||
encodeURIComponent(accessToken), // URL-encode the token
|
||||
config.cookie(maxAge),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session container from request headers.
|
||||
*
|
||||
* @param headers - Request headers to extract session from.
|
||||
*/
|
||||
export async function getSessionByRequestHeader(headers: Headers) {
|
||||
const response = await auth.api.getSession({ headers });
|
||||
if (response === null) {
|
||||
return undefined;
|
||||
}
|
||||
return response.session;
|
||||
}
|
||||
50
modules/iam/types.ts
Normal file
50
modules/iam/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import "@platform/relay";
|
||||
import "@platform/storage";
|
||||
|
||||
import type { Session } from "better-auth";
|
||||
|
||||
import type { identity } from "./client.ts";
|
||||
import type { Principal } from "./models/principal.ts";
|
||||
|
||||
declare module "@platform/storage" {
|
||||
interface StorageContext {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
session?: Session;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
principal?: Principal;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
access?: typeof identity.access;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@platform/relay" {
|
||||
interface ServerContext {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
isAuthenticated: boolean;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
session: Session;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
principal: Principal;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
access: typeof identity.access;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user