amanpay / web /src /api /identity.ts
MHamdan's picture
CI deploy f303ea9 (part 2)
fab9291 verified
Raw
History Blame Contribute Delete
13.9 kB
// Programme D2 — persistent customer accounts + multi-device passkeys.
// Typed client for the /identity/v1 surface. The session lives in an HttpOnly
// `amanpay_session` cookie the server sets; the browser never reads it. State-changing
// requests carry `X-CSRF-Token` (the csrf_token returned by enroll/login/stepup/recovery).
// WebAuthn challenges are ALWAYS produced by the backend — we only run the ceremonies.
import { apiRequest, ApiRequestError } from './client'
import {
isWebAuthnSupported, PasskeyCancelled, PasskeyUnsupported,
serializeAssertion, serializeRegistration, toCreationOptions, toRequestOptions,
} from '../auth/passkey'
const V1 = '/identity/v1'
// ---- Types (only operator-safe / customer-safe fields are modelled) ----
export interface D2Account {
id: string
public_handle: string
display_alias: string
role: string
status: string
preferred_language: string
profile_generation?: number
passkey_count?: number
}
export interface D2Passkey {
id: string
nickname: string
device_type: string
backup_eligible: boolean
backup_state: string
transports: string[]
created_at: string
last_used_at: string | null
revoked: boolean
}
export interface D2Session {
id: string
created_at: string
last_seen_at: string
device_nickname: string
current: boolean
}
export interface D2Invitation {
id: string
role: string
purpose: string
max_uses: number
use_count: number
expires_at: string
created_at: string
revoked: boolean
claimed: boolean
}
export interface D2OperatorAccount {
id: string
public_handle: string
display_alias: string
role: string
status: string
preferred_language: string
passkey_count: number
active_session_count: number
created_at: string
}
export interface D2AccountEvent {
id: string
kind: string
at: string
detail?: string
}
export interface InvitationValidation {
valid: boolean
role: string
purpose: string
tenant: { name_en: string; name_ar: string }
}
export interface AuthResult {
account: D2Account
csrf_token: string
}
export interface DeletionReceipt {
deleted: boolean
retired_generation: number
note: string
}
export interface EnrollProfile {
code: string
public_handle: string
display_alias: string
preferred_language: string
consent_accepted: boolean
}
// ---- In-memory D2 store (mirrors auth/session.ts; lost on refresh by design) ----
let d2Account: D2Account | null = null
let d2Csrf: string | null = null
const listeners = new Set<() => void>()
function emit(): void {
listeners.forEach((fn) => fn())
}
export function setD2Account(account: D2Account | null): void {
d2Account = account
emit()
}
export function getD2Account(): D2Account | null {
return d2Account
}
export function setD2Csrf(token: string | null): void {
d2Csrf = token
emit()
}
export function getD2Csrf(): string | null {
return d2Csrf
}
/** Clears the in-memory account + csrf (server clears the cookie separately). */
export function clearD2(): void {
d2Account = null
d2Csrf = null
emit()
}
export function onD2Change(fn: () => void): () => void {
listeners.add(fn)
return () => {
listeners.delete(fn)
}
}
function csrfHeaders(): Record<string, string> {
const c = getD2Csrf()
return c ? { 'X-CSRF-Token': c } : {}
}
function adopt(r: AuthResult): AuthResult {
setD2Account(r.account)
setD2Csrf(r.csrf_token)
return r
}
/** True when an error is the backend's recent-auth challenge (403 recent_auth_required). */
export function isRecentAuthRequired(e: unknown): boolean {
return e instanceof ApiRequestError && e.status === 403 && /recent_auth_required/.test(e.message)
}
/** True when a passkey delete was blocked because it is the account's only passkey. */
export function isFinalPasskeyProtected(e: unknown): boolean {
return e instanceof ApiRequestError && e.status === 422 && /final_passkey_protected/.test(e.message)
}
// ---- WebAuthn ceremony helpers (reuse auth/passkey serializers) ----
async function runCreate(options: unknown): Promise<unknown> {
if (!isWebAuthnSupported()) throw new PasskeyUnsupported('WebAuthn not supported')
let cred: PublicKeyCredential | null
try {
cred = (await navigator.credentials.create(toCreationOptions(options))) as PublicKeyCredential | null
} catch (e) {
throw new PasskeyCancelled((e as Error)?.name ?? 'cancelled')
}
if (!cred) throw new PasskeyCancelled('no credential')
return serializeRegistration(cred)
}
async function runGet(options: unknown): Promise<unknown> {
if (!isWebAuthnSupported()) throw new PasskeyUnsupported('WebAuthn not supported')
let cred: PublicKeyCredential | null
try {
cred = (await navigator.credentials.get(toRequestOptions(options))) as PublicKeyCredential | null
} catch (e) {
throw new PasskeyCancelled((e as Error)?.name ?? 'cancelled')
}
if (!cred) throw new PasskeyCancelled('no credential')
return serializeAssertion(cred)
}
// ---- Step-up ceremony + retry wrapper ----
/** Runs the step-up assertion ceremony and rotates the stored csrf token. */
export async function stepUp(): Promise<void> {
const options = await apiRequest<unknown>(`${V1}/stepup/options`, { body: {}, headers: csrfHeaders() })
const credential = await runGet(options)
const r = await apiRequest<{ ok: boolean; csrf_token: string }>(`${V1}/stepup/verify`, {
body: { credential }, headers: csrfHeaders(),
})
setD2Csrf(r.csrf_token)
}
/** Runs `fn`; on a 403 recent_auth_required, performs step-up then retries once. */
export async function withStepUp<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn()
} catch (e) {
if (isRecentAuthRequired(e)) {
await stepUp()
return fn()
}
throw e
}
}
// ---- Status / demo ----
export interface IdentityStatus {
enabled: boolean
ready?: boolean
demo_open_enroll?: boolean
rp_id?: string
origin?: string
}
/** Safe, secret-free D2 status. Absent surface (D2 dormant) throws — callers treat that as off. */
export async function getIdentityStatus(): Promise<IdentityStatus> {
return apiRequest<IdentityStatus>(`${V1}/status`)
}
/** One-click demo enrollment (only offered when the server reports demo_open_enroll).
* The server mints its own single-use invite; we run the passkey ceremony and adopt the session. */
export async function demoEnroll(
publicHandle: string, displayAlias: string, language = 'en', role: 'customer' | 'operator' = 'customer',
): Promise<AuthResult> {
const options = await apiRequest<unknown>(`${V1}/demo/enrollment/options`, {
body: { public_handle: publicHandle, display_alias: displayAlias, preferred_language: language, role },
})
const credential = await runCreate(options)
return adopt(await apiRequest<AuthResult>(`${V1}/enrollment/verify`, { body: { credential } }))
}
/** No-passkey demo login — works everywhere (incl. the HF embed iframe, iPad, any browser).
* Creates a demo account + session with no WebAuthn ceremony. Only when the server reports
* demo_open_enroll. Passkey enrollment remains the real security story on the direct origin. */
export async function demoQuickSession(role: 'customer' | 'operator' = 'customer'): Promise<AuthResult> {
return adopt(await apiRequest<AuthResult>(`${V1}/demo/quick-session`, { body: { role } }))
}
// ---- Public / auth ----
export async function validateInvitation(code: string): Promise<InvitationValidation> {
return apiRequest<InvitationValidation>(`${V1}/invitations/validate`, { body: { code } })
}
/** Full enrollment: options -> create() -> verify (adopts account + csrf). */
export async function enroll(profile: EnrollProfile): Promise<AuthResult> {
const options = await apiRequest<unknown>(`${V1}/enrollment/options`, { body: profile })
const credential = await runCreate(options)
return adopt(await apiRequest<AuthResult>(`${V1}/enrollment/verify`, { body: { credential } }))
}
/** Sign in with a passkey. Omit `publicHandle` for a discoverable (resident-key) sign-in. */
export async function signIn(publicHandle?: string): Promise<AuthResult> {
const body = publicHandle ? { public_handle: publicHandle } : {}
const options = await apiRequest<unknown>(`${V1}/authentication/options`, { body })
const credential = await runGet(options)
return adopt(await apiRequest<AuthResult>(`${V1}/authentication/verify`, { body: { credential } }))
}
export async function getSession(): Promise<{ authenticated: boolean; account?: D2Account }> {
const r = await apiRequest<{ authenticated: boolean; account?: D2Account }>(`${V1}/session`)
if (r.authenticated && r.account) setD2Account(r.account)
return r
}
export async function logout(): Promise<void> {
try {
await apiRequest<{ ok: boolean }>(`${V1}/logout`, { body: {}, headers: csrfHeaders() })
} finally {
clearD2()
}
}
/** Recover access with an operator-issued code by registering a fresh passkey. */
export async function recover(code: string): Promise<AuthResult> {
const options = await apiRequest<unknown>(`${V1}/recovery/options`, { body: { code } })
const credential = await runCreate(options)
return adopt(await apiRequest<AuthResult>(`${V1}/recovery/verify`, { body: { credential } }))
}
// ---- Authenticated customer ----
export async function getAccount(): Promise<D2Account> {
const r = await apiRequest<{ account: D2Account }>(`${V1}/account`)
setD2Account(r.account)
return r.account
}
export async function listPasskeys(): Promise<D2Passkey[]> {
const r = await apiRequest<{ passkeys: D2Passkey[] }>(`${V1}/passkeys`)
return r.passkeys
}
/** Register an additional passkey on this device (step-up on recent_auth_required). */
export async function addPasskey(nickname: string): Promise<D2Passkey> {
return withStepUp(async () => {
const options = await apiRequest<unknown>(`${V1}/passkeys/options`, { body: {}, headers: csrfHeaders() })
const credential = await runCreate(options)
const r = await apiRequest<{ passkey: D2Passkey }>(`${V1}/passkeys/verify`, {
body: { credential, nickname }, headers: csrfHeaders(),
})
return r.passkey
})
}
export async function renamePasskey(id: string, nickname: string): Promise<void> {
await withStepUp(() =>
apiRequest<{ ok: boolean }>(`${V1}/passkeys/${id}`, { method: 'PATCH', body: { nickname }, headers: csrfHeaders() }),
)
}
export async function revokePasskey(id: string): Promise<void> {
await withStepUp(() =>
apiRequest<{ ok: boolean }>(`${V1}/passkeys/${id}`, { method: 'DELETE', headers: csrfHeaders() }),
)
}
export async function listSessions(): Promise<D2Session[]> {
const r = await apiRequest<{ sessions: D2Session[] }>(`${V1}/sessions`)
return r.sessions
}
export async function revokeSession(id: string): Promise<void> {
await apiRequest<{ ok: boolean }>(`${V1}/sessions/${id}`, { method: 'DELETE', headers: csrfHeaders() })
}
export async function revokeOtherSessions(): Promise<void> {
await apiRequest<{ ok: boolean }>(`${V1}/sessions/revoke-others`, { body: {}, headers: csrfHeaders() })
}
/** Pause the account (needs recent auth). Server clears the cookie; we clear local state. */
export async function pauseAccount(): Promise<{ ok: boolean; status: string }> {
return withStepUp(async () => {
const r = await apiRequest<{ ok: boolean; status: string }>(`${V1}/account/pause`, {
body: {}, headers: csrfHeaders(),
})
clearD2()
return r
})
}
/** Delete the account: options -> get() -> confirm. Returns the deletion receipt. */
export async function deleteAccount(): Promise<DeletionReceipt> {
const options = await apiRequest<unknown>(`${V1}/account/delete/options`, { body: {}, headers: csrfHeaders() })
const credential = await runGet(options)
const r = await apiRequest<DeletionReceipt>(`${V1}/account/delete/confirm`, {
body: { credential }, headers: csrfHeaders(),
})
clearD2()
return r
}
// ---- Operator ----
export async function createInvitations(
role: string, count: number, ttlSeconds: number,
): Promise<{ id: string; code: string }[]> {
const r = await apiRequest<{ invitations: { id: string; code: string }[] }>(`${V1}/operator/invitations`, {
body: { role, count, ttl_seconds: ttlSeconds }, headers: csrfHeaders(),
})
return r.invitations
}
export async function listInvitations(): Promise<D2Invitation[]> {
const r = await apiRequest<{ invitations: D2Invitation[] }>(`${V1}/operator/invitations`)
return r.invitations
}
export async function revokeInvitation(id: string): Promise<void> {
await apiRequest<{ ok: boolean }>(`${V1}/operator/invitations/${id}`, { method: 'DELETE', headers: csrfHeaders() })
}
export async function listOperatorAccounts(): Promise<D2OperatorAccount[]> {
const r = await apiRequest<{ accounts: D2OperatorAccount[] }>(`${V1}/operator/accounts`)
return r.accounts
}
export async function getOperatorAccount(
id: string,
): Promise<{ account: D2OperatorAccount; events: D2AccountEvent[] }> {
return apiRequest<{ account: D2OperatorAccount; events: D2AccountEvent[] }>(`${V1}/operator/accounts/${id}`)
}
export async function operatorPauseAccount(id: string): Promise<void> {
await apiRequest<{ ok: boolean }>(`${V1}/operator/accounts/${id}/pause`, { body: {}, headers: csrfHeaders() })
}
export async function operatorReactivateAccount(id: string): Promise<void> {
await apiRequest<{ ok: boolean }>(`${V1}/operator/accounts/${id}/reactivate`, { body: {}, headers: csrfHeaders() })
}
export async function operatorDeleteAccount(id: string): Promise<void> {
await apiRequest<{ ok: boolean }>(`${V1}/operator/accounts/${id}/delete`, { body: {}, headers: csrfHeaders() })
}
export async function operatorRecoveryInvitation(id: string): Promise<string> {
const r = await apiRequest<{ code: string }>(`${V1}/operator/accounts/${id}/recovery-invitation`, {
body: {}, headers: csrfHeaders(),
})
return r.code
}