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,121 @@
import type { Context } from "hono"
import type { Adapter } from "@openauthjs/openauth/adapter/adapter"
import { generateUnbiasedDigits, timingSafeCompare } from "@openauthjs/openauth/random"
export type ApiAdapterState =
| {
type: "start"
}
| {
type: "code"
resend?: boolean
code: string
claims: Record<string, string>
}
export type ApiAdapterError =
| {
type: "invalid_code"
}
| {
type: "invalid_claim"
key: string
value: string
}
export function ApiAdapter<
Claims extends Record<string, string> = Record<string, string>,
>(config: {
length?: number
request: (
req: Request,
state: ApiAdapterState,
body?: Claims,
error?: ApiAdapterError,
) => Promise<Response>
sendCode: (claims: Claims, code: string) => Promise<void | ApiAdapterError>
}) {
const length = config.length || 6
function generate() {
return generateUnbiasedDigits(length)
}
return {
type: "api", // this is a miscellaneous name, for lack of a better one
init(routes, ctx) {
async function transition(
c: Context,
next: ApiAdapterState,
claims?: Claims,
err?: ApiAdapterError,
) {
await ctx.set<ApiAdapterState>(c, "adapter", 60 * 60 * 24, next)
const resp = ctx.forward(
c,
await config.request(c.req.raw, next, claims, err),
)
return resp
}
routes.get("/authorize", async (c) => {
const resp = await transition(c, {
type: "start",
})
return resp
})
routes.post("/authorize", async (c) => {
const code = generate()
const body = await c.req.json()
const state = await ctx.get<ApiAdapterState>(c, "adapter")
const action = body.action
if (action === "request" || action === "resend") {
const claims = body.claims as Claims
delete body.action
const err = await config.sendCode(claims, code)
if (err) return transition(c, { type: "start" }, claims, err)
return transition(
c,
{
type: "code",
resend: action === "resend",
claims,
code,
},
claims,
)
}
if (
body.action === "verify" &&
state.type === "code"
) {
const body = await c.req.json()
const compare = body.code
if (
!state.code ||
!compare ||
!timingSafeCompare(state.code, compare)
) {
return transition(
c,
{
...state,
resend: false,
},
body.claims,
{ type: "invalid_code" },
)
}
await ctx.unset(c, "adapter")
return ctx.forward(
c,
await ctx.success(c, { claims: state.claims as Claims }),
)
}
})
},
} satisfies Adapter<{ claims: Claims }>
}
export type ApiAdapterOptions = Parameters<typeof ApiAdapter>[0]

View File

@@ -0,0 +1,153 @@
import "zod-openapi/extend";
import { Resource } from "sst";
import { ZodError } from "zod";
import { logger } from "hono/logger";
import { subjects } from "../subjects";
import { VisibleError } from "../error";
import { MachineApi } from "./machine";
import { openAPISpecs } from "hono-openapi";
import { ActorContext } from '@nestri/core/actor';
import { Hono, type MiddlewareHandler } from "hono";
import { HTTPException } from "hono/http-exception";
import { createClient } from "@openauthjs/openauth/client";
const auth: MiddlewareHandler = async (c, next) => {
const client = createClient({
clientID: "api",
issuer: Resource.Urls.auth
});
const authHeader =
c.req.query("authorization") ?? c.req.header("authorization");
if (authHeader) {
const match = authHeader.match(/^Bearer (.+)$/);
if (!match || !match[1]) {
throw new VisibleError(
"input",
"auth.token",
"Bearer token not found or improperly formatted",
);
}
const bearerToken = match[1];
const result = await client.verify(subjects, bearerToken!);
if (result.err)
throw new VisibleError("input", "auth.invalid", "Invalid bearer token");
if (result.subject.type === "user") {
return ActorContext.with(
{
type: "user",
properties: {
userID: result.subject.properties.userID,
accessToken: result.subject.properties.accessToken,
auth: {
type: "oauth",
clientID: result.aud,
},
},
},
next,
);
} else if (result.subject.type === "device") {
return ActorContext.with(
{
type: "device",
properties: {
fingerprint: result.subject.properties.fingerprint,
id: result.subject.properties.id,
auth: {
type: "oauth",
clientID: result.aud,
},
},
},
next,
);
}
}
return ActorContext.with({ type: "public", properties: {} }, next);
};
const app = new Hono();
app
.use(logger(), async (c, next) => {
c.header("Cache-Control", "no-store");
return next();
})
.use(auth);
const routes = app
.get("/", (c) => c.text("Hello there 👋🏾"))
.route("/machine", MachineApi.route)
.onError((error, c) => {
console.error(error);
if (error instanceof VisibleError) {
return c.json(
{
code: error.code,
message: error.message,
},
error.kind === "auth" ? 401 : 400,
);
}
if (error instanceof ZodError) {
const e = error.errors[0];
if (e) {
return c.json(
{
code: e?.code,
message: e?.message,
},
400,
);
}
}
if (error instanceof HTTPException) {
return c.json(
{
code: "request",
message: "Invalid request",
},
400,
);
}
return c.json(
{
code: "internal",
message: "Internal server error",
},
500,
);
});
app.get(
"/doc",
openAPISpecs(routes, {
documentation: {
info: {
title: "Nestri API",
description:
"The Nestri API gives you the power to run your own customized cloud gaming platform.",
version: "0.0.3",
},
components: {
securitySchemes: {
Bearer: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
},
security: [{ Bearer: [] }],
servers: [
{ description: "Production", url: "https://api.nestri.io" },
],
},
}),
);
export type Routes = typeof routes;
export default app

View File

@@ -0,0 +1,160 @@
import { z } from "zod";
import { Result } from "../common";
import { Hono } from "hono";
import { describeRoute } from "hono-openapi";
import { validator, resolver } from "hono-openapi/zod";
import { Examples } from "@nestri/core/examples";
import { Machine } from "@nestri/core/machine/index";
import { useCurrentUser } from "@nestri/core/actor";
export module MachineApi {
export const route = new Hono()
.get(
"/",
describeRoute({
tags: ["Machine"],
summary: "List machines",
description: "List the current user's machines.",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Machine.Info.array().openapi({
description: "List of machines.",
example: [Examples.Machine],
}),
),
},
},
description: "List of machines.",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "This user has no machines.",
},
},
}),
async (c) => {
const machines = await Machine.list();
if (!machines) return c.json({ error: "This user has no machines." }, 404);
return c.json({ data: machines }, 200);
},
)
.get(
"/:id",
describeRoute({
tags: ["Machine"],
summary: "Get machine",
description: "Get the machine with the given ID.",
responses: {
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "Machine not found.",
},
200: {
content: {
"application/json": {
schema: Result(
Machine.Info.openapi({
description: "Machine.",
example: Examples.Machine,
}),
),
},
},
description: "Machine.",
},
},
}),
validator(
"param",
z.object({
id: z.string().openapi({
description: "ID of the machine to get.",
example: Examples.Machine.id,
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const machine = await Machine.fromID(param.id);
if (!machine) return c.json({ error: "Machine not found." }, 404);
return c.json({ data: machine }, 200);
},
)
.post(
"/:id",
describeRoute({
tags: ["Machine"],
summary: "Link a machine to a user",
description: "Link a machine to the owner.",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok"))
},
},
description: "Machine was linked successfully.",
},
},
}),
validator(
"param",
z.object({
id: Machine.Info.shape.fingerprint.openapi({
description: "Fingerprint of the machine to link to.",
example: Examples.Machine.id,
}),
}),
),
async (c) => {
const request = c.req.valid("param")
const machine = await Machine.fromFingerprint(request.id)
if (!machine) return c.json({ error: "Machine not found." }, 404);
await Machine.link({machineId:machine.id })
return c.json({ data: "ok" as const }, 200);
},
)
.delete(
"/:id",
describeRoute({
tags: ["Machine"],
summary: "Delete machine",
description: "Delete the machine with the given ID.",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok")),
},
},
description: "Machine was deleted successfully.",
},
},
}),
validator(
"param",
z.object({
id: Machine.Info.shape.id.openapi({
description: "ID of the machine to delete.",
example: Examples.Machine.id,
}),
}),
),
async (c) => {
const param = c.req.valid("param");
await Machine.remove(param.id);
return c.json({ data: "ok" as const }, 200);
},
);
}

View File

@@ -0,0 +1,140 @@
import { Resource } from "sst"
import {
type ExecutionContext,
type KVNamespace,
} from "@cloudflare/workers-types"
import { subjects } from "./subjects"
import { User } from "@nestri/core/user/index"
import { Email } from "@nestri/core/email/index"
import { authorizer } from "@openauthjs/openauth"
import { type CFRequest } from "@nestri/core/types"
import { Select } from "@openauthjs/openauth/ui/select";
import { PasswordUI } from "@openauthjs/openauth/ui/password"
import type { Adapter } from "@openauthjs/openauth/adapter/adapter"
import { PasswordAdapter } from "@openauthjs/openauth/adapter/password"
import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
import { Machine } from "@nestri/core/machine/index"
interface Env {
CloudflareAuthKV: KVNamespace
}
export type CodeAdapterState =
| {
type: "start"
}
| {
type: "code"
resend?: boolean
code: string
claims: Record<string, string>
}
export default {
async fetch(request: CFRequest, env: Env, ctx: ExecutionContext) {
const location = `${request.cf.country},${request.cf.continent}`
return authorizer({
select: Select({
providers: {
device: {
hide: true,
},
},
}),
theme: {
title: "Nestri | Auth",
primary: "#FF4F01",
//TODO: Change this in prod
logo: "https://nestri.pages.dev/logo.webp",
favicon: "https://nestri.pages.dev/seo/favicon.ico",
background: {
light: "#f5f5f5 ",
dark: "#171717"
},
radius: "lg",
font: {
family: "Geist, sans-serif",
},
css: `
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100;200;300;400;500;600;700;800;900&display=swap');
`,
},
storage: CloudflareStorage({
namespace: env.CloudflareAuthKV,
}),
subjects,
providers: {
password: PasswordAdapter(
PasswordUI({
sendCode: async (email, code) => {
console.log("email & code:", email, code)
await Email.send(email, code)
},
}),
),
device: {
type: "device",
async client(input) {
if (input.clientSecret !== Resource.AuthFingerprintKey.value) {
throw new Error("Invalid authorization token");
}
const fingerprint = input.params.fingerprint;
if (!fingerprint) {
throw new Error("Fingerprint is required");
}
const hostname = input.params.hostname;
if (!hostname) {
throw new Error("Hostname is required");
}
return {
fingerprint,
hostname
};
},
init() { }
} as Adapter<{ fingerprint: string; hostname: string }>,
},
allow: async (input) => {
const url = new URL(input.redirectURI);
const hostname = url.hostname;
if (hostname.endsWith("nestri.io")) return true;
if (hostname === "localhost") return true;
return true;
},
success: async (ctx, value) => {
if (value.provider === "device") {
let machineID = await Machine.fromFingerprint(value.fingerprint).then((x) => x?.id);
if (!machineID) {
machineID = await Machine.create({
fingerprint: value.fingerprint,
hostname: value.hostname,
location,
});
}
return await ctx.subject("device", {
id: machineID,
fingerprint: value.fingerprint
})
}
const email = value.email;
if (email) {
const token = await User.create(email);
const user = await User.fromEmail(email);
return await ctx.subject("user", {
accessToken: token,
userID: user.id
});
}
throw new Error("This is not implemented yet");
},
}).fetch(request, env, ctx)
}
}

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
import { resolver } from "hono-openapi/zod";
export function Result<T extends z.ZodTypeAny>(schema: T) {
return resolver(
z.object({
data: schema,
}),
);
}

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,81 @@
import { z } from "zod";
import { Hono } from "hono";
import { Result } from "../common"
import { describeRoute } from "hono-openapi";
import type * as Party from "partykit/server";
import { validator, resolver } from "hono-openapi/zod";
const paramsObj = z.object({
code: z.string(),
state: z.string()
})
export module AuthApi {
export const route = new Hono()
.get("/:connection",
describeRoute({
tags: ["Auth"],
summary: "Authenticate the remote device",
description: "This is a callback function to authenticate the remote device.",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("Device authenticated successfully"))
},
},
description: "Authentication successful.",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "This device does not exist.",
},
},
}),
validator(
"param",
z.object({
connection: z.string().openapi({
description: "The hostname of the device to login to.",
example: "desktopeuo8vsf",
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const env = c.env as any
const room = env.room as Party.Room
const connection = room.getConnection(param.connection)
if (!connection) {
return c.json({ error: "This device does not exist." }, 404);
}
const authParams = getUrlParams(new URL(c.req.url))
const res = paramsObj.safeParse(authParams)
if (res.error) {
return c.json({ error: "Expected url params are missing" })
}
connection.send(JSON.stringify({ ...authParams, type: "auth" }))
// FIXME:We just assume the authentication was successful, might wanna do some questioning in the future
return c.text("Device authenticated successfully")
}
)
}
function getUrlParams(url: URL) {
const urlString = url.toString()
const hash = urlString.substring(urlString.indexOf('?') + 1); // Extract the part after the #
const params = new URLSearchParams(hash);
const paramsObj = {} as any;
for (const [key, value] of params.entries()) {
paramsObj[key] = decodeURIComponent(value);
}
return paramsObj;
}

View File

@@ -0,0 +1,116 @@
import "zod-openapi/extend";
import type * as Party from "partykit/server";
// import { Resource } from "sst";
import { ZodError } from "zod";
import { logger } from "hono/logger";
// import { subjects } from "../subjects";
import { VisibleError } from "../error";
// import { ActorContext } from '@nestri/core/actor';
import { Hono, type MiddlewareHandler } from "hono";
import { HTTPException } from "hono/http-exception";
import { AuthApi } from "./auth";
const app = new Hono().basePath('/parties/main/:id');
// const auth: MiddlewareHandler = async (c, next) => {
// const client = createClient({
// clientID: "api",
// issuer: "http://auth.nestri.io" //Resource.Urls.auth
// });
// const authHeader =
// c.req.query("authorization") ?? c.req.header("authorization");
// if (authHeader) {
// const match = authHeader.match(/^Bearer (.+)$/);
// if (!match || !match[1]) {
// throw new VisibleError(
// "input",
// "auth.token",
// "Bearer token not found or improperly formatted",
// );
// }
// const bearerToken = match[1];
// const result = await client.verify(subjects, bearerToken!);
// if (result.err)
// throw new VisibleError("input", "auth.invalid", "Invalid bearer token");
// if (result.subject.type === "user") {
// // return ActorContext.with(
// // {
// // type: "user",
// // properties: {
// // accessToken: result.subject.properties.accessToken,
// // userID: result.subject.properties.userID,
// // auth: {
// // type: "oauth",
// // clientID: result.aud,
// // },
// // },
// // },
// // next,
// // );
// }
// }
// }
app
.use(logger(), async (c, next) => {
c.header("Cache-Control", "no-store");
return next();
})
// .use(auth)
app
.route("/auth", AuthApi.route)
// .get("/parties/main/:id", (c) => {
// const id = c.req.param();
// const env = c.env as any
// const party = env.room as Party.Room
// party.broadcast("hello from hono")
// return c.text(`Hello there, ${id.id} 👋🏾`)
// })
.onError((error, c) => {
console.error(error);
if (error instanceof VisibleError) {
return c.json(
{
code: error.code,
message: error.message,
},
error.kind === "auth" ? 401 : 400,
);
}
if (error instanceof ZodError) {
const e = error.errors[0];
if (e) {
return c.json(
{
code: e?.code,
message: e?.message,
},
400,
);
}
}
if (error instanceof HTTPException) {
return c.json(
{
code: "request",
message: "Invalid request",
},
400,
);
}
return c.json(
{
code: "internal",
message: "Internal server error",
},
500,
);
});
export default app

View File

@@ -0,0 +1,53 @@
import type * as Party from "partykit/server";
import app from "./hono"
export default class Server implements Party.Server {
constructor(readonly room: Party.Room) { }
onRequest(request: Party.Request): Response | Promise<Response> {
return app.fetch(request as any, { room: this.room })
}
getConnectionTags(
conn: Party.Connection,
ctx: Party.ConnectionContext
) {
console.log("Tagging", conn.id)
// const country = (ctx.request.cf?.country as string) ?? "unknown";
// return [country];
return [conn.id]
// return ["AF"]
}
onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
// A websocket just connected!
this.getConnectionTags(conn, ctx)
console.log(
`Connected:
id: ${conn.id}
room: ${this.room.id}
url: ${new URL(ctx.request.url).pathname}`
);
// let's send a message to the connection
// conn.send("hello from server");
}
onMessage(message: string, sender: Party.Connection) {
// let's log the message
console.log(`connection ${sender.id} sent message: ${message}`);
// console.log("tags", this.room.getConnections())
// for (const british of this.room.getConnections(sender.id)) {
// british.send(`Pip-pip!`);
// }
// // as well as broadcast it to all the other connections in the room...
// this.room.broadcast(
// `${sender.id}: ${message}`,
// // ...except for the connection it came from
// [sender.id]
// );
}
}
Server satisfies Party.Worker;

View File

@@ -0,0 +1,13 @@
import * as v from "valibot"
import { createSubjects } from "@openauthjs/openauth"
export const subjects = createSubjects({
user: v.object({
accessToken: v.string(),
userID: v.string(),
}),
device: v.object({
fingerprint: v.string(),
id: v.string()
})
})