Sessions and storage

Where the SDK keeps tokens, how restore and refresh work, staying signed in with offline_access, several tabs, signing out, and what happens when a person removes your app.

After sign-in the SDK holds a session: an access token, optionally a refresh token, and the profile. It keeps the access token fresh, shares the session between tabs when you ask, and ends it cleanly. This page explains the choices you have and what happens at each edge.

Where tokens live

The storage option decides where the SDK keeps the session and the one-time data of a sign-in in progress.

storageWhereSurvivesNotes
"session" (default)sessionStoragethe tab, until it is closedOne session per tab. A new tab starts signed out. The safest browser option.
"local"localStoragebrowser restartsShared by all tabs. Readable by any script on your origin, so a cross-site scripting bug can steal it. Use it only with a strict content security policy.
"memory"the page's memorynothing; lost on reloadCannot complete a redirect sign-in: the page is left and the pending sign-in is lost. Useful for tests and tools.
your own objectanythingup to youAn object with get, set and remove (the KeyValueStore interface).

If the browser blocks storage (some private modes), the SDK falls back to memory without throwing.

import { UnetId } from "@unet/id-web";

export const unet = new UnetId({
  clientId: "unet_xxx",
  redirectUri: `${location.origin}/auth/callback`,
  storage: "local", // keep the person signed in across restarts
});

Keys are namespaced by App ID (unet-id:<clientId>:...), so several apps on one origin do not collide. Tokens from a different issuer, for example after switching from the test server to production, are ignored.

Restoring a session

Call restore() once when your app starts. It loads the saved session, refreshes the token if needed, reads the profile from UNET, and returns the user, or null when nobody is signed in.

const user = await unet.restore();

restore() makes a network request by default, so the profile is always current. If you want the app to start instantly from the profile saved last time, skip the request. The tokens still refresh on first use.

const user = await unet.restore({ revalidate: false });

If the saved session is no longer valid (the refresh token was rejected), restore() clears it and returns null. Other failures, such as no network, are thrown as NetworkError and the session is kept.

Reading the session

CallReturns
getUser()The signed-in user from memory, or null. No request.
isSignedIn()true when tokens are saved.
refreshUser()Reloads the profile from UNET, for example after the person changed their name.
getAccessToken()A valid access token, refreshed first if it is about to expire.
onSessionChange(cb)Calls cb with the user on sign-in or profile change and with null on sign-out. Returns a function that removes the listener.
const off = unet.onSessionChange((user) => {
  document.querySelector("#who")!.textContent = user ? `@${user.username}` : "Signed out";
});
// off() stops listening

Staying signed in

An access token lasts about an hour. To keep the person signed in for longer, ask for offline_access: UNET then also issues a refresh token, and the SDK exchanges it for a new access token shortly before the old one expires (one minute early).

import { UnetId } from "@unet/id-web";

const unet = new UnetId({
  clientId: "unet_xxx",
  redirectUri: `${location.origin}/auth/callback`,
  scopes: ["openid", "profile", "offline_access"],
});

A refresh token is only issued when the person sees the permission screen, so the SDK adds prompt=consent to the sign-in whenever offline_access is requested and you set no prompt yourself. For an app with this permission, expect the consent screen at sign-in.

Without offline_access there is no refresh token. When the access token expires, getAccessToken() clears the session and throws SessionExpired, and the person has to sign in again.

How refreshing behaves

  • Automatic. Every API call and getAccessToken() refresh the token when it is within a minute of expiring. If an API call still gets a 401, the SDK forces one refresh and retries once.
  • One at a time. Concurrent calls share a single refresh request.
  • Across tabs. Refresh tokens rotate: each exchange issues a new one and retires the old. In browsers with Web Locks, tabs take turns, and a tab that waited uses the tokens the other tab just obtained instead of spending the old refresh token again. With storage: "local", tabs also mirror sign-in, sign-out and profile changes.
  • A failed refresh does not always sign out. Only the server saying the grant is gone (invalid_grant) ends the session. A network error or a server error keeps the tokens, and the next call tries again.

Signing out

await unet.signOut();

signOut() clears the session in this browser first, then asks UNET to revoke the refresh token (or the access token, if there is no refresh token). The revocation is best effort: if it fails, the local sign-out has already happened. It does not sign the person out of unet.social, only your app.

When the person removes your app

People can remove an app in their UNET settings under Connected apps. The effect is immediate: the next refresh fails, the SDK clears the session, and the call throws SessionExpired. An app should treat SessionExpired as "signed out", not as a bug.

import { SessionExpired } from "@unet/id-web";

try {
  await api.feed();
} catch (e) {
  if (e instanceof SessionExpired) {
    showSignInButton(); // the session is gone: sign in again
  } else {
    throw e;
  }
}

declare function showSignInButton(): void;

If your server verifies tokens, it learns of the removal at its next check; see Verify on your server.

Common mistakes

  • Expecting a new tab to be signed in with the default "session" storage. Use "local" if that is what you want.
  • Not calling restore() at startup. getUser() is empty until you do, or until sign-in.
  • Caching the access token yourself. Ask getAccessToken() every time you need one; it returns the cached one when it is still good.
  • Treating SessionExpired as an error to log. It happens in normal use.

What next