AbsoluteJS

Why Citra

A Complete OAuth Library Built to Last

We built Citra because one auth layer should be able to use GitHub, Withings, Slack, or an enterprise OIDC issuer without hiding how any of them works. Provider behavior lives in typed data instead of another client class.

78Built-in provider configurations
1Shared implementation

OAuth providers share a protocol, but their clients need different contracts.

Our auth layer accepts a provider selected from a route, database row, or application setting. It needs one orchestration path while preserving the inputs and operations each provider actually supports.

What the auth layer knows

A provider name, an optional named client, callback data, and session state arrive at runtime. That is enough to choose the client, not enough to erase its capabilities.

A shared engine does not require a flattened interface.

GitHub has no refresh method. Google requires PKCE and scopes. Apple has no UserInfo endpoint. Withings disconnects a user with a numeric ID and a signed request. Citra keeps those facts in the selected client type and provider definition.

Provider definitions drive HTTP behavior and capability methods. A provider-to-credentials map gives each built-in its constructor type.

We reached this design while using Arctic inside absolutejs/auth. The auth package needed one callback and session flow for a provider selected at runtime. Its Arctic adapter grew a class registry, PKCE detection, and a second provider catalog just to recover information the OAuth layer did not expose. The full history is below.

The strongest case for one wrapper per provider is that provider differences remain explicit. We agree with that goal. The cost is that construction, capability checks, profile requests, and response normalization move into every application adapter. Citra keeps the differences explicit as typed definitions and lets a shared engine execute them.

One provider model drives requests, capabilities, and credentials.

A provider definition is executable configuration. The request engine reads it at runtime. Capability types read the literal flags at compile time.

providers.ts
withings: {
  // changes the returned client type
  isRefreshable: true,
  scopeRequired: true,

  // changes authorization URL construction
  scopeDelimiter: ',',

  // normalizes a nonstandard response
  accessTokenPath: ['body', 'access_token'],

  revocationRequest: {
    authIn: 'body',
    inputSource: 'subject',
    inputType: 'number',
    tokenParamName: 'userid',
    body: config =>
      getWithingsSignatureParams(config, 'revoke'),
    validateResponse: assertWithingsSuccess
  },

  tokenRequest: {
    authIn: 'body',
    encoding: 'application/x-www-form-urlencoded',
    url: 'https://wbsapi.withings.net/v2/oauth2'
  },

  subject: ['userid'],
  subjectBySource: {
    tokenResponse: ['body', 'userid']
  },
  subjectType: 'number'
}

isRefreshable: true

The shared implementation can refresh a token, and refreshAccessToken() appears on the returned TypeScript type.

scopeRequired: true

createAuthorizationUrl() will not compile without a non-empty scope array.

authIn and encoding

The request engine knows where credentials belong and how the endpoint expects its payload. No Withings branch is needed in the engine.

Built-in credentials are mapped separately

CredentialsFor<P> maps the provider name to its constructor type. That is how Entra requires tenantId and Intuit limits its environment.

Withings makes its differences impossible to ignore.

Its scopes are comma-separated, tokens are nested, operations share an action-based endpoint, and disconnecting a user requires a fresh nonce, a second HMAC, and the numeric userid. Citra represents each requirement directly.

Where the differences live

The application uses the same authorization, token, refresh, and revocation lifecycle. Identity comes from the token response because Withings does not expose a general UserInfo request in this configuration.

The revocation input changes too. The definition selects subject, the shared resolver checks that it is a number, and revokeToken() receives the Withings userid. Standard endpoints default to the access token, while providers such as Reddit select the refresh token.

Scopes
scopeDelimiter: ','
Token path
['body', 'access_token']
Exchange
action=requesttoken
Identity
body.userid from the token response
Revoke
POST + fresh nonce + second HMAC + numeric userid; JSON status validated
App API
createAuthorizationUrl · validateAuthorizationCode · refreshAccessToken · revokeToken(userid)
The same request model

We added these extension points because providers needed them.

HubSpotprofileRequest.authIn: 'path'

The access token becomes the final URL path segment instead of a Bearer header.

Slack user OAuthscopeParamName + accessTokenPath

Scopes use user_scope, and the token arrives at authed_user.access_token.

AniListPOST · application/json · GraphQL

Fetching identity means sending a GraphQL query rather than calling a normal UserInfo endpoint.

GoHighLevelsubjectBySource.tokenResponse

The connected account ID comes from the token response, and profile requests require a version header.

Intuitenvironment + computed headers

Sandbox and production use different UserInfo URLs. Revocation builds its own Basic Auth header.

ApplecreateClientSecret + idToken subject

Token requests receive a freshly signed ES256 client-secret JWT. Authorization uses form_post, revocation reuses a fresh assertion, and identity is mapped from the ID token because there is no UserInfo endpoint.

Utilities used throughout the callback.

generateState()generateCodeVerifier()

Both use crypto.getRandomValues() and return URL-safe values.

Provider errors with context

Failed requests include HTTP status, URL, and the parsed JSON or text response when the provider sends one.

Authorization escape hatch

Pass extra searchParams without forking a provider definition for one query parameter.

Zero runtime dependencies

URL handling, PKCE, JWT signatures, and OIDC verification use platform APIs.

Selecting a provider changes the interface.

Conditional types read the selected provider’s literal configuration and add its required arguments and supported methods.

PKCE

Required inputs are really required

A PKCE provider requires codeVerifier. A scope-required provider requires a non-empty array.

API

Unsupported methods disappear

Refresh, revoke, and profile methods appear only when their configuration exists. They are absent from both the type and runtime object otherwise.

OIDC

OIDC is not assumed

id_token is optional in the token response. Even an OIDC-capable provider may omit it when the flow did not request OpenID Connect.

HTTP

Runtime checks remain

Provider servers can still lie. Citra rejects OAuth error objects returned with HTTP 200 and responses missing an access token.

the compiler is part of the API
const google = await createOAuth2Client(
  'google',
  credentials
);

google.createAuthorizationUrl({
  state,
  scope: ['openid']
});
Type error · TS2345

Property 'codeVerifier' is missing in type '{ state: string; scope: [string]; }' but required in type '{ codeVerifier: string; }'.

const facebook = await createOAuth2Client(
  'facebook',
  credentials
);

facebook.revokeToken(token);
Type error · TS2339

Property 'revokeToken' does not exist on type 'OAuth2Client<"facebook">'.

const tokens =
  await facebook.validateAuthorizationCode({
    code,
    codeVerifier
  });

tokens.id_token?.toString();
// optional because a token exchange may omit it

What Citra checks at the token boundary

Code exchange, refresh, and discovered OIDC token responses must be objects with a non-empty access_token. When refresh_token, token_type, scope, or id_token is present, it must be a string. expires_in must be a non-negative number; numeric strings are normalized. Citra also rejects OAuth error objects returned with HTTP 200. Extra provider-specific fields remain unknown until the application extracts and validates them.

Provider-specific credentials fail at compile time.

Built-in credential types are maintained in CredentialsMap. The provider name selects the matching constructor type.

createOAuth2Client('microsoftentraid', {
  clientId,
  clientSecret,
  redirectUri
});
Type error · TS2345

Property 'tenantId' is missing in type '{ clientId: string; clientSecret: string; redirectUri: string; }' but required in type 'MicrosoftEntraIdOAuth2Credentials'.

createOAuth2Client('intuit', {
  clientId,
  clientSecret,
  redirectUri,
  environment: 'staging'
});
Type error · TS2322

Type '"staging"' is not assignable to type '"production" | "sandbox"'.

Generic code can narrow a client without classes.

A route parameter or database value is not a string literal. Citra exports provider-name guards, capability lists, and client guards for that case.

disconnect-provider.ts
function disconnectIfSupported(
  client: OAuth2Client<ProviderOption>,
  session: RevocationInputContext
) {
  if (isRevocableOAuth2Client(client)) {
    const input =
      client.resolveRevocationInput(session);

    return client.revokeToken(input);
  }
}

// works for built-in and custom clients selected at runtime

The provider catalog is optional for OIDC.

For issuers that support authorization code flow with PKCE and client_secret_post, Citra can discover the endpoints at runtime and verify the resulting ID token with WebCrypto.

enterprise-oidc.ts
const oidc = await createOIDCClient({
  issuer: 'https://login.example.com',
  clientId,
  clientSecret,
  redirectUri
});

const authorizationUrl =
  await oidc.createAuthorizationUrl({
    codeVerifier,
    nonce,
    state
  });

const tokens =
  await oidc.validateAuthorizationCode({
    code,
    codeVerifier
  });

if (!tokens.id_token) {
  throw new Error('Missing id_token');
}

const claims =
  await oidc.verifyIdToken(
    tokens.id_token,
    { nonce }
  );

Discovery

Reads the authorization, token, JWKS, and optional UserInfo endpoints from /.well-known/openid-configuration. The returned issuer must match the one we configured.

PKCE inputs are required

Authorization and code exchange require a verifier. S256 is built in, and default scopes are openid email profile.

Signature and claim checks

Supports RS256 and ES256, then checks issuer, audience, authorized party, expiration, issued-at, not-before, subject, and an optional nonce.

JWKS caching and rotation

Signing keys are cached. Once the cache is at least 60 seconds old, a failed verification triggers one JWKS refresh and retry.

This verification path belongs to createOIDCClient(). Selecting an OIDC provider from the catalog does not automatically verify its ID token.

Security responsibilities stay visible

Citra generates cryptographically random state and verifier values, but the application must store them with the browser session and compare the returned state. If it sends a nonce, it must retain that value and pass it to verifyIdToken(). The discovered client currently supports confidential clients using client_secret_post; it does not negotiate every token-endpoint authentication method. It also does not validate hybrid-flow at_hash or c_hash claims.

We map the fields our auth layer needs.

A successful token exchange still leaves us parsing a different identity response for every provider. GitHub gives us profile.id. Etsy nests the ID under results[0]. GoHighLevel puts it in the token response. We record those paths in the provider definition.

GitHubprofile.idnumber
Etsyprofile.results[0].user_idnumber
Tiltifyprofile.data.idstring
Tumblrprofile.response.user.namestring
Facebookprofile.id · id_token.substring
GoHighLeveltokenResponse.locationIdstring
What Citra exports
PATH

subject

The canonical nested path and its expected primitive type live in the provider definition.

SOURCE

subjectBySource

A provider can declare different paths for a profile, ID token, or token response.

READ

extractPropFromIdentity()

Walks the nested path and can reject a value with the wrong string, number, boolean, or object type.

MOVE

normalizeProviderIdentity()

Copies a source-specific subject to the provider’s canonical subject path.

application reads
extractPropFromIdentity(
  identity,
  provider.subject,
  provider.subjectType
)

These are metadata and helper functions, not an automatic unified-profile method. Our auth layer chooses whether it is handling a profile, verified ID token, or token response, then calls the helper with that source.

subjectStable provider identity with an expected string or number type.
emailMapped whether it lives at email or deeper.
fullNameA provider’s display-name field, declared once.
givenName / familyNameSeparate name parts when the provider exposes them.
pictureEven nested avatar paths become provider metadata.

Our auth layer does not need provider parsing branches.

We do not keep a GitHub branch, an Etsy branch, and a GoHighLevel branch in our auth layer. Generic code reads the provider mapping and runs the same extractor. When we add a provider, we map its response once.

Custom providers keep the capability inference.

We do not need to wait for a catalog release to integrate a private server or a provider Citra has not seen. A literal definition controls the same request engine. Its flags determine which operations exist on both the inferred type and the runtime object.

acme-provider.ts
type AcmeCredentials = {
  clientId: string;
  clientSecret: string;
  redirectUri: string;
  tenantId: string;
};

const acme = defineProvider<AcmeCredentials>()({
  authorizationUrl: ({ tenantId }) =>
    `https://${tenantId}.acme.test/oauth/authorize`,
  isOIDC: true,
  isRefreshable: true,
  PKCEMethod: 'S256',
  scopeRequired: true,
  subject: ['sub'],
  subjectType: 'string',
  profileRequest: {
    url: ({ tenantId }) =>
      `https://${tenantId}.acme.test/oauth/userinfo`,
    method: 'GET',
    authIn: 'header',
    encoding: 'application/json'
  },
  tokenRequest: {
    url: ({ tenantId }) =>
      `https://${tenantId}.acme.test/oauth/token`,
    authIn: 'body',
    encoding: 'application/x-www-form-urlencoded'
  }
});

const client = await createCustomOAuth2Client(acme, {
  clientId, clientSecret, redirectUri, tenantId
});

await client.refreshAccessToken(refreshToken);

The definition infers capabilities and carries its exact credentials.

Those facts come from isRefreshable, PKCEMethod, and scopeRequired. There is no separate custom-client interface to keep in sync.

defineProvider<AcmeCredentials>() types every credential-dependent URL, header, body, and client-secret factory. The same type is then required by createCustomOAuth2Client(), so missing, mistyped, and undeclared custom fields fail before construction.

createCustomOAuth2Client(acme, {
  clientId,
  clientSecret,
  redirectUri
});
Type error · TS2345

Property 'tenantId' is missing but required in type 'AcmeCredentials'.

client.createAuthorizationUrl({
  state,
  scope: ['openid']
});
Type error · TS2345

Property 'codeVerifier' is missing in type '{ state: string; scope: [string]; }' but required in type '{ codeVerifier: string; }'.

client.revokeToken(accessToken);
Compiler result, shortened · TS2339

Property 'revokeToken' does not exist on the inferred client type.

We needed OAuth to fit inside a complete auth system.

Citra did not begin as an abstract argument about provider design. It began while we were building absolutejs/auth, where OAuth is one part of a larger authentication system.

What we were actually building

One provider selected at runtime, one auth flow for the application.

The auth package owned routes, state and PKCE cookies, callbacks, sessions, user lookup, refresh, revocation, and redirects. A route or stored session selected the provider. The same orchestration then had to work for every configured provider.

Elysia hosted those HTTP routes, but it was not the source of the problem. Any generalized auth system reaches the same boundary once provider selection becomes data instead of a hardcoded import.

What the Arctic adapter accumulated
Construction
A manual registry of every provider class and a suppressed type error around the dynamic constructor.
PKCE
Function source converted to text and searched for a codeVerifier parameter.
Identity
Try to decode an ID token, catch its absence, then fall back to a profile request.
UserInfo
A separate 322-line catalog describing profile endpoints, methods, headers, bodies, and token placement.
April 2025

The mismatch became explicit

In Arctic issue #299, we showed the callback from absolutejs/auth. idToken() threw when a non-OIDC response omitted id_token, forcing a normal branch through exception handling. Arctic's response was that its clients were not designed to be passed around or used through a shared interface.

May 2025

The OAuth boundary became Citra

We began replacing the class registry and application-owned profile catalog with one client driven by provider definitions. As Citra matured, PKCE, request placement, response paths, identity extraction, refresh, and revocation became facts the OAuth layer could expose directly.

Today

The larger system is the proof

absolutejs/auth now accepts typed built-in or custom provider configuration, resolves the selected client, and runs it through common authorization, callback, profile, refresh, and revocation routes. Auth owns the application workflow. Citra owns provider-aware OAuth.

July 2026

Arctic was deprecated

Pilcrow later deprecated Arctic, citing provider maintenance, an API that was not tailored enough, and a belief that OAuth was the wrong layer to abstract. Those concerns are worth taking seriously. Citra's answer is to keep the shared operations small, make capabilities visible in the type, and leave wire details in provider configuration.

Configuration does not stop providers from changing their APIs. Contract fixtures and catalog maintenance still matter. What it changes is where that work happens and how much application code it can disturb.

absolutejs/auth is one demanding consumer, not the boundary of the idea. It can remain an Elysia auth system because Citra remains a framework-neutral OAuth layer: 78 provider configurations, one engine, and no requirement that another application organize its routes or sessions the same way.

Keep provider details out of application code.

Citra is useful today because adding a provider usually means describing its HTTP behavior, not implementing another client. Application code gets a stable OAuth surface while provider definitions retain the differences.

Custom provider definitions carry their own typed credential requirements alongside their capabilities, request details, response parsers, and extractors. Supporting another provider adds its requirements to the catalog instead of scattering new branches through the application.

See the provider matrix running

Our hosted authentication demo shows the current status of every provider configuration. For providers configured in our demo environment, you can run authorization, profile, refresh, and revocation from the same page.

bun add citra
Read the source ↗