rfunds-online/lib/auth.ts

186 lines
6.3 KiB
TypeScript

/**
* EncryptID Auth Store for rfunds-online
*
* Provides passkey-based identity for space creation and editing.
* Uses Zustand with localStorage persistence.
*/
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com'
interface AuthState {
isAuthenticated: boolean
token: string | null
did: string | null
username: string | null
loading: boolean
login: () => Promise<void>
register: (username: string) => Promise<void>
logout: () => void
}
function toBase64url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
function fromBase64url(str: string): Uint8Array {
return Uint8Array.from(atob(str.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
isAuthenticated: false,
token: null,
did: null,
username: null,
loading: false,
login: async () => {
set({ loading: true })
try {
const startRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const { options } = await startRes.json()
const publicKeyOptions: PublicKeyCredentialRequestOptions = {
challenge: fromBase64url(options.challenge).buffer as ArrayBuffer,
rpId: options.rpId,
userVerification: options.userVerification as UserVerificationRequirement,
timeout: options.timeout,
allowCredentials: options.allowCredentials?.map((c: { type: string; id: string; transports: string[] }) => ({
type: c.type as PublicKeyCredentialType,
id: fromBase64url(c.id).buffer as ArrayBuffer,
transports: c.transports as AuthenticatorTransport[],
})),
}
const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions }) as PublicKeyCredential
if (!assertion) throw new Error('Authentication cancelled')
const response = assertion.response as AuthenticatorAssertionResponse
const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: options.challenge,
credential: {
credentialId: assertion.id,
authenticatorData: toBase64url(response.authenticatorData),
clientDataJSON: toBase64url(response.clientDataJSON),
signature: toBase64url(response.signature),
userHandle: response.userHandle ? toBase64url(response.userHandle) : null,
},
}),
})
const result = await completeRes.json()
if (!result.success) throw new Error(result.error || 'Authentication failed')
set({
isAuthenticated: true,
token: result.token,
did: result.did,
username: result.username,
loading: false,
})
} catch (error) {
set({ loading: false })
throw error
}
},
register: async (username: string) => {
set({ loading: true })
try {
const startRes = await fetch(`${ENCRYPTID_SERVER}/api/register/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName: username }),
})
const { options, userId } = await startRes.json()
const publicKeyOptions: PublicKeyCredentialCreationOptions = {
challenge: fromBase64url(options.challenge).buffer as ArrayBuffer,
rp: options.rp,
user: {
id: fromBase64url(options.user.id).buffer as ArrayBuffer,
name: options.user.name,
displayName: options.user.displayName,
},
pubKeyCredParams: options.pubKeyCredParams,
authenticatorSelection: options.authenticatorSelection,
timeout: options.timeout,
attestation: options.attestation as AttestationConveyancePreference,
}
const credential = await navigator.credentials.create({ publicKey: publicKeyOptions }) as PublicKeyCredential
if (!credential) throw new Error('Registration cancelled')
const response = credential.response as AuthenticatorAttestationResponse
const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/register/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: options.challenge,
userId,
username,
credential: {
credentialId: credential.id,
publicKey: toBase64url(response.getPublicKey?.() || response.attestationObject),
attestationObject: toBase64url(response.attestationObject),
clientDataJSON: toBase64url(response.clientDataJSON),
transports: (response as AuthenticatorAttestationResponse & { getTransports?: () => string[] }).getTransports?.() || [],
},
}),
})
const result = await completeRes.json()
if (!result.success) throw new Error(result.error || 'Registration failed')
set({
isAuthenticated: true,
token: result.token,
did: result.did,
username,
loading: false,
})
} catch (error) {
set({ loading: false })
throw error
}
},
logout: () => {
set({
isAuthenticated: false,
token: null,
did: null,
username: null,
loading: false,
})
},
}),
{
name: 'rfunds-auth',
partialize: (state) => ({
isAuthenticated: state.isAuthenticated,
token: state.token,
did: state.did,
username: state.username,
}),
}
)
)