diff --git a/apps/docs/sst-env.d.ts b/apps/docs/sst-env.d.ts index f90ea1f4..002938bb 100644 --- a/apps/docs/sst-env.d.ts +++ b/apps/docs/sst-env.d.ts @@ -21,6 +21,22 @@ declare module "sst" { "CloudflareAuthKV": { "type": "sst.cloudflare.Kv" } + "DiscordClientID": { + "type": "sst.sst.Secret" + "value": string + } + "DiscordClientSecret": { + "type": "sst.sst.Secret" + "value": string + } + "GithubClientID": { + "type": "sst.sst.Secret" + "value": string + } + "GithubClientSecret": { + "type": "sst.sst.Secret" + "value": string + } "InstantAdminToken": { "type": "sst.sst.Secret" "value": string diff --git a/apps/www/src/routes/(auth)/login/index.tsx b/apps/www/src/routes/(auth)/login-test/index.tsx similarity index 97% rename from apps/www/src/routes/(auth)/login/index.tsx rename to apps/www/src/routes/(auth)/login-test/index.tsx index cb615a80..1b606cc1 100644 --- a/apps/www/src/routes/(auth)/login/index.tsx +++ b/apps/www/src/routes/(auth)/login-test/index.tsx @@ -27,7 +27,7 @@ export default component$(() => { issuer: "https://auth.lauryn.dev.nestri.io" }) - const { url } = await client.authorize("http://localhost:5173/login", "token", { pkce: true }) + const { url } = await client.authorize("http://localhost:5173/login-test", "token", { pkce: true }) window.location.href = url }) diff --git a/apps/www/sst-env.d.ts b/apps/www/sst-env.d.ts index f90ea1f4..002938bb 100644 --- a/apps/www/sst-env.d.ts +++ b/apps/www/sst-env.d.ts @@ -21,6 +21,22 @@ declare module "sst" { "CloudflareAuthKV": { "type": "sst.cloudflare.Kv" } + "DiscordClientID": { + "type": "sst.sst.Secret" + "value": string + } + "DiscordClientSecret": { + "type": "sst.sst.Secret" + "value": string + } + "GithubClientID": { + "type": "sst.sst.Secret" + "value": string + } + "GithubClientSecret": { + "type": "sst.sst.Secret" + "value": string + } "InstantAdminToken": { "type": "sst.sst.Secret" "value": string diff --git a/bun.lockb b/bun.lockb index 429e5271..25e22184 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/infra/api.ts b/infra/api.ts index 5ff65d8a..6937b1c7 100644 --- a/infra/api.ts +++ b/infra/api.ts @@ -30,7 +30,11 @@ export const auth = new sst.cloudflare.Worker("Auth", { authFingerprintKey, secret.InstantAdminToken, secret.InstantAppId, - secret.LoopsApiKey + secret.LoopsApiKey, + secret.GithubClientID, + secret.GithubClientSecret, + secret.DiscordClientID, + secret.DiscordClientSecret, ], handler: "./packages/functions/src/auth.ts", url: true, @@ -43,7 +47,7 @@ export const api = new sst.cloudflare.Worker("Api", { authFingerprintKey, secret.InstantAdminToken, secret.InstantAppId, - secret.LoopsApiKey + secret.LoopsApiKey, ], url: true, handler: "./packages/functions/src/api/index.ts", diff --git a/infra/secrets.ts b/infra/secrets.ts index 5e642c9c..8793a50b 100644 --- a/infra/secrets.ts +++ b/infra/secrets.ts @@ -1,7 +1,11 @@ export const secret = { InstantAdminToken: new sst.Secret("InstantAdminToken"), InstantAppId: new sst.Secret("InstantAppId"), - LoopsApiKey: new sst.Secret("LoopsApiKey") + LoopsApiKey: new sst.Secret("LoopsApiKey"), + GithubClientSecret: new sst.Secret("GithubClientSecret"), + GithubClientID: new sst.Secret("GithubClientID"), + DiscordClientSecret: new sst.Secret("DiscordClientSecret"), + DiscordClientID: new sst.Secret("DiscordClientID"), }; export const allSecrets = Object.values(secret); \ No newline at end of file diff --git a/packages/core/instant.schema.ts b/packages/core/instant.schema.ts index 3c935003..3b28c460 100644 --- a/packages/core/instant.schema.ts +++ b/packages/core/instant.schema.ts @@ -11,6 +11,14 @@ const _schema = i.schema({ deletedAt: i.date().optional().indexed(), createdAt: i.date() }), + profiles: i.entity({ + avatarUrl: i.string().optional(), + username: i.string().indexed(), + ownerID: i.string().unique().indexed(), + updatedAt: i.date(), + createdAt: i.date(), + discriminator: i.string().indexed() + }), games: i.entity({ name: i.string(), steamID: i.number().unique().indexed(), @@ -23,6 +31,10 @@ const _schema = i.schema({ }), }, links: { + UserProfiles: { + forward: { on: "profiles", has: "one", label: "owner" }, + reverse: { on: "$users", has: "one", label: "profile" } + }, UserMachines: { forward: { on: "machines", has: "one", label: "owner" }, reverse: { on: "$users", has: "many", label: "machines" } diff --git a/packages/core/package.json b/packages/core/package.json index 5ee442d9..7dadb820 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,6 +16,6 @@ "zod-openapi": "^4.2.2" }, "dependencies": { - "@instantdb/admin": "^0.17.3" + "@instantdb/admin": "^0.17.7" } } \ No newline at end of file diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index 65b81a85..17b2c130 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -5,6 +5,15 @@ export module Examples { email: "john@example.com", }; + export const Profile = { + id: "0bfcb712-df13-4454-81a8-fbee66eddca4", + username: "janedoe47", + avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png", + discriminator: 12, //it needs to be two digits + createdAt: '2025-01-04T11:56:23.902Z', + updatedAt: '2025-01-09T01:56:23.902Z' + } + export const Machine = { id: "0bfcb712-df13-4454-81a8-fbee66eddca4", hostname: "DESKTOP-EUO8VSF", diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts index 01b0c8e1..5f51472a 100644 --- a/packages/core/src/machine/index.ts +++ b/packages/core/src/machine/index.ts @@ -29,7 +29,7 @@ export module Machines { }) .openapi({ ref: "Machine", - description: "Represents a a physical or virtual machine connected to the Nestri network..", + description: "Represents a physical or virtual machine connected to the Nestri network..", example: Examples.Machine, }); diff --git a/packages/core/src/profile/index.ts b/packages/core/src/profile/index.ts new file mode 100644 index 00000000..f28bdea3 --- /dev/null +++ b/packages/core/src/profile/index.ts @@ -0,0 +1,232 @@ +import { z } from "zod" +import { fn } from "../utils"; +import { Common } from "../common"; +import { Examples } from "../examples"; +import databaseClient from "../database"; +import { groupBy, map, pipe, values } from "remeda" +import { id as createID } from "@instantdb/admin"; + +export module Profiles { + const MAX_ATTEMPTS = 50; + + export const Info = z + .object({ + id: z.string().openapi({ + description: Common.IdDescription, + example: Examples.Machine.id, + }), + username: z.string().openapi({ + description: "The user's unique username", + example: Examples.Profile.username, + }), + avatarUrl: z.string().or(z.undefined()).openapi({ + description: "The url to the profile picture.", + example: Examples.Profile.username, + }), + discriminator: z.string().or(z.number()).openapi({ + description: "The number discriminator for each username", + example: Examples.Profile.discriminator, + }), + createdAt: z.string().or(z.number()).openapi({ + description: "The time when this profile was first created", + example: Examples.Profile.createdAt, + }), + updatedAt: z.string().or(z.number()).openapi({ + description: "The time when this profile was last edited", + example: Examples.Profile.updatedAt, + }) + }) + .openapi({ + ref: "Profile", + description: "Represents a profile of a user on Nestri", + example: Examples.Profile, + }); + + export type Info = z.infer; + + export const sanitizeUsername = (username: string): string => { + // Remove spaces and numbers + return username.replace(/[\s0-9]/g, ''); + }; + + export const generateDiscriminator = (): string => { + return Math.floor(Math.random() * 100).toString().padStart(2, '0'); + }; + + export const isValidDiscriminator = (discriminator: string): boolean => { + return /^\d{2}$/.test(discriminator); + }; + + export const fromUsername = fn(z.string(), async (input) => { + const sanitizedUsername = sanitizeUsername(input); + + const db = databaseClient() + + const query = { + profiles: { + $: { + where: { + username: sanitizedUsername, + } + } + } + } + + const res = await db.query(query) + + const profiles = res.profiles + + if (!profiles || profiles.length == 0) { + + return null + } + + return pipe( + profiles, + groupBy(x => x.id), + values(), + map((group): Info => ({ + id: group[0].id, + username: group[0].username, + createdAt: group[0].createdAt, + discriminator: group[0].discriminator, + updatedAt: group[0].updatedAt + })) + ) + }) + + export const findAvailableDiscriminator = fn(z.string(), async (input) => { + const db = databaseClient() + const username = sanitizeUsername(input); + + for (let i = 0; i < MAX_ATTEMPTS; i++) { + const discriminator = generateDiscriminator(); + const query = { + profiles: { + $: { + where: { + username, + discriminator + } + } + } + } + const res = await db.query(query) + const profiles = res.profiles + if (profiles.length === 0) { + return discriminator; + } + } + return null; // No available discriminators + + }) + + export const create = fn(z.object({ username: z.string(), customDiscriminator: z.string().optional(), avatarUrl: z.string().optional(), owner: z.string() }), async (input) => { + const username = sanitizeUsername(input.username); + + // if (!username || username.length < 2 || username.length > 32) { + // // throw new Error('Invalid username length'); + // } + + const db = databaseClient() + const id = createID() + const now = new Date().toISOString() + + let discriminator: string | null; + if (input.customDiscriminator) { + if (!isValidDiscriminator(input.customDiscriminator)) { + console.error('Invalid discriminator format') + return null + // throw new Error('Invalid discriminator format'); + } + + const query = { + profiles: { + $: { + where: { + username, + discriminator: input.customDiscriminator + } + } + } + } + const res = await db.query(query) + const profiles = res.profiles + if (profiles.length != 0) { + console.error("Username and discriminator combination already taken ") + return null + // throw new Error('Username and discriminator combination already taken'); + } + + discriminator = input.customDiscriminator + } else { + // Generate a random available discriminator + discriminator = await findAvailableDiscriminator(username); + + if (!discriminator) { + console.error("No available discriminators for this username ") + return null + // throw new Error('No available discriminators for this username'); + } + } + + return await db.transact( + db.tx.profiles[id]!.update({ + username, + avatarUrl: input.avatarUrl, + createdAt: now, + updatedAt: now, + ownerID: input.owner, + discriminator, + }).link({ owner: input.owner }) + ) + }) + + export const getFullUsername = async (username: string) => { + const db = databaseClient() + + const query = { + profiles: { + $: { + where: { + username, + } + } + } + } + const res = await db.query(query) + const profiles = res.profiles + + if (!profiles || profiles.length === 0) { + console.error('User not found') + return null + // throw new Error('User not found'); + } + + return `${profiles[0]?.username}#${profiles[0]?.discriminator}`; + } + + export const getProfile = async (ownerID: string) => { + + const db = databaseClient() + + const query = { + profiles: { + $: { + where: { + ownerID + } + }, + } + } + const res = await db.query(query) + + const profiles = res.profiles + + if (!profiles || profiles.length === 0) { + return null + } + + return profiles + } +}; \ No newline at end of file diff --git a/packages/core/sst-env.d.ts b/packages/core/sst-env.d.ts index f90ea1f4..002938bb 100644 --- a/packages/core/sst-env.d.ts +++ b/packages/core/sst-env.d.ts @@ -21,6 +21,22 @@ declare module "sst" { "CloudflareAuthKV": { "type": "sst.cloudflare.Kv" } + "DiscordClientID": { + "type": "sst.sst.Secret" + "value": string + } + "DiscordClientSecret": { + "type": "sst.sst.Secret" + "value": string + } + "GithubClientID": { + "type": "sst.sst.Secret" + "value": string + } + "GithubClientSecret": { + "type": "sst.sst.Secret" + "value": string + } "InstantAdminToken": { "type": "sst.sst.Secret" "value": string diff --git a/packages/functions/src/auth.ts b/packages/functions/src/auth.ts index c560607c..c4b68c2b 100644 --- a/packages/functions/src/auth.ts +++ b/packages/functions/src/auth.ts @@ -3,18 +3,20 @@ import { type ExecutionContext, type KVNamespace, } from "@cloudflare/workers-types" +import { Select } from "./ui/select"; import { subjects } from "./subjects" -import { User } from "@nestri/core/user/index" -import { Email } from "@nestri/core/email/index" +import { PasswordUI } from "./ui/password" 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 { GithubAdapter } from "./ui/adapters/github"; +import { DiscordAdapter } from "./ui/adapters/discord"; import { Machines } from "@nestri/core/machine/index" - +import { PasswordAdapter } from "./ui/adapters/password" +import { type Adapter } from "@openauthjs/openauth/adapter/adapter" +import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare" +import { handleDiscord, handleGithub } from "./utils"; +import { User } from "@nestri/core/user/index" +import { Profiles } from "@nestri/core/profile/index" interface Env { CloudflareAuthKV: KVNamespace } @@ -30,6 +32,15 @@ export type CodeAdapterState = claims: Record } +type OauthUser = { + primary: { + email: any; + primary: any; + verified: any; + }; + avatar: any; + username: any; +} export default { async fetch(request: CFRequest, env: Env, ctx: ExecutionContext) { // const location = `${request.cf.country},${request.cf.continent}` @@ -64,11 +75,21 @@ export default { }), subjects, providers: { + github: GithubAdapter({ + clientID: Resource.GithubClientID.value, + clientSecret: Resource.GithubClientSecret.value, + scopes: ["user:email"] + }), + discord: DiscordAdapter({ + clientID: Resource.DiscordClientID.value, + clientSecret: Resource.DiscordClientSecret.value, + scopes: ["email", "identify"] + }), password: PasswordAdapter( PasswordUI({ sendCode: async (email, code) => { console.log("email & code:", email, code) - await Email.send(email, code) + // await Email.send(email, code) }, }), ), @@ -116,27 +137,83 @@ export default { id: machineID, fingerprint: value.fingerprint }) - } + } return await ctx.subject("device", { id: exists.id, fingerprint: value.fingerprint }) - + } - const email = value.email; - - if (email) { - const token = await User.create(email); - const user = await User.fromEmail(email); + if (value.provider === "password") { + const email = value.email + const username = value.username + const token = await User.create(email) + const usr = await User.fromEmail(email); + const exists = await Profiles.getProfile(usr.id) + if(username && !exists){ + await Profiles.create({ owner: usr.id, username }) + } return await ctx.subject("user", { accessToken: token, - userID: user.id + userID: usr.id }); + } + let user = undefined as OauthUser | undefined; + + if (value.provider === "github") { + const access = value.tokenset.access; + user = await handleGithub(access) + // console.log("user", user) + } + + if (value.provider === "discord") { + const access = value.tokenset.access + user = await handleDiscord(access) + // console.log("user", user) + } + + if (user) { + try { + const token = await User.create(user.primary.email) + const usr = await User.fromEmail(user.primary.email); + const exists = await Profiles.getProfile(usr.id) + console.log("exists",exists) + if (!exists) { + await Profiles.create({ owner: usr.id, avatarUrl: user.avatar, username: user.username }) + } + + return await ctx.subject("user", { + accessToken: token, + userID: usr.id + }); + + } catch (error) { + console.error("error registering the user", error) + } + + } + + // if (email) { + // console.log("email", email) + // // value.username && console.log("username", value.username) + + // } + + // 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) diff --git a/packages/functions/src/ui/adapters/discord.ts b/packages/functions/src/ui/adapters/discord.ts new file mode 100644 index 00000000..7eef9e27 --- /dev/null +++ b/packages/functions/src/ui/adapters/discord.ts @@ -0,0 +1,12 @@ +import { Oauth2Adapter, type Oauth2WrappedConfig } from "./oauth2" + +export function DiscordAdapter(config: Oauth2WrappedConfig) { + return Oauth2Adapter({ + type: "discord", + ...config, + endpoint: { + authorization: "https://discord.com/oauth2/authorize", + token: "https://discord.com/api/oauth2/token", + }, + }) +} diff --git a/packages/functions/src/ui/adapters/github.ts b/packages/functions/src/ui/adapters/github.ts new file mode 100644 index 00000000..7a53faf0 --- /dev/null +++ b/packages/functions/src/ui/adapters/github.ts @@ -0,0 +1,12 @@ +import { Oauth2Adapter, type Oauth2WrappedConfig } from "./oauth2" + +export function GithubAdapter(config: Oauth2WrappedConfig) { + return Oauth2Adapter({ + ...config, + type: "github", + endpoint: { + authorization: "https://github.com/login/oauth/authorize", + token: "https://github.com/login/oauth/access_token", + }, + }) +} diff --git a/packages/functions/src/ui/adapters/oauth2.tsx b/packages/functions/src/ui/adapters/oauth2.tsx new file mode 100644 index 00000000..a1ccb711 --- /dev/null +++ b/packages/functions/src/ui/adapters/oauth2.tsx @@ -0,0 +1,137 @@ +/** @jsxImportSource hono/jsx */ +import { Layout } from "../base" +import { OauthError } from "@openauthjs/openauth/error" +import { getRelativeUrl } from "@openauthjs/openauth/util" +import { type Adapter } from "@openauthjs/openauth/adapter/adapter" + +export interface Oauth2Config { + type?: string + clientID: string + clientSecret: string + endpoint: { + authorization: string + token: string + } + scopes: string[] + query?: Record +} + +export type Oauth2WrappedConfig = Omit + +export interface Oauth2Token { + access: string + refresh: string + expiry: number + raw: Record +} + +interface AdapterState { + state: string + redirect: string +} + +export function Oauth2Adapter( + config: Oauth2Config, +): Adapter<{ tokenset: Oauth2Token; clientID: string }> { + const query = config.query || {} + return { + type: config.type || "oauth2", + init(routes, ctx) { + routes.get("/authorize", async (c) => { + const state = crypto.randomUUID() + await ctx.set(c, "adapter", 60 * 10, { + state, + redirect: getRelativeUrl(c, "./popup"), + }) + const authorization = new URL(config.endpoint.authorization) + authorization.searchParams.set("client_id", config.clientID) + authorization.searchParams.set( + "redirect_uri", + getRelativeUrl(c, "./popup"), + ) + authorization.searchParams.set("response_type", "code") + authorization.searchParams.set("state", state) + authorization.searchParams.set("scope", config.scopes.join(" ")) + for (const [key, value] of Object.entries(query)) { + authorization.searchParams.set(key, value) + } + return c.redirect(authorization.toString()) + }) + + routes.get("/popup", async (c) => { + const jsx = ( + +
+
+
+ {new Array(12).fill(0).map((i, k) => ( +
+ ))} +
+
+ Nestri is verifying your connection... +
+ + ) as string + return new Response(jsx.toString(), { + status: 200, + headers: { + "Content-Type": "text/html", + }, + }) + }) + + routes.get("/callback", async (c) => { + const adapter = (await ctx.get(c, "adapter")) as AdapterState + const code = c.req.query("code") + const state = c.req.query("state") + const error = c.req.query("error") + if (error) { + console.log("oauth2 error", error) + throw new OauthError( + error.toString() as any, + c.req.query("error_description")?.toString() || "", + ) + } + if (!adapter || !code || (adapter.state && state !== adapter.state)) + return c.redirect(getRelativeUrl(c, "./authorize")) + const body = new URLSearchParams({ + client_id: config.clientID, + client_secret: config.clientSecret, + code, + grant_type: "authorization_code", + redirect_uri: adapter.redirect, + }) + const json: any = await fetch(config.endpoint.token, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: body.toString(), + }).then((r) => r.json()) + if ("error" in json) { + console.error("oauth2 error", error) + throw new OauthError(json.error, json.error_description) + } + return ctx.success(c, { + clientID: config.clientID, + tokenset: { + get access() { + return json.access_token + }, + get refresh() { + return json.refresh_token + }, + get expiry() { + return json.expires_in + }, + get raw() { + return json + }, + }, + }) + }) + }, + } +} diff --git a/packages/functions/src/ui/adapters/password.ts b/packages/functions/src/ui/adapters/password.ts new file mode 100644 index 00000000..e07dfaed --- /dev/null +++ b/packages/functions/src/ui/adapters/password.ts @@ -0,0 +1,441 @@ +import { Profiles } from "@nestri/core/profile/index" +import { UnknownStateError } from "@openauthjs/openauth/error" +import { Storage } from "@openauthjs/openauth/storage/storage" +import { type Adapter } from "@openauthjs/openauth/adapter/adapter" +import { generateUnbiasedDigits, timingSafeCompare } from "@openauthjs/openauth/random" + +export interface PasswordHasher { + hash(password: string): Promise + verify(password: string, compare: T): Promise +} + +export interface PasswordConfig { + length?: number + hasher?: PasswordHasher + login: ( + req: Request, + form?: FormData, + error?: PasswordLoginError, + ) => Promise + register: ( + req: Request, + state: PasswordRegisterState, + form?: FormData, + error?: PasswordRegisterError, + ) => Promise + change: ( + req: Request, + state: PasswordChangeState, + form?: FormData, + error?: PasswordChangeError, + ) => Promise + sendCode: (email: string, code: string) => Promise +} + +export type PasswordRegisterState = + | { + type: "start" + } + | { + type: "code" + code: string + email: string + password: string + username: string + } + +export type PasswordRegisterError = + | { + type: "invalid_code" + } + | { + type: "email_taken" + } + | { + type: "invalid_email" + } + | { + type: "invalid_password" + } + | { + type: "invalid_username" + }| { + type: "username_taken" + } + +export type PasswordChangeState = + | { + type: "start" + redirect: string + } + | { + type: "code" + code: string + email: string + redirect: string + } + | { + type: "update" + redirect: string + email: string + } + +export type PasswordChangeError = + | { + type: "invalid_email" + } + | { + type: "invalid_code" + } + | { + type: "invalid_password" + } + | { + type: "password_mismatch" + } + +export type PasswordLoginError = + | { + type: "invalid_password" + } + | { + type: "invalid_email" + } + +export function PasswordAdapter(config: PasswordConfig) { + const hasher = config.hasher ?? ScryptHasher() + function generate() { + return generateUnbiasedDigits(6) + } + return { + type: "password", + init(routes, ctx) { + routes.get("/authorize", async (c) => + ctx.forward(c, await config.login(c.req.raw)), + ) + + routes.post("/authorize", async (c) => { + const fd = await c.req.formData() + async function error(err: PasswordLoginError) { + return ctx.forward(c, await config.login(c.req.raw, fd, err)) + } + const email = fd.get("email")?.toString()?.toLowerCase() + if (!email) return error({ type: "invalid_email" }) + const hash = await Storage.get(ctx.storage, [ + "email", + email, + "password", + ]) + const password = fd.get("password")?.toString() + if (!password || !hash || !(await hasher.verify(password, hash))) + return error({ type: "invalid_password" }) + return ctx.success( + c, + { + email: email, + }, + { + invalidate: async (subject) => { + await Storage.set( + ctx.storage, + ["email", email, "subject"], + subject, + ) + }, + }, + ) + }) + + routes.get("/register", async (c) => { + const state: PasswordRegisterState = { + type: "start", + } + await ctx.set(c, "adapter", 60 * 60 * 24, state) + return ctx.forward(c, await config.register(c.req.raw, state)) + }) + + routes.post("/register", async (c) => { + const fd = await c.req.formData() + const email = fd.get("email")?.toString()?.toLowerCase() + const action = fd.get("action")?.toString() + const adapter = await ctx.get(c, "adapter") + + async function transition( + next: PasswordRegisterState, + err?: PasswordRegisterError, + ) { + await ctx.set(c, "adapter", 60 * 60 * 24, next) + return ctx.forward(c, await config.register(c.req.raw, next, fd, err)) + } + + if (action === "register" && adapter.type === "start") { + const password = fd.get("password")?.toString() + const username = fd.get("username")?.toString() + const usernameRegex = /^[a-zA-Z]{1,32}$/; + if (!email) return transition(adapter, { type: "invalid_email" }) + if (!username) return transition(adapter, { type: "invalid_username" }) + if (!password) + return transition(adapter, { type: "invalid_password" }) + if (!usernameRegex.test(username)) + return transition(adapter, { type: "invalid_username" }) + const existing = await Storage.get(ctx.storage, [ + "email", + email, + "password", + ]) + if (existing) return transition(adapter, { type: "email_taken" }) + const existingUsername = await Profiles.fromUsername(username) + if (existingUsername) return transition(adapter, { type: "username_taken" }) + const code = generate() + await config.sendCode(email, code) + return transition({ + type: "code", + code, + password: await hasher.hash(password), + email, + username + }) + } + + if (action === "verify" && adapter.type === "code") { + const code = fd.get("code")?.toString() + if (!code || !timingSafeCompare(code, adapter.code)) + return transition(adapter, { type: "invalid_code" }) + const existing = await Storage.get(ctx.storage, [ + "email", + adapter.email, + "password", + ]) + if (existing) + return transition({ type: "start" }, { type: "email_taken" }) + await Storage.set( + ctx.storage, + ["email", adapter.email, "password"], + adapter.password, + ) + return ctx.success(c, { + email: adapter.email, + username: adapter.username + }) + } + + return transition({ type: "start" }) + }) + + routes.get("/change", async (c) => { + let redirect = + c.req.query("redirect_uri") || getRelativeUrl(c, "./authorize") + const state: PasswordChangeState = { + type: "start", + redirect, + } + await ctx.set(c, "adapter", 60 * 60 * 24, state) + return ctx.forward(c, await config.change(c.req.raw, state)) + }) + + routes.post("/change", async (c) => { + const fd = await c.req.formData() + const action = fd.get("action")?.toString() + const adapter = await ctx.get(c, "adapter") + if (!adapter) throw new UnknownStateError() + + async function transition( + next: PasswordChangeState, + err?: PasswordChangeError, + ) { + await ctx.set(c, "adapter", 60 * 60 * 24, next) + return ctx.forward(c, await config.change(c.req.raw, next, fd, err)) + } + + if (action === "code") { + const email = fd.get("email")?.toString()?.toLowerCase() + if (!email) + return transition( + { type: "start", redirect: adapter.redirect }, + { type: "invalid_email" }, + ) + const code = generate() + await config.sendCode(email, code) + + return transition({ + type: "code", + code, + email, + redirect: adapter.redirect, + }) + } + + if (action === "verify" && adapter.type === "code") { + const code = fd.get("code")?.toString() + if (!code || !timingSafeCompare(code, adapter.code)) + return transition(adapter, { type: "invalid_code" }) + return transition({ + type: "update", + email: adapter.email, + redirect: adapter.redirect, + }) + } + + if (action === "update" && adapter.type === "update") { + const existing = await Storage.get(ctx.storage, [ + "email", + adapter.email, + "password", + ]) + if (!existing) return c.redirect(adapter.redirect, 302) + + const password = fd.get("password")?.toString() + const repeat = fd.get("repeat")?.toString() + if (!password) + return transition(adapter, { type: "invalid_password" }) + if (password !== repeat) + return transition(adapter, { type: "password_mismatch" }) + + await Storage.set( + ctx.storage, + ["email", adapter.email, "password"], + await hasher.hash(password), + ) + const subject = await Storage.get(ctx.storage, [ + "email", + adapter.email, + "subject", + ]) + if (subject) await ctx.invalidate(subject) + + return c.redirect(adapter.redirect, 302) + } + + return transition({ type: "start", redirect: adapter.redirect }) + }) + }, + } satisfies Adapter<{ email: string; username?:string }> +} + +import * as jose from "jose" +import { TextEncoder } from "node:util" + +interface HashedPassword {} + +export function PBKDF2Hasher(opts?: { interations?: number }): PasswordHasher<{ + hash: string + salt: string + iterations: number +}> { + const iterations = opts?.interations ?? 600000 + return { + async hash(password) { + const encoder = new TextEncoder() + const bytes = encoder.encode(password) + const salt = crypto.getRandomValues(new Uint8Array(16)) + const keyMaterial = await crypto.subtle.importKey( + "raw", + bytes, + "PBKDF2", + false, + ["deriveBits"], + ) + const hash = await crypto.subtle.deriveBits( + { + name: "PBKDF2", + hash: "SHA-256", + salt: salt, + iterations, + }, + keyMaterial, + 256, + ) + const hashBase64 = jose.base64url.encode(new Uint8Array(hash)) + const saltBase64 = jose.base64url.encode(salt) + return { + hash: hashBase64, + salt: saltBase64, + iterations, + } + }, + async verify(password, compare) { + const encoder = new TextEncoder() + const passwordBytes = encoder.encode(password) + const salt = jose.base64url.decode(compare.salt) + const params = { + name: "PBKDF2", + hash: "SHA-256", + salt, + iterations: compare.iterations, + } + const keyMaterial = await crypto.subtle.importKey( + "raw", + passwordBytes, + "PBKDF2", + false, + ["deriveBits"], + ) + const hash = await crypto.subtle.deriveBits(params, keyMaterial, 256) + const hashBase64 = jose.base64url.encode(new Uint8Array(hash)) + return hashBase64 === compare.hash + }, + } +} +import { timingSafeEqual, randomBytes, scrypt } from "node:crypto" +import { getRelativeUrl } from "@openauthjs/openauth/util" + +export function ScryptHasher(opts?: { + N?: number + r?: number + p?: number +}): PasswordHasher<{ + hash: string + salt: string + N: number + r: number + p: number +}> { + const N = opts?.N ?? 16384 + const r = opts?.r ?? 8 + const p = opts?.p ?? 1 + + return { + async hash(password) { + const salt = randomBytes(16) + const keyLength = 32 // 256 bits + + const derivedKey = await new Promise((resolve, reject) => { + scrypt(password, salt, keyLength, { N, r, p }, (err, derivedKey) => { + if (err) reject(err) + else resolve(derivedKey) + }) + }) + + const hashBase64 = derivedKey.toString("base64") + const saltBase64 = salt.toString("base64") + + return { + hash: hashBase64, + salt: saltBase64, + N, + r, + p, + } + }, + + async verify(password, compare) { + const salt = Buffer.from(compare.salt, "base64") + const keyLength = 32 // 256 bits + + const derivedKey = await new Promise((resolve, reject) => { + scrypt( + password, + salt, + keyLength, + { N: compare.N, r: compare.r, p: compare.p }, + (err, derivedKey) => { + if (err) reject(err) + else resolve(derivedKey) + }, + ) + }) + + return timingSafeEqual(derivedKey, Buffer.from(compare.hash, "base64")) + }, + } +} diff --git a/packages/functions/src/ui/base.tsx b/packages/functions/src/ui/base.tsx new file mode 100644 index 00000000..7183cee1 --- /dev/null +++ b/packages/functions/src/ui/base.tsx @@ -0,0 +1,279 @@ +/** @jsxImportSource hono/jsx */ +import { css } from "./css" +import { type PropsWithChildren } from "hono/jsx" +import { getTheme } from "@openauthjs/openauth/ui/theme" + +export function Layout( + props: PropsWithChildren<{ + size?: "small", + page?: "root" | "password" | "popup" + }>, +) { + const theme = getTheme() + function get(key: "primary" | "background" | "logo", mode: "light" | "dark") { + if (!theme) return + if (!theme[key]) return + if (typeof theme[key] === "string") return theme[key] + + return theme[key][mode] as string | undefined + } + + const radius = (() => { + if (theme?.radius === "none") return "0" + if (theme?.radius === "sm") return "1" + if (theme?.radius === "md") return "1.25" + if (theme?.radius === "lg") return "1.5" + if (theme?.radius === "full") return "1000000000001" + return "1" + })() + + const script = "const DEFAULT_COLORS = ['#6A5ACD', '#E63525','#20B2AA', '#E87D58'];" + + "const getModulo = (value, divisor, useEvenCheck) => {" + + "const remainder = value % divisor;" + + "if (useEvenCheck && Math.floor(value / Math.pow(10, useEvenCheck) % 10) % 2 === 0) {" + + " return -remainder;" + + " }" + + " return remainder;" + + " };" + + "const generateColors = (name, colors = DEFAULT_COLORS) => {" + + "const hashCode = name.split('').reduce((acc, char) => {" + + "acc = ((acc << 5) - acc) + char.charCodeAt(0);" + + " return acc & acc;" + + " }, 0);" + + "const hash = Math.abs(hashCode);" + + "const numColors = colors.length;" + + "return Array.from({ length: 3 }, (_, index) => ({" + + "color: colors[(hash + index) % numColors]," + + "translateX: getModulo(hash * (index + 1), 4, 1)," + + "translateY: getModulo(hash * (index + 1), 4, 2)," + + " scale: 1.2 + getModulo(hash * (index + 1), 2) / 10," + + " rotate: getModulo(hash * (index + 1), 360, 1)" + + "}));" + + "};" + + "const generateFallbackAvatar = (text = 'wanjohi', size = 80, colors = DEFAULT_COLORS) => {" + + " const colorData = generateColors(text, colors);" + + " return '' +" + + " 'Fallback avatar for ' + text + '' +" + + " '' +" + + " '' +" + + " '' +" + + " '' +" + + " '' +" + + " '' +" + + " '' +" + + " '' +" + + " '' +" + + " '' +" + + " '' +" + + " '' +" + + " '' +" + + " '' +" + + " '' +" + + " '';" + + "};" + + "const input = document.getElementById('username');" + + "const avatarSpan = document.getElementById('username-icon');" + + "input.addEventListener('input', (e) => {" + + " avatarSpan.innerHTML = generateFallbackAvatar(e.target.value);" + + "});"; + + const authWindowScript = ` + const openAuthWindow = async (provider) => { + const POLL_INTERVAL = 300; + const BASE_URL = window.location.origin; + + const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent + ); + + const createDesktopWindow = (authUrl) => { + const config = { + width: 700, + height: 700, + features: "toolbar=no,location=no,directories=no,status=no,menubar=no,scrollbars=no,resizable=no,copyhistory=no" + }; + + const top = window.top.outerHeight / 2 + window.top.screenY - (config.height / 2); + const left = window.top.outerWidth / 2 + window.top.screenX - (config.width / 2); + + return window.open( + authUrl, + 'Auth Popup', + \`width=\${config.width},height=\${config.height},left=\${left},top=\${top},\${config.features}\` + ); + }; + + const monitorAuthWindow = (targetWindow) => { + return new Promise((resolve, reject) => { + const handleAuthSuccess = (event) => { + if (event.origin !== BASE_URL) return; + + try { + const data = JSON.parse(event.data); + if (data.type === 'auth_success') { + cleanup(); + window.location.href = window.location.origin + "/" + provider + "/callback" + data.searchParams; + resolve(); + } + } catch (e) { + // Ignore invalid JSON messages + } + }; + + window.addEventListener('message', handleAuthSuccess); + + const timer = setInterval(() => { + if (targetWindow.closed) { + cleanup(); + reject(new Error('Authentication window was closed')); + } + }, POLL_INTERVAL); + + function cleanup() { + clearInterval(timer); + window.removeEventListener('message', handleAuthSuccess); + if (!targetWindow.closed) { + targetWindow.location.href = 'about:blank' + targetWindow.close(); + } + window.focus(); + } + }); + }; + + const authUrl = \`\${BASE_URL}/\${provider}/authorize\`; + const newWindow = isMobile ? window.open(authUrl, '_blank') : createDesktopWindow(authUrl); + + if (!newWindow) { + throw new Error('Failed to open authentication window'); + } + + return monitorAuthWindow(newWindow); + }; + + + const buttons = document.querySelectorAll('button[id^="button-"]'); + const formRoot = document.querySelector('[data-component="form-root"]'); + + const setLoadingState = (activeProvider) => { + formRoot.setAttribute('data-disabled', 'true'); + + buttons.forEach(button => { + button.style.pointerEvents = 'none'; + + const provider = button.id.replace('button-', ''); + if (provider === activeProvider) { + button.setAttribute('data-loading', 'true'); + } + }); + }; + + const resetState = () => { + formRoot.removeAttribute('data-disabled'); + + buttons.forEach(button => { + button.style.pointerEvents = ''; + button.removeAttribute('data-loading'); + }); + }; + + buttons.forEach(button => { + const provider = button.id.replace('button-', ''); + + if (provider === "password"){ + button.addEventListener('click', async (e) => { + window.location.href = window.location.origin + "/" + provider + "/authorize"; + }) + } else { + button.addEventListener('click', async (e) => { + try { + setLoadingState(provider); + await openAuthWindow(provider); + } catch (error) { + resetState(); + console.error(\`Authentication failed for \${provider}:\`, error); + } + // finally { + // resetState(); + // } + }); + } + });`; + + const callbackScript = ` + if (window.opener == null) { + window.location.href = "about:blank"; + } + + const searchParams = window.location.search; + + try { + window.opener.postMessage( + JSON.stringify({ + type: 'auth_success', + searchParams: searchParams + }), + window.location.origin + ); + } catch (e) { + console.error('Failed to send message to parent window:', e); + }`; + return ( + + + + {theme?.title || "OpenAuthJS"} + + +