@eetr/eetr-auth-client
v0.4.0
Published
TypeScript client library for eetr-auth OAuth 2.1 / OIDC server
Readme
@eetr/eetr-auth-client
TypeScript client library for the eetr-auth OAuth 2.1 / OIDC server.
It wraps the server's token, introspection, UserInfo, admin, and passkey-management
endpoints, plus helpers for OIDC discovery and JWT verification. Everything is
fetch-based and ships with full type definitions.
Installation
npm install @eetr/eetr-auth-clientpnpm add @eetr/eetr-auth-client
# or
yarn add @eetr/eetr-auth-clientRequirements: Node.js 18+ (the library relies on the global fetch; decodeJwtPayload
also uses Node's Buffer). jose is a runtime dependency used for JWT verification.
The package is ESM-only ("type": "module") and exports both JavaScript and .d.ts types.
Quick start
import {
fetchOIDCDiscovery,
exchangeToken,
validateJwt,
getUserInfo,
} from "@eetr/eetr-auth-client";
const ISSUER = "https://auth.example.com";
// 1. Discover endpoints
const discovery = await fetchOIDCDiscovery(ISSUER);
// 2. Exchange an authorization code for tokens (PKCE)
const tokens = await exchangeToken(
{
grantType: "authorization_code",
clientId: "my-client",
code: authorizationCode,
redirectUri: "https://app.example.com/callback",
codeVerifier,
},
{ tokenEndpoint: discovery.token_endpoint }
);
// 3. Verify the access/ID token against the server's JWKS
const payload = await validateJwt(tokens.access_token, discovery.jwks_uri, {
issuer: ISSUER,
audience: "my-client",
});
// 4. Fetch the user's profile
const user = await getUserInfo(tokens.access_token, discovery.userinfo_endpoint);API reference
Discovery
fetchOIDCDiscovery(issuerUrl: string): Promise<OIDCDiscovery>
fetchOAuthMetadata(issuerUrl: string): Promise<OAuthServerMetadata>Fetch the server's /.well-known/openid-configuration or
/.well-known/oauth-authorization-server metadata. Use the returned
token_endpoint, jwks_uri, userinfo_endpoint, etc. to configure the rest of
the client rather than hard-coding paths.
Authorization URL
buildAuthorizationUrl(authorizationEndpoint: string, params: AuthorizationUrlParams): stringBuilds the authorization-request URL for the authorization_code + PKCE flow.
code_challenge is required (the server only accepts S256). Pass nonce to
request an OIDC nonce that the server binds into the issued id_token, then
verify it on the returned token with validateIdToken.
const url = buildAuthorizationUrl(discovery.authorization_endpoint, {
clientId: "my-client",
redirectUri: "https://app.example.com/callback",
codeChallenge, // base64url SHA-256 of your PKCE verifier
scope: "openid profile email",
state,
nonce,
});Prefer the OIDCScope constants and the scopes array so a typo can't silently
drop openid (which would make /userinfo return 403 insufficient_scope). scope
and scopes are merged and de-duplicated, so either or both work:
import { OIDCScope, STANDARD_OIDC_SCOPES } from "@eetr/eetr-auth-client";
const url = buildAuthorizationUrl(discovery.authorization_endpoint, {
clientId: "my-client",
redirectUri: "https://app.example.com/callback",
codeChallenge,
scopes: [OIDCScope.OpenId, OIDCScope.Profile, OIDCScope.Email], // or STANDARD_OIDC_SCOPES
state,
nonce,
});The client must be granted these scopes by an admin, and
openid/profile/invalid_scope.
Token exchange
exchangeToken(params: ExchangeTokenParams, config: ExchangeTokenConfig): Promise<TokenResponse>Performs an OAuth token request. grantType is one of "authorization_code",
"client_credentials", or "refresh_token"; supply the fields relevant to the grant.
When the openid scope was granted on an authorization_code exchange, the
TokenResponse also includes a signed id_token.
// Client credentials (machine-to-machine)
const tokens = await exchangeToken(
{
grantType: "client_credentials",
clientId: "service-a",
clientSecret: process.env.CLIENT_SECRET,
scope: "admin",
},
{ tokenEndpoint: discovery.token_endpoint }
);On a non-2xx response it throws an OAuthError carrying the
server's error code and error_description.
TokenManager
A small helper that caches an access token and transparently refreshes it (using a 30-second expiry skew) when a refresh token is available.
import { TokenManager } from "@eetr/eetr-auth-client";
const manager = new TokenManager({
issuerUrl: ISSUER,
clientId: "my-client",
clientSecret: process.env.CLIENT_SECRET, // optional for public clients
tokenEndpoint: discovery.token_endpoint,
});
manager.setTokens(tokens); // seed from an initial exchange
const accessToken = await manager.getAccessToken(); // refreshes if expiredgetAccessToken() throws an OAuthError with code no_token if there is no valid
token and no refresh token to fall back on.
JWT verification
validateJwt(token: string, jwksUri: string, options?: ValidateJwtOptions): Promise<JWTPayload>
validateIdToken(token: string, jwksUri: string, options?: ValidateIdTokenOptions): Promise<IDTokenClaims>
decodeJwtPayload(token: string): JWTPayloadvalidateJwt verifies the signature against the server's JWKS (remote keys are
cached per jwksUri) and validates issuer/audience/expiry (clockTolerance
defaults to 5 seconds). decodeJwtPayload decodes the payload without verifying
the signature — use it only for inspecting claims you have already verified.
const payload = await validateJwt(accessToken, discovery.jwks_uri, {
issuer: ISSUER,
audience: "my-client",
clockTolerance: 10,
});validateIdToken verifies an OIDC id_token the same way and, when you pass the
nonce you sent to the authorization endpoint, additionally checks the token's
nonce claim — throwing id_token nonce mismatch on a mismatch. It returns the
typed IDTokenClaims (sub, auth_time, nonce, at_hash, plus scope-gated
name/preferred_username/picture/email/email_verified).
const claims = await validateIdToken(tokens.id_token!, discovery.jwks_uri, {
issuer: ISSUER,
audience: "my-client",
nonce, // the value passed to buildAuthorizationUrl
});Token introspection
introspectToken(params: IntrospectTokenParams, config: IntrospectTokenConfig): Promise<TokenValidationResponse>Asks the server whether a token is active within a given environment. The endpoint
is published as token_introspection_endpoint in the OAuth metadata (defaults to
${ISSUER}/api/token/validate).
const metadata = await fetchOAuthMetadata(ISSUER);
const result = await introspectToken(
{ token: accessToken, scopes: ["read"], environmentName: "production" },
{ introspectionEndpoint: metadata.token_introspection_endpoint! }
);
// → { valid, active, client_id, expires_at }UserInfo
getUserInfo(accessToken: string, userInfoEndpoint: string): Promise<UserInfoResponse>Returns the OIDC UserInfo claims for the bearer token. The endpoint requires the
openid scope; only sub is always present, and the remaining claims are gated by
scope (profile → name/preferred_username/picture, email →
email/email_verified).
On failure it throws an OAuthError. A token that is valid but
lacks openid yields a 403, surfaced as code === "insufficient_scope" (use
err.isInsufficientScope) so you can re-authorize with the right scopes rather than
discarding the token:
try {
const info = await getUserInfo(accessToken, discovery.userinfo_endpoint);
} catch (err) {
if (err instanceof OAuthError && err.isInsufficientScope) {
// token is fine but missing `openid` — restart the dance requesting it
}
}Normalized profile
toUserProfile(userInfo: UserInfoResponse, idTokenClaims?: IDTokenClaims): UserProfileMerges the UserInfo response (and optionally the decoded id_token claims) into a
single camelCased UserProfile (sub, name, preferredUsername, picture,
email, emailVerified). UserInfo wins; the id_token fills any gap.
const info = await getUserInfo(tokens.access_token, discovery.userinfo_endpoint);
const profile = toUserProfile(info, decodeJwtPayload(tokens.id_token!));
// → { sub, name?, preferredUsername?, picture?, email?, emailVerified? }Admin API
User management against the server's admin API. Requires an access token from a client configured as an admin API client on the server.
import {
getAdminUser,
createAdminUser,
updateAdminUser,
deleteAdminUser,
} from "@eetr/eetr-auth-client";
const config = { baseUrl: ISSUER, accessToken }; // AdminClientConfig
const created = await createAdminUser(
{ username: "alice", password: "•••", email: "[email protected]" },
config
);
await updateAdminUser("alice", { name: "Alice B." }, config);
const user = await getAdminUser("alice", config); // by username or UUID
await deleteAdminUser(created.id, config);All admin calls throw OAuthError on non-2xx responses.
Passkey management
List, rename, and remove a user's passkeys. The access token must belong to the user whose passkeys are being managed.
import { listPasskeys, renamePasskey, removePasskey } from "@eetr/eetr-auth-client";
const config = { baseUrl: ISSUER, accessToken }; // UserClientConfig
const passkeys = await listPasskeys(config);
await renamePasskey(passkeys[0].id, "Work laptop", config);
await removePasskey(passkeys[0].id, config);Only passkey management is available here. Creating or authenticating with a passkey is a WebAuthn ceremony that requires a browser and cannot be driven from a server-side client.
removePasskeydeletes the server-side record only — it does not remove the credential from the device/authenticator.
Error handling
API helpers throw OAuthError (a subclass of Error) on non-2xx responses, exposing
the server's machine-readable code alongside the message:
import { OAuthError } from "@eetr/eetr-auth-client";
try {
await exchangeToken(params, config);
} catch (err) {
if (err instanceof OAuthError) {
console.error(err.code, err.message); // e.g. "invalid_grant"
}
}OAuthError also exposes the HTTP status and the server's description
(error_description) when available, plus an isInsufficientScope convenience for
the /userinfo 403 case (token valid but missing openid).
The discovery helpers throw a plain Error with the HTTP status on failure.
Types
The package exports TypeScript types for every request and response shape, including
TokenResponse, UserInfoResponse, UserProfile, IDTokenClaims, OIDCDiscovery,
OAuthServerMetadata, AuthClientConfig, JWTPayload, TokenValidationResponse,
GrantType, ExchangeTokenParams/Config, AuthorizationUrlParams,
IntrospectTokenParams/Config, OIDCScopeValue, ValidateJwtOptions,
ValidateIdTokenOptions, AdminUserRecord, AdminClientConfig, CreateUserParams,
UpdateUserParams, PasskeySummary, and UserClientConfig. It also exports the
OIDCScope / STANDARD_OIDC_SCOPES constants, the resolveScopeParam helper, and
the toUserProfile profile normalizer.
License
See the eetr-auth repository.
