Auth
A client is built with exactly one way to say who calls (see The client).
identityis a caller that never changes:"anonymous"for a read-only client, or anIdentityfor a script, a server or a test. It has no sign-in.authis a factory for a sign-in, and gives you users who sign in and out. This page is aboutauth.
Query keys carry the caller. When the user signs in or out, the client moves every read to the new caller’s keys, and nothing read for one principal shows under another.
Internet Identity with @icp-sdk/auth 10
Section titled “Internet Identity with @icp-sdk/auth 10”The AuthClient of @icp-sdk/auth 10 is an AuthLike as it is. The
supported range is ^10.0.1, and a weekly CI job checks the newest release
against AuthLike. A later release whose session has a state the client does
not know still compiles: the client calls anonymously in that state, and
useAuth() reads it as "anonymous". npm’s newest release is 11, which is
outside that range, so name the major version when you install it, and pass a
factory that builds one:
npm install @icp-sdk/auth@^10import { createClient } from "@ic-reactor/core"import { AuthClient } from "@icp-sdk/auth/client"
export const client = createClient({ network: "ic", auth: (network) => new AuthClient(network),})You write no adapter. The client does the rest:
- It calls the factory once, on first use, and only in a browser. On a server the factory never runs and the client is anonymous.
- It owns what the factory returns.
client.dispose()stops listening to it and calls itsdispose(). - It reads
getStatus()andgetPrincipal()to decide who calls, and asksgetIdentity()for the identity to sign with. - It sets a timer for the session’s end,
getStatus().expiresAtMs.AuthClient’s status turns"expired"by the clock and tells nobody, so the client tells its listeners itself:useAuth()renders"expired"when the session ends, with no user activity needed. - It forwards
signIn(options)andsignOut(options)to it. TwosignIn()calls with the same options in one synchronous run reach it once (see Sign-in options). - If the factory throws, it is not called again.
new AuthClient()readslocalStorage, which throws where site data is blocked or in a sandboxed frame. The client then calls anonymously, so public reads still work, logs the error once in development, and rejectssignIn()andsignOut()with what the factory threw. A factory that returns something other than an auth, such as a Promise from anasyncfactory, is treated the same way, with aTypeErrorthat names what the result lacks. Build a new client to try again.
AuthClient is not a peer dependency of core: you install @icp-sdk/auth
yourself when you want Internet Identity.
The factory returns the auth synchronously, so the module that builds the
client imports AuthClient statically, and, when the entry module imports
that module statically, a bundler puts it in the page’s first chunk. To keep
the client, the sign-in code and the generated module out of that chunk
instead, lazy-load the component that holds the ReactorProvider: see
Keep the IC stack out of the first load,
which also says what a route guard sees while it loads.
The network the factory is handed
Section titled “The network the factory is handed”The client calls the factory with one argument, network, under
AuthClient’s own option names, so passing it through signs users in on the
network the client calls:
agentOptionsis the host, root key andshouldFetchRootKeyof the client’s own agents, and itsfetch, when you gave one.AuthClientmakes its mint and revoke calls with them.identityProvideris the Internet Identity to sign in with, as the two valuesAuthClient10 takes (the page a person signs in on and the canister that mints their delegations), or absent, whichAuthClientreads as mainnet’s.
The client picks the identity provider by these rules, in order:
- The
ic_envcookie is trusted (see The client) and namesINTERNET_IDENTITY_PROVIDER: that URL, with the cookie’sPUBLIC_CANISTER_ID:internet_identity, or Internet Identity’s own id,rdmx6-jaaaa-aaaaa-aaadq-cai, when it names none. The Vite plugin writes this cookie in dev and preview. - The trusted cookie names an
internet_identitycanister but no URL: the project deploys its own Internet Identity, whose sign-in page cannot be derived from its id. No provider. - The network is
"local", or an object whose host is local: icp-cli’s built-in Internet Identity,http://id.ai.localhost:<port>/authorizeon the replica’s port, withrdmx6-jaaaa-aaaaa-aaadq-cai. For"local"that ishttp://id.ai.localhost:8000/authorize, the Vite plugin’s default too. "env"on a local page whose cookie names no provider: the agent’s host is the page’s (a dev server, or a gateway serving an asset canister), which does not say where icp-cli’s built-in Internet Identity is. No provider.- Any other network whose root key is not mainnet’s (one given, from the cookie, or fetched): no provider.
- Mainnet: no provider, and
AuthClient’s defaults are right.
So in the Vite wallet, with network: "env" and
the plugin’s cookie, the one line above signs in with the local Internet
Identity in dev and with mainnet’s on a deployed page.
A local replica rejects every delegation mainnet’s Internet Identity mints.
So in cases 2, 4 and 5, on a network that is not mainnet’s, the client warns
when the factory reads network.identityProvider, as new AuthClient(network)
does. The warning comes once, in development: sign-in cannot work on this
network without an identity provider, and the line that names one is
(network) => new AuthClient({ ...network, identityProvider: { authorizeUrl, canisterId } }).
A factory that names its own provider, as that line does, or builds an auth
that is not Internet Identity, never sees the warning. Nor does a spread that
adds other options and names no provider, such as
new AuthClient({ ...network, derivationOrigin }): it does not read
identityProvider, so on these networks it must name one.
For the internet_identity your project deploys, authorizeUrl is the
sign-in page it serves and canisterId the id icp deploy gave its backend:
import { createClient } from "@ic-reactor/core"import { AuthClient } from "@icp-sdk/auth/client"
// Your own Internet Identity: its sign-in page, and its backend canister.const authorizeUrl = "http://my-ii.localhost:8000/authorize"const canisterId = "bkyz2-fmaaa-aaaaa-qaaaq-cai"
export const client = createClient({ network: "env", auth: (network) => new AuthClient({ ...network, identityProvider: { authorizeUrl, canisterId }, }),})identityProvider takes both values or neither. A factory that takes no
argument, () => new AuthClient(), still works on mainnet. It ignores the
network the client hands it, so on any other network it signs in with
mainnet’s Internet Identity, whose delegations the replica rejects, and the
client cannot tell that it did. On any network but mainnet, pass network
through.
On dfx. Rule 3 names icp-cli’s built-in Internet Identity, which dfx does
not serve. A client of dfx’s replica, { host: "http://127.0.0.1:4943" }, is
handed http://id.ai.localhost:4943/authorize, a page that does not load,
and nothing warns. Name the Internet Identity your dfx project deploys, as
above: authorizeUrl is its sign-in page on dfx’s gateway,
http://<the id of the canister that serves it>.localhost:4943/authorize,
and canisterId the canister that mints its delegations. It must be a release
of Internet Identity that answers the ii_session_delegation request
@icp-sdk/auth 10 signs in with.
Sign-in options
Section titled “Sign-in options”signIn and signOut of the client and of useAuth() pass their argument to
the auth unchanged. For an AuthClient that is maxTimeToLive and
maxTimeToIdle (nanoseconds, as bigint) and returnTo for signIn, and
returnTo for signOut:
import { useAuth } from "@ic-reactor/react"
const HOUR_NS = 3_600_000_000_000n
export function EightHourSession() { const { status, signIn, signOut } = useAuth() return status === "signed-in" ? ( <button onClick={() => signOut({ returnTo: "/" }).catch(console.error)}> Sign out </button> ) : ( <button onClick={() => signIn({ maxTimeToLive: 8n * HOUR_NS, returnTo: "/account" }).catch( console.error ) } > Sign in for 8 hours </button> )}The client types options as unknown, so a wrong option is not a type error.
Check it against AuthClientSignInOptions in @icp-sdk/auth.
Two signIn() calls with the same options in one synchronous run are one
sign-in: the later one returns the earlier one’s promise, and the auth is
asked once. Options are the same when they are the same value (none at all
included), or objects with the same keys and the same values (compared with
Object.is, one level deep), so { returnTo: "/account" } built twice is the
same. StrictMode makes such a pair in development, when it runs an effect
twice in a row. A call in a later run, such as a second click, signs in
again, as does a call with other options.
AuthClient has more options, such as openIdProvider: "google" for a
one-click sign-in and transport: "redirect". They belong to
@icp-sdk/auth, not to this library: Internet Identity 2.0
lists them. One of them, transport: "redirect", changes how the page that
signs in is written: see Redirect sign-in.
Redirect sign-in
Section titled “Redirect sign-in”With transport: "redirect", AuthClient signs in over the ICRC-167 URL
transport instead of a window: the page itself goes to Internet Identity,
which sends the user back to the same page with its answer in the URL
fragment. @icp-sdk/auth suggests it for a full-page sign-in that needs no
click to open a window, and for a native app that hands off through a
universal link.
The page unloads on the way out, so the signIn() that left never settles.
On the load Internet Identity returns to, the same signIn() runs again, and
AuthClient finishes the sign-in from the URL. So the sign-in is a route of
its own that calls signIn() each time it loads, not from a click. Build the
AuthClient with the option in the client’s factory:
auth: (network) => new AuthClient({ ...network, transport: "redirect" }).
A spread like this one reads no provider, so on a network the client names
none for (see The network the factory is handed),
name identityProvider in it too.
AuthClient takes its callback, the URL Internet Identity returns to, from
the page it is built on: location.origin plus location.pathname. The
client builds it once, on first use, and keeps it while the app moves between
routes without a page load. So enter the sign-in route with a full page load,
a plain <a href="/sign-in"> or location.assign("/sign-in"), not a
router’s link, and start every sign-in there.
The route renders a component like this one:
import { useAuth } from "@ic-reactor/react"import { useEffect, useState } from "react"
/** Signs in each time it loads; Internet Identity's return goes to /account. */export function SignInPage() { const { status, signIn } = useAuth() const [failed, setFailed] = useState(false) useEffect(() => { if (status === "signed-in") return const start = setTimeout(() => { signIn({ returnTo: "/account" }).catch((error: unknown) => { console.error(error) setFailed(true) }) }) return () => clearTimeout(start) }, [status, signIn]) if (status === "signed-in") return <a href="/account">Continue</a> if (failed) { return ( <p> The sign-in did not finish. <a href="/sign-in">Try again</a> </p> ) } return <p>Signing you in…</p>}- The first load leaves for Internet Identity. The load it returns to
finishes the sign-in, and
AuthClientgoes toreturnToin place of the sign-in page’s history entry. - Pass the same options on every load. The return load replays the first
load’s request, and a different one (another
maxTimeToLive, say) fails.AuthClientkeeps the first load’sreturnTo, so it may come from a query such as?next=, which the callback drops; pass one on every load or on none. - A user who cancels at Internet Identity comes back with an error, and
signIn()rejects. A new visit to the route starts a new sign-in. So does a return more than 10 minutes later:AuthClientkeeps the flow in the tab’ssessionStoragefor that long. - Once the user is signed in,
statuschanges, the effect runs again, and returns. A user who is already signed in gets the link on. - The timer is for a page rendered on a server. There, the render that
hydrates shows
"anonymous"whatever session the browser holds (see The hydrating render), and React renders again with the session right after. The cleanup then clears the timer, so a user who is already signed in is not sent to Internet Identity. It also clears the first of the two runsStrictModemakes in development. In an app with no server render, callingsignIn()straight from the effect works as well:StrictMode’s second call joins the first.
The route, by framework:
-
Vite. With a router, give
/sign-ina route that rendersSignInPage, below theReactorProvider. Without one, choose by the path:// src/App.tsx, with a redirect sign-inimport { createClient } from "@ic-reactor/core"import { ReactorProvider, useAuth } from "@ic-reactor/react"import { AuthClient } from "@icp-sdk/auth/client"import { SignInPage } from "./components/sign-in-page"export function App() {return (<ReactorProviderclient={() =>createClient({network: "ic",auth: (network) =>new AuthClient({ ...network, transport: "redirect" }),})}>{location.pathname === "/sign-in" ? <SignInPage /> : <Home />}</ReactorProvider>)}function Home() {const { status, signOut } = useAuth()return status === "signed-in" ? (<button onClick={() => signOut({ returnTo: "/" }).catch(console.error)}>Sign out</button>) : (<a href="/sign-in">Sign in</a>)} -
Next.js (App Router). The provider in the root layout (see SSR and hydration) builds the
AuthClientwithtransport: "redirect", and the page is a client module. Link to it with<a href="/sign-in">, not<Link>:src/app/sign-in/page.tsx "use client"import { SignInPage } from "@/components/sign-in-page"export default SignInPage -
TanStack Start. A file route, below the root route that renders the provider. Link to it with
<a href="/sign-in">, or a<Link>withreloadDocument:src/routes/sign-in.tsx import { createFileRoute } from "@tanstack/react-router"import { SignInPage } from "../components/sign-in-page"export const Route = createFileRoute("/sign-in")({ component: SignInPage })
signOut({ returnTo }) works as with a window: it ends the session and goes
to returnTo, from any page.
The callbacks file
Section titled “The callbacks file”Internet Identity sends its answer only to a URL that the page’s origin
lists. The callback is the sign-in page’s location.origin plus
location.pathname, so list exactly that:
{ "callbacks": ["https://app.example/sign-in"] }Serve it at /.well-known/ii-auth-callbacks on that origin:
- as JSON, with
Content-Type: application/json; - with an
Access-Control-Allow-Originheader, so that Internet Identity’s page can read it; - at that URL itself: Internet Identity does not follow a redirect.
An entry matches the callback byte for byte, with nothing normalized: an
absolute URL on the same origin, with no fragment.
https://app.example/sign-in/ does not match https://app.example/sign-in.
Each origin the app signs in from lists its own callback in its own file.
Check the two headers there, for example with curl -i, a dev server
included: Vite’s serves public/.well-known/ii-auth-callbacks with an empty
Content-Type, since the name has no extension, so set the header in a
middleware ahead of Vite’s own (a plugin’s configureServer).
On the IC, the asset canister’s upload skips a name that starts with a dot
unless .ic-assets.json5 in the assets folder (in a Vite app, public/)
says otherwise, as for Internet Identity’s alternative-origins file:
[ { match: ".well-known", ignore: false }, { match: ".well-known/ii-auth-callbacks", headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "application/json", }, ignore: false, },]Values the return load needs
Section titled “Values the return load needs”The return load runs the page’s code from the start. A value of your own
that the first load computed and the code after the sign-in reads, such as
an id it generated or something it fetched, goes through
authClient.memoize(produce): it runs produce on the first load, keeps the
result, and returns it on the return load. Call it in the same order relative
to signIn() on every load, and keep the value JSON. It is a method of
AuthClient, not of the client, so keep your own reference to it, as in
Identity attributes.
requestAttributes keeps the nonce its callback returns by itself, so the
canister that issues it is asked once. @icp-sdk/auth advises giving each
flow its own route, so this guide’s SignInPage calls signIn() only, and
requestAttributes() goes on a route of its own.
Internet Identity 2.0
Section titled “Internet Identity 2.0”What Internet Identity offers an app beyond a plain sign-in, its 2.0 features
among them, is an option of AuthClient, so it works through the factory, with
nothing from this library. An option of new AuthClient() goes in the factory,
beside ...network, and is fixed for the client’s life, since the client calls
its factory once. An option of signIn() goes in the call.
| To get | Pass | In |
|---|---|---|
| One-click sign-in with Google, Apple or Microsoft | openIdProvider: "google", "apple" or "microsoft" |
the factory |
| Sign-in with an organization’s own SSO | ssoDomain: "example.com" |
the factory |
| A verified email and the user’s name | requestAttributes() with verified_email and name |
Identity attributes |
| A shorter session, or a shorter idle time | maxTimeToLive, maxTimeToIdle (nanoseconds) |
signIn() |
| Sessions that only calls keep alive | disableBrowserActivity: true |
the factory |
| Two clients on one origin, each with its sign-in | namespace: "admin" on one of them |
the factory |
| One principal on several origins | derivationOrigin |
the factory |
| One sign-in for sibling subdomains | stateStorage: new CookieStateStorage({ domain }) and a derivationOrigin |
the factory |
| A full-page sign-in instead of a window | transport: "redirect" |
the factory |
import { createClient } from "@ic-reactor/core"import { AuthClient } from "@icp-sdk/auth/client"
// An admin console beside the main app on the same origin, signed in with// the organization's SSO.export const adminClient = createClient({ network: "ic", auth: (network) => new AuthClient({ ...network, ssoDomain: "example.com", namespace: "admin", }),})openIdProviderandssoDomainexclude each other.disableBrowserActivitystopsAuthClientfrom reading page visits, focus, pointer and keys as signs that the user is there. Calls are then the only sign, so a session in which the user only reads can reach its idle time.- Sibling subdomains share one sign-in only when they share a principal, so
every app passes the same
derivationOrigin. A sibling whose browser holds the shared record but no credential of its own reads as"signed-in-elsewhere"(see AuthState) and calls anonymously. Taking that sign-in over without asking the user again needsprompt: "none"andhint, which are options of the constructor, on a redirect route: see@icp-sdk/auth’s documentation. - With
transport: "redirect"the page itself goes to Internet Identity and comes back, soAuthClientasks forsignIn()to be called on the page’s load, not from a click, on a route of its own for each flow: see Redirect sign-in.
Any other sign-in: AuthLike
Section titled “Any other sign-in: AuthLike”An AuthLike is six methods and an optional dispose:
interface AuthLike { getPrincipal(): { toText(): string } | undefined getStatus(): { readonly state: | "signed-in" | "signed-in-elsewhere" | "expired" | "signed-out" // A state a later @icp-sdk/auth adds: the client calls anonymously. | (string & {}) readonly expiresAtMs?: number // when the session ends, as Date.now() counts } getIdentity(): Promise<Identity> subscribe(listener: () => void): () => void signIn(options?: unknown): Promise<unknown> signOut(options?: unknown): Promise<unknown> dispose?(): void}The client reads getStatus() and getPrincipal() synchronously. It calls
getIdentity() only when it builds or checks an agent for a signed-in
principal, and it reads both again after every subscribe() notification.
Only the state "signed-in" makes the client call as getPrincipal(). Every
other state calls as the anonymous principal.
expiresAtMs is optional. When the status carries it while "signed-in" or
"signed-in-elsewhere", the client sets a timer for it and reads the status
again then, so an AuthLike whose status turns "expired" by the clock need
not notify for it. A deadline that has passed while the status still says
"signed-in" sets no timer. And whenever client.caller() or
client.authState() reads a change no notification announced, the client
tells its listeners in a microtask.
getIdentity() should return the same object until the identity really
changes. The client builds a new agent for every new identity object it is
handed, and reads a new object for the same principal as a renewal.
Any other sign-in that hands over an Identity fits with a small adapter:
one that gives a delegation (a hand-rolled session, or Internet Identity
through a client other than AuthClient) or a key (a local dev account).
This is the one in the guide, packages/core/llms.txt:
import type { AuthLike } from "@ic-reactor/core"import type { Identity } from "@icp-sdk/core/agent"
export interface SignInSource { getIdentity(): Identity // anonymous while signed out isAuthenticated(): boolean login(): Promise<void> logout(): Promise<void> subscribe(listener: () => void): () => void}
export function toAuthLike(source: SignInSource): AuthLike { const signedIn = () => source.isAuthenticated() && !source.getIdentity().getPrincipal().isAnonymous() return { getPrincipal: () => signedIn() ? source.getIdentity().getPrincipal() : undefined, getStatus: () => ({ state: signedIn() ? "signed-in" : "signed-out" }), getIdentity: async () => source.getIdentity(), subscribe: (listener) => source.subscribe(listener), signIn: () => source.login(), signOut: () => source.logout(), }}One client has one auth. To offer two sign-in sources, write one AuthLike
that holds both and picks one from the options of signIn. The Vite wallet’s
src/auth/wallet-auth.ts does this for Internet Identity and a local dev
account.
Wallets that sign each call
Section titled “Wallets that sign each call”A wallet used through ICRC-25, ICRC-27 and ICRC-49 (OISY, Plug, an NFID
account) signs each call: the user approves it in the wallet, which signs and
sends it, and the app never holds an Identity. Used this way, the wallet is
not an AuthLike, and the client cannot send as it. (A wallet that also
speaks ICRC-34 can hand the app a delegation instead, from
signer.requestDelegation(); a DelegationIdentity made from it is an
Identity, and an AuthLike can hold it as for Internet Identity above.) Keep the client for reads, anonymous (a ledger’s balances are
public; SignerAgent would send each read to the wallet for approval too),
and send the wallet’s writes through SignerAgent from @icp-sdk/signer,
then invalidate the reads yourself:
// src/wallet.ts: needs @icp-sdk/signer, @icp-sdk/canisters and @dfinity/utilsimport { createClient } from "@ic-reactor/core"import { IcrcLedgerCanister } from "@icp-sdk/canisters/ledger/icrc"import { Principal } from "@icp-sdk/core/principal"import { Signer } from "@icp-sdk/signer"import { SignerAgent } from "@icp-sdk/signer/agent"import { PostMessageTransport } from "@icp-sdk/signer/web"import { actor, type Actor } from "./generated/icrc1"
const LEDGER = "ryjl3-tyaaa-aaaaa-aaaba-cai"
// Reads: the client, anonymous.export const client = createClient({ network: "ic", identity: "anonymous" })export const ledger = client.canister<Actor>(actor, { id: LEDGER })
type Account = Awaited<ReturnType<Signer["getAccounts"]>>[number]
// The wallet's window may open only in a click handler: call connect()// straight from a "Connect" button's onClick, before any await.export async function connect( // A wallet may share several accounts (NFID does): let the user pick one. choose: ( accounts: Account[] ) => Account | undefined | Promise<Account | undefined> = (accounts) => accounts[0]) { const signer = new Signer({ transport: new PostMessageTransport({ url: "https://oisy.com/sign" }), // Keep the channel open between the requests of one session. autoCloseTransportChannel: false, }) try { // ICRC-27: the accounts the user shares. A wallet that lacks it cannot // be used this way; signer.getSupportedStandards() says which it has. const account = await choose(await signer.getAccounts()) if (!account) throw new Error("the wallet shared no account") const agent = await SignerAgent.create({ signer, account: account.owner }) const wallet = IcrcLedgerCanister.create({ agent, canisterId: Principal.fromText(LEDGER), }) // The whole account: its subaccount, if any, is the one to debit. Call // disconnect() when the session ends, or before connecting again: it // closes the wallet's window, which stays open otherwise. return { account, wallet, disconnect: () => signer.closeChannel() } } catch (error) { // Cancelled or failed: close the window the request opened. await signer.closeChannel() throw error }}
// Writes: through the wallet, which shows each call to the user.export async function send( { account, wallet }: Awaited<ReturnType<typeof connect>>, to: Principal, amount: bigint, // Nanoseconds since the epoch, BigInt(Date.now()) * 1_000_000n, fixed // per transfer so a re-send is deduplicated. createdAt: bigint) { try { return await wallet.transfer({ // Without it the ledger debits the owner's default subaccount. from_subaccount: account.subaccount, to: { owner: to, subaccount: [] }, amount, created_at_time: createdAt, }) } finally { // The client did not send this write, so it cannot invalidate for it. await client.queryClient.invalidateQueries({ queryKey: client.queryKey(ledger), }) }}What the client gives a write does not reach this one: no ReactorError, no
mayHaveExecuted, no invalidation of its own. Read the failure yourself:
- A
SignerError(from@icp-sdk/signer) with ICRC-25’s code3001, action aborted, is the user’s refusal: nothing was sent. - A ledger
Err(IcrcTransferErrorfrom@icp-sdk/canisters) means the ledger answered and did not move the funds. - Anything else after the user approved, a closed wallet window included, can
mean the transfer ran. Read the balance, or send the same transfer again
with the same
created_at_time, which the ledger answersDuplicateif the first went through.
Show the wallet’s account (account.owner, and its subaccount when it has
one) as the user’s, not useAuth()’s principal, which stays anonymous. A wallet that is a browser extension (Plug) connects with
BrowserExtensionTransport from @icp-sdk/signer/extension instead.
@icp-sdk/signer peers only @icp-sdk/core 6, so it installs beside the
client. NFID IdentityKit 2.0 does not: it peers @icp-sdk/auth 5, and npm
refuses it beside the @icp-sdk/auth 10 the client uses.
AuthState
Section titled “AuthState”client.authState() and useAuth() give an AuthState: a status, a
principal and, while signed in, expiresAt.
| The auth says | status |
principal |
|---|---|---|
signed-in |
"signed-in" |
the signed-in user’s |
signed-out |
"anonymous" |
2vxsx-fae |
expired |
"expired" |
2vxsx-fae |
signed-in-elsewhere |
"signed-in-elsewhere" |
2vxsx-fae |
principal is the principal calls go out as, so in every state but
"signed-in" it is the anonymous principal, and a check such as
principal !== "2vxsx-fae" means what it looks like. An expired session
and one signed in on a sibling origin cannot sign a call, so they do not name
their principal. To say whose session ended, read the auth’s own
getStatus(), which keeps it. For that you need your own reference to the
AuthClient (see
Your own reference to the AuthClient),
because the client does not expose its auth.
expiresAt is when the session ends, in milliseconds since the epoch (as
Date.now() counts; new Date(expiresAt) for a date), so you can show
“signed in until”. It is there only while status is "signed-in" through an
auth whose getStatus() carries expiresAtMs, as AuthClient 10’s does.
When that time passes, the status turns "expired" and listeners are told.
A client built with identity reports "signed-in" with that identity’s
principal, or "anonymous" for "anonymous" and for an explicit
AnonymousIdentity, with no expiresAt. A client on a server reports
"anonymous".
useAuth
Section titled “useAuth”useAuth() returns the AuthState plus signIn and signOut:
import { useAuth } from "@ic-reactor/react"
export function SessionButton() { const { status, principal, signIn, signOut } = useAuth() if (status === "signed-in") { return ( <button onClick={() => signOut().catch(console.error)}> Sign out {principal} </button> ) } return ( <> {status === "expired" && <p>Your session expired.</p>} <button onClick={() => signIn().catch(console.error)}>Sign in</button> </> )}- A component that calls it renders once for each change of
status,principalorexpiresAt, and never otherwise. A renewed delegation does not render it. A session that ends by the clock renders it with"expired"when it ends. - The object it returns is the same until one of them changes, so it is safe in a dependency array or as a prop of a memoized child.
- It is
"anonymous"on a server and during a hydrating render, even when the browser holds a session, so the HTML matches the server’s. The component renders again with the real state right after hydration. Show the same thing signed out and while the session is read. See SSR and hydration. - It throws outside a
ReactorProvider.
signIn and signOut return a promise, and the promise can reject:
- on a client built with
identity, with aTypeError: there is no sign-in; - on a server, and after
client.dispose(), with anError; - with what the auth factory threw, when it threw, or a
TypeErrorwhen it returned something other than an auth; - with whatever the auth rejects with, never a
ReactorError.AuthClientrejects when the user closes Internet Identity’s window or the browser blocks it, and rejects a sign-in that a later sign-in or sign-out took over from with aSupersededError, which you can usually ignore.
Always catch them, as the examples do.
import { useAuth } from "@ic-reactor/react"import { SupersededError } from "@icp-sdk/auth/client"import { useState } from "react"
export function SignInButton() { const { signIn } = useAuth() const [failure, setFailure] = useState<string>() return ( <> <button onClick={() => signIn().catch((error: unknown) => { if (error instanceof SupersededError) return setFailure(error instanceof Error ? error.message : String(error)) }) } > Sign in </button> {failure !== undefined && <p role="alert">{failure}</p>} </> )}Outside React, client.authState() is the same state, and
client.subscribe(listener) calls listener once after each change of it.
It returns a function that stops listening.
import { client } from "./ledger"
const stop = client.subscribe(() => { const { status, principal } = client.authState() console.log(`caller is now ${principal} (${status})`)})
// laterstop()When the caller changes
Section titled “When the caller changes”| Question | Answer |
|---|---|
| the caller’s principal | useAuth().principal, client.caller() (2vxsx-fae unless signed in) |
| may a write be sent | useAuth().status === "signed-in"; the client refuses the rest itself |
| when did it change | useAuth() re-renders; client.subscribe(fn) |
A "signed-in" session may still be read-only (see AuthState).
A call goes out as the principal that was current when it was made, or not at all. The client keeps one agent per principal and never points an agent at another identity. What that means in practice:
- Components.
useClient()re-renders its component on a sign-in, a switch of account and a sign-out, and returns a new object for the new caller, so the options it builds in that render use the new caller’s keys, and auseMemoor effect that lists it in its dependencies (or React Compiler’s cache) builds them again. A change of status that keeps the caller (a session that expired, one signed in elsewhere) renders nothing, because both already called anonymously. CalluseClient()in the body of each component that builds options, on every render. - Reads in flight. A read started for the old caller is cancelled when
someone else is current, before anything is sent. It fails with
kind: "cancelled"andcode: "caller_changed", and it never lands in the new caller’s key. Treat this error as “ignore” (see Errors). - Writes. A write goes out as the caller current when
mutate()was called, or not at all. When TanStack Query holds it back (offline, or queued behind another mutation of itsscope) and the account changes in between, it fails withkind: "cancelled",code: "caller_changed"andmayHaveExecuted: false, and nothing is sent. Built from a render’suseClient(), its options also send only as that render’s caller: a press handled before a switch reached the render is refused the same way. A re-sendclient.resendOf()offered is refused asoutcome_unknownwithmayHaveExecuted: trueinstead, since the write it re-sends may have run. If nobody is signed in, the client refuses it before sending:kind: "unauthenticated", codeanonymous_write. - Data. After a sign-in, a sign-out or a switch, the new keys are empty
until they load. Do not use
placeholderData: keepPreviousDataor aninitialDatataken from another key: it would show one principal’s data to another.
A read that waits for a user uses skipToken (see
Reads):
import { principal } from "@candid-core/schema"import { useClient, useAuth } from "@ic-reactor/react"import { skipToken, useQuery } from "@tanstack/react-query"import { actor, type Actor } from "./generated/icrc1"
export function MyBalance() { const client = useClient() const { status, principal: caller } = useAuth() const ledger = client.canister<Actor>(actor, { id: "ryjl3-tyaaa-aaaaa-aaaba-cai", }) const balance = useQuery( client.queryOptions( ledger, "icrc1_balance_of", status === "signed-in" ? { owner: principal(caller), subaccount: null } : skipToken ) ) if (status !== "signed-in") return <p>Sign in to see your balance.</p> return <p>{balance.data?.toString() ?? "…"}</p>}Identity attributes
Section titled “Identity attributes”Internet Identity can sign attributes about the user for your canister, such as their name and an email address. The canister reads them from a call that carries them, and they reach it in three steps:
- Your canister issues a nonce: an update call to
_internet_identity_sign_in_start, made as the anonymous principal, which returns ablob. - The app signs in and asks for the attributes with that nonce. Started
together, they are one Internet Identity interaction for the user. The
answer is the attributes, Candid-encoded, as
data, and Internet Identity’ssignatureover them. - The app calls
_internet_identity_sign_in_finishas the signed-in user, withdataandsignatureattached to the call as itssender_info. The canister checks them and keeps what it needs.
DFINITY’s identity-attributes library adds both methods to a canister and
makes the checks: mo:identity-attributes
for Motoko, a mixin you include, and the
identity-attributes
crate for Rust, #[identity_attributes]. Both answer with the same Candid, so
the app’s side below works with either. Their READMEs say how to set the
canister up. They pair the library with @icp-sdk/auth 7, whose
requestAttributes takes the nonce as a promise; in 10 it takes a function
that returns one, as below.
The app’s client calls every update as the signed-in user and attaches nothing to a call, so steps 1 and 3 each go through a second client on the same network, built for that one call and disposed after it:
- Step 1 through a client built with
new AnonymousIdentity()from@icp-sdk/core/agent, which may send an update as the anonymous principal. A client built withidentity: "anonymous", like a signed-out one, refuses an update before it sends it. - Step 3 through a client built with an
AttributesIdentityfrom@icp-sdk/core/identity, which attachesdataandsignatureto each call it signs.
Step 2 needs the AuthClient itself, which the client does not hand out, so
you keep your own reference to it.
Your own reference to the AuthClient
Section titled “Your own reference to the AuthClient”Keep what each client’s factory builds, beside the client:
import { createClient, type Client } from "@ic-reactor/core"import { AuthClient } from "@icp-sdk/auth/client"
/** The app's network, for its client and the clients built beside it. */export const network = "ic"
/** Internet Identity's canister, where the factory is handed no provider. */const INTERNET_IDENTITY = "rdmx6-jaaaa-aaaaa-aaadq-cai"
interface Built { /** * The client itself, whose keys are the live caller's. A view of it that * `useClient()` returns keys reads by the caller of the render it came from. */ readonly client: Client readonly authClient: AuthClient /** The canister that mints the sign-in, which signs the attributes too. */ readonly signer: string}
// Keyed by the client's QueryClient, which every view of the client that// `useClient()` returns shares: a view is an object of its own.const builtBy = new WeakMap<Client["queryClient"], () => Built | undefined>()
/** What `client`'s auth factory built, once it has run. */export const getAuthClient = (client: Client) => builtBy.get(client.queryClient)?.()
export function createAppClient(): Client { let built: Built | undefined const client: Client = createClient({ network, auth: (handed) => { const authClient = new AuthClient({ ...handed, openIdProvider: "google" }) built = { client, authClient, signer: handed.identityProvider?.canisterId ?? INTERNET_IDENTITY, } return authClient }, }) builtBy.set(client.queryClient, () => built) return client}Hand it to the provider, <ReactorProvider client={createAppClient}>, or, in
an app with no server render, call it once in a module. The client still owns
the AuthClient and disposes it with itself. The factory runs in a browser,
on first use, so getAuthClient(client) is undefined on a server and until
the client has built its auth. In a component, pass it what useClient()
returns: that is a view of the provider’s client, another object, but it has
the client’s queryClient, which the map is keyed by.
Sign in with attributes
Section titled “Sign in with attributes”import { createClient, type Canister, type Client } from "@ic-reactor/core"import { scopedKeys } from "@icp-sdk/auth/client"import { AnonymousIdentity, type Identity } from "@icp-sdk/core/agent"import { AttributesIdentity } from "@icp-sdk/core/identity"import { Principal } from "@icp-sdk/core/principal"import { getAuthClient, network } from "./auth-client"import { actor, type Actor as Accounts } from "./generated/accounts"
const ACCOUNTS_ID = "be2us-64aaa-aaaaa-qaabq-cai"
/** One call to the accounts canister as `identity`, through a client of its own. */async function callAs<T>( identity: Identity, call: (accounts: Canister<Accounts>) => Promise<T>): Promise<T> { const client = createClient({ network, identity }) try { return await call(client.canister<Accounts>(actor, { id: ACCOUNTS_ID })) } finally { client.dispose() }}
/** * Signs in, has the accounts canister verify the user's name and email, and * returns what Internet Identity signed. */export async function signInWithAttributes(app: Client) { // Builds the auth if the client has not yet, and opens Internet Identity. const signedIn = app.signIn() const built = getAuthClient(app) if (built === undefined) { await signedIn // on a server it rejects, and says why throw new Error("This client was not built by createAppClient") } const { client, authClient, signer } = built
// 1 and 2: the nonce, and the attributes in the same interaction. const [, attributes] = await Promise.all([ signedIn, authClient.requestAttributes({ keys: scopedKeys({ openIdProvider: "google", keys: ["name", "verified_email"], }), nonce: () => callAs(new AnonymousIdentity(), (accounts) => accounts._internet_identity_sign_in_start() ), }), ])
// 3: the finish call, with the attributes as its sender_info. const identity = new AttributesIdentity({ inner: await authClient.getIdentity(), attributes, signer: { canisterId: Principal.fromText(signer) }, }) await callAs(identity, (accounts) => accounts._internet_identity_sign_in_finish() )
// The app's client did not send that write, so it cannot invalidate for it. // The key is the client's, for the user now signed in: `app` may be a view // from a render before the sign-in, whose keys are the anonymous caller's. await client.queryClient.invalidateQueries({ queryKey: client.queryKey( client.canister<Accounts>(actor, { id: ACCOUNTS_ID }) ), }) return attributes}This imports ./auth-client, the module above, and ./generated/accounts,
the module candid-core-cli gen writes for the accounts canister’s .did.
- Call it straight from a click handler, with no
awaitbefore it: Internet Identity’s window opens only inside a click. Catch it as you catchsignIn(). - That is the window transport. With
transport: "redirect",AuthClientasks forsignIn()andrequestAttributes()to be called on the page’s load, not from a click, each flow on a route of its own (see Redirect sign-in). So call it on each load of that route:requestAttributes()keeps the nonce across the loads, so the canister issues one. - For a user who is signed in already, skip the sign-in: call
authClient.requestAttributes()alone, with a new nonce, then the finish call. - A failure after the sign-in leaves the user signed in without the attributes, so offer to try again.
_internet_identity_sign_in_finishanswersvariant { ok; err : … }, so attributes the canister refuses reject as acanister_errwhoseerr.tagsays why (see Errors). OnStaleorUnknownNonce, ask for them again, with a new nonce. A signer the canister does not trust, or a canister with notrusted_attribute_signers, traps instead: arejectederror with reject code 5.- The signer is the canister that minted the sign-in: Internet Identity’s,
rdmx6-jaaaa-aaaaa-aaadq-cai, on mainnet and in icp-cli’s built-in one, or theidentityProvidercanister the factory is handed. The accounts canister must list it intrusted_attribute_signers.
Which attributes to ask for
Section titled “Which attributes to ask for”| Key | What it is |
|---|---|
name |
The name on the account the user signed in with |
email |
An email address from that account. Internet Identity does not check it: treat it as user input |
verified_email |
Present only when Internet Identity established that the user controls the address |
Grant access on verified_email, not email. scopedKeys from
@icp-sdk/auth/client scopes keys to the provider of a one-click sign-in, so
the user shares them in the same step:
scopedKeys({ openIdProvider: "google", keys: ["verified_email"] }) is
["openid:https://accounts.google.com:verified_email"]. An organization’s
SSO is the exception: scopedKeys({ ssoDomain }) gives sso:<domain>:name
and sso:<domain>:email, with no verified_email, and the library takes the
email from sso:<domain>:email only for a domain the canister lists in
trusted_sso_domains.
Show what the user shared
Section titled “Show what the user shared”signInWithAttributes returns what Internet Identity signed. Its data is a
Candid-encoded ICRC-3 Value: a Map of the requested keys, plus
implicit:nonce, implicit:origin and implicit:issued_at_timestamp_ns. A
scoped key stays scoped in the map. To show the user what they shared,
describe that type with candid-core’s schema builders and read it with
decode:
import { c, type Schema } from "@candid-core/schema"import { decode } from "@candid-core/schema/codec"
type Icrc3Value = | { tag: "Nat"; value: bigint } | { tag: "Int"; value: bigint } | { tag: "Blob"; value: Uint8Array } | { tag: "Text"; value: string } | { tag: "Array"; value: Icrc3Value[] } | { tag: "Map"; value: Array<[string, Icrc3Value]> }
const Icrc3Value: Schema<Icrc3Value> = c.rec(() => c.variant({ Nat: c.nat, Int: c.int, Blob: c.blob(), Text: c.text, Array: c.vec(Icrc3Value), Map: c.vec(c.tuple([c.text, Icrc3Value])), }))
/** The text the attributes hold under `key`, for display. */export function readAttribute(data: Uint8Array, key: string) { const decoded = decode(Icrc3Value, data) // never throws if (!decoded.ok) throw new Error(decoded.issues[0]?.message) const value = decoded.value as Icrc3Value // `decode` checked it against the schema
if (value.tag !== "Map") return undefined const entry = value.value.find(([name]) => name === key) return entry?.[1].tag === "Text" ? entry[1].value : undefined}readAttribute(attributes.data, "openid:https://accounts.google.com:name")
is the name the user shared.
See also
Section titled “See also”- The client:
identityandauth, networks,dispose. - SSR and hydration:
useAuth()anduseClient()while a page hydrates. - Errors:
unauthenticatedandcancelled. - Testing:
createTestClient()has a test auth you can sign in, switch and expire. - Vite wallet: Internet Identity, a dev account and one client.