feat: modular domain driven boilerplate
This commit is contained in:
28
modules/identity/.keys/private
Normal file
28
modules/identity/.keys/private
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCy5ZoXkKP9mZTk
|
||||
sKbQdSwspHZqyMH33Gby23+9ycNHMIww7djcWFfPRW4s7tu3SNaac6qVg9OI43+Z
|
||||
6BPXxuh4nhQ4LX5No9iVEmcWvZtKE4ghwzsoU0llT7+aKl9UYvgqU1YX4zyfiyo2
|
||||
bW0nVPasEHTyjLCVPK5BKlq+UmuyJTVcduALDnVETpUefu5Vca6tIRXsOovvAf5b
|
||||
zmcxPccaXIatR/AeipxT0YWoInn8dxD3kyFgTPXtinuBZxvp6MUeSs5IE8OJRJRP
|
||||
PEo1MQ9HFw9aYRIn9uIkbARbNZMGz77zB1+0TrPGyKOB5lLReWGMUFAJhjLrnTsY
|
||||
z19se4kNAgMBAAECgf9QkG6A6ViiHIMnUskIDeP5Xir19d9kbGwrcn0F2OXYaX+l
|
||||
Oot9w3KM6loRJx380/zk/e0Uch1MeZ2fyqQRUmAGQIzkXUm6LUWIekYQN6vZ3JlP
|
||||
YA2/M+otdd8Tpws9hFSDMUlx0SP3GAi0cE48xdBkVAT0NjZ3Jjor7Wv6GLe//Kzg
|
||||
1OVrbPAA/+RrPB+BQn5nmZFT0aLuLpyxB4f4ArHG/8DEBY49Syy7/3Ke0kfHMnhl
|
||||
5Eg5Yau89wSLqEoUSuQvNixu/5nTTQ6v1VYPVG8D1hn773SbNoY9o5vZOPRl1P0q
|
||||
9YC/qpzPJkm/A5TZLsoalIxuGTdwts+DaEeoKmECgYEA5CddLQbMNu9kYElxpSA3
|
||||
xXoTL71ZBCQsWExmJrcGe2lQhGO40lF8jE6QnEvMt0mp8Dg9n2ih4J87+2Ozb0fp
|
||||
2G2ilNeMxM7keywA/+Cwg71QyImppU0lQ5PYLv+pllfxN8FPpLBluy7rDahzphkn
|
||||
1rijqI5d4bHNG6IgD2ynteECgYEAyLs2eBWxX39Jff3OdpSVmHf7NtacbtsUf1qM
|
||||
RJSvLsiSwKn39n1+Y6ebzftxm/XD/j8FbN8XvMZMI4OrlfzP+YJaTybIbHrLzCE2
|
||||
B5E9j0GbJRhJ/D3l9FQBGdY4g5yC4mgbncXURQqqQTtKk2d+ixZSrw8iyDGN+aMJ
|
||||
ybqZoK0CgYALb6GvARk5Y7R/Uw8cPMou3tiZWv9cQsfqQSIZrLDpfLTpfeokuKrq
|
||||
iYGcI/yF725SOS91jxQWI0Upa6zx1gP1skEk/szyjIBNYD5IlSWj5NhoxOW5AG3u
|
||||
vjlm2a/RdmUD62+njKP8xvRHQftSBw7FJ4okh8ZS6suiJ/U9cK/TYQKBgFg+jTyP
|
||||
dNGhuKJN0NUqjvVfUa4S/ORzJXizStTfdIAhpvpR/nN7SfPvfDw6nQBOM+JyvCTX
|
||||
kqznlBNM0EL4yElNN/xx9UxTU4Ki2wjKngB7fAP7wJLGd3BI+c7s8R1S0etMj091
|
||||
59KOVLimoytYJTZqEuFoywatWlfzh9sKUH1lAoGBAID6mqGL3SZhh+i2/kAytfzw
|
||||
UswTQqA0CCBTzN/Eo1QozmUVTLQPj8rBchNSoiSc92y+lPIL8ePdU7imRB77i+9D
|
||||
9MSmc5u3ACACOSkwF0JCEGN+Rju4HR5wwm3h6Kvf/FQ3yvSEOKAWhqXIY95qtYTU
|
||||
j3O+iJbY32pbQsawIAkw
|
||||
-----END PRIVATE KEY-----
|
||||
9
modules/identity/.keys/public
Normal file
9
modules/identity/.keys/public
Normal file
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsuWaF5Cj/ZmU5LCm0HUs
|
||||
LKR2asjB99xm8tt/vcnDRzCMMO3Y3FhXz0VuLO7bt0jWmnOqlYPTiON/megT18bo
|
||||
eJ4UOC1+TaPYlRJnFr2bShOIIcM7KFNJZU+/mipfVGL4KlNWF+M8n4sqNm1tJ1T2
|
||||
rBB08oywlTyuQSpavlJrsiU1XHbgCw51RE6VHn7uVXGurSEV7DqL7wH+W85nMT3H
|
||||
GlyGrUfwHoqcU9GFqCJ5/HcQ95MhYEz17Yp7gWcb6ejFHkrOSBPDiUSUTzxKNTEP
|
||||
RxcPWmESJ/biJGwEWzWTBs++8wdftE6zxsijgeZS0XlhjFBQCYYy6507GM9fbHuJ
|
||||
DQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
66
modules/identity/aggregates/code.ts
Normal file
66
modules/identity/aggregates/code.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { AggregateRoot, getDate } from "@valkyr/event-store";
|
||||
|
||||
import { EventRecord, EventStoreFactory } from "../event-store.ts";
|
||||
import { CodeIdentity } from "../events/code.ts";
|
||||
|
||||
export class Code extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "code";
|
||||
|
||||
identity!: CodeIdentity;
|
||||
value!: string;
|
||||
|
||||
createdAt!: Date;
|
||||
claimedAt?: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Accessors
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
get isClaimed(): boolean {
|
||||
return this.claimedAt !== undefined;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Folder
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
with(event: EventRecord): void {
|
||||
switch (event.type) {
|
||||
case "code:created": {
|
||||
this.value = event.data.value;
|
||||
this.identity = event.data.identity;
|
||||
this.createdAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "code:claimed": {
|
||||
this.claimedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
create(identity: CodeIdentity): this {
|
||||
return this.push({
|
||||
type: "code:created",
|
||||
stream: this.id,
|
||||
data: {
|
||||
identity,
|
||||
value: crypto
|
||||
.getRandomValues(new Uint8Array(5))
|
||||
.map((v) => v % 10)
|
||||
.join(""),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
claim(): this {
|
||||
return this.push({
|
||||
type: "code:claimed",
|
||||
stream: this.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
211
modules/identity/aggregates/identity.ts
Normal file
211
modules/identity/aggregates/identity.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { AuditActor, auditors } from "@platform/spec/audit/actor.ts";
|
||||
import { AggregateRoot, getDate } from "@valkyr/event-store";
|
||||
|
||||
import { db } from "../database.ts";
|
||||
import { type EventRecord, eventStore, type EventStoreFactory, projector } from "../event-store.ts";
|
||||
import type { Avatar } from "../schemas/avatar.ts";
|
||||
import type { Contact } from "../schemas/contact.ts";
|
||||
import type { Email } from "../schemas/email.ts";
|
||||
import type { Name } from "../schemas/name.ts";
|
||||
import type { Role } from "../schemas/role.ts";
|
||||
import type { Strategy } from "../schemas/strategies.ts";
|
||||
|
||||
export class Identity extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "identity";
|
||||
|
||||
avatar?: Avatar;
|
||||
name?: Name;
|
||||
contact: Contact = {
|
||||
emails: [],
|
||||
};
|
||||
strategies: Strategy[] = [];
|
||||
roles: Role[] = [];
|
||||
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
with(event: EventRecord): void {
|
||||
switch (event.type) {
|
||||
case "identity:created": {
|
||||
this.id = event.stream;
|
||||
this.createdAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "identity:avatar:added": {
|
||||
this.avatar = { url: event.data };
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "identity:name:added": {
|
||||
this.name = event.data;
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "identity:email:added": {
|
||||
this.contact.emails.push(event.data);
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "identity:role:added": {
|
||||
this.roles.push(event.data);
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "identity:strategy:email:added": {
|
||||
this.strategies.push({ type: "email", value: event.data });
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "identity:strategy:password:added": {
|
||||
this.strategies.push({ type: "password", ...event.data });
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
create(meta: AuditActor = auditors.system) {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:created",
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addAvatar(url: string, meta: AuditActor = auditors.system): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:avatar:added",
|
||||
data: url,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addName(name: Name, meta: AuditActor = auditors.system): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:name:added",
|
||||
data: name,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addEmail(email: Email, meta: AuditActor = auditors.system): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:email:added",
|
||||
data: email,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addRole(role: Role, meta: AuditActor = auditors.system): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:role:added",
|
||||
data: role,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addEmailStrategy(email: string, meta: AuditActor = auditors.system): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:strategy:email:added",
|
||||
data: email,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addPasswordStrategy(alias: string, password: string, meta: AuditActor = auditors.system): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:strategy:password:added",
|
||||
data: { alias, password },
|
||||
meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export async function isEmailClaimed(email: string): Promise<boolean> {
|
||||
const relations = await eventStore.relations.getByKey(getIdentityEmailRelation(email));
|
||||
if (relations.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Relations
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function getIdentityEmailRelation(email: string): string {
|
||||
return `/identities/emails/${email}`;
|
||||
}
|
||||
|
||||
export function getIdentityAliasRelation(alias: string): string {
|
||||
return `/identities/aliases/${alias}`;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Projectors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
projector.on("identity:created", async ({ stream: id }) => {
|
||||
await db.collection("identities").insertOne({
|
||||
id,
|
||||
name: {
|
||||
given: null,
|
||||
family: null,
|
||||
},
|
||||
contact: {
|
||||
emails: [],
|
||||
},
|
||||
strategies: [],
|
||||
roles: [],
|
||||
});
|
||||
});
|
||||
|
||||
projector.on("identity:avatar:added", async ({ stream: id, data: url }) => {
|
||||
await db.collection("identities").updateOne({ id }, { $set: { avatar: { url } } });
|
||||
});
|
||||
|
||||
projector.on("identity:name:added", async ({ stream: id, data: name }) => {
|
||||
await db.collection("identities").updateOne({ id }, { $set: { name } });
|
||||
});
|
||||
|
||||
projector.on("identity:email:added", async ({ stream: id, data: email }) => {
|
||||
await db.collection("identities").updateOne({ id }, { $push: { "contact.emails": email } });
|
||||
});
|
||||
|
||||
projector.on("identity:role:added", async ({ stream: id, data: role }) => {
|
||||
await db.collection("identities").updateOne({ id }, { $push: { roles: role } });
|
||||
});
|
||||
|
||||
projector.on("identity:strategy:email:added", async ({ stream: id, data: email }) => {
|
||||
await eventStore.relations.insert(getIdentityEmailRelation(email), id);
|
||||
await db.collection("identities").updateOne({ id }, { $push: { strategies: { type: "email", value: email } } });
|
||||
});
|
||||
|
||||
projector.on("identity:strategy:password:added", async ({ stream: id, data: strategy }) => {
|
||||
await eventStore.relations.insert(getIdentityAliasRelation(strategy.alias), id);
|
||||
await db.collection("identities").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } });
|
||||
});
|
||||
15
modules/identity/auth.ts
Normal file
15
modules/identity/auth.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { resources } from "@platform/cerbos/resources.ts";
|
||||
import { Auth } from "@valkyr/auth";
|
||||
|
||||
import { access } from "./auth/access.ts";
|
||||
import { jwt } from "./auth/jwt.ts";
|
||||
import { principal } from "./auth/principal.ts";
|
||||
|
||||
export const auth = new Auth({
|
||||
principal,
|
||||
resources,
|
||||
access,
|
||||
jwt,
|
||||
});
|
||||
|
||||
export type Session = typeof auth.$session;
|
||||
89
modules/identity/auth/access.ts
Normal file
89
modules/identity/auth/access.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { cerbos } from "@platform/cerbos/client.ts";
|
||||
import { Resource } from "@platform/cerbos/resources.ts";
|
||||
|
||||
import type { Principal } from "./principal.ts";
|
||||
|
||||
export function access(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: Resource, 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: Resource, 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: Resource; actions: string[] }[]) {
|
||||
return cerbos.checkResources({ principal, resources });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Access = ReturnType<typeof access>;
|
||||
9
modules/identity/auth/jwt.ts
Normal file
9
modules/identity/auth/jwt.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { config } from "../config.ts";
|
||||
|
||||
export const jwt = {
|
||||
algorithm: "RS256",
|
||||
privateKey: config.auth.privateKey,
|
||||
publicKey: config.auth.publicKey,
|
||||
issuer: "http://localhost",
|
||||
audience: "http://localhost",
|
||||
};
|
||||
32
modules/identity/auth/principal.ts
Normal file
32
modules/identity/auth/principal.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { HttpAdapter, makeClient } from "@platform/relay";
|
||||
import { PrincipalProvider } from "@valkyr/auth";
|
||||
|
||||
import { config } from "../config.ts";
|
||||
import resolve from "../routes/identities/resolve/spec.ts";
|
||||
import { RoleSchema } from "../schemas/role.ts";
|
||||
|
||||
export const identity = makeClient(
|
||||
{
|
||||
adapter: new HttpAdapter({
|
||||
url: config.url,
|
||||
}),
|
||||
},
|
||||
{
|
||||
resolve: resolve.crypto({
|
||||
publicKey: config.internal.publicKey,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
export const principal = new PrincipalProvider(RoleSchema, {}, async function (id: string) {
|
||||
const response = await identity.resolve({ params: { id } });
|
||||
if ("data" in response) {
|
||||
return {
|
||||
id,
|
||||
roles: response.data.roles,
|
||||
attributes: {},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export type Principal = typeof principal.$principal;
|
||||
53
modules/identity/client.ts
Normal file
53
modules/identity/client.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { HttpAdapter, makeClient } from "@platform/relay";
|
||||
|
||||
import { config } from "./config.ts";
|
||||
import getById from "./routes/identities/get/spec.ts";
|
||||
import me from "./routes/identities/me/spec.ts";
|
||||
import register from "./routes/identities/register/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";
|
||||
|
||||
export const identity = makeClient(
|
||||
{
|
||||
adapter: new HttpAdapter({
|
||||
url: config.url,
|
||||
}),
|
||||
},
|
||||
{
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
register,
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
getById,
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
me,
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
login: {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
email: loginByEmail,
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
password: loginByPassword,
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
code: loginByCode,
|
||||
},
|
||||
},
|
||||
);
|
||||
59
modules/identity/config.ts
Normal file
59
modules/identity/config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
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",
|
||||
}),
|
||||
auth: {
|
||||
privateKey: getEnvironmentVariable({
|
||||
key: "AUTH_PRIVATE_KEY",
|
||||
type: z.string(),
|
||||
fallback: await readFile(resolve(import.meta.dirname!, ".keys", "private"), "utf-8"),
|
||||
}),
|
||||
publicKey: getEnvironmentVariable({
|
||||
key: "AUTH_PUBLIC_KEY",
|
||||
type: z.string(),
|
||||
fallback: await readFile(resolve(import.meta.dirname!, ".keys", "public"), "utf-8"),
|
||||
}),
|
||||
},
|
||||
internal: {
|
||||
privateKey: getEnvironmentVariable({
|
||||
key: "INTERNAL_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: "INTERNAL_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,
|
||||
};
|
||||
11
modules/identity/crypto/password.ts
Normal file
11
modules/identity/crypto/password.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as bcrypt from "@felix/bcrypt";
|
||||
|
||||
export const password = { hash, verify };
|
||||
|
||||
async function hash(password: string): Promise<string> {
|
||||
return bcrypt.hash(password);
|
||||
}
|
||||
|
||||
async function verify(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.verify(password, hash);
|
||||
}
|
||||
49
modules/identity/database.ts
Normal file
49
modules/identity/database.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getDatabaseAccessor } from "@platform/database/accessor.ts";
|
||||
|
||||
import { type Identity, parseIdentity } from "./models/identity.ts";
|
||||
import type { PasswordStrategy } from "./schemas/strategies.ts";
|
||||
|
||||
export const db = getDatabaseAccessor<{
|
||||
identities: Identity;
|
||||
}>(`identity:read-store`);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Identity
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieve a single account by its primary identifier.
|
||||
*
|
||||
* @param id - Unique identity.
|
||||
*/
|
||||
export async function getIdentityById(id: string): Promise<Identity | undefined> {
|
||||
return db
|
||||
.collection("identities")
|
||||
.findOne({ id })
|
||||
.then((document) => parseIdentity(document));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get strategy details for the given password strategy alias.
|
||||
*
|
||||
* @param alias - Alias to get strategy for.
|
||||
*/
|
||||
export async function getPasswordStrategyByAlias(
|
||||
alias: string,
|
||||
): Promise<({ accountId: string } & PasswordStrategy) | undefined> {
|
||||
const account = await db.collection("identities").findOne({
|
||||
strategies: {
|
||||
$elemMatch: { type: "password", alias },
|
||||
},
|
||||
});
|
||||
if (account === null) {
|
||||
return undefined;
|
||||
}
|
||||
const strategy = account.strategies.find((strategy) => strategy.type === "password" && strategy.alias === alias);
|
||||
if (strategy === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { accountId: account.id, ...strategy } as { accountId: string } & PasswordStrategy;
|
||||
}
|
||||
7
modules/identity/errors.ts
Normal file
7
modules/identity/errors.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ConflictError } from "@platform/relay";
|
||||
|
||||
export class IdentityEmailClaimedError extends ConflictError {
|
||||
constructor(email: string) {
|
||||
super(`Email '${email}' is already claimed by another identity.`);
|
||||
}
|
||||
}
|
||||
54
modules/identity/event-store.ts
Normal file
54
modules/identity/event-store.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { container } from "@platform/database/container.ts";
|
||||
import { EventFactory, EventStore, Prettify, Projector } from "@valkyr/event-store";
|
||||
import { MongoAdapter } from "@valkyr/event-store/mongo";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Event Factory
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const eventFactory = new EventFactory([
|
||||
...(await import("./events/code.ts")).default,
|
||||
...(await import("./events/identity.ts")).default,
|
||||
]);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Event Store
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const eventStore = new EventStore({
|
||||
adapter: new MongoAdapter(() => container.get("mongo"), `identity:event-store`),
|
||||
events: eventFactory,
|
||||
snapshot: "auto",
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Projector
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const projector = new Projector<EventStoreFactory>();
|
||||
|
||||
eventStore.onEventsInserted(async (records, { batch }) => {
|
||||
if (batch !== undefined) {
|
||||
await projector.pushMany(batch, records);
|
||||
} else {
|
||||
for (const record of records) {
|
||||
await projector.push(record, { hydrated: false, outdated: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Events
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type EventStoreFactory = typeof eventFactory;
|
||||
|
||||
export type EventRecord = Prettify<EventStoreFactory["$events"][number]["$record"]>;
|
||||
18
modules/identity/events/code.ts
Normal file
18
modules/identity/events/code.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { event } from "@valkyr/event-store";
|
||||
import z from "zod";
|
||||
|
||||
const CodeIdentitySchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export default [
|
||||
event.type("code:created").data(
|
||||
z.object({
|
||||
identity: CodeIdentitySchema,
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
event.type("code:claimed"),
|
||||
];
|
||||
|
||||
export type CodeIdentity = z.infer<typeof CodeIdentitySchema>;
|
||||
21
modules/identity/events/identity.ts
Normal file
21
modules/identity/events/identity.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { AuditActorSchema } from "@platform/spec/audit/actor.ts";
|
||||
import { event } from "@valkyr/event-store";
|
||||
import z from "zod";
|
||||
|
||||
import { EmailSchema } from "../schemas/email.ts";
|
||||
import { NameSchema } from "../schemas/name.ts";
|
||||
import { RoleSchema } from "../schemas/role.ts";
|
||||
|
||||
export default [
|
||||
event.type("identity:created").meta(AuditActorSchema),
|
||||
event.type("identity:avatar:added").data(z.string()).meta(AuditActorSchema),
|
||||
event.type("identity:name:added").data(NameSchema).meta(AuditActorSchema),
|
||||
event.type("identity:email:added").data(EmailSchema).meta(AuditActorSchema),
|
||||
event.type("identity:role:added").data(RoleSchema).meta(AuditActorSchema),
|
||||
event.type("identity:strategy:email:added").data(z.string()).meta(AuditActorSchema),
|
||||
event.type("identity:strategy:passkey:added").meta(AuditActorSchema),
|
||||
event
|
||||
.type("identity:strategy:password:added")
|
||||
.data(z.object({ alias: z.string(), password: z.string() }))
|
||||
.meta(AuditActorSchema),
|
||||
];
|
||||
35
modules/identity/models/identity.ts
Normal file
35
modules/identity/models/identity.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { makeDocumentParser } from "@platform/database/utilities.ts";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AvatarSchema } from "../schemas/avatar.ts";
|
||||
import { ContactSchema } from "../schemas/contact.ts";
|
||||
import { NameSchema } from "../schemas/name.ts";
|
||||
import { RoleSchema } from "../schemas/role.ts";
|
||||
import { StrategySchema } from "../schemas/strategies.ts";
|
||||
|
||||
export const IdentitySchema = z.object({
|
||||
id: z.uuid(),
|
||||
avatar: AvatarSchema.optional(),
|
||||
name: NameSchema.optional(),
|
||||
contact: ContactSchema.default({
|
||||
emails: [],
|
||||
}),
|
||||
strategies: z.array(StrategySchema).default([]),
|
||||
roles: z.array(RoleSchema).default([]),
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Parsers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const parseIdentity = makeDocumentParser(IdentitySchema);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type Identity = z.infer<typeof IdentitySchema>;
|
||||
28
modules/identity/package.json
Normal file
28
modules/identity/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@modules/identity",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./client.ts": "./client.ts",
|
||||
"./server.ts": "./server.ts"
|
||||
},
|
||||
"types": "types.d.ts",
|
||||
"dependencies": {
|
||||
"@cerbos/http": "0.23.1",
|
||||
"@felix/bcrypt": "npm:@jsr/felix__bcrypt@1.0.5",
|
||||
"@platform/cerbos": "workspace:*",
|
||||
"@platform/config": "workspace:*",
|
||||
"@platform/database": "workspace:*",
|
||||
"@platform/logger": "workspace:*",
|
||||
"@platform/relay": "workspace:*",
|
||||
"@platform/server": "workspace:*",
|
||||
"@platform/spec": "workspace:*",
|
||||
"@platform/storage": "workspace:*",
|
||||
"@platform/vault": "workspace:*",
|
||||
"@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.4",
|
||||
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1",
|
||||
"cookie": "1.0.2",
|
||||
"zod": "4.1.11"
|
||||
}
|
||||
}
|
||||
47
modules/identity/policies/identity.yaml
Normal file
47
modules/identity/policies/identity.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
# 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
|
||||
16
modules/identity/routes/identities/get/handle.ts
Normal file
16
modules/identity/routes/identities/get/handle.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ForbiddenError, NotFoundError } from "@platform/relay";
|
||||
|
||||
import { getIdentityById } from "../../../database.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ params: { id } }, { access }) => {
|
||||
const identity = await getIdentityById(id);
|
||||
if (identity === undefined) {
|
||||
return new NotFoundError("Identity does not exist, or has been removed.");
|
||||
}
|
||||
const decision = await access.isAllowed({ kind: "identity", id: identity.id, attr: {} }, "read");
|
||||
if (decision === false) {
|
||||
return new ForbiddenError("You do not have permission to view this identity.");
|
||||
}
|
||||
return identity;
|
||||
});
|
||||
12
modules/identity/routes/identities/get/spec.ts
Normal file
12
modules/identity/routes/identities/get/spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
import { IdentitySchema } from "../../../models/identity.ts";
|
||||
|
||||
export default route
|
||||
.get("/api/v1/identities/:id")
|
||||
.params({
|
||||
id: z.string(),
|
||||
})
|
||||
.errors([UnauthorizedError, ForbiddenError, NotFoundError])
|
||||
.response(IdentitySchema);
|
||||
12
modules/identity/routes/identities/me/handle.ts
Normal file
12
modules/identity/routes/identities/me/handle.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { UnauthorizedError } from "@platform/relay";
|
||||
|
||||
import { getIdentityById } from "../../../database.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ principal }) => {
|
||||
const identity = await getIdentityById(principal.id);
|
||||
if (identity === undefined) {
|
||||
return new UnauthorizedError("You must be signed in to view your session.");
|
||||
}
|
||||
return identity;
|
||||
});
|
||||
5
modules/identity/routes/identities/me/spec.ts
Normal file
5
modules/identity/routes/identities/me/spec.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NotFoundError, route, UnauthorizedError } from "@platform/relay";
|
||||
|
||||
import { IdentitySchema } from "../../../models/identity.ts";
|
||||
|
||||
export default route.get("/api/v1/identities/me").response(IdentitySchema).errors([UnauthorizedError, NotFoundError]);
|
||||
11
modules/identity/routes/identities/register/handle.ts
Normal file
11
modules/identity/routes/identities/register/handle.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Identity, isEmailClaimed } from "../../../aggregates/identity.ts";
|
||||
import { IdentityEmailClaimedError } from "../../../errors.ts";
|
||||
import { eventStore } from "../../../event-store.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("public").handle(async ({ body: { name, email } }) => {
|
||||
if ((await isEmailClaimed(email)) === true) {
|
||||
return new IdentityEmailClaimedError(email);
|
||||
}
|
||||
return eventStore.aggregate.from(Identity).create().addName(name).addEmailStrategy(email).addRole("user").save();
|
||||
});
|
||||
17
modules/identity/routes/identities/register/spec.ts
Normal file
17
modules/identity/routes/identities/register/spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
import { IdentityEmailClaimedError } from "../../../errors.ts";
|
||||
import { IdentitySchema } from "../../../models/identity.ts";
|
||||
import { NameSchema } from "../../../schemas/name.ts";
|
||||
|
||||
export default route
|
||||
.post("/api/v1/identities")
|
||||
.body(
|
||||
z.object({
|
||||
name: NameSchema,
|
||||
email: z.email(),
|
||||
}),
|
||||
)
|
||||
.errors([IdentityEmailClaimedError])
|
||||
.response(IdentitySchema);
|
||||
13
modules/identity/routes/identities/resolve/handle.ts
Normal file
13
modules/identity/routes/identities/resolve/handle.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NotFoundError } from "@platform/relay";
|
||||
|
||||
import { config } from "../../../config.ts";
|
||||
import { getIdentityById } from "../../../database.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access(["internal:public", config.internal.privateKey]).handle(async ({ params: { id } }) => {
|
||||
const identity = await getIdentityById(id);
|
||||
if (identity === undefined) {
|
||||
return new NotFoundError();
|
||||
}
|
||||
return identity;
|
||||
});
|
||||
5
modules/identity/routes/identities/resolve/keys.ts
Normal file
5
modules/identity/routes/identities/resolve/keys.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { importVault } from "@platform/vault";
|
||||
|
||||
import { config } from "../../../config.ts";
|
||||
|
||||
export const vault = importVault(config.internal);
|
||||
12
modules/identity/routes/identities/resolve/spec.ts
Normal file
12
modules/identity/routes/identities/resolve/spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NotFoundError, route, UnauthorizedError } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
import { IdentitySchema } from "../../../models/identity.ts";
|
||||
|
||||
export default route
|
||||
.get("/api/v1/identities/:id/resolve")
|
||||
.params({
|
||||
id: z.string(),
|
||||
})
|
||||
.response(IdentitySchema)
|
||||
.errors([UnauthorizedError, NotFoundError]);
|
||||
85
modules/identity/routes/login/code/handle.ts
Normal file
85
modules/identity/routes/login/code/handle.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { logger } from "@platform/logger";
|
||||
import cookie from "cookie";
|
||||
|
||||
import { Code } from "../../../aggregates/code.ts";
|
||||
import { Identity } from "../../../aggregates/identity.ts";
|
||||
import { auth } from "../../../auth.ts";
|
||||
import { config } from "../../../config.ts";
|
||||
import { eventStore } from "../../../event-store.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("public").handle(async ({ params: { identityId, codeId, value }, query: { next } }) => {
|
||||
const code = await eventStore.aggregate.getByStream(Code, codeId);
|
||||
|
||||
if (code === undefined) {
|
||||
return logger.info({
|
||||
type: "code:claimed",
|
||||
session: false,
|
||||
message: "Invalid Code ID",
|
||||
received: codeId,
|
||||
});
|
||||
}
|
||||
|
||||
if (code.claimedAt !== undefined) {
|
||||
return logger.info({
|
||||
type: "code:claimed",
|
||||
session: false,
|
||||
message: "Code Already Claimed",
|
||||
received: codeId,
|
||||
});
|
||||
}
|
||||
|
||||
await code.claim().save();
|
||||
|
||||
if (code.value !== value) {
|
||||
return logger.info({
|
||||
type: "code:claimed",
|
||||
session: false,
|
||||
message: "Invalid Value",
|
||||
expected: code.value,
|
||||
received: value,
|
||||
});
|
||||
}
|
||||
|
||||
if (code.identity.id !== identityId) {
|
||||
return logger.info({
|
||||
type: "code:claimed",
|
||||
session: false,
|
||||
message: "Invalid Identity ID",
|
||||
expected: code.identity.id,
|
||||
received: identityId,
|
||||
});
|
||||
}
|
||||
|
||||
const account = await eventStore.aggregate.getByStream(Identity, identityId);
|
||||
if (account === undefined) {
|
||||
return logger.info({
|
||||
type: "code:claimed",
|
||||
session: false,
|
||||
message: "Account Not Found",
|
||||
expected: code.identity.id,
|
||||
received: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({ type: "code:claimed", session: true });
|
||||
|
||||
const options = config.cookie(1000 * 60 * 60 * 24 * 7);
|
||||
|
||||
if (next !== undefined) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: next,
|
||||
"set-cookie": cookie.serialize("token", await auth.generate({ id: account.id }, "1 week"), options),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"set-cookie": cookie.serialize("token", await auth.generate({ id: account.id }, "1 week"), options),
|
||||
},
|
||||
});
|
||||
});
|
||||
13
modules/identity/routes/login/code/spec.ts
Normal file
13
modules/identity/routes/login/code/spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route
|
||||
.get("/api/v1/identities/login/code/:identityId/code/:codeId/:value")
|
||||
.params({
|
||||
identityId: z.string(),
|
||||
codeId: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
.query({
|
||||
next: z.string().optional(),
|
||||
});
|
||||
27
modules/identity/routes/login/email/handle.ts
Normal file
27
modules/identity/routes/login/email/handle.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { logger } from "@platform/logger";
|
||||
|
||||
import { Code } from "../../../aggregates/code.ts";
|
||||
import { getIdentityEmailRelation, Identity } from "../../../aggregates/identity.ts";
|
||||
import { eventStore } from "../../../event-store.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("public").handle(async ({ body: { base, email } }) => {
|
||||
const identity = await eventStore.aggregate.getByRelation(Identity, getIdentityEmailRelation(email));
|
||||
if (identity === undefined) {
|
||||
return logger.info({
|
||||
type: "auth:email",
|
||||
code: false,
|
||||
message: "Identity Not Found",
|
||||
received: email,
|
||||
});
|
||||
}
|
||||
const code = await eventStore.aggregate.from(Code).create({ id: identity.id }).save();
|
||||
logger.info({
|
||||
type: "auth:email",
|
||||
data: {
|
||||
code: code.id,
|
||||
identityId: identity.id,
|
||||
},
|
||||
link: `${base}/api/v1/admin/auth/${identity.id}/code/${code.id}/${code.value}?next=${base}/admin`,
|
||||
});
|
||||
});
|
||||
9
modules/identity/routes/login/email/spec.ts
Normal file
9
modules/identity/routes/login/email/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/email").body(
|
||||
z.object({
|
||||
base: z.url(),
|
||||
email: z.email(),
|
||||
}),
|
||||
);
|
||||
36
modules/identity/routes/login/password/handle.ts
Normal file
36
modules/identity/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/identity/routes/login/password/spec.ts
Normal file
9
modules/identity/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(),
|
||||
}),
|
||||
);
|
||||
7
modules/identity/schemas/avatar.ts
Normal file
7
modules/identity/schemas/avatar.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import z from "zod";
|
||||
|
||||
export const AvatarSchema = z.object({
|
||||
url: z.string().describe("A valid URL pointing to the user's avatar image."),
|
||||
});
|
||||
|
||||
export type Avatar = z.infer<typeof AvatarSchema>;
|
||||
9
modules/identity/schemas/contact.ts
Normal file
9
modules/identity/schemas/contact.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import z from "zod";
|
||||
|
||||
import { EmailSchema } from "./email.ts";
|
||||
|
||||
export const ContactSchema = z.object({
|
||||
emails: z.array(EmailSchema).default([]).describe("A list of email addresses associated with the contact."),
|
||||
});
|
||||
|
||||
export type Contact = z.infer<typeof ContactSchema>;
|
||||
11
modules/identity/schemas/email.ts
Normal file
11
modules/identity/schemas/email.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import z from "zod";
|
||||
|
||||
export const EmailSchema = z.object({
|
||||
type: z.enum(["personal", "work"]).describe("The context of the email address, e.g., personal or work."),
|
||||
value: z.email().describe("A valid email address string."),
|
||||
primary: z.boolean().describe("Indicates if this is the primary email."),
|
||||
verified: z.boolean().describe("True if the email address has been verified."),
|
||||
label: z.string().optional().describe("Optional display label for the email address."),
|
||||
});
|
||||
|
||||
export type Email = z.infer<typeof EmailSchema>;
|
||||
8
modules/identity/schemas/name.ts
Normal file
8
modules/identity/schemas/name.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const NameSchema = z.object({
|
||||
family: z.string().nullable().describe("Family name, also known as last name or surname."),
|
||||
given: z.string().nullable().describe("Given name, also known as first name."),
|
||||
});
|
||||
|
||||
export type Name = z.infer<typeof NameSchema>;
|
||||
5
modules/identity/schemas/role.ts
Normal file
5
modules/identity/schemas/role.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import z from "zod";
|
||||
|
||||
export const RoleSchema = z.union([z.literal("user"), z.literal("admin")]);
|
||||
|
||||
export type Role = z.infer<typeof RoleSchema>;
|
||||
37
modules/identity/schemas/strategies.ts
Normal file
37
modules/identity/schemas/strategies.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import z from "zod";
|
||||
|
||||
const EmailStrategySchema = z.object({
|
||||
type: z.literal("email"),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const PasswordStrategySchema = z.object({
|
||||
type: z.literal("password"),
|
||||
alias: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
const PasskeyStrategySchema = z.object({
|
||||
type: z.literal("passkey"),
|
||||
credId: z.string(),
|
||||
credPublicKey: z.string(),
|
||||
webauthnUserId: z.string(),
|
||||
counter: z.number(),
|
||||
backupEligible: z.boolean(),
|
||||
backupStatus: z.boolean(),
|
||||
transports: z.string(),
|
||||
createdAt: z.date(),
|
||||
lastUsed: z.date(),
|
||||
});
|
||||
|
||||
export const StrategySchema = z.discriminatedUnion("type", [
|
||||
EmailStrategySchema,
|
||||
PasswordStrategySchema,
|
||||
PasskeyStrategySchema,
|
||||
]);
|
||||
|
||||
export type EmailStrategy = z.infer<typeof EmailStrategySchema>;
|
||||
export type PasswordStrategy = z.infer<typeof PasswordStrategySchema>;
|
||||
export type PasskeyStrategy = z.infer<typeof PasskeyStrategySchema>;
|
||||
|
||||
export type Strategy = z.infer<typeof StrategySchema>;
|
||||
96
modules/identity/server.ts
Normal file
96
modules/identity/server.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import "./types.d.ts";
|
||||
|
||||
import { idIndex } from "@platform/database/id.ts";
|
||||
import { register as registerReadStore } from "@platform/database/registrar.ts";
|
||||
import { UnauthorizedError } from "@platform/relay";
|
||||
import { context } from "@platform/relay";
|
||||
import { storage } from "@platform/storage";
|
||||
import { register as registerEventStore } from "@valkyr/event-store/mongo";
|
||||
import cookie from "cookie";
|
||||
|
||||
import { auth } from "./auth.ts";
|
||||
import { db } from "./database.ts";
|
||||
import { eventStore } from "./event-store.ts";
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
(await import("./routes/identities/get/handle.ts")).default,
|
||||
(await import("./routes/identities/register/handle.ts")).default,
|
||||
(await import("./routes/identities/me/handle.ts")).default,
|
||||
(await import("./routes/identities/resolve/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,
|
||||
],
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
bootstrap: async (): Promise<void> => {
|
||||
await registerReadStore(db.db, [
|
||||
{
|
||||
name: "identities",
|
||||
indexes: [
|
||||
idIndex,
|
||||
[{ "strategies.type": 1, "strategies.alias": 1 }, { name: "strategy.password" }],
|
||||
[{ "strategies.type": 1, "strategies.value": 1 }, { name: "strategy.email" }],
|
||||
],
|
||||
},
|
||||
]);
|
||||
await registerEventStore(eventStore.db.db, console.info);
|
||||
Object.defineProperties(context, {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
isAuthenticated: {
|
||||
get() {
|
||||
return storage.getStore()?.principal !== undefined;
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
resolve: async (request: Request): Promise<void> => {
|
||||
const token = cookie.parse(request.headers.get("cookie") ?? "").token;
|
||||
if (token !== undefined) {
|
||||
const session = await auth.resolve(token);
|
||||
if (session.valid === true) {
|
||||
const context = storage.getStore();
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
context.principal = session.principal;
|
||||
context.access = session.access;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
38
modules/identity/types.d.ts
vendored
Normal file
38
modules/identity/types.d.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
import "@platform/relay";
|
||||
import "@platform/storage";
|
||||
|
||||
import type { Access } from "./auth/access.ts";
|
||||
import type { Principal } from "./auth/principal.ts";
|
||||
|
||||
declare module "@platform/storage" {
|
||||
interface StorageContext {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
principal?: Principal;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
access?: Access;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@platform/relay" {
|
||||
interface ServerContext {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
isAuthenticated: boolean;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
principal: Principal;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
access: Access;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user