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")) }, } }