| |
| |
| |
| |
| |
|
|
| import { apiRequest, ApiRequestError } from './client' |
| import { |
| isWebAuthnSupported, PasskeyCancelled, PasskeyUnsupported, |
| serializeAssertion, serializeRegistration, toCreationOptions, toRequestOptions, |
| } from '../auth/passkey' |
|
|
| const V1 = '/identity/v1' |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| export function isRecentAuthRequired(e: unknown): boolean { |
| return e instanceof ApiRequestError && e.status === 403 && /recent_auth_required/.test(e.message) |
| } |
|
|
| |
| export function isFinalPasskeyProtected(e: unknown): boolean { |
| return e instanceof ApiRequestError && e.status === 422 && /final_passkey_protected/.test(e.message) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| |
| 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) |
| } |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| export interface IdentityStatus { |
| enabled: boolean |
| ready?: boolean |
| demo_open_enroll?: boolean |
| rp_id?: string |
| origin?: string |
| } |
|
|
| |
| export async function getIdentityStatus(): Promise<IdentityStatus> { |
| return apiRequest<IdentityStatus>(`${V1}/status`) |
| } |
|
|
| |
| |
| 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 } })) |
| } |
|
|
| |
| |
| |
| export async function demoQuickSession(role: 'customer' | 'operator' = 'customer'): Promise<AuthResult> { |
| return adopt(await apiRequest<AuthResult>(`${V1}/demo/quick-session`, { body: { role } })) |
| } |
|
|
| |
| export async function validateInvitation(code: string): Promise<InvitationValidation> { |
| return apiRequest<InvitationValidation>(`${V1}/invitations/validate`, { body: { code } }) |
| } |
|
|
| |
| 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 } })) |
| } |
|
|
| |
| 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() |
| } |
| } |
|
|
| |
| 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 } })) |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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() }) |
| } |
|
|
| |
| 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 |
| }) |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|