feat[auth]: custom hasher & api key validation

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2025-10-20 00:14:50 +00:00
parent e58caa6b16
commit 14e2ee1e28
2 changed files with 140 additions and 7 deletions

89
packages/auth/hasher.ts Normal file
View File

@@ -0,0 +1,89 @@
export interface PassswordHasher {
hash(password: string): Promise<string>
verify(password: string, hash: string): Promise<boolean>
}
export class BunSha256Hasher implements PassswordHasher {
async hash(password: string): Promise<string> {
const hasher = new Bun.CryptoHasher("sha256")
hasher.update(password)
return hasher.digest("base64url")
}
async verify(password: string, hash: string): Promise<boolean> {
const hasher = new Bun.CryptoHasher("sha256")
hasher.update(password)
const passwordHash = hasher.digest()
const hashBytes = Buffer.from(hash, "base64url")
if (passwordHash.byteLength !== hashBytes.byteLength) {
return false
}
return crypto.timingSafeEqual(passwordHash, hashBytes)
}
}
export class WebCryptoSha256Hasher implements PassswordHasher {
async hash(password: string): Promise<string> {
const hash = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(password),
)
return this.arrayBufferToBase64url(hash)
}
async verify(password: string, hash: string): Promise<boolean> {
const passwordHash = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(password),
)
const hashBytes = this.base64urlToArrayBuffer(hash)
if (passwordHash.byteLength !== hashBytes.byteLength) {
return false
}
// Timing-safe comparison
const passwordHashBytes = new Uint8Array(passwordHash)
let result = 0
for (let i = 0; i < passwordHashBytes.length; i++) {
result |= passwordHashBytes[i]! ^ hashBytes[i]!
}
return result === 0
}
private arrayBufferToBase64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ""
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!)
}
return btoa(binary).replace(/[+/=]/g, (char) => {
switch (char) {
case "+": return "-"
case "/": return "_"
case "=": return ""
default: return char
}
})
}
private base64urlToArrayBuffer(base64url: string): Uint8Array {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
const padded = base64.padEnd(
base64.length + ((4 - (base64.length % 4)) % 4),
"=",
)
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
}

View File

@@ -1,3 +1,7 @@
import { BunSha256Hasher, type PassswordHasher } from "./hasher"
export { WebCryptoSha256Hasher } from "./hasher"
/** /**
* An unhashed api key. * An unhashed api key.
* Always starts with sk, then the prefix specified at time of generation, * Always starts with sk, then the prefix specified at time of generation,
@@ -7,6 +11,11 @@ export type UnhashedApiKey = `sk-${ApiKeyPrefix}-${string}`
export type ApiKeyPrefix = string & { __brand: "ApiKeyPrefix" } export type ApiKeyPrefix = string & { __brand: "ApiKeyPrefix" }
export type ParsedApiKey = {
prefix: ApiKeyPrefix
unhashedKey: UnhashedApiKey
}
export type GenerateApiKeyOptions = { export type GenerateApiKeyOptions = {
/** /**
* How long the key should be (excluding prefix) in bytes. * How long the key should be (excluding prefix) in bytes.
@@ -21,6 +30,8 @@ export type GenerateApiKeyOptions = {
expiresAt?: Date expiresAt?: Date
description: string description: string
hasher?: PassswordHasher
} }
export type GenerateApiKeyResult = { export type GenerateApiKeyResult = {
@@ -30,11 +41,21 @@ export type GenerateApiKeyResult = {
description: string description: string
} }
export type VerifyApiKeyOptions = {
keyToBeVerified: UnhashedApiKey
hashedKey: string
hasher?: PassswordHasher
}
function validatePrefix(prefix: string): prefix is ApiKeyPrefix {
return !prefix.includes("-")
}
export function newPrefix(prefix: string): ApiKeyPrefix | null { export function newPrefix(prefix: string): ApiKeyPrefix | null {
if (prefix.includes("-")) { if (!validatePrefix(prefix)) {
return null return null
} }
return prefix as ApiKeyPrefix return prefix
} }
export async function generateApiKey({ export async function generateApiKey({
@@ -42,6 +63,7 @@ export async function generateApiKey({
prefix, prefix,
expiresAt, expiresAt,
description, description,
hasher = new BunSha256Hasher(),
}: GenerateApiKeyOptions): Promise<GenerateApiKeyResult> { }: GenerateApiKeyOptions): Promise<GenerateApiKeyResult> {
const keyContent = new Uint8Array(keyByteLength) const keyContent = new Uint8Array(keyByteLength)
crypto.getRandomValues(keyContent) crypto.getRandomValues(keyContent)
@@ -49,11 +71,7 @@ export async function generateApiKey({
const base64KeyContent = Buffer.from(keyContent).toString("base64url") const base64KeyContent = Buffer.from(keyContent).toString("base64url")
const unhashedKey: UnhashedApiKey = `sk-${prefix}-${base64KeyContent}` const unhashedKey: UnhashedApiKey = `sk-${prefix}-${base64KeyContent}`
const hashedKey = await Bun.password.hash(unhashedKey, { const hashedKey = await hasher.hash(unhashedKey)
algorithm: "argon2id",
memoryCost: 4, // memory usage in kibibytes
timeCost: 3, // the number of iterations
})
return { return {
unhashedKey, unhashedKey,
@@ -62,3 +80,29 @@ export async function generateApiKey({
description, description,
} }
} }
export function parseApiKey(key: string): ParsedApiKey | null {
if (!key.startsWith("sk-")) {
return null
}
const parts = key.split("-")
if (parts.length !== 3) {
return null
}
const prefix = parts[1]
if (!prefix || !validatePrefix(prefix)) {
return null
}
return {
prefix,
unhashedKey: key as UnhashedApiKey,
}
}
export async function verifyApiKey({
keyToBeVerified,
hashedKey,
hasher = new BunSha256Hasher(),
}: VerifyApiKeyOptions): Promise<boolean> {
return await hasher.verify(keyToBeVerified, hashedKey)
}