The application problem
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.
Provider definitions
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.
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.
Provider behavior in practice
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.
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)
We added these extension points because providers needed them.
profileRequest.authIn: 'path'The access token becomes the final URL path segment instead of a Bearer header.
scopeParamName + accessTokenPathScopes use user_scope, and the token arrives at authed_user.access_token.
POST · application/json · GraphQLFetching identity means sending a GraphQL query rather than calling a normal UserInfo endpoint.
subjectBySource.tokenResponseThe connected account ID comes from the token response, and profile requests require a version header.
environment + computed headersSandbox and production use different UserInfo URLs. Revocation builds its own Basic Auth header.
createClientSecret + idToken subjectToken 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.
Useful in every callback
Utilities used throughout the callback.
generateState()generateCodeVerifier()Both use crypto.getRandomValues() and return URL-safe values.
Failed requests include HTTP status, URL, and the parsed JSON or text response when the provider sends one.
Pass extra searchParams without forking a provider definition for one query parameter.
URL handling, PKCE, JWT signatures, and OIDC verification use platform APIs.
TypeScript
Selecting a provider changes the interface.
Conditional types read the selected provider’s literal configuration and add its required arguments and supported methods.
Required inputs are really required
A PKCE provider requires codeVerifier. A scope-required provider requires a non-empty array.
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 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.
Runtime checks remain
Provider servers can still lie. Citra rejects OAuth error objects returned with HTTP 200 and responses missing an access token.
const google = await createOAuth2Client(
'google',
credentials
);
google.createAuthorizationUrl({
state,
scope: ['openid']
});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);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 itWhat 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.
Before the first request
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
});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 '"staging"' is not assignable to type '"production" | "sandbox"'.
Providers chosen at runtime
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.
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 runtimeOIDC discovery
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.
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.
Identity mappings and helpers
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.
profile.id→numberprofile.results[0].user_id→numberprofile.data.id→stringprofile.response.user.name→stringprofile.id · id_token.sub→stringtokenResponse.locationId→stringsubject
The canonical nested path and its expected primitive type live in the provider definition.
subjectBySource
A provider can declare different paths for a profile, ID token, or token response.
extractPropFromIdentity()
Walks the nested path and can reject a value with the wrong string, number, boolean, or object type.
normalizeProviderIdentity()
Copies a source-specific subject to the provider’s canonical subject path.
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.
Bring your own provider
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.
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);Inferred from the literal
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
});Property 'tenantId' is missing but required in type 'AcmeCredentials'.
client.createAuthorizationUrl({
state,
scope: ['openid']
});Property 'codeVerifier' is missing in type '{ state: string; scope: [string]; }' but required in type '{ codeVerifier: string; }'.
client.revokeToken(accessToken);Property 'revokeToken' does not exist on the inferred client type.
Where Citra came from
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.
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.
- 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
codeVerifierparameter. - 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.
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.
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.
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.
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.
Where Citra is going
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