The sign-in flow

How signIn and handleRedirectCallback work in practice. Options, the return page, cancelling, returnTo, and what the person sees on unet.social.

Signing in is a round trip: your page sends the person to unet.social, they approve, and UNET sends them back to your return page. The quickstart shows the shortest version; this page covers the details you meet in a real app. For how the pieces fit together, see How sign-in works.

Starting the sign-in

signIn() does three things and then leaves the page: it reads UNET's discovery document, saves a one-time state and a PKCE verifier in the SDK's storage, and sends the browser to unet.social. Nothing is sent to your server.

await unet.signIn({ returnTo: "/dashboard" });
OptionWhat it does
returnToA path or address of your own that handleRedirectCallback() hands back to you. Default "/". The SDK keeps it locally; it is not sent to UNET.
promptThe OIDC prompt parameter: "login", "consent", "none" or "select_account". See below.
loginHintPre-fills the account on the sign-in screen, for example a username you already know.
extraParamsExtra parameters for the authorization request. The protocol ones (client_id, redirect_uri, response_type, scope, state, code_challenge, code_challenge_method) are ignored if you pass them.

Two prompt values matter in practice:

  • "consent" shows the permission screen again. Use it after you add scopes, so the person can grant the new ones. See Permissions.
  • "login" asks the person to sign in again even if they are already signed in on unet.social.

If your scopes include offline_access and you pass no prompt, the SDK sends prompt=consent for you: a refresh token is only issued that way. See Sessions and storage.

The return page

The page at your return URL finishes the sign-in with handleRedirectCallback(). It checks state, exchanges the code for tokens (PKCE, no secret), loads the profile, and resolves with the user and the returnTo you passed to signIn().

import { AccessDenied, StateMismatch, UnetIdError } from "@unet/id-web";

try {
  const { user, returnTo } = await unet.handleRedirectCallback();
  console.log(`Signed in as ${user.username}`);
  location.replace(returnTo);
} catch (e) {
  if (e instanceof AccessDenied) {
    location.replace("/"); // the person pressed Deny
  } else if (e instanceof StateMismatch) {
    location.replace("/sign-in?retry=1"); // expired, reused, or started somewhere else
  } else if (e instanceof UnetIdError) {
    console.error(e.code, e.message); // e.g. "invalid_scope"
  } else {
    throw e;
  }
}

What can go wrong on the return page

ErrorcodeMeaning
AccessDeniedaccess_deniedThe person pressed Deny. Not a failure; offer to try again.
StateMismatchstate_mismatchThere is no pending sign-in, the state differs, the sign-in is older than 15 minutes, or it was already used.
UnetIdErrorissuer_mismatchThe response says it came from a different issuer than yours.
UnetIdErrorinvalid_scope and other OIDC errorsThe server refused the request. message carries its explanation. A scope you did not register on the app causes invalid_scope.
UnetIdErrorinvalid_clientThe App ID is wrong, the app is turned off, or it is on the other server (test or production).
UnetIdErrorinvalid_responseThe return URL has no code.
NetworkErrornetwork_errorUNET could not be reached while exchanging the code.

The pending sign-in is one-shot: it is removed as soon as handleRedirectCallback() reads it, even when the call fails. After any error the only way forward is to call signIn() again.

One page for the callback and the app

isRedirectCallback() tells you whether the current address is an authorization response for your app: it has a state and a code or an error. Use it when the return URL is the same page as the rest of your app.

if (unet.isRedirectCallback()) {
  const { returnTo } = await unet.handleRedirectCallback();
  history.replaceState(null, "", returnTo); // drops ?code=... from the address bar
}

Call handleRedirectCallback() once per page load. In frameworks that run effects twice in development, guard it, as the React recipe does.

A person who clicks a link to /projects/42 while signed out should land on /projects/42 after signing in, not on the home page. Pass the current location:

await unet.signIn({ returnTo: location.pathname + location.search });

returnTo is stored in the browser by the SDK and returned to you unchanged. It does not travel through UNET, so nobody else can change it. If you ever build it from something the person controls, such as a ?next= parameter, accept only paths on your own site before you navigate to it:

function safeReturn(value: string | null): string {
  return value && value.startsWith("/") && !value.startsWith("//") ? value : "/";
}

What the person sees on unet.social

On unet.social the person signs in with whatever their account uses: password, an authenticator code, or a security key or passkey. Your app never sees which. Then they see a permission screen that lists what your app asks for, in plain words, and choose Allow or Deny. An app whose domain is not verified is marked as unverified. If the person is already signed in on unet.social and has allowed your app before, they may return to your page without seeing anything.

Common mistakes

  • The return URL does not match. The address in the console and redirectUri must be identical, including scheme, port, path and trailing slash.
  • Starting in one tab, finishing in another. The pending sign-in lives in the storage of the tab or browser that started it. Open links from the confirmation screen in the same tab.
  • Using storage: "memory". The page is left during sign-in, so a memory store is empty on return and the sign-in always ends in StateMismatch. See Sessions and storage.
  • Calling handleRedirectCallback() twice. The second call finds no pending sign-in and throws StateMismatch.
  • Trusting returnTo from the address bar. Check it first, as above.

What next