Integrate a SPA (public client)
Connect a browser single-page app using a public, PKCE-only client and the TypeScript client library.
A browser single-page app can't keep a client secret, so it uses a public client with
token_endpoint_auth_method: none and proves possession with PKCE. This guide wires one up.
1. Create a public client
Either register one in the admin dashboard (Clients → New Client, auth method none) or let
the app self-register via DCR. Note the client_id (there is no
secret) and set the redirect URI to your app's callback (exact match).
Grant the client the scopes it needs — at least openid if you want an id_token and /userinfo.
2. Build the authorization URL with PKCE
Use the client library so a scope typo can't drop openid:
import { fetchOIDCDiscovery, buildAuthorizationUrl, OIDCScope } from "@eetr/eetr-auth-client";
const ISSUER = "https://auth.yourdomain.com";
const discovery = await fetchOIDCDiscovery(ISSUER);
// Generate a PKCE verifier + S256 challenge (Web Crypto), and a random state/nonce.
const url = buildAuthorizationUrl(discovery.authorization_endpoint, {
clientId: "your-public-client-id",
redirectUri: "https://app.example.com/callback",
codeChallenge, // base64url SHA-256 of the verifier
scopes: [OIDCScope.OpenId, OIDCScope.Profile, OIDCScope.Email],
state,
nonce,
});
window.location.assign(url);Store the PKCE verifier, state, and nonce (e.g. in sessionStorage) for the callback.
3. Exchange the code for tokens
On the callback, verify state, then exchange the code with the verifier (no secret):
import { exchangeToken, validateIdToken } from "@eetr/eetr-auth-client";
const tokens = await exchangeToken(
{
grantType: "authorization_code",
clientId: "your-public-client-id",
code: authorizationCode,
redirectUri: "https://app.example.com/callback",
codeVerifier, // PKCE proof of possession
},
{ tokenEndpoint: discovery.token_endpoint }
);
// Verify the id_token and the nonce you sent
const claims = await validateIdToken(tokens.id_token!, discovery.jwks_uri, {
issuer: ISSUER,
audience: "your-public-client-id",
nonce,
});4. Manage the token lifecycle
TokenManager caches the access token and transparently refreshes it (public clients need no
secret):
import { TokenManager } from "@eetr/eetr-auth-client";
const manager = new TokenManager({
issuerUrl: ISSUER,
clientId: "your-public-client-id",
tokenEndpoint: discovery.token_endpoint,
});
manager.setTokens(tokens);
const accessToken = await manager.getAccessToken(); // refreshes if expired5. Read the user profile
import { getUserInfo, toUserProfile, decodeJwtPayload, OAuthError } from "@eetr/eetr-auth-client";
try {
const info = await getUserInfo(accessToken, discovery.userinfo_endpoint);
const profile = toUserProfile(info, decodeJwtPayload(tokens.id_token!));
// { sub, name?, preferredUsername?, picture?, email?, emailVerified? }
} catch (err) {
if (err instanceof OAuthError && err.isInsufficientScope) {
// token is valid but missing `openid` — re-authorize requesting it
}
}Bind tokens to your API
If your SPA calls a separate protected API, pass a resource parameter in steps 2–3 so the token's
aud is your API's URL (RFC 8707), and have the API validate the audience. See
resource indicators.