Skip to content

client.auth

Authentication is exposed through client.auth.zk. See the Authentication guide for usage.

interface AuthUser {
username: string;
/** Decimal-string Poseidon commitment — the user's stable id. */
commitment: string;
}
register(params: {
username: string;
password: string;
email?: string | null;
login?: boolean; // default: true
}): Promise<AuthUser>

Derives the identity, registers the commitment, and (unless login: false) signs in. Throws if the username is taken.

login(username: string, password: string, opts?: { rememberMe?: boolean }): Promise<AuthUser>

Re-derives the identity, proves it against a fresh server challenge, and establishes the session and identity. rememberMe: true requests a longer-lived session. Throws on bad credentials / expired challenge.

restore(): Promise<AuthUser | null>

Validates a persisted session token (from the sessionStore). Returns the user when valid, or null (clearing a stale token). Does not unlock the identity — call unlock() for encryption/messaging.

unlock(password: string): Promise<void>

Re-derives the identity for an already-authenticated (restored) session. Verifies the password by matching the derived commitment to the session’s; throws on mismatch.

enrollPasskey(opts?: { rpId?: string; rpName?: string; label?: string }): Promise<void>

Adds a WebAuthn passkey (PRF) that wraps the master seed. Signed-in only.

loginWithPasskey(username: string, opts?: { rememberMe?: boolean }): Promise<AuthUser>

Passwordless sign-in via an enrolled passkey. Unwraps the seed and re-derives the identity, establishing session and identity.

passkeyAvailable(): boolean

Whether WebAuthn is usable in this browser (sync).

passkeyPrfAvailable(): Promise<boolean | null>

Whether the authenticator supports the required PRF extension. null means it can’t be determined. Gate the passkey UI on a true result.

enrollRecoveryPhrase(): Promise<string>

Returns a 24-word BIP39 phrase to display once — it is the seed, nothing is stored server-side. Deterministic, so it returns the same words each call. Signed-in only.

recoverWithPhrase(username: string, mnemonic: string): Promise<AuthUser>

“Forgot password” path: re-derives the identity from the phrase and signs in. Follow with changePassword() to set a new password.

enrollEmailFactor(email: string): Promise<{
email: string;
confirm: (code: string) => Promise<void>;
}>

Add an email as a recovery + login factor. Sends a one-time code to the address and returns a confirm continuation — call it with the code from the inbox to finish enrolling:

const { confirm } = await client.auth.zk.enrollEmailFactor("me@example.com");
await confirm(codeFromInbox);

Signed-in only (the seed must be held). Verification-gated: the server only releases the split-key OPRF eval after the address is OTP-verified. Disclose the custody trade-off (recoverability vs. custody) in your enroll UI.

recoverWithEmail(username: string, opts?: { rememberMe?: boolean }): Promise<{
confirm: (code: string) => Promise<AuthUser>;
}>

“Forgot password” path via an enrolled email factor. Sends a code to the stored address (the response never reveals whether one exists), then confirm(code) unwraps the seed and signs in. Follow with changePassword().

const { confirm } = await client.auth.zk.recoverWithEmail("alice");
const user = await confirm(codeFromInbox);
enrollGoogleFactor(idToken: string): Promise<void>

Link a Google account as a recovery + login factor. idToken is a fresh Google ID token obtained via Google Identity Services (the SDK never renders Google UI). Signed-in only.

loginWithGoogle(username: string, idToken: string, opts?: { rememberMe?: boolean }): Promise<AuthUser>

Sign in with Google — verifies the ID token, unlocks the seed via the gated split-key eval, and establishes session and identity. The account must have linked the factor first (enrollGoogleFactor). Serves both “Sign in with Google” and “recover with Google”.

registerWithGoogle(username: string, idToken: string): Promise<AuthUser>

Register a brand-new, passwordless account whose only factor is Google. A random master seed is generated and wrapped under the Google-gated key; the user can add a password / passkey / phrase later. ZK registration is identical to a password signup.

changePassword(newPassword: string): Promise<void>

Re-wraps the unchanged seed under a new password. Identity and commitment never move. Signed-in only.

listFactors(): Promise<Array<{
id: string;
type: "password" | "passkey" | "phrase-marker" | "email" | "google";
label?: string;
createdAt?: number;
masked?: string;
}>>

Lists the user’s enrolled login methods (metadata only). Gated factors (email / google) carry a masked display hint (e.g. "m•••@gmail.com"); the full value is never returned.

removeFactor(id: string): Promise<void>

Removes an enrolled factor. Throws if it’s the last remaining one.

recover(): Promise<boolean>

Silently re-mints a session token by re-proving the in-memory identity — no password prompt. Resolves true if it succeeded, false if it can’t (the identity isn’t in memory, e.g. after a reload that kept only the token). The client calls this automatically when a request hits 401; you normally use the client-level recoverSession() + onSessionExpired instead of calling it directly.

if (await client.auth.zk.hasPasskeyForThisOrigin()) { /* offer passkey sign-in */ }

Whether this account has a passkey that will actually work here. Passkeys are per-origin, so an account with a passkey enrolled on another host has one that WebAuthn will refuse on this one. Use this before offering a passkey button — not listFactors(), which will happily report a passkey you cannot use.

For a CLI, a server-side tool, or any process that needs your encryption identity without a person typing a password each time.

const { factorId, deviceKey } = await client.auth.zk.enrollDevice("ci-runner");

Generates a random key, wraps the master seed under it, and stores only the ciphertext in your vault as a device factor. The key is returned for you to persist; it is never sent anywhere.

The split is the point: the seed itself never lands on the machine’s disk, and revoking the factor makes the stored key inert immediately — which a copied seed can never be, because nothing can reach out and un-store it.

Treat deviceKey as the credential it is, and store it with the tightest permissions available.

unlockWithDevice(username, factorId, deviceKey)

Section titled “unlockWithDevice(username, factorId, deviceKey)”
await client.auth.zk.unlockWithDevice(username, factorId, deviceKey);

Fetches the wrapped seed and opens it locally. Establishes the encryption identity only — pair it with a session for calls that need one.

Throws distinguishably: a revoked factor is gone from the vault and says so, where a wrong key fails to decrypt. A machine whose access was withdrawn should be able to tell you that, not merely report a decryption failure.

await client.auth.zk.listDevices(); // [{ id, label, createdAt }]
await client.auth.zk.revokeDevice(id); // that machine's key stops working
client.auth.zk.unlockWithSeed(process.env.MUHKOO_SEED);

The inverse of seedBase64, for hosts that hold the seed themselves. Without it, anything outside a browser must run a full ZK login — circuit download and proof — on every invocation.

Sets the encryption identity only; it does not authenticate. A persisted seed is not scoped and cannot be revoked server-side the way a session token or a device factor can, so prefer enrollDevice where you have the choice.

logout(): Promise<void>

Clears the session, identity, and persisted token.

GetterTypeDescription
client.auth.zk.userAuthUser | nullCurrent user (sync).
client.auth.zk.tokenstring | nullCurrent session token (sync).
client.auth.zk.identityZkIdentity | nullDerived identity, or null when locked.
client.auth.zk.seedBase64string | nullMaster seed as base64, or null when locked. Wrap per-user app data to this so it survives password changes and passkey login.
client.auth.userAuthUser | nullConvenience alias.
import { VaultUnavailableError } from "@muhkoo/connect";
  • VaultUnavailableError — thrown by login / unlock when the per-user vault is unreachable (network / 5xx / rate-limit). Distinct from a wrong password — tell the user to retry rather than re-enter credentials.

Centralized hosted auth on auth.muhkoo.dev (authorization-code + PKCE). See the Hosted authentication guide.

  • login({ appId, redirectUri })Promise<string>. Stashes a PKCE verifier + state and redirects the browser to the hosted /authorize page; resolves to the URL (also returned for popup/test use). redirectUri must be registered for the app (portal → App Detail → Hosted sign-in).
  • handleCallback()Promise<{ username, commitment }>. On your callback route: verifies state, exchanges the code for a session, unseals the master seed from the URL fragment, establishes the session + identity, and scrubs the URL. Throws on a bad/expired code or PKCE/CSRF mismatch.
  • isCallback()boolean. true when the current URL is a hosted-auth callback (?code&state). Use it to guard handleCallback().
  • manageAccount({ returnUri? })Promise<string>. Redirect to centralized account & security management (auth.muhkoo.dev/security), where the user manages passkeys, recovery email, Google, recovery phrase, and password. returnUri (default: the current URL) becomes a “Back to app” button. Wire it to a “Manage account” / settings entry in your app — there’s no per-app security UI to build.

Set authBaseUrl on the Client to override the hosted origin (default auth.muhkoo.dev).

Sign-in for keyboard-less devices (TVs, set-top boxes). Requires @muhkoo/connect@0.10.13-alpha.0 or later. See the TV & device pairing guide for the full flow, the storage caveats, and the error table.

Device side:

  • startDevicePairing({ appId, label? })Promise<DevicePairingSession>. Generates the ephemeral keypair and returns { userCode, verificationCode, verificationUri, verificationUriComplete, expiresAt, interval, deviceCode, privateKey, devicePublicKeyB64 }. Display userCode and verificationCode.
  • waitForDevicePairing(session, { signal?, onTick? })Promise<{ username, commitment }>. Polls to completion (with slow_down backoff), unseals the seed, verifies the commitment, mints the device’s own session, and persists it. Rejects with DevicePairingError.
  • pollDevicePairing(session)Promise<DevicePairingPoll>. One poll, for custom loops: { status: "pending" | "slow_down" | "approved" | "denied" | "expired" }.
  • cancelDevicePairing(session)void. Abandons the attempt; an in-flight waitForDevicePairing rejects with reason aborted.
  • resumeDeviceSession()Promise<{ username, commitment } | null>. Cold start from the persisted identity. null means “pair again”; never throws.
  • hasDeviceSession()Promise<boolean>. Whether anything is persisted.
  • forgetDeviceSession()Promise<void>. Wipes the persisted seed, the device identity key, and the session.

Approver side (signed in, seed unlocked):

  • lookupDevicePairing(userCode)Promise<DevicePairingRequest>. Resolves a typed code to { userCode, devicePublicKeyB64, verificationCode, deviceFingerprint, deviceLabel, appId, appLabel, isKnownDevice, requiresReauth, expiresAt }.
  • approveDevicePairing(req, { label? })Promise<{ deviceId }>. Seals the seed to the device’s key and posts it. Throws ReauthRequiredError when a new device needs a fresh sign-in factor.
  • denyDevicePairing(userCode)Promise<void>. Refuse. No fresh factor needed.
  • listPairedDevices()Promise<PairedDevice[]>{ id, label, appId, pairedAt, lastSeenAt, activeSession }.
  • revokePairedDevice(id)Promise<void>. Removes the device from the roster so it must be re-approved. It does not end the device’s current session (that survives until expiry) and does not take back the encryption key the device already holds.

Errors: DevicePairingError (.reason, .code, .status, .retryAfter, .details) and ReauthRequiredError (extends it; adds .authAgeSeconds, .maxAgeSeconds), both exported from @muhkoo/connect.