JavaScript browser SDK
@keenauth/js is the browser core of KeenAuth. It signs users in to your single-page app, keeps their tokens and calls your API. It has no runtime dependencies, and the React components build on it. The package is not published to npm yet.
Sign in with a redirect
The SDK is a standard OpenID Connect public client:
- It uses PKCE (S256) and DPoP-bound tokens.
- It checks
state,nonceandisson the callback. - It validates the ID token's signature and claims against your environment's keys.
import { KeenAuth } from "@keenauth/js";
const auth = new KeenAuth({
issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
redirectUri: "https://app.acme.example/callback",
postLogoutRedirectUri: "https://app.acme.example/",
});
// On your sign-in button:
await auth.signInRedirect({ appState: "/orders" });
// On https://app.acme.example/callback:
const { appState } = await auth.handleRedirectCallback();
handleRedirectCallback accepts a response only once. It fails with a KeenAuthError when:
stateorissdoes not match;- the pending sign-in is older than 10 minutes;
- the ID token is not valid.
An error that the server sends back is raised as a KeenAuthOAuthError carrying the OAuth error code.
Call your API
To get an access token for your API, pass its identifier as resource (RFC 8707). It is sent on the authorization request, the code exchange and every refresh, and the token's aud is that identifier:
const auth = new KeenAuth({
issuer: "https://acme.eu.keenauth.app",
clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
redirectUri: "https://app.acme.example/callback",
resource: "https://api.acme.example", // your API's identifier
scope: "openid profile email offline_access messages:read",
});
- The value must be an absolute URI without a fragment (
#). It is sent exactly as you write it, so use the API identifier as it is registered. An invalid value throws aKeenAuthErrorwith codeconfig_invalidfrom the constructor. signInRedirect({ resource })overrides it for one sign-in.- Without
resource, the server uses the application's default API, if one is set; otherwise no API scopes are granted. - The option also accepts an array, sent as repeated
resourceparameters as RFC 8707 allows. A KeenAuth access token has exactly one audience, so the server rejects more than one value withinvalid_target(aKeenAuthOAuthError).
const res = await auth.authorizedFetch("https://api.acme.example/v1/orders");
// Or take the token yourself; it is refreshed when it is about to expire.
const token = await auth.getAccessToken();
authorizedFetch sends Authorization: DPoP with a fresh proof bound to the token. If the server asks for a DPoP nonce, it retries once with that nonce. Concurrent calls share one refresh request, and every refresh rotates the refresh token. To verify these tokens in your API, see Verifying tokens in your API.
Sign out
await auth.signOut();
signOut drops the tokens and revokes the refresh token. It then sends the browser to your environment's logout endpoint, which returns it to postLogoutRedirectUri.
Where tokens are kept
- Access, refresh and ID tokens are kept only in memory, never in localStorage, sessionStorage, IndexedDB, cookies or the URL. After a page reload, the user is signed in again with a redirect; the SDK does not restore the session.
- sessionStorage holds only the one-time values of a sign-in in progress.
- The DPoP key is a non-extractable WebCrypto key, kept in memory by default.
Embedded sign-in
The same object also gives the embedded components a front API client:
transactionsfor sign-in steps;mefor the account's profile anduser_metadata, emails (with code verification), passkeys and sessions;webauthnfor passkey ceremonies.
These calls use your environment's cookies and a CSRF header. The server accepts them only from the environment's own login origin.
Sign in without a redirect
On a page outside your login origin, start the transaction with your clientId. The SDK then binds it to its DPoP key instead of cookies:
let tx = await auth.transactions.create({ client_id: "app_01j9...", organization: "acme" });
tx = await auth.transactions.step("identifier", { identifier: "ada@acme.example" });
tx = await auth.transactions.step("password", { password });
if (tx.state === "completed") await auth.complete(tx); // { kind: "signed_in" }
- The application must be a first-party application, and the page origin must be one of its allowed web origins.
- Every call carries a DPoP proof and no cookie. If the server asks for a DPoP nonce, the SDK retries once with that nonce.
- The SDK creates the PKCE verifier,
stateandnonceitself and keeps them in memory.scopeandresourcecome from the options; you can passorganizationanduiLocalestocreate. Use oneresourcevalue here. completechecks the answer, exchanges the authorization code with the same DPoP key and keeps the tokens in memory. The page does not navigate, and the code is never returned to your code.- Every
stepcall, with or withoutclient_id, sends the browser's time zone in theIdp-Zoneinfoheader. When the step completes the sign-in and the user has not picked a time zone, KeenAuth saves it to the profile'szoneinfo, as the hosted sign-in page does. If the browser reports no usable zone, the header is left out and the step is unchanged. - If the sign-in needs a page the user must see (consent, legal terms, choosing an organization), the last step fails with a
KeenAuthProblemErrorwhose code isauthorization_failed. UsesignInRedirectfor that user.
Account calls from your own origin
Once the user is signed in and the SDK holds a refresh token (the default scope asks for offline_access), the me and organizations calls no longer need the sign-in host's session cookie:
- The SDK refreshes with
resource=<issuer>/fapiand gets a short-lived access token for the front API. It is DPoP-bound and kept in memory only, like the other tokens. - Each account call sends
Authorization: DPoP <token>and a DPoP proof for that call, with no cookie. If the server asks for a DPoP nonce, the SDK retries once with that nonce; if the token is no longer valid, it gets a new one and retries once. - The refresh token rotates on every refresh, so the SDK runs refreshes one after another.
- This works only for a first-party application whose allowed web origins include your page's origin.
- Without tokens, the SDK uses the session cookie as before.
See Front API access token for the server rules.
Switching the organization
organizations.switch(orgId) moves the signed-in user to another organization without a page. Pass the org_… ID of an organization from organizations.listMine():
const { data } = await auth.organizations.listMine();
await auth.organizations.switch(data[1].id);
- It needs the same as the account calls above: a refresh token,
redirectUriand a first-party application whose allowed web origins include your page's origin. - The SDK sends a switch request with the front API token, exchanges the code with a proof of the same key and keeps the new tokens, whose
org_idis the organization. The tokens you held stay until all of that succeeds. - If the organization needs a page the user must see (MFA, SSO, consent), the call fails with a
KeenAuthProblemErrorwhose code isauthorization_failed. UsesignInRedirect({ organization })for that user.
Current limits
- Profile writes (
me.update,me.updateMetadata) sendIf-Matchwith theETagof the last profile read or write. If the profile changed elsewhere in the meantime, the write fails with aKeenAuthProblemErrorwhose code isprecondition_failed; callme.get()again before retrying. - Phone numbers are not available yet:
me.phonesisundefined.