feat: Add auth flow (#146)

This adds a simple way to incorporate a centralized authentication flow.

The idea is to have the user, API and SSH (for machine authentication)
all in one place using `openauthjs` + `SST`

We also have a database now :)

> We are using InstantDB as it allows us to authenticate a use with just
the email. Plus it is super simple simple to use _of course after the
initial fumbles trying to design the db and relationships_
This commit is contained in:
Wanjohi
2025-01-04 00:02:28 +03:00
committed by GitHub
parent 33895974a7
commit fc5a755408
136 changed files with 3512 additions and 1914 deletions

View File

@@ -0,0 +1,85 @@
import { createContext } from "./context";
import { VisibleError } from "./error";
export interface UserActor {
type: "user";
properties: {
accessToken: string;
userID: string;
auth?:
| {
type: "personal";
token: string;
}
| {
type: "oauth";
clientID: string;
};
};
}
export interface DeviceActor {
type: "device";
properties: {
fingerprint: string;
id: string;
auth?:
| {
type: "personal";
token: string;
}
| {
type: "oauth";
clientID: string;
};
};
}
export interface PublicActor {
type: "public";
properties: {};
}
type Actor = UserActor | PublicActor | DeviceActor;
export const ActorContext = createContext<Actor>();
export function useCurrentUser() {
const actor = ActorContext.use();
if (actor.type === "user") return {
id:actor.properties.userID,
token: actor.properties.accessToken
};
throw new VisibleError(
"auth",
"unauthorized",
`You don't have permission to access this resource`,
);
}
export function useCurrentDevice() {
const actor = ActorContext.use();
if (actor.type === "device") return {
fingerprint:actor.properties.fingerprint,
id: actor.properties.id
};
throw new VisibleError(
"auth",
"unauthorized",
`You don't have permission to access this resource`,
);
}
export function useActor() {
try {
return ActorContext.use();
} catch {
return { type: "public", properties: {} } as PublicActor;
}
}
export function assertActor<T extends Actor["type"]>(type: T) {
const actor = useActor();
if (actor.type !== type)
throw new VisibleError("auth", "actor.invalid", `Actor is not "${type}"`);
return actor as Extract<Actor, { type: T }>;
}

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
import "zod-openapi/extend";
export module Common {
export const IdDescription = `Unique object identifier.
The format and length of IDs may change over time.`;
}

View File

@@ -0,0 +1,17 @@
import { AsyncLocalStorage } from "node:async_hooks";
export function createContext<T>() {
const storage = new AsyncLocalStorage<T>();
return {
use() {
const result = storage.getStore();
if (!result) {
throw new Error("No context available");
}
return result;
},
with<R>(value: T, fn: () => R) {
return storage.run<R>(value, fn);
},
};
}

View File

@@ -0,0 +1,12 @@
import { Resource } from "sst";
import { init } from "@instantdb/admin";
import schema from "../instant.schema";
const databaseClient = () => init({
appId: Resource.InstantAppId.value,
adminToken: Resource.InstantAdminToken.value,
schema
})
export default databaseClient

View File

@@ -0,0 +1,25 @@
import { LoopsClient } from "loops";
import { Resource } from "sst/resource"
export namespace Email {
export const Client = () => new LoopsClient(Resource.LoopsApiKey.value);
export async function send(
to: string,
body: string,
) {
try {
await Client().sendTransactionalEmail(
{
transactionalId: "cm58pdf8d03upb5ecirnmvrfb",
email: to,
dataVariables: {
logincode: body
}
}
);
} catch (error) {
console.log("error sending email", error)
}
}
}

View File

@@ -0,0 +1,9 @@
export class VisibleError extends Error {
constructor(
public kind: "input" | "auth",
public code: string,
public message: string,
) {
super(message);
}
}

View File

@@ -0,0 +1,45 @@
export module Examples {
export const User = {
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
email: "john@example.com",
};
export const Machine = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
hostname: "desktopeuo8vsf",
fingerprint: "fc27f428f9ca47d4b41b70889ae0c62090",
location: "KE, AF"
}
// export const Team = {
// id: createID(),
// name: "Jane's Family",
// type: "Family"
// }
// export const ProductVariant = {
// id: createID(),
// name: "FamilySM",
// price: 10,
// };
// export const Product = {
// id: createID(),
// name: "Family",
// description: "The ideal subscription tier for dedicated gamers who crave more flexibility and social gaming experiences.",
// variants: [ProductVariant],
// subscription: "allowed" as const,
// };
// export const Subscription = {
// id: createID(),
// productVariant: ProductVariant,
// quantity: 1,
// polarOrderID: createID(),
// frequency: "monthly" as const,
// next: new Date("2024-02-01 19:36:19.000").getTime(),
// owner: User
// };
}

View File

@@ -0,0 +1,140 @@
import { z } from "zod"
import { fn } from "../utils";
import { Common } from "../common";
import { Examples } from "../examples";
import databaseClient from "../database"
import { useCurrentUser } from "../actor";
import { id as createID } from "@instantdb/admin";
export module Machine {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Machine.id,
}),
hostname: z.string().openapi({
description: "Hostname of the machine",
example: Examples.Machine.hostname,
}),
fingerprint: z.string().openapi({
description: "The machine's fingerprint, derived from the machine's Linux machine ID.",
example: Examples.Machine.fingerprint,
}),
location: z.string().openapi({
description: "The machine's approximate location; country and continent.",
example: Examples.Machine.location,
})
})
.openapi({
ref: "Machine",
description: "A machine running on the Nestri network.",
example: Examples.Machine,
});
export const create = fn(z.object({
fingerprint: z.string(),
hostname: z.string(),
location: z.string()
}), async (input) => {
const id = createID()
const now = new Date().getTime()
const db = databaseClient()
await db.transact(
db.tx.machines[id]!.update({
fingerprint: input.fingerprint,
hostname: input.hostname,
location: input.location,
createdAt: now,
})
)
return id
})
export const remove = fn(z.string(), async (id) => {
const now = new Date().getTime()
// const device = useCurrentDevice()
// const db = databaseClient()
// if (device.id) { // the machine can delete itself
// await db.transact(db.tx.machines[device.id]!.update({ deletedAt: now }))
// } else {// the user can delete it manually
const user = useCurrentUser()
const db = databaseClient().asUser({ token: user.token })
await db.transact(db.tx.machines[id]!.update({ deletedAt: now }))
// }
return "ok"
})
export const fromID = fn(z.string(), async (id) => {
const user = useCurrentUser()
const db = databaseClient().asUser({ token: user.token })
const query = {
machines: {
$: {
where: {
id: id,
deletedAt: { $isNull: true }
}
}
}
}
const res = await db.query(query)
return res.machines[0]
})
export const fromFingerprint = fn(z.string(), async (input) => {
const db = databaseClient()
const query = {
machines: {
$: {
where: {
fingerprint: input,
deletedAt: { $isNull: true }
}
}
}
}
const res = await db.query(query)
return res.machines[0]
})
export const list = async () => {
const user = useCurrentUser()
const db = databaseClient().asUser({ token: user.token })
const query = {
$users: {
$: { where: { id: user.id } },
machines: {
$: {
deletedAt: { $isNull: true }
}
}
},
}
const res = await db.query(query)
return res.$users[0]?.machines
}
export const link = fn(z.object({
machineId: z.string()
}), async (input) => {
const user = useCurrentUser()
const db = databaseClient()
await db.transact(db.tx.machines[input.machineId]!.link({ owner: user.id }))
return "ok"
})
}

View File

@@ -0,0 +1,48 @@
import databaseClient from "../database"
import { z } from "zod"
import { Common } from "../common";
import { createID, fn } from "../utils";
import { Examples } from "../examples";
export module Team {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Team.id,
}),
name: z.string().openapi({
description: "Name of the machine",
example: Examples.Team.name,
}),
type: z.string().nullable().openapi({
description: "Whether this is a personal or family type of team",
example: Examples.Team.type,
})
})
.openapi({
ref: "Team",
description: "A group of Nestri user's who share the same machine",
example: Examples.Team,
});
export const create = fn(z.object({
name: z.string(),
type: z.enum(["personal", "family"]),
owner: z.string(),
}), async (input) => {
const id = createID("machine")
const now = new Date().getTime()
const db = databaseClient()
await db.transact(db.tx.teams[id]!.update({
name: input.name,
type: input.type,
createdAt: now
}).link({
owner: input.owner,
}))
return id
})
}

View File

@@ -0,0 +1,17 @@
export interface CloudflareCF {
colo: string;
continent: string;
country: string,
city: string;
region: string;
longitude: number;
latitude: number;
metroCode: string;
postalCode: string;
timezone: string;
regionCode: number;
}
export interface CFRequest extends Request {
cf: CloudflareCF
}

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import databaseClient from "../database"
import { fn } from "../utils";
import { Common } from "../common";
import { Examples } from "../examples";
export module User {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.User.id,
}),
email: z.string().nullable().openapi({
description: "Email address of the user.",
example: Examples.User.email,
}),
})
.openapi({
ref: "User",
description: "A Nestri console user.",
example: Examples.User,
});
export const fromEmail = fn(z.string(), async (email) => {
const db = databaseClient()
const res = await db.auth.getUser({ email })
return res
})
export const create = fn(z.string(), async (email) => {
const db = databaseClient()
const token = await db.auth.createToken(email)
return token
})
}

View File

@@ -0,0 +1,13 @@
import { ZodSchema, z } from "zod";
export function fn<
Arg1 extends ZodSchema,
Callback extends (arg1: z.output<Arg1>) => any,
>(arg1: Arg1, cb: Callback) {
const result = function (input: z.input<typeof arg1>): ReturnType<Callback> {
const parsed = arg1.parse(input);
return cb.apply(cb, [parsed as any]);
};
result.schema = arg1;
return result;
}

View File

@@ -0,0 +1 @@
export * from "./fn"