mirror of
https://github.com/get-drexa/drive.git
synced 2025-11-30 21:41:39 +00:00
feat[auth]: custom hasher & api key validation
Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
89
packages/auth/hasher.ts
Normal file
89
packages/auth/hasher.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user