File size: 13,943 Bytes
dc47d57 68eaf46 518b0c2 68eaf46 518b0c2 68eaf46 fab9291 dc47d57 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 | // 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
}
|