mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-16 18:55:37 +02:00
⭐feat(auth): Update the authentication UI (#153)
We added a new Auth UI, with all the business logic to handle profiles and such... it works alright
This commit is contained in:
12
packages/functions/src/ui/adapters/discord.ts
Normal file
12
packages/functions/src/ui/adapters/discord.ts
Normal file
@@ -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",
|
||||
},
|
||||
})
|
||||
}
|
||||
12
packages/functions/src/ui/adapters/github.ts
Normal file
12
packages/functions/src/ui/adapters/github.ts
Normal file
@@ -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",
|
||||
},
|
||||
})
|
||||
}
|
||||
137
packages/functions/src/ui/adapters/oauth2.tsx
Normal file
137
packages/functions/src/ui/adapters/oauth2.tsx
Normal file
@@ -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<string, string>
|
||||
}
|
||||
|
||||
export type Oauth2WrappedConfig = Omit<Oauth2Config, "endpoint" | "name">
|
||||
|
||||
export interface Oauth2Token {
|
||||
access: string
|
||||
refresh: string
|
||||
expiry: number
|
||||
raw: Record<string, any>
|
||||
}
|
||||
|
||||
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<AdapterState>(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 = (
|
||||
<Layout page="popup">
|
||||
<div data-component="popup">
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
Nestri is verifying your connection...
|
||||
</div>
|
||||
</Layout>
|
||||
) 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
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
441
packages/functions/src/ui/adapters/password.ts
Normal file
441
packages/functions/src/ui/adapters/password.ts
Normal file
@@ -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<T> {
|
||||
hash(password: string): Promise<T>
|
||||
verify(password: string, compare: T): Promise<boolean>
|
||||
}
|
||||
|
||||
export interface PasswordConfig {
|
||||
length?: number
|
||||
hasher?: PasswordHasher<any>
|
||||
login: (
|
||||
req: Request,
|
||||
form?: FormData,
|
||||
error?: PasswordLoginError,
|
||||
) => Promise<Response>
|
||||
register: (
|
||||
req: Request,
|
||||
state: PasswordRegisterState,
|
||||
form?: FormData,
|
||||
error?: PasswordRegisterError,
|
||||
) => Promise<Response>
|
||||
change: (
|
||||
req: Request,
|
||||
state: PasswordChangeState,
|
||||
form?: FormData,
|
||||
error?: PasswordChangeError,
|
||||
) => Promise<Response>
|
||||
sendCode: (email: string, code: string) => Promise<void>
|
||||
}
|
||||
|
||||
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<HashedPassword>(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<PasswordRegisterState>(c, "adapter")
|
||||
|
||||
async function transition(
|
||||
next: PasswordRegisterState,
|
||||
err?: PasswordRegisterError,
|
||||
) {
|
||||
await ctx.set<PasswordRegisterState>(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<PasswordChangeState>(c, "adapter")
|
||||
if (!adapter) throw new UnknownStateError()
|
||||
|
||||
async function transition(
|
||||
next: PasswordChangeState,
|
||||
err?: PasswordChangeError,
|
||||
) {
|
||||
await ctx.set<PasswordChangeState>(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<string>(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<Buffer>((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<Buffer>((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"))
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user