From f6287ef58679347e8d64c54d9589e66dffed613f Mon Sep 17 00:00:00 2001 From: Wanjohi <71614375+wanjohiryan@users.noreply.github.com> Date: Tue, 7 Jan 2025 23:58:27 +0300 Subject: [PATCH] =?UTF-8?q?=E2=AD=90feat(auth):=20Update=20the=20authentic?= =?UTF-8?q?ation=20UI=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We added a new Auth UI, with all the business logic to handle profiles and such... it works alright --- apps/docs/sst-env.d.ts | 16 + .../(auth)/{login => login-test}/index.tsx | 2 +- apps/www/sst-env.d.ts | 16 + bun.lockb | Bin 741440 -> 744240 bytes infra/api.ts | 8 +- infra/secrets.ts | 6 +- packages/core/instant.schema.ts | 12 + packages/core/package.json | 2 +- packages/core/src/examples.ts | 9 + packages/core/src/machine/index.ts | 2 +- packages/core/src/profile/index.ts | 232 +++++++ packages/core/sst-env.d.ts | 16 + packages/functions/src/auth.ts | 111 +++- packages/functions/src/ui/adapters/discord.ts | 12 + packages/functions/src/ui/adapters/github.ts | 12 + packages/functions/src/ui/adapters/oauth2.tsx | 137 ++++ .../functions/src/ui/adapters/password.ts | 441 +++++++++++++ packages/functions/src/ui/base.tsx | 279 +++++++++ packages/functions/src/ui/css.ts | 586 ++++++++++++++++++ packages/functions/src/ui/password.tsx | 481 ++++++++++++++ packages/functions/src/ui/select.tsx | 122 ++++ packages/functions/src/utils.ts | 75 +++ packages/functions/sst-env.d.ts | 16 + packages/functions/tsconfig.json | 10 +- packages/input/sst-env.d.ts | 16 + packages/moq/sst-env.d.ts | 16 + packages/ui/sst-env.d.ts | 16 + sst-env.d.ts | 16 + 28 files changed, 2639 insertions(+), 28 deletions(-) rename apps/www/src/routes/(auth)/{login => login-test}/index.tsx (97%) create mode 100644 packages/core/src/profile/index.ts create mode 100644 packages/functions/src/ui/adapters/discord.ts create mode 100644 packages/functions/src/ui/adapters/github.ts create mode 100644 packages/functions/src/ui/adapters/oauth2.tsx create mode 100644 packages/functions/src/ui/adapters/password.ts create mode 100644 packages/functions/src/ui/base.tsx create mode 100644 packages/functions/src/ui/css.ts create mode 100644 packages/functions/src/ui/password.tsx create mode 100644 packages/functions/src/ui/select.tsx create mode 100644 packages/functions/src/utils.ts 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 429e52710f8147cf7ece7c7ed49e95e8a920c42c..25e22184df348754ecbbd6d71eb0e2713da6ac2d 100755 GIT binary patch delta 22178 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 delta 20936 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 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"} + + +