mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
✨ 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:
175
packages/functions/.gitignore
vendored
Normal file
175
packages/functions/.gitignore
vendored
Normal file
@@ -0,0 +1,175 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Caches
|
||||
|
||||
.cache
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
15
packages/functions/README.md
Normal file
15
packages/functions/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# auth
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.1.34. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||
20
packages/functions/package.json
Normal file
20
packages/functions/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@nestri/functions",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241224.0",
|
||||
"@nestri/core": "*",
|
||||
"@types/bun": "latest",
|
||||
"partykit": "^0.0.111",
|
||||
"valibot": "^1.0.0-beta.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.6.15",
|
||||
"hono-openapi": "^0.3.1",
|
||||
"partysocket": "1.0.3"
|
||||
}
|
||||
}
|
||||
6
packages/functions/partykit.json
Normal file
6
packages/functions/partykit.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://www.partykit.io/schema.json",
|
||||
"name": "nestri-party",
|
||||
"main": "src/party/index.ts",
|
||||
"compatibilityDate": "2024-12-31"
|
||||
}
|
||||
121
packages/functions/src/adapter.ts
Normal file
121
packages/functions/src/adapter.ts
Normal 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]
|
||||
153
packages/functions/src/api/index.ts
Normal file
153
packages/functions/src/api/index.ts
Normal 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
|
||||
160
packages/functions/src/api/machine.ts
Normal file
160
packages/functions/src/api/machine.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
140
packages/functions/src/auth.ts
Normal file
140
packages/functions/src/auth.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
10
packages/functions/src/common.ts
Normal file
10
packages/functions/src/common.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
9
packages/functions/src/error.ts
Normal file
9
packages/functions/src/error.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export class VisibleError extends Error {
|
||||
constructor(
|
||||
public kind: "input" | "auth",
|
||||
public code: string,
|
||||
public message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
81
packages/functions/src/party/auth.ts
Normal file
81
packages/functions/src/party/auth.ts
Normal 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;
|
||||
}
|
||||
116
packages/functions/src/party/hono.ts
Normal file
116
packages/functions/src/party/hono.ts
Normal 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
|
||||
53
packages/functions/src/party/index.ts
Normal file
53
packages/functions/src/party/index.ts
Normal 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;
|
||||
13
packages/functions/src/subjects.ts
Normal file
13
packages/functions/src/subjects.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
41
packages/functions/sst-env.d.ts
vendored
Normal file
41
packages/functions/sst-env.d.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
import "sst"
|
||||
export {}
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"AuthFingerprintKey": {
|
||||
"type": "random.index/randomString.RandomString"
|
||||
"value": string
|
||||
}
|
||||
"InstantAdminToken": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"InstantAppId": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"LoopsApiKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Urls": {
|
||||
"api": string
|
||||
"auth": string
|
||||
"type": "sst.sst.Linkable"
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types";
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": cloudflare.Service
|
||||
"Auth": cloudflare.Service
|
||||
"CloudflareAuthKV": cloudflare.KVNamespace
|
||||
}
|
||||
}
|
||||
27
packages/functions/tsconfig.json
Normal file
27
packages/functions/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Enable latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user