Authentication and sessions
Passkey, OTP, social and external-wallet flows, sessions and profiles.
Status and source boundary
This is a proposed SDK surface for product review. It specifies target application-facing behavior and required business outcomes. It is not an installable library, an accepted implementation specification, a security audit or proof of one-to-one source-to-target parity.
The source review was refreshed on 2026-09-16 at revision c4fad72f4bc21c3faf11ba0fccc77f0662134a16. The inventory was independently derived from the public current browser core, legacy browser client, React adapter, server client and generated public types. Source names are retained only where they make field mapping precise.
Authority boundaries#
| Boundary | What it permits | What it never permits by itself |
|---|---|---|
| Identity authentication | Establish one bounded user session | Wallet signing, treasury authority, policy changes, owner approval or custody recovery |
| Wallet permission | Exercise a current grant on one wallet | Another wallet, broader action, server-wallet service role or ownership transfer |
| Approval | Authorize the exact immutable action and requestId |
Changed material, another request/tenant or replay after expiry |
| Custody recovery | Rebind wallet access under an independent recovery policy | Ordinary login recovery, factor linking or import |
Every protected backend call revalidates session expiry, tenant, actor, wallet control mode, grant, policy and exact action authorization. A UI state or session token alone is not authorization.
Authentication applies differently to the two wallet modes. An embedded wallet remains owner-controlled; an application backend may validate and coordinate but does not silently acquire owner authority. A server wallet is a separately selected service-controlled mode for treasury, payouts and network fees. Its remote signer acts only as an explicit service identity within policies, transaction limits and approval rules. Authenticating an end user never grants server-wallet treasury authority. Wallet creation requires explicit controlMode; there is no default or silent migration between modes. Neither mode returns secret signing material to a browser or ordinary result.
Stable target types#
type SessionPrivilege = "readOnly" | "readWrite";
type Session = {
sessionId: string;
userId: string;
organizationId: string;
privilege: SessionPrivilege;
scope: string;
issuedAt: string; // RFC 3339 UTC
expiresAt: string; // RFC 3339 UTC
profileId?: string;
};
type AuthResult = {
action: "login" | "signup";
userId: string;
session: Session;
localSessionKey: string;
};
sessionId and localSessionKey are opaque identifiers, not key bytes. Session tokens, verification tokens and encrypted credential bundles are sensitive. The browser implementation stores them in its protected facility and returns only the safe projection above. No example logs OTPs, tokens, bundles, client secrets or signing material.
All expirationSeconds values are unsigned decimal seconds. Zero, negatives, fractions, exponent notation and values above the configured maximum are rejected. A source default such as 900 seconds is evidence, not a fixed target promise; omission uses the organization/profile default and the result returns the effective expiry.
Cancellation before dispatch has no effect. After dispatch, timeout or cancellation may leave a mutation unknown; reconcile by stable resource/operation ID. Challenges and callbacks are single-use, so a consumed proof is never retried.
| Error/result | Required behavior |
|---|---|
cancelled |
No effect only when cancellation is confirmed before dispatch; otherwise outcome is unknown |
challengeExpired / challengeReplayed / invalidProof |
No session/factor; start a fresh challenge, never retry consumed proof |
accountNotFound / accountAlreadyExists |
Preserve explicit login versus signup intent; never silently provision from login-only |
mfaRequired / policyDenied / readOnlyDenied |
Preserve required authority; never downgrade factor, policy or privilege |
sessionExpired / sessionRevoked |
Deny new protected work and clear local state after server truth is known |
rateLimited |
Honor server retry boundary; do not rotate identity/contact to evade it |
outcomeUnknown |
Reconcile the same attempt/session/activity/resource; do not mint a broader replacement |
Runtime availability#
| Capability | Browser TypeScript | React | Server TypeScript | Python | Go |
|---|---|---|---|---|---|
| Passkey ceremony | Yes | UI wrapper | Validate only | Validate only | Validate only |
| Email/SMS OTP | Yes | UI wrapper | Coordinate | Coordinate | Coordinate |
| Social login | Public start/callback | Provider UI | Confidential exchange/validation | Same | Same |
| External-wallet signature | Yes | Connection/auth UI | Challenge validation | Same | Same |
| Local session storage/switch | Yes | Inherited | No browser storage | No browser storage | No browser storage |
| MFA policy/OAuth credentials | No secret admin | No secret admin | Privileged admin | Privileged admin | Privileged admin |
Python and Go never imply WebAuthn dialogs, browser redirects, wallet popups or hooks.
Passkeys#
createPasskey#
createPasskey(input?: { name?: string; challenge?: string }): Promise<{
attestation: PasskeyAttestation;
encodedChallenge: string;
}>
name is a display label. challenge is optional in the verified core; when omitted, the browser flow generates one and returns its encoded value. An integration requiring a server-issued challenge supplies it and verifies exact equality. Output includes authenticator attestation and credential-ID metadata, never an authenticator secret. This ceremony neither creates a user nor logs in.
Requires a secure context and configured relying party/origin. Cancellation, unsupported authenticator, duplicate credential, wrong origin/RP or invalid/expired challenge creates no account or session.
signUpWithPasskey#
signUpWithPasskey(input?: {
createSubOrgParams?: AccountCreation; sessionKey?: string;
passkeyDisplayName?: string; expirationSeconds?: string;
challenge?: string; sessionProfileId?: string; captchaToken?: string;
}): Promise<PasskeyAuthResult>
This is signup-only. createSubOrgParams is explicit account data; sessionKey is an opaque local slot; display name defaults to a generated label. The source returns sensitive sessionToken, credentialId and optional application proofs. The target stores the token internally, maps the session to AuthResult, and exposes credential ID only in the factor projection. Duplicate identity, invalid anti-abuse proof, policy denial or response loss is never reported as signup. After response loss, inspect account state before a new attempt.
loginWithPasskey#
loginWithPasskey(input?: {
publicKey?: string; sessionKey?: string; expirationSeconds?: string;
organizationId?: string; allowCredentials?: PublicKeyCredentialDescriptor[];
sessionProfileId?: string;
}): Promise<PasskeyAuthResult>
publicKey is the future session public key and is generated in protected browser storage when omitted. The browser ceremony may use configured single-tenant organization context; any later server validation/provisioning requires explicit organization and never runs the WebAuthn dialog. allowCredentials restricts the chooser. sessionProfileId caps the resulting read/write session.
The current core signature has no sessionType field and, without a profile, creates the default read/write session. The legacy browser signature separately accepts sessionType, defaults it to read/write and branches to createReadOnlySession. The current React adapter inherits the current core path. Target sessionPrivilege: "readOnly" must route to actual read-only issuance; if unavailable it fails explicitly and never falls back to read/write. Refresh never elevates privilege.
User cancellation before proof dispatch, credential mismatch, invalid assertion, unknown account, expired challenge or revoked factor returns no new session. Cancellation or response loss after the login request is dispatched leaves issuance unknown: reconcile the bound session public key/local slot and backend session state before minting a replacement, and never replace an uncertain read-only request with read/write.
const signup = await auth.signUpWithPasskey({
challenge: registrationChallenge,
passkeyDisplayName: "This device",
createSubOrgParams: newAccount,
sessionProfileId: embeddedWalletProfileId,
});
await auth.loginWithPasskey({
allowCredentials: [{ type: "public-key", id: signup.credentialId }],
sessionProfileId: embeddedWalletProfileId,
});
Passwordless email and SMS#
initOtp(input: {
otpType: "OTP_TYPE_EMAIL" | "OTP_TYPE_SMS";
contact: string; captchaToken?: string;
}): Promise<{ otpId: string; otpEncryptionTargetBundle: string }>
verifyOtp(input: {
otpId: string; otpCode: string; otpEncryptionTargetBundle: string;
publicKey?: string;
}): Promise<{ verificationToken: string; publicKey: string }>
otpId names one short-lived challenge. The bundle binds delivery and verification. Email is normalized under the account contract; SMS requires an accepted E.164 number. publicKey is protected-client generated when omitted. verificationToken is sensitive and used only by the immediate completion call. Wrong contact/type/code, expiry, replay, rate limit or invalid anti-abuse proof creates no user/session.
loginWithOtp(input: {
verificationToken: string; organizationId?: string;
invalidateExisting?: boolean; sessionKey?: string;
expirationSeconds?: string; sessionProfileId?: string;
}): Promise<BaseAuthResult>
signUpWithOtp(input: {
verificationToken: string; contact: string;
otpType: "OTP_TYPE_EMAIL" | "OTP_TYPE_SMS";
createSubOrgParams?: AccountCreation; invalidateExisting?: boolean;
sessionKey?: string; sessionProfileId?: string; captchaToken?: string;
}): Promise<BaseAuthResult>
completeOtp(input: {
otpId: string; otpCode: string; otpEncryptionTargetBundle: string;
contact: string; otpType: "OTP_TYPE_EMAIL" | "OTP_TYPE_SMS";
publicKey?: string; invalidateExisting?: boolean; sessionKey?: string;
createSubOrgParams?: AccountCreation; sessionProfileId?: string;
captchaToken?: string;
}): Promise<BaseAuthResult & { action: "LOGIN" | "SIGNUP" }>
loginWithOtp is existing-account only. signUpWithOtp is explicit signup. completeOtp may automatically sign up when lookup finds no account; it is allowed only on a screen whose product copy and input explicitly permit loginOrSignup. A login-only screen must call loginWithOtp and return accountNotFound, never silently provision a user. invalidateExisting defaults false and requests backend invalidation; local clearing remains separate.
const started = await auth.initOtp({ otpType: "OTP_TYPE_EMAIL", contact: email });
const verified = await auth.verifyOtp({
otpId: started.otpId,
otpCode: codeFromUser,
otpEncryptionTargetBundle: started.otpEncryptionTargetBundle,
});
await auth.loginWithOtp({
verificationToken: verified.verificationToken,
organizationId,
sessionProfileId: embeddedWalletProfileId,
});
SMS uses the same sequence with OTP_TYPE_SMS; availability is configuration-dependent.
Social OAuth/OIDC#
The verified React convenience providers are Google, Apple, Facebook, Discord and X. There is no first-class Telegram source helper. The target requires Google, Apple and Telegram; Telegram is explicit target-owned adapter work, not a source-parity claim.
beginSocialLogin(input: {
provider: "google" | "apple" | "telegram";
redirectUri: string;
mode: "login" | "signup" | "loginOrSignup";
sessionPrivilege: "readOnly" | "readWrite";
sessionProfileId?: string;
}): Promise<{ authorizationUrl: string; attemptId: string }>
completeSocialLogin(input: {
attemptId: string; callbackUrl: string;
}): Promise<AuthResult>
The browser generates state, OIDC nonce and PKCE verifier/challenge and keeps attempt state in an expiry-bound protected slot. Redirect URI must match registration exactly. The server performs confidential code exchange where required and verifies signature, issuer, audience, nonce, expiry and one-use callback. A provider secret never enters browser code. Telegram uses its current OIDC Authorization Code + PKCE flow. Telegram cloud storage/stamping is unrelated and never accepted as identity proof.
sessionPrivilege is a target requirement. Read-only routes to read-only issuance; unsupported read-only fails closed. Provider identity never implies privilege.
loginWithOauth(input: {
oidcToken: string; publicKey: string; organizationId?: string;
invalidateExisting?: boolean; sessionKey?: string;
expirationSeconds?: string; sessionProfileId?: string;
}): Promise<BaseAuthResult>
signUpWithOauth(input: {
oidcToken: string; publicKey: string; providerName?: string;
invalidateExisting?: boolean; createSubOrgParams?: AccountCreation;
sessionKey?: string; sessionProfileId?: string; captchaToken?: string;
}): Promise<BaseAuthResult>
completeOauth(input: {
oidcToken: string; publicKey: string; providerName?: string;
sessionKey?: string; invalidateExisting?: boolean;
createSubOrgParams?: AccountCreation; sessionProfileId?: string;
captchaToken?: string;
}): Promise<BaseAuthResult & { action: "LOGIN" | "SIGNUP" }>
These verified core methods consume an already exchanged/validated OIDC token and future session public key. loginWithOauth is login-only; signUpWithOauth is signup-only; completeOauth may automatically sign up and therefore is only used by an explicit loginOrSignup flow. providerName does not authorize an arbitrary issuer. The Telegram adapter validates and normalizes its provider subject in the target service; it does not pretend the source enum accepts Telegram.
const attempt = await auth.beginSocialLogin({
provider: "telegram",
redirectUri,
mode: "loginOrSignup",
sessionPrivilege: "readOnly",
});
navigate(attempt.authorizationUrl);
// Registered callback route:
const result = await auth.completeSocialLogin({
attemptId: attempt.attemptId,
callbackUrl,
});
Invalid/replayed state, wrong redirect, nonce/PKCE mismatch, wrong issuer/audience, expired token, ambiguous subject mapping or callback replay creates no session/user.
External-wallet identity#
buildWalletLoginRequest(input: {
walletProvider: WalletProvider; publicKey?: string;
expirationSeconds?: string; sessionProfileId?: string;
}): Promise<{ publicKey: string; signedRequest: SignedLoginRequest }>
loginWithWallet(input: {
walletProvider: WalletProvider; publicKey?: string; sessionKey?: string;
expirationSeconds?: string; organizationId?: string; sessionProfileId?: string;
}): Promise<WalletAuthResult>
signUpWithWallet(input: {
walletProvider: WalletProvider; createSubOrgParams?: AccountCreation;
sessionKey?: string; expirationSeconds?: string;
sessionProfileId?: string; captchaToken?: string;
}): Promise<WalletAuthResult>
loginOrSignupWithWallet(input: {
walletProvider: WalletProvider; publicKey?: string;
createSubOrgParams?: AccountCreation; sessionKey?: string;
expirationSeconds?: string; sessionProfileId?: string; captchaToken?: string;
}): Promise<WalletAuthResult & { action: "LOGIN" | "SIGNUP" }>
The challenge binds domain, URI, address/public key, chain namespace, nonce, issued/expiry time, organization and future session public key. EVM uses SIWE-compatible material; Solana uses SIWS-compatible material. The backend validates every field and consumes the nonce once. buildWalletLoginRequest signs but does not submit. Login and signup are distinct; the combined method may provision and is restricted to explicit loginOrSignup UI.
Current core wallet login has no sessionType; target read-only routes to real read-only issuance and never silently falls back. Connecting a wallet or signing an unrelated message is neither authentication nor action approval.
User rejection before dispatch, changed domain/URI/chain/address, stale time, replay, invalid signature, unknown account or policy denial returns no session. Cancellation or loss after dispatch leaves issuance unknown and requires reconciliation by bound session public key/slot. A wallet-auth signature cannot authorize a transfer or server-wallet treasury action.
Account identity and linked factors#
fetchUser(input?: { organizationId?: string; userId?: string }): Promise<User>
updateUserName(input: { userName: string; userId?: string; organizationId?: string }): Promise<ActivityResult>
updateUserEmail(input: { email: string; verificationToken?: string; userId?: string; organizationId?: string }): Promise<ActivityResult>
removeUserEmail(input?: { userId?: string; organizationId?: string }): Promise<ActivityResult>
updateUserPhoneNumber(input: { phoneNumber: string; verificationToken?: string; userId?: string; organizationId?: string }): Promise<ActivityResult>
removeUserPhoneNumber(input?: { userId?: string; organizationId?: string }): Promise<ActivityResult>
addPasskey(input?: { name?: string; displayName?: string; userId?: string; organizationId?: string }): Promise<ActivityResult>
removePasskeys(input: { authenticatorIds: string[]; userId?: string; organizationId?: string }): Promise<ActivityResult>
addOauthProvider(input: { providerName: string; oidcToken?: string; oidcClaims?: OidcClaim[]; userId?: string; organizationId?: string }): Promise<ActivityResult>
removeOauthProviders(input: { providerIds: string[]; userId?: string; organizationId?: string }): Promise<ActivityResult>
Omitted userId may mean current user only in the single-user browser path; cross-user admin calls require explicit target. Contact updates require new-contact verification when policy says so. oidcClaims is a trusted-admin path and never accepts unverified browser claims. ID arrays are non-empty and duplicate-free. Removing the last usable factor, required MFA factor or required recovery factor fails. These methods change account factors, not wallet ownership, grants or custody recovery.
Generated advanced credential administration preserves these exact source names and identifiers:
| Family | Reads | Mutations | Input/result boundary |
|---|---|---|---|
| API keys | getApiKey({organizationId?, apiKeyId}), getApiKeys({organizationId?, userId?}) |
createApiKeys({organizationId?, userId, apiKeys}), deleteApiKeys({organizationId?, userId, apiKeyIds}) |
reads return metadata; mutations return activity plus exact key IDs; no secret key is returned |
| Authenticators | getAuthenticator({organizationId?, authenticatorId}), getAuthenticators({organizationId?, userId}) |
createAuthenticators({organizationId?, userId, authenticators}), deleteAuthenticators({organizationId?, userId, authenticatorIds}) |
authenticator params contain name/challenge/attestation; mutations return activity plus exact authenticator IDs |
| Social links | getOauthProviders({organizationId?, userId?}) |
createOauthProviders({organizationId?, userId, oauthProviders}), deleteOauthProviders({organizationId?, userId, providerIds}) |
reads return linked metadata; mutations return activity plus exact provider IDs |
Arrays are bounded, non-empty and duplicate-free. Cross-user mutation requires explicit organization/user admin authority. Secret API-key material is generated/installed only in a protected client/server store. Get/list never returns it. Response loss is reconciled by ID/list; last-factor and required-factor deletion is denied. Normal UI prefers the high-level methods.
Sessions and profiles#
Explicit creation#
createReadOnlySession(input?: { organizationId?: string }): Promise<{
activity: Activity; organizationId: string; organizationName: string;
userId: string; username: string; session: string; sessionExpiry: string;
}>
createReadWriteSession(input: {
organizationId?: string; targetPublicKey: string; userId?: string;
apiKeyName?: string; expirationSeconds?: string; invalidateExisting?: boolean;
}): Promise<{
activity: Activity; organizationId: string; organizationName: string;
userId: string; username: string; apiKeyId: string; credentialBundle: string;
}>
createReadOnlySession requires an already authenticated stamped actor. It returns a sensitive opaque token and UTC epoch expiry seconds. Its target projection can authorize only scope-permitted reads. Signing, sending, exporting, user/organization mutation, policy mutation, approval, treasury action and custody recovery fail with readOnlyDenied before effect.
createReadWriteSession requires the recipient targetPublicKey; the returned bundle is encrypted to it. userId defaults to the current actor. apiKeyName defaults to a timestamped label. Source expiry defaults to 15 minutes when omitted; target configuration may be stricter. invalidateExisting defaults false. Success is the returned activity plus IDs/bundle; it is not a completed target Operation until the adapter maps and confirms the activity. Token and bundle are installed only by the intended protected runtime.
Profiles#
getSessionProfile(input: { organizationId?: string; sessionProfileId: string }): Promise<{ sessionProfile: SessionProfile }>
getSessionProfiles(input?: { organizationId?: string }): Promise<{ sessionProfiles: SessionProfile[] }>
createSessionProfile(input: {
organizationId?: string; sessionProfileName: string; scope: string;
expirationSeconds?: string; notes?: string;
}): Promise<{ activity: Activity; sessionProfileId: string }>
SessionProfile has ID, name, scope, optional expiration seconds/notes and timestamps. Names/notes are bounded text. scope is a validated permission expression, not executable code. Omitted expiry defers to login/organization settings; it never means unlimited. A profile caps authority and cannot elevate an actor or broaden an issued session. The reviewed source exposes create/get/list, not generic update/delete.
Local lifecycle#
storeSession(input: { sessionToken: string; sessionKey?: string }): Promise<void>
getSession(input?: { sessionKey?: string }): Promise<Session | undefined>
getAllSessions(): Promise<Record<string, Session> | undefined>
getActiveSessionKey(): Promise<string | undefined>
setActiveSession(input: { sessionKey: string }): Promise<void>
refreshSession(input?: {
expirationSeconds?: string; publicKey?: string; sessionKey?: string;
invalidateExisting?: boolean;
}): Promise<{ session: string } | undefined>
clearSession(input?: { sessionKey?: string }): Promise<void>
clearAllSessions(): Promise<void>
logout(input?: { sessionKey?: string }): Promise<void>
// Proposed target server method; not a verified high-level source method.
revokeSession(input: {
requestId: string; organizationId: string; userId: string; sessionId: string;
}): Promise<Operation<{ sessionId: string; revokedAt: string }>>
The source misspells one refresh input invalidateExisitng; the target corrects it and its adapter maps explicitly. sessionKey is a local slot. getAllSessions is not a server audit. setActiveSession changes only the local pointer. storeSession is an advanced SDK hook; it validates structure, tenant and expiry before storage.
Refresh rotates the session key, preserves profile and privilege, and never elevates read-only to read/write. If the underlying client cannot refresh the same privilege it returns an explicit unsupported/reauthentication error. clear* and source logout remove local records/keypairs only; they do not prove backend revocation.
revokeSession is explicit proposed target work. At issuance the target records a revocation reference. Read/write sessions map to verified generated deleteApiKeys({organizationId, userId, apiKeyIds}); stateless read-only tokens require a target server deny-list by stable token/session identity until expiry. requestId, organization, user and session are required; cross-tenant mismatch is non-disclosing. Success is a confirmed terminal delete activity/deny-list commit and returns only session ID/time. Denial/expiry has no revocation effect. Timeout after dispatch is unknown and reconciled with the same requestId and server session status. If no revocation reference exists, return revocationUnsupported; never claim immediate logout. Immediate logout requires confirmed revocation plus local clear. Backend revoke does not clear a device.
const current = await auth.getSession(); // target safe projection
if (!current || Date.parse(current.expiresAt) <= Date.now()) throw new Error("session expired");
await auth.refreshSession({ sessionKey: current.localSessionKey, expirationSeconds: "900" });
await auth.revokeSession({ sessionId: current.sessionId, requestId });
await auth.clearSession({ sessionKey: current.localSessionKey });
session = await sdk.get_session({
"organization_id": organization_id,
"session_id": session_id,
})
if session.privilege == "readOnly" and requested_action != "read":
raise ReadOnlyDenied()
await sdk.revoke_session({"request_id": request_id, "session_id": session_id})
session, err := sdk.GetSession(ctx, &GetSessionInput{OrganizationID: organizationID, SessionID: sessionID})
if err != nil { return err }
if session.Privilege == SessionPrivilegeReadOnly && requestedAction != ActionRead { return ErrReadOnlyDenied }
_, err = sdk.RevokeSession(ctx, &RevokeSessionInput{RequestID: requestID, SessionID: sessionID})
return err
The TypeScript target call and Python/Go examples are proposed server revocation bindings with the source mapping above; they are not browser-local methods.
MFA#
getMfaPolicies(input: { organizationId?: string; userId: string }): Promise<{ mfaPolicies: MfaPolicy[] }>
getMfaPolicy(input: { organizationId?: string; userId: string; mfaPolicyId: string }): Promise<{ mfaPolicy: MfaPolicy }>
getMfaStatus(input: { organizationId?: string; activityId: string; userId?: string }): Promise<{ mfaStatuses: MfaStatus[] }>
createMfaPolicy(input: {
organizationId?: string; userId: string; mfaPolicyName: string;
condition: string; requiredAuthenticationMethods: RequiredAuthStep[];
order: number; notes?: string;
}): Promise<{ activity: Activity; mfaPolicyId: string }>
updateMfaPolicy(input: {
organizationId?: string; userId: string; mfaPolicyId: string;
mfaPolicyName?: string; condition?: string;
requiredAuthenticationMethods?: RequiredAuthStep[]; order?: number; notes?: string;
}): Promise<{ activity: Activity; mfaPolicyId: string }>
deleteMfaPolicy(input: { organizationId?: string; userId: string; mfaPolicyId: string }): Promise<{ activity: Activity; mfaPolicyId: string }>
Each RequiredAuthStep is { any: AuthenticationMethod[] }: steps run in order; one listed method satisfies a step. order starts at 0 relative to other policies. MfaStatus has policy ID, user ID, satisfied, satisfied methods and ordered required methods. Names/notes/condition are bounded and validated. Admin mutation may itself require approval; response loss is reconciled by exact ID/list.
type MfaContext = {
activityId: string; fingerprint: string; organizationId: string;
activityType: string; activityStatus: string; mfaStatuses: MfaStatus[];
};
createHttpClient({ onMfaRequired?: (context: MfaContext) => Promise<void> }): HttpClient
setMfaHandler(handler: (context: MfaContext) => Promise<void>): void
The callback fires only after the backend marks the activity MFA-required. UI presents the immutable fingerprint and outstanding steps. The application owns factor UI; setMfaHandler does not complete a factor. The handler resolves only after a verified factor stamps approveActivity({organizationId, fingerprint}); then core re-polls activityId. Resolution is not success, so final status is rechecked. Rejection cancels local continuation, not an accepted activity. With no handler, the client returns current state. Source activityId maps to the same protected business record surfaced through approval/operation correlation; it is not another action.
- completion: recheck same
activityIdand continue only when satisfied; - user denial/cancel: return
cancelled, never substitute a weaker factor; - expiry: return
challengeExpiredand start a new action/challenge; - response loss: call
getMfaStatusbefore repeating a factor; - policy change: re-evaluate and fail closed if requirements increased or became ambiguous.
auth.setMfaHandler(async context => {
// ApplicationMfaAdapter is application-owned and returns only after it has
// submitted an allowed factor for this exact activity/fingerprint.
const completed = await applicationMfaAdapter.complete(context);
if (completed.activityId !== context.activityId) throw new AuthError("invalidProof");
// Core re-polls the same activity after this handler returns.
});
const operation = await sdk.sendTransaction({ requestId, walletId, transaction });
The adapter above is not another SDK business method. A source-specific adapter may implement the email-OTP factor using these verified lower-level public calls; ordinary integrator code does not need to expose this stamping sequence:
auth.setMfaHandler(async context => {
// App-owned UI selected EMAIL_OTP from context.mfaStatuses.
const started = await auth.initOtp({ otpType: "OTP_TYPE_EMAIL", contact: verifiedAccountEmail });
const verified = await auth.verifyOtp({
otpId: started.otpId,
otpCode: await mfaUi.readCode(),
otpEncryptionTargetBundle: started.otpEncryptionTargetBundle,
});
await auth.overrideAttestedStamper({
verificationToken: verified.verificationToken,
publicKey: verified.publicKey,
});
await auth.httpClient.approveActivity(
{ organizationId: context.organizationId, fingerprint: context.fingerprint },
StamperType.Attested,
);
// Returning lets core re-poll context.activityId; it does not declare success.
});
MFA completion remains bound to that activity; the owner/service approval still binds exact transaction material and requestId.
Account recovery is not wallet recovery#
initUserEmailRecovery(input: {
organizationId?: string; email: string; targetPublicKey: string;
expirationSeconds?: string;
emailCustomization: { appName: string; logoUrl?: string; magicLinkTemplate?: string; templateVariables?: string; templateId?: string };
sendFromEmailAddress?: string; sendFromEmailSenderName?: string;
replyToEmailAddress?: string;
}): Promise<{ activity: Activity; userId: string }>
recoverUser(input: {
organizationId?: string; userId: string;
authenticator: {
authenticatorName: string; challenge: string; attestation: PasskeyAttestation;
};
}): Promise<{ activity: Activity; authenticatorId: string[] }>
These signatures preserve the verified generated field names. Init encrypts the out-of-band recovery credential to targetPublicKey; source default expiry is 15 minutes when omitted. emailCustomization.appName is required. Its response is an activity plus user ID, not a session or plaintext recovery token. The protected recipient opens the recovery link/bundle, creates a fresh passkey ceremony, and uses that recovered credential to stamp recoverUser. Recover registers exactly the supplied authenticator and returns the activity plus authenticator IDs. Bundle decryption and stamping stay inside the protected client; neither method accepts a plaintext password or returns a session. A later explicit login is required.
Wallet custody recovery is the separate recoverWalletAccess contract in Recover wallet access. Email recovery, an ordinary linked factor, lost factor alone or backend credential cannot recover embedded-wallet custody. Server-wallet recovery follows service-control approval and never changes wallet mode.
Unknown email receives the accepted anti-enumeration response. Wrong recipient key, expired/replayed link/bundle, challenge/attestation mismatch, failed MFA or denial creates no authenticator. After dispatch loss, reconcile the returned/target user factor list before repeating recovery.
Organization OAuth2 credentials#
These are privileged server/admin methods, not user social links and never a browser/React surface.
getOauth2Credential(input: { organizationId: string; oauth2CredentialId: string }): Promise<{ oauth2Credential: Oauth2Credential }>
listOauth2Credentials(input: { organizationId: string }): Promise<{ oauth2Credentials: Oauth2Credential[] }>
createOauth2Credential(input: {
organizationId: string; provider: Oauth2Provider; clientId: string;
encryptedClientSecret: string;
}): Promise<{ activity: Activity; oauth2CredentialId: string }>
updateOauth2Credential(input: {
organizationId: string; oauth2CredentialId: string; provider: Oauth2Provider;
clientId: string; encryptedClientSecret: string;
}): Promise<{ activity: Activity; oauth2CredentialId: string }>
deleteOauth2Credential(input: { organizationId: string; oauth2CredentialId: string }): Promise<{ activity: Activity; oauth2CredentialId: string }>
The DTO contains ID, organization, provider, client ID, encrypted client secret and timestamps. Even encrypted values are confidential and redacted. Create/update accept a value already encrypted to the configured server fetcher key; plaintext encryption occurs inside an approved server secret boundary. Update is complete replacement. Delete is not user unlink. Mutations return an activity plus ID; the proposed Operation mapping must preserve pending/denied/failed states rather than declaring success from dispatch.
created = await admin.create_oauth2_credential({
"organization_id": organization_id,
"provider": provider,
"client_id": client_id,
"encrypted_client_secret": encrypted_client_secret,
})
current = await admin.get_oauth2_credential({
"organization_id": organization_id,
"oauth2_credential_id": created.oauth2_credential_id,
})
created, err := admin.CreateOauth2Credential(ctx, &CreateOauth2CredentialInput{
OrganizationID: organizationID,
Provider: provider,
ClientID: clientID,
EncryptedClientSecret: encryptedClientSecret,
})
if err != nil { return err }
_, err = admin.GetOauth2Credential(ctx, &GetOauth2CredentialInput{
OrganizationID: organizationID,
Oauth2CredentialID: created.Oauth2CredentialID,
})
Denial/expiry has no effect. Timeout after dispatch is outcomeUnknown; reconcile by ID/list. Never retry create with changed secret under the same request identity.
React adapter#
handleLogin(params) opens the built-in chooser. Verified params are sessionKey, light/dark logo, logo class and title. There is no action parameter and the chooser may offer signup. Login-only UI must call an explicit core login method or a target wrapper that prohibits signup.
Verified additions are refreshUser; handleGoogleOauth, handleAppleOauth, handleFacebookOauth, handleDiscordOauth, handleXOauth; handleUpdateUserEmail, handleUpdateUserPhoneNumber, handleUpdateUserName, handleAddEmail, handleAddPhoneNumber, handleAddOauthProvider, handleRemoveOauthProvider, handleAddPasskey, handleRemovePasskey, handleRemoveUserEmail, handleRemoveUserPhoneNumber; setMfaHandler; and connection-only handleConnectExternalWallet. Social handlers accept optional primary/secondary client IDs, additional state, page/popup choice, anti-abuse token and onOauthSuccess({publicKey, oidcToken, providerName}). Additional state supplements, never replaces, generated anti-CSRF state. successPageDuration is milliseconds.
There is no verified Telegram hook. The target generic beginSocialLogin({provider:"telegram"}) is explicit work.
"use client";
function LoginPanel() {
const auth = useAuth();
return <>
<button onClick={() => auth.handleGoogleOauth({ openInPage: true })}>Continue with Google</button>
<button onClick={() => auth.handleAppleOauth({ openInPage: true })}>Continue with Apple</button>
<button onClick={() => auth.beginSocialLogin({
provider: "telegram", redirectUri, mode: "loginOrSignup",
sessionPrivilege: "readOnly",
})}>Continue with Telegram</button>
<button onClick={() => auth.loginWithPasskey({ sessionProfileId })}>Continue with passkey</button>
</>;
}
Illustrative only: hooks run in client components; provider secrets remain server-side; session presence enables UI but never authorizes wallet action.
Advanced methods and explicit exclusions#
Public support methods reviewed include createApiKeyPair, deleteApiKeyPair, clearUnusedKeyPairs, fetchOrCreateP256ApiKeyUser, getProxyAuthConfig, signWithApiKey, stamper overrides and HTTP-client construction. MFA additionally uses verified generated approveActivity({fingerprint}) with a permitted factor stamper. getProxyAuthConfig wraps generated proxyGetWalletKitConfig. These manage protected session keys, public configuration, factor decisions or stamping and are not new login choices.
Generated emailAuth, initOtpAuth, otpAuth, otpLogin, oauth, oauth2Authenticate, oauthLogin, stampLogin and stamp* are compatibility/transport backing methods. Legacy injectCredentialBundle, export/import bundle injection/extraction, getEmbeddedPublicKey, clearEmbeddedKey, initEmbeddedKey, clear, getPublicKey, init, resetKeyPair and sign are protected storage/crypto mechanics and excluded from the primary facade. React export/import/sign/send/on-ramp/application-proof methods belong to wallet/transaction references. External-wallet connection is listed only to state it is not login.
Independent inventory and classification#
| Group | Public methods reviewed | Target treatment |
|---|---|---|
| Current core auth | passkey 3; wallet-auth 4; OTP 5; OAuth 3; logout | Primary, with exact source signatures |
| Core user/factors | fetch plus email/phone/name and passkey/OAuth link/unlink | Primary account methods |
| Core sessions | store/clear/all-clear/refresh/get/all-get/switch/active-key | Lifecycle; local versus server effects explicit |
| Generated auth/admin | read-only/read-write, profile create/get/list, MFA/status, recovery, OAuth2 lifecycle, linked API-key/authenticator/provider lifecycle | Business-facing admin or advanced credential methods |
| Legacy browser variants | login, refreshSession, loginWithPasskey, loginWithWallet, loginWithSession, loginWithBundle, deleteUserAuth, addUserAuth, createUserPasskey |
Consolidated with equivalent target actions; signature differences disclosed |
| React additions | chooser, five source social handlers, factor/profile handlers, external connect, MFA handler | UI convenience; no fabricated hook |
| Transport/helpers | aliases, stampers, request builders, crypto/storage | Advanced/compatibility or excluded with reason |
Legacy and current browser generations differ: legacy passkey/wallet login accepts sessionType; current core does not and defaults to read/write-style login; current React inherits core. Target explicit privilege maps to the actual branch and fails closed if unavailable.
Requirement traceability#
| Requirement | Methods/section | Positive acceptance | Negative acceptance |
|---|---|---|---|
| Passkey signup/login/link/remove | Passkeys; factors | Valid ceremony creates/uses one credential and bounded session | Pre-dispatch cancel/wrong challenge/origin/last-factor removal creates no change; post-dispatch loss is reconciled |
| Email/SMS passwordless | OTP | Init → verify → explicit login/signup once | Wrong/expired/replayed code creates no user/session |
| Google/Apple | Social; React | State/nonce/PKCE/token checks precede session | Secret never in browser; wrong issuer/audience/replay denied |
| Telegram | Target social adapter | OIDC code+PKCE and server claims map one subject | No source-helper claim; cloud storage never identity proof |
| External-wallet auth | External wallet | All message fields bind and nonce consumed | Connect/unrelated signature cannot authenticate or approve action |
| Factor management | Account section | Fetch/link/unlink/update safe projection/activity | Last required factor/unverified claims denied |
| Read-only/read-write | Sessions | Explicit privilege maps to actual issuance | Read-only cannot mutate/sign/send/export/recover/approve/run treasury |
| Refresh/switch/store/clear/logout | Lifecycle | Refresh preserves scope; immediate logout revokes + clears | Local clear is not revoke; switch is not elevation |
| MFA | MFA | Ordered requirements/status continue same activity | Denial/expiry/change cannot be bypassed/downgraded |
| Account recovery | Recovery | One-use proof rebinds account session credential | Never recovers embedded-wallet custody |
| OAuth2 credentials | Admin | Exact org/credential IDs and secret-safe lifecycle | No browser/log secret; delete is not user unlink |
| Embedded mode | Authority boundary | User identity + owner approval remain separate | Backend does not acquire owner authority |
| Server mode | Authority boundary | Service identity acts within treasury policy/limits | End-user login never grants treasury; no silent migration |
| Exact action proof | Boundaries/MFA/Approvals | Approval binds action and requestId |
Changed material/tenant/replay denied |
Acceptance checklist#
- Every login class has login/signup boundary, sensitive-field rule, replay/expiry behavior and example.
- Google, Apple, Telegram and passkeys are visible; Telegram is target-owned, not fabricated source parity.
- Current/legacy session differences are explicit; requested read-only never falls back to read/write.
- MFA denial, expiry, progress and lost-response recovery remain on one activity.
- Account identity recovery and wallet custody recovery are separate.
- Embedded/server authority is explicit, non-inferable and never silently migrated.
- Local logout/clear and backend revoke are separate effects.
- OAuth2 administration is server-only and secret-safe.
- Every reviewed public auth method is primary, advanced/compatibility, or routed to its owning domain.
- Browser/React and TypeScript/Python/Go server boundaries are explicit.