| name | viverse-auth |
| description | VIVERSE Login SDK integration for user authentication and SSO |
| prerequisites | ["VIVERSE SDK script tag","VIVERSE Studio App ID"] |
| tags | ["viverse","authentication","login","sso"] |
VIVERSE Auth Integration
Add VIVERSE user authentication to any web project. Supports SSO across VIVERSE experiences.
When To Use This Skill
Use this when a project needs:
- User login/logout via VIVERSE accounts
- Access to user profile data (name, avatar URL, account ID)
- Single Sign-On between multiple VIVERSE experiences
Read Order
- This file
- patterns/robust-profile-fetch.md
- examples/react-login-flow.md
Prerequisites
- VIVERSE SDK loaded in
index.html:
<script src="https://www.viverse.com/static-assets/viverse-sdk/index.umd.cjs"></script>
- App ID from VIVERSE Studio — create a World App to get one.
Preflight Checklist
Mandatory Compliance Gates (MUST PASS)
These are release blockers for any auth integration task:
- MUST implement the Iframe Handshake Delay: wait 1200ms after SDK detection before first
client.checkAuth().
- MUST use auth domain
account.htcvive.com for new vSdk.client(...).
- MUST use the Avatar SDK constructor with base URL
https://sdk-api.viverse.com/.
- MUST pass token as
accessToken, token, and authorization when constructing new vSdk.avatar(...).
- MUST NOT use request header/key
accesstoken (lowercase); it is blocked in production CORS preflight.
- MUST run this profile strategy order:
avatarClient.getProfile() -> client.getUserInfo() -> client.getUser() -> client.getProfileByToken(token) -> direct API fallback.
Continue to the next strategy whenever profile is missing or missing required identity/avatar fields.
- MUST NOT display
account_id (full or partial) as username in UI fallback.
- MANDATORY (Version Traceability): generated auth code must include a
VERSION_NAME constant logged on startup.
- MUST run auth bootstrap exactly once per page mount/session (guard with
useRef); do not re-run full initialize()->checkAuth() due to hook dependency churn.
- MUST use the currently detected SDK instance for profile strategies in the same init cycle (do not rely on async
setSdk state before calling avatar.getProfile()).
- MUST apply bridge-ready retry for profile enrichment: when
vSdk.bridge.isReady === false, wait 500ms before profile fallback chain.
- MUST NOT downgrade profile quality: generic fallback names (
VIVERSE Player/Player-*) must not overwrite a previously resolved specific name.
- MUST use a single auth service as source of truth; do not keep parallel
ViverseService implementations in different folders.
- MUST resolve App ID robustly: use
VITE_VIVERSE_CLIENT_ID when valid, and in Worlds iframe fallback to hostname-derived app id (<appId>-preview.world.viverse.app -> <appId>).
Implementation Workflow
1. Initialize the Client
const vSdk = window.viverse || window.VIVERSE_SDK || window.vSdk;
const client = new vSdk.client({
clientId: 'YOUR_APP_ID',
domain: 'account.htcvive.com'
});
[!IMPORTANT]
The SDK may expose itself as window.vSdk, window.viverse, or window.VIVERSE_SDK depending on the version. Always check all candidates.
1.5 Bridge Resilience Pattern
To avoid "openUrl" destructuring crashes during initialization, you must wait for the VIVERSE message bridge:
const detectSdk = () => {
const vSdk = window.viverse || window.VIVERSE_SDK || window.vSdk;
const bridgeReady = vSdk && (vSdk.bridge ? vSdk.bridge.isReady !== false : true);
if (vSdk?.client && bridgeReady) {
} else {
requestAnimationFrame(detectSdk);
}
};
1.6 Single-Bootstrap Guard (Critical)
const initOnceRef = useRef(false);
useEffect(() => {
if (initOnceRef.current) return;
initOnceRef.current = true;
initialize();
}, []);
If this guard is missing, repeated checkAuth() cycles can race and leave profile stuck at generic fallback.
2. Check Existing Session
const result = await client.checkAuth();
if (result) {
console.log('Token:', result.access_token);
console.log('Account ID:', result.account_id);
console.log('Expires in:', result.expires_in, 'seconds');
} else {
}
[!CAUTION]
checkAuth() does NOT return user profile data (display name, avatar). It only returns access_token, account_id, and expires_in. See step 2b below.
2b. Get User Profile (Canonical Robust Pattern)
checkAuth() gives token/account identity, then profile must be recovered with a deterministic fallback chain.
const vSdk = window.viverse || window.VIVERSE_SDK || window.vSdk;
const appId = import.meta.env.VITE_VIVERSE_CLIENT_ID;
const auth = await client.checkAuth();
if (!auth?.access_token) return null;
let mergedProfile = null;
const token = auth.access_token;
const accountId = auth.account_id;
const merge = (p) => {
if (!p || typeof p !== 'object') return;
mergedProfile = mergedProfile ? { ...mergedProfile, ...p } : { ...p };
};
if (vSdk?.avatar) {
try {
const avatarClient = new vSdk.avatar({
baseURL: 'https://sdk-api.viverse.com/',
accessToken: token,
token,
authorization: token,
appId,
clientId: appId,
});
merge(await avatarClient.getProfile());
} catch (_) {}
}
const hasIdentity = (p) =>
!!(p && (p.name || p.displayName || p.display_name || p.nickName || p.nickname || p.userName || p.email));
const hasAvatar = (p) =>
!!(p && (p.activeAvatar?.avatarUrl || p.avatarUrl || p.avatar_url || p.profilePicUrl));
const needsMoreProfile = (p) => !p || !hasIdentity(p) || !hasAvatar(p);
if (needsMoreProfile(mergedProfile) && client?.getUserInfo) {
try { merge(await client.getUserInfo()); } catch (_) {}
}
if (needsMoreProfile(mergedProfile) && client?.getUser) {
try { merge(await client.getUser()); } catch (_) {}
}
if (needsMoreProfile(mergedProfile) && client?.getProfileByToken) {
try { merge(await client.getProfileByToken(token)); } catch (_) {}
}
if (needsMoreProfile(mergedProfile)) {
try {
const resp = await fetch('https://account-profile.htcvive.com/SS/Profiles/v3/Me', {
headers: { Authorization: `Bearer ${token}` },
});
if (resp.ok) merge(await resp.json());
} catch (_) {}
}
const displayName =
mergedProfile?.displayName ||
mergedProfile?.display_name ||
mergedProfile?.name ||
mergedProfile?.nickname ||
mergedProfile?.userName ||
mergedProfile?.email ||
'VIVERSE Player';
const avatarUrl =
mergedProfile?.activeAvatar?.headIconUrl ||
mergedProfile?.activeAvatar?.head_icon_url ||
mergedProfile?.headIconUrl ||
mergedProfile?.head_icon_url ||
mergedProfile?.avatarUrl ||
mergedProfile?.avatar_url ||
null;
setUser({
accountId,
accessToken: token,
displayName,
avatarUrl,
});
[!TIP]
Use the reusable helper in patterns/robust-profile-fetch.md so every project applies the same fallback order.
3. Login (Redirect-based SSO)
client.loginWithWorlds({ state: 'optional-custom-value' });
4. Logout
await client.logout();
SDK Loading Pattern
The SDK loads asynchronously. Poll for it:
async function waitForSDK(maxAttempts = 50, interval = 100) {
return new Promise((resolve) => {
let attempts = 0;
const check = () => {
attempts++;
const vSdk = window.viverse || window.VIVERSE_SDK;
if (vSdk?.client) {
resolve(vSdk);
} else if (attempts > maxAttempts) {
console.warn('VIVERSE SDK failed to load');
resolve(null);
} else {
setTimeout(check, interval);
}
};
check();
});
}
Recommended Integration Blueprint (React)
Use this structure to avoid the common auth bugs:
- Single source of auth state in
App (or a single AuthProvider).
- Pass
user/loading/error/login/logout down to UI components (AuthGate, Lobby) via props/context.
- Keep VIVERSE SDK calls in a service layer (
ViverseService), not inside multiple UI components.
- Build profile data with the canonical multi-strategy fetch (
avatar.getProfile -> getUserInfo -> getUser -> getProfileByToken -> API fallback).
Minimal component wiring:
function App() {
const { user, loading, error, login, logout } = useViverseAuth();
return (
<AuthGate user={user} loading={loading} error={error} login={login}>
<Lobby user={user} onLogout={logout} />
</AuthGate>
);
}
[!IMPORTANT]
Do not call useViverseAuth() separately in both App and AuthGate/Lobby. That creates desynced states.
Profile Display Best Practices
- Prefer profile fields (
name, displayName, display_name, userName, email) for UI.
- Treat raw
account_id as an internal identifier only.
- If profile is missing, use generic fallback
VIVERSE Player only (never append account fragments).
- Surface avatar via
headIconUrl/activeAvatar.headIconUrl when available.
Verification Checklist
checkAuth() returns access_token and account_id
- profile fetch returns at least one of: display name, email, avatar
- UI shows profile identity, not raw UUID
- UI does not show partial UUID/account fragments either (for example
VIVERSE Player ab12cd)
- UI fallback name is exactly
VIVERSE Player when identity is unavailable
Critical Gotchas
- Mock mode for local dev: The SDK requires HTTPS and a registered redirect URI. For local development, create a mock service that simulates checkAuth/login/logout.
- Token expiry:
expires_in is in seconds. Refresh the session before it expires for long-running experiences.
- Flat namespace: Some SDK versions don't have a
client constructor — the namespace itself has methods directly. Handle both cases.
- checkAuth ≠ profile:
checkAuth() only returns auth tokens, NOT user profile data. Use the full fallback chain, not checkAuth() fields alone.
- Empty
clientId causes silent checkAuth timeout: Passing an empty string to new sdk.client({ clientId: '', ... }) does NOT throw — it silently times out with "Timeout waiting for parent message". This happens whenever VIVERSE_APP_ID is unset (e.g. no ?appId= param, no localStorage entry, no hostname fallback). Always verify the App ID is resolved before constructing the client. For Worlds iframe apps the hostname regex ([a-z0-9]+)(?:-preview)?\.world\.viverse\.app reliably extracts the App ID without any query param:
const _hostnameAppId = (() => {
const m = location.hostname.match(/^([a-z0-9]+)(?:-preview)?\.world\.viverse\.app$/);
return m ? m[1] : null;
})();
const appId = new URLSearchParams(location.search).get('appId')
|| localStorage.getItem('my_app_id')
|| _hostnameAppId
|| '';
if (!appId) { console.warn('[Auth] App ID not resolved — skipping checkAuth'); return null; }
- Call
_initViverseAuth() eagerly on page load, not lazily on first user action: If auth is only triggered when the user first clicks an AI/social feature, there is a noticeable cold-start delay (1200ms handshake + checkAuth + profile fetch). Call auth bootstrap proactively on DOMContentLoaded or during app init — results are cached and reused. Only skip eager init for dev environments that use a direct bearer token instead of SSO:
if (GATEWAY_ENV !== 'dev') {
initViverseAuth().then(auth => {
if (auth) showToast(`✓ 已登入 · ${auth.displayName}`);
}).catch(() => {});
}
- Iframe Auth Hang (
checkAuth:ack): If the application hangs on VIVERSE Studio or logs unhandled methods: VIVERSE_SDK/checkAuth:ack, it is almost always caused by an App ID mismatch. The VIVERSE parent iframe security model prevents the auth handshake if clientId (from your .env file) does not exactly match the App ID the iframe was launched with. Double check copied .env files.
- Placeholder App ID trap: If
.env still has VITE_VIVERSE_CLIENT_ID=YOUR_APP_ID, auth may silently fall back to guest mode in preview even when publish uses correct --app-id. Build/runtime must resolve a real app id (env or iframe hostname fallback).
- TypeError: Cannot read properties of null (reading 'accountId'): This occurs when
getProfile() returns null (often due to App ID mismatch or invalid token) and the code tries to access profile.accountId without a check. Safety Fix: Always use optional chaining profile?.accountId or a null-guard if (profile).
- Build-time env trap (Vite/React):
import.meta.env.VITE_* is compiled at build time. If App ID changes, update .env and run a fresh npm run build before publishing. Re-publishing an old dist keeps the old/invalid App ID and causes guest-mode auth.
unauthorized origin console noise: Logs like Received message from unauthorized origin: https://www.viverse.com commonly indicate parent-iframe auth security rejecting the handshake (usually App ID mismatch, or redirect URI/origin not registered in Studio). Verify App ID + Studio auth settings together.
- Silent SSO Failure (Guest Mode Loop): Local development might work with manual login, but in VIVERSE Worlds, the user expects automatic SSO. If
checkAuth() returns null, do NOT silently fall back to guest mode without logging a clear warning about VITE_VIVERSE_CLIENT_ID mismatch.
- Wait for Parent Handshake: In some environments,
checkAuth might need a few milliseconds after SDK load to establish the iframe message bridge. If checkAuth fails immediately, try one retry after 500ms.
initialize() does NOT complete auth — you must call checkAuth() explicitly: initialize() only sets up the SDK client and returns true/false (ready). Auth state remains isAuthenticated: false until checkAuth() is separately awaited. The correct sequence is always: const ready = await initialize(); if (ready) await checkAuth();. Stopping after initialize() leaves the login button showing even when the user is already logged in.
checkAuth() race with initialize-auth postMessage: The VIVERSE parent frame sends an initialize-auth postMessage to the iframe after the handshake. On first call checkAuth() may return null if this message hasn't arrived yet. Retry 2–3 times with 1s delay before concluding the user is unauthenticated: for (let i = 0; i < 3; i++) { auth = await client.checkAuth(); if (auth) break; await delay(1000); }
- CSS
.hidden rule must exist for every toggled auth UI element: classList.add('hidden') and classList.remove('hidden') silently do nothing if no CSS rule defines display: none for that class on that element. A generic .hidden { display: none; } in base styles is safer, but if you use ID-scoped styles (e.g. #profile-chip { ... }), you must explicitly add #login-btn.hidden { display: none; } for each auth-toggled element. Missing this rule causes the login button to remain visible even after successful auth.
- Verified production SDK shape (v1.1.76, Worlds iframe):
window.viverse in a VIVERSE Worlds iframe exposes: Avatar, Client (constructor), DEFAULT_SCOPE, GameDashboard, Play, Storage, TokenType, User, avatar (constructor), client (constructor, typeof === 'function'), default, gameDashboard, getGuestLeaderboardRanking, getLeaderboardRanking, getSessionToken, getUserAchievement, play, postLeaderboardRecord, postUserAchievement, storage. Use typeof sdk.client === 'function' to confirm a constructor is available before calling new sdk.client(...).
- Duplicate auth hooks create stale UI: Do not call
useViverseAuth() in multiple top-level components independently (for example in both App and AuthGate). Keep one source of truth in App, then pass user/loading/error/login/logout down via props/context. This prevents "logout button looks fake" and "user info not updating after account switch" behavior caused by desynced local hook state.
- Logout does not always mean account switch prompt: VIVERSE SSO can keep a parent session. App-side logout should still clear local state/client and run
checkAuth() again, but switching to another account may still require explicit SSO sign-out at platform level.
- Do not expose account IDs in UI names: Profile fetch can fail or return sparse data. Never render
account_id directly or partially as display name. Prefer profile name/email, then generic VIVERSE Player.
References