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;}Methods
Section titled “Methods”register(params)
Section titled “register(params)”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, password, opts?)
Section titled “login(username, password, opts?)”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 (30 days). Throws on bad credentials / expired challenge,
VaultUnavailableError when the vault can’t be reached, and
VaultProtocolMismatchError when the vault refuses this SDK’s OPRF protocol
(update the app).
restore()
Section titled “restore()”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.
resume()
Section titled “resume()”resume(): Promise<{ user: AuthUser | null; unlocked: boolean }>The boot helper: call it once on app start. Runs restore() and, when the
account has a passkey that works on this origin, silently unlocks the identity
with WebAuthn. user is null when there is no valid session. When unlocked
is false (no usable passkey, the user cancelled, or the browser needed a
gesture), prompt for the password and call unlock().
unlock(password)
Section titled “unlock(password)”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 (incorrect password (commitment mismatch)) and when there is
no session to unlock. Throws VaultUnavailableError if the vault can’t be
reached.
enrollPasskey(opts?)
Section titled “enrollPasskey(opts?)”enrollPasskey(opts?: { rpId?: string; rpName?: string; label?: string }): Promise<void>Adds a WebAuthn passkey (PRF) that wraps the master seed. Signed-in only.
rpId defaults to the current host and label to "Passkey". Passkeys are
bound to the origin they were enrolled on.
loginWithPasskey(username, opts?)
Section titled “loginWithPasskey(username, opts?)”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. Throws PasskeyOriginError when the
account’s passkey belongs to a different origin — treat it as “no passkey here”
and fall back to another factor.
passkeyAvailable()
Section titled “passkeyAvailable()”passkeyAvailable(): booleanWhether WebAuthn is usable in this browser (sync).
passkeyPrfAvailable()
Section titled “passkeyPrfAvailable()”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()
Section titled “enrollRecoveryPhrase()”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, mnemonic, opts?)
Section titled “recoverWithPhrase(username, mnemonic, opts?)”recoverWithPhrase(username: string, mnemonic: string, opts?: { rememberMe?: boolean }): 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)
Section titled “enrollEmailFactor(email)”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 OPRF evaluation after the address is OTP-verified. Muhkoo holds the keys behind that evaluation, so it could technically recover the seed of any account with an email factor. Hosted sign-in discloses this; a custom enroll UI must show equivalent disclosure — see the custody trade-off.
recoverWithEmail(username, opts?)
Section titled “recoverWithEmail(username, opts?)”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)
Section titled “enrollGoogleFactor(idToken)”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. Same custody trade-off as email: Muhkoo could technically recover
the seed of an account with a Google factor, so a custom enroll UI must disclose
it.
loginWithGoogle(username, idToken, opts?)
Section titled “loginWithGoogle(username, idToken, opts?)”loginWithGoogle(username: string, idToken: string, opts?: { rememberMe?: boolean }): Promise<AuthUser>Sign in with Google — verifies the ID token, unlocks the seed via the gated
OPRF evaluation, 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, idToken)
Section titled “registerWithGoogle(username, idToken)”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. Like any gated factor, Google leaves the seed recoverable by Muhkoo — see the custody trade-off.
changePassword(newPassword)
Section titled “changePassword(newPassword)”changePassword(newPassword: string): Promise<void>Re-wraps the unchanged seed under a new password. Identity and commitment never move. Signed-in only.
listFactors()
Section titled “listFactors()”listFactors(): Promise<Array<{ id: string; type: string; // "password" | "passkey" | "phrase-marker" | "email" | "google" | "device" label?: string; createdAt?: number; masked?: string; rpId?: string; // passkeys: the origin it was enrolled on}>>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. device entries are
paired browsers and machines — an account has
one password but may hold many of those.
removeFactor(id)
Section titled “removeFactor(id)”removeFactor(id: string): Promise<void>Removes an enrolled factor. Throws if it’s the last remaining one.
recover()
Section titled “recover()”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.
hasPasskeyForThisOrigin()
Section titled “hasPasskeyForThisOrigin()”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.
Paired browsers and machines
Section titled “Paired browsers and machines”A device factor gives a browser or a machine long-lived access to the account
without a password and without storing the seed anywhere: a CLI that shouldn’t
prompt on every command, a server-side tool, or a browser whose owner asked to be
remembered. The hosted sign-in page’s “Keep me signed in on this browser”
enrolls one of these — see
Staying signed in.
enrollDevice(label)
Section titled “enrollDevice(label)”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 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.
label is what the owner reads when deciding which pairing to remove
("ci-runner", "Chrome on macOS"), so make it something they’ll recognise.
Treat deviceKey as the credential it is, and store it with the tightest
permissions available — on the origin or the machine that will use it, and
nowhere else.
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. That suits a
caller already holding a valid session token on disk, such as a CLI; a browser
usually wants loginWithDevice below.
Throws DeviceRevokedError when the pairing has been revoked. That is a
different failure from a wrong key, which fails to decrypt rather than to
resolve — a machine whose access was withdrawn should be able to say so, not
merely report a decryption failure.
loginWithDevice(username, factorId, deviceKey, opts?)
Section titled “loginWithDevice(username, factorId, deviceKey, opts?)”loginWithDevice( username: string, factorId: string, deviceKey: string, opts?: { rememberMe?: boolean },): Promise<AuthUser>The same unwrap as unlockWithDevice, followed by a real sign-in: the ZK
identity is derived from the recovered seed and proved against a fresh challenge,
exactly as recoverWithPhrase does. Establishes session and identity. The
seed has always been sufficient to authenticate, so asking for a password here
would have proved nothing new.
Use it wherever a session can lapse while the pairing is still valid — a browser, most obviously, which would otherwise hold the means to decrypt everything and no way to make an authenticated call. This is what makes signing in once per browser possible with no credential at rest: nothing stores the seed, only a key that opens a blob the vault holds.
Throws DeviceRevokedError on a revoked pairing. Discard the stored key and fall
back to a password — see Errors.
Requires @muhkoo/connect@0.14.0-alpha.5 or later, as does DeviceRevokedError
and login’s prompt option below.
listDevices() / revokeDevice(factorId)
Section titled “listDevices() / revokeDevice(factorId)”await client.auth.zk.listDevices(); // [{ id, label, createdAt }]await client.auth.zk.revokeDevice(id); // Promise<boolean>: whether it existedEvery device factor on the account, newest first — remembered browsers and
paired machines alike, metadata only. Revocation takes effect on the next unlock:
the factor is gone from the vault, so loginWithDevice / unlockWithDevice
throw DeviceRevokedError rather than returning a seed. The same pairings appear
as Paired browser or machine in the account’s login methods on the hosted
security page, and in muhkoo devices ls.
unlockWithSeed(seedBase64)
Section titled “unlockWithSeed(seedBase64)”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()
Section titled “logout()”logout(): Promise<void>Clears the session, identity, and persisted token.
Getters
Section titled “Getters”| Getter | Type | Description |
|---|---|---|
client.auth.zk.user | AuthUser | null | Current user (sync). |
client.auth.zk.token | string | null | Current session token (sync). |
client.auth.zk.identity | ZkIdentity | null | Derived identity, or null when locked. |
client.auth.zk.seedBase64 | string | null | Master seed as base64, or null when locked. Wrap per-user app data to this so it survives password changes and passkey login. |
client.auth.user | AuthUser | null | Convenience alias. |
Errors
Section titled “Errors”import { VaultUnavailableError, VaultProtocolMismatchError, DeviceRevokedError, PasskeyOriginError,} from "@muhkoo/connect";VaultUnavailableError— thrown bylogin/unlockwhen 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.VaultProtocolMismatchError— thrown bylogin/unlock/register/changePasswordwhen the vault refuses the OPRF protocol this SDK speaks (HTTP 409oprf_protocol_unsupported). Retrying cannot help: update the app (or the server). Not a wrong password, and not a connection problem.PasskeyOriginError(.code === "passkey_wrong_origin") — thrown byloginWithPasskeywhen the account’s passkey was enrolled for another origin.DeviceRevokedError— thrown byloginWithDevice/unlockWithDevicewhen thedevicefactor is no longer in the vault. Typed because the right response is specific: discard the stored key and fall back to a password. Retrying cannot succeed, and re-pairing silently would undo a revocation someone performed on purpose.
client.auth.hosted
Section titled “client.auth.hosted”Centralized hosted auth on auth.muhkoo.dev (authorization-code + PKCE). See the
Hosted authentication guide.
login({ appId, redirectUri, redirect?, prompt?, returnTo? })—Promise<string>. Stashes a PKCE verifier +stateand redirects the browser to the hosted/authorizepage; resolves to the URL (also returned for popup/test use).redirectUrimust be registered for the app (portal → App Detail → Hosted sign-in).redirect: falsereturns the URL without navigating.prompt: "login"forces the credential screen even on a browser the hosted page remembers — which is how your app’s own sign-out survives. Named after the OIDC parameter that solves the same problem.returnTois a same-origin path (starts with/, not//) that rides the PKCE stash and comes back fromhandleCallback(); anything else throws.handleCallback()—Promise<{ username, commitment, returnTo? }>. On your callback route: verifiesstate, exchanges the code for a session, unseals the master seed from the URL fragment, establishes the session + identity, and scrubs the URL.returnTois the value passed tologin(), when still a same-origin path. Throws on a bad/expired code or PKCE/CSRF mismatch.isCallback()—boolean.truewhen the current URL is a hosted-auth callback (?code&state). Use it to guardhandleCallback().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.completeAuthorize({ appId, redirectUri, codeChallenge, state })—Promise<string>. Hosted-page side only (used byauth.muhkoo.devitself, not by apps): seals the in-memory seed, mints the grant, and returns the URL to send the browser back to.
A browser that ticked “Keep me signed in on this browser” on the hosted page
resumes without a password on its next visit: silently for Muhkoo-owned surfaces,
behind a “Continue as …” confirmation for every other app. There is nothing to
call — it happens inside /authorize. The pairing is a
device factor, and prompt: "login" is how
you bypass it.
Set authBaseUrl on the Client to override the hosted origin (default
https://auth.muhkoo.dev).
Device pairing
Section titled “Device pairing”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 }. DisplayuserCodeandverificationCode.waitForDevicePairing(session, { signal?, onTick? })—Promise<{ username, commitment }>. Polls to completion (withslow_downbackoff), unseals the seed, verifies the commitment, mints the device’s own session, and persists it. Rejects withDevicePairingError.pollDevicePairing(session)—Promise<DevicePairingPoll>. One poll, for custom loops, resolving to a union onstatus:pending(interval,expiresIn),slow_down(interval,retryAfter),approved(user),deniedorexpired.cancelDevicePairing(session)—void. Abandons the attempt; an in-flightwaitForDevicePairingrejects with reasonaborted.resumeDeviceSession()—Promise<{ username, commitment } | null>. Cold start from the persisted identity.nullmeans “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. ThrowsReauthRequiredErrorwhen 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.