/**
* Browser Identity — complete browser-side authentication module
*
* Self-contained: uses ANProtoClient for crypto, does its own fetch calls.
* No dependency on hyper-web-ui or any other package.
*
* Usage:
* import { BrowserIdentity } from 'hyper-identity/browser'
* const identity = new BrowserIdentity()
* await identity.authenticate()
* const me = await identity.getMe()
*/
import { ANProtoClient } from './anproto-client.js'
const DEFAULT_KEYPAIR_KEY = 'hyper-identity-keypair'
const DEFAULT_TOKEN_KEY = 'hyper-identity-token'
export class BrowserIdentity {
constructor (opts = {}) {
this._keypairKey = opts.storageKey || DEFAULT_KEYPAIR_KEY
this._tokenKey = opts.tokenKey || DEFAULT_TOKEN_KEY
this._baseUrl = opts.baseUrl || ''
this._keypair = null
}
/**
* Get or create Ed25519 keypair (stored in localStorage)
*/
async getKeypair () {
if (this._keypair) return this._keypair
const stored = typeof localStorage !== 'undefined' && localStorage.getItem(this._keypairKey)
if (stored && stored.length === 132) {
this._keypair = stored
return this._keypair
}
// Generate keypair on server (fast, uses sodium-native)
// Browser only stores it and uses it for signing
const result = await this._fetch('/api/identity/generate', { method: 'POST' })
this._keypair = result.keypair
if (typeof localStorage !== 'undefined') {
localStorage.setItem(this._keypairKey, this._keypair)
}
return this._keypair
}
/**
* Get public key as hex (for API calls)
*/
async getPublicKeyHex () {
const kp = await this.getKeypair()
return ANProtoClient.getPublicKeyHex(kp)
}
/**
* Get public key as base64 (for display)
*/
async getPublicKeyB64 () {
const kp = await this.getKeypair()
return ANProtoClient.getPublicKey(kp)
}
/**
* Sign a challenge string
*/
async sign (challenge) {
const kp = await this.getKeypair()
return ANProtoClient.sign(challenge, kp)
}
/**
* Full authentication flow: challenge → sign → verify → token
*/
async authenticate () {
const publicKey = await this.getPublicKeyHex()
// 1. Get challenge
const challengeRes = await this._fetch('/api/auth/challenge')
const { challengeId, challenge } = challengeRes
// 2. Sign it
const signature = await this.sign(challenge)
// 3. Verify with server
const result = await this._fetch('/api/auth/verify', {
method: 'POST',
body: { publicKey, signature, challengeId }
})
// 4. Store token
this.storeToken(result.token)
return result
}
/**
* Store auth token
*/
storeToken (token) {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(this._tokenKey, token)
}
}
/**
* Get stored token
*/
getToken () {
if (typeof localStorage === 'undefined') return null
return localStorage.getItem(this._tokenKey)
}
/**
* Check if authenticated
*/
isAuthenticated () {
return !!this.getToken()
}
/**
* Fetch current user from server
*/
async getMe () {
const token = this.getToken()
if (!token) return null
try {
return await this._fetch('/api/users/me')
} catch {
return null
}
}
/**
* Logout — clear token (keeps keypair)
*/
logout () {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(this._tokenKey)
}
}
/**
* Clear everything — keypair + token
*/
clearIdentity () {
this.logout()
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(this._keypairKey)
}
this._keypair = null
}
/**
* Internal fetch with auth header
*/
async _fetch (path, opts = {}) {
const url = this._baseUrl + path
const headers = { Accept: 'application/json' }
const token = this.getToken()
if (token) headers.Authorization = `Bearer ${token}`
const fetchOpts = { headers }
if (opts.method) fetchOpts.method = opts.method
if (opts.body) {
fetchOpts.method = fetchOpts.method || 'POST'
headers['Content-Type'] = 'application/json'
fetchOpts.body = JSON.stringify(opts.body)
}
const res = await fetch(url, fetchOpts)
if (!res.ok) {
const text = await res.text()
throw new Error(`${res.status} ${text}`)
}
return res.json()
}
}
export default BrowserIdentity