citra
v0.30.0
Published
OAuth 2.0 library for TypeScript
Readme
Citra
Table of Contents
- Introduction
- Why Citra?
- Installation
- Getting Started
- Custom Providers
- Building the Authorization URL
- Handling the Callback
- Fetching the User Profile
- Refreshing and Revoking Tokens
- Types
- Provider Tags
- Available Providers
- Contributing
- License
Introduction
Citra is a curated collection of OAuth 2.0 provider configurations, each bundled with the correct endpoints and request details. It provides a ready-to-use foundation for integrating secure authentication into JavaScript and TypeScript applications. See the complete Citra guide for installation, provider configuration, and integration patterns.
Why Citra?
- Interchangeability: All OAuth 2.0 providers follow the same authorization flow, and Citra abstracts this process into a unified interface (see arctic interchangeability issue).
- Type Safety: Leverage TypeScript generics and type guards to catch configuration mistakes at compile time.
Inspired by Arctic, Citra reduces boilerplate and minimizes integration errors by enforcing a uniform configuration approach.
Installation
bun install citranpm install citrayarn add citraGetting Started
Import Citra and create a client for your desired provider:
import { createOAuth2Client } from 'citra';
const googleClient = await createOAuth2Client('google', {
// defining your config directly in the function will make it type safe
clientId: 'YOUR_CLIENT_ID',
clientSecret: 'YOUR_CLIENT_SECRET',
redirectUri: 'https://yourapp.com/auth/callback'
});All providers have their proper environment variables listed in env.example. Feel free to copy that into your project, remove .example from the name, and uncomment out the providers you need.
Custom Providers
A custom provider can carry an exact credential contract. The contract types
every credential-dependent URL, header, body, and client-secret factory in the
definition, then becomes the required input to createCustomOAuth2Client().
import { createCustomOAuth2Client, defineProvider } from 'citra';
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',
tokenRequest: {
authIn: 'body',
encoding: 'application/x-www-form-urlencoded',
url: ({ tenantId }) =>
`https://${tenantId}.acme.test/oauth/token`
}
});
const acmeClient = await createCustomOAuth2Client(acme, {
clientId: 'YOUR_CLIENT_ID',
clientSecret: 'YOUR_CLIENT_SECRET',
redirectUri: 'https://yourapp.com/auth/callback',
tenantId: 'north'
});Omitting tenantId, assigning it a non-string value, or passing an undeclared
credential is a type error. Calling defineProvider(config) directly remains
supported and uses the open, backward-compatible custom credential shape.
Building the Authorization URL
Generate the authorization URL from the provider metadata (including a PKCE verifier when required). You can redirect to this URL to initiate the OAuth2 flow.
import { generateState, generateCodeVerifier } from 'citra';
const currentState = generateState();
const codeVerifier = generateCodeVerifier();
const authUrl = await googleClient.createAuthorizationUrl({
codeVerifier, // type error if not provided since google is a PKCEProvider
scope: ['profile', 'openid'], // type error if not provided since google is a ScopeRequiredProvider
searchParams: [
['access_type', 'offline'],
['prompt', 'consent']
],
state: currentState
});
// store state and PKCE verifier in HttpOnly cookies so we can authenticate on callback
const headers = new Headers();
headers.set('Location', authUrl.toString());
headers.append(
'Set-Cookie',
`oauth_state=${currentState}; HttpOnly; Path=/; Secure; SameSite=Lax`
);
headers.append(
'Set-Cookie',
`pkce_code_verifier=${codeVerifier}; HttpOnly; Path=/; Secure; SameSite=Lax`
);
// redirect to the generated authorization URL
return new Response(null, {
status: 302,
headers: {
Location: authUrl.toString()
}
});Handling the Callback
Exchange the code, and optionally the verifier, for an OAuth2TokenResponse:
const params = new URL(request.url).searchParams;
const code = params.get('code');
const callback_state = params.get('state');
const cookieHeader = request.headers.get('cookie') ?? '';
const cookies = parse(cookieHeader);
const stored_state = cookies['state'];
const code_verifier = cookies['code_verifier'];
if (stored_state === undefined || code_verifier === undefined) {
return new Response('Cookies are missing', { status: 400 });
}
if (code === undefined) {
return new Response('Code is missing in query', { status: 400 });
}
if (callback_state === undefined || stored_state.value === undefined) {
return new Response('State parameter is missing', { status: 400 });
}
if (callback_state !== stored_state.value) {
return new Response(
`Invalid state mismatch: expected "${stored_state.value}", got "${callback_state}"`,
{ status: 400 }
);
}
const tokenResponse = await googleClient.validateAuthorizationCode({
code,
codeVerifier
});Fetching the User Profile
When the selected provider declares a profile request,
fetchUserProfile() is present on both the client type and runtime object:
const profile = await googleClient.fetchUserProfile(tokenResponse.access_token);
console.log(profile);Refreshing and Revoking Tokens
If supported by the provider, you can refresh and revoke tokens:
const { refresh_token, access_token } = tokenResponse;
if (refresh_token) {
const newTokens = await googleClient.refreshAccessToken(refresh_token);
}
await googleClient.revokeToken(access_token);Unsupported refresh, revoke, and profile methods are absent from the inferred
type and the runtime object. Most revocation endpoints accept a token string.
Withings is provider-specific: its inferred revokeToken() input is the
numeric userid returned by its token exchange.
Generalized auth systems can ask a revocable client to select that provider-specific value from an authorization context:
const input = client.resolveRevocationInput({
accessToken,
refreshToken,
subject: userIdentity.sub
});
await client.revokeToken(input);The provider definition declares whether revocation uses the access token, refresh token, or normalized subject. Custom clients work with the same runtime capability guards without requiring a built-in provider name:
if (isRevocableOAuth2Client(customClient)) {
const input = customClient.resolveRevocationInput({ accessToken });
await customClient.revokeToken(input);
}Types
Citra’s TypeScript definitions let you configure and consume OAuth2 providers with full type safety.
Core Aliases
NonEmptyArray<T>
Ensures an array has at least one element ([T, ...T[]]). Used when a provider requires at least one scope.URLSearchParamsInit
Union for query-parameter inputs:type URLSearchParamsInit = | string | Record<string, string> | string[][] | URLSearchParams;
Provider Definitions
ProviderConfigThe
ProviderConfigtype specifies the complete set of metadata and endpoint definitions required for each OAuth2 provider. It guarantees that every provider entry includes:Flow flags
isOIDC: supports OpenID ConnectisRefreshable: allows token refreshscopeRequired: enforces at least one explicit scope
PKCE support
PKCEMethod: either'S256'or'plain'when PKCE is supported
Endpoint definitions
authorizationUrl: The authorization endpoint’s URL, or a function that receives the provider’s config and returns the URL.profileRequest: user-info fetch settingsrevocationRequest: optional token revocation settings if the provider supports revocationtokenRequest: token exchange/refresh settings
Static additions (optional)
createAuthorizationURLSearchParams: extra auth URL paramsrefreshAccessTokenBody: extra refresh-token body fieldsvalidateAuthorizationCodeBody: extra token-exchange body fields
export type ProviderConfig = { authorizationUrl: string | ((config: any) => string); // some providers need properties from the config to build the authorization url, such as Auth0 // authorizationUrl: (config) => `https://${config.domain}/authorize`, createAuthorizationURLSearchParams?: | Record<string, string> | ((config: any) => Record<string, string>); isOIDC: boolean; isRefreshable: boolean; PKCEMethod?: 'S256' | 'plain'; profileRequest: ProfileRequestConfig; refreshAccessTokenBody?: Record<string, string>; revocationRequest?: RevocationRequestConfig; scopeRequired: boolean; tokenRequest: TokenRequestConfig; validateAuthorizationCodeBody?: Record<string, string>; };
Capability Subsets
Conditional types for narrowing providers by feature:
PKCEProvider
Providers withPKCEMethod: 'S256' | 'plain'OIDCProvider
Providers whereisOIDC === trueRefreshableProvider
Providers whereisRefreshable === trueProfileProviderProviders definingprofileRequestRevocableProvider
Providers definingrevocationRequestScopeRequiredProvider
Providers wherescopeRequired === true
CredentialsFor
CredentialsFor<P>
Resolves a provider keyPto the credentials type you must supply (e.g.clientId,clientSecret,redirectUri)—not the internal provider configuration metadata:export type CredentialsFor<P extends keyof typeof providers> = P extends keyof CredentialsMap ? CredentialsMap[P] : never;
Client Types
BaseOAuth2Client
Core methods available on every OAuth2 clientNote: In TypeScript,
T & unknownsimplifies toT.export type BaseOAuth2Client<P extends ProviderOption> = { /** * Build the authorization URL. * - `state` is required. * - If the provider requires PKCE, `codeVerifier` is required. * - If the provider requires scopes, `scope` must be a non-empty array. * - `searchParams` can add any extra query parameters. */ createAuthorizationUrl( opts: { state: string } & (P extends PKCEProvider ? { codeVerifier: string } : unknown) & (P extends ScopeRequiredProvider ? { scope: NonEmptyArray<string> } : { scope?: string[] }) & { searchParams?: [string, string][]; } ): Promise<URL>; /** * Exchange an authorization code for tokens. * - `code` is required. * - If the provider uses PKCE, `codeVerifier` is required. */ validateAuthorizationCode( opts: { code: string } & (P extends PKCEProvider ? { codeVerifier: string } : unknown) ): Promise<OAuth2TokenResponse>; };ProfileOAuth2Client
Available when
profileRequestis defined.export type ProfileOAuth2Client = { fetchUserProfile( accessToken: string ): Promise<Record<string, unknown>>; };RefreshableOAuth2Client
Available when
isRefreshable === trueexport type RefreshableOAuth2Client = { /** * Use a refresh token to obtain a new `OAuth2TokenResponse`. */ refreshAccessToken(refreshToken: string): Promise<OAuth2TokenResponse>; };RevocableOAuth2Client
Available when
revocationRequestis defined;export type RevocableOAuth2Client<Input extends string | number = string> = { resolveRevocationInput(context: RevocationInputContext): Input; /** * Revoke using the provider-specific input. */ revokeToken(input: Input): Promise<void>; };OAuth2Client
The full client type returned by
createOAuth2Client().export type OAuth2Client<P extends ProviderOption> = BaseOAuth2Client<P> & (P extends RefreshableProvider ? RefreshableOAuth2Client : unknown) & (P extends RevocableProvider ? RevocableOAuth2Client : unknown);
Type Guards
Runtime checks that narrow types safely:
export const isValidOAuth2TokenResponse = (
tokens: unknown
): tokens is OAuth2TokenResponse => {
/* ... */
};
export const isValidProviderOption = (
provider: string
): provider is ProviderOption => {
/* ... */
};
export const isRefreshableProvider = (
provider: string
): provider is RefreshableProvider => {
/* ... */
};
export const isRevocableProvider = (
provider: string
): provider is RevocableProvider => {
/* ... */
};
export const hasClientSecret = <P extends ProviderOption>(
credentials: CredentialsFor<P>
): credentials is CredentialsFor<P> & { clientSecret: string } => {
/* ... */
};Provider Tags
Providers are grouped by special requirements:
- HTTPS Required: Only accepts TLS redirects. To test locally with mkcert:
- Install mkcert for your operating system.
- Run
mkcert -install. - Run
mkcert localhost 127.0.0.1 ::1to generate certificate files. - Configure your development server to use the generated
localhost.pemandlocalhost-key.pemfiles.
- Untested: Signup restrictions or pending approvals prevented local validation.
- Public Domain Only: Disallow
localhostor127.0.0.1—use a TLS-enabled host. - In Development: Configuration is incomplete and awaiting tests.
Available Providers
| Provider | Tag | | ------------------ | ------------------------------------------------------ | | 42 | Untested: Restricted | | Amazon Cognito | Untested: client credentials and hosted domain required | | AniList | — | | Apple | Untested: Paid | | Attio | Untested | | Atlassian | — | | Auth0 | — | | Authentik | Untested | | Autodesk | — | | Azure AD B2C | Legacy CIAM: tenant subdomain + policy required | | Battlenet | — | | Bitbucket | — | | Box | — | | Bungie | Untested: HTTPS Required | | Calendly | — | | Close | Untested | | Coinbase | HTTPS Required | | Discord | — | | Donation Alerts | — | | Dribbble | Untested: Paid | | Dropbox | — | | Epic Games | Untested: HTTPS Required | | Etsy | Untested: Pending Approval | | Facebook | — | | Figma | — | | Gitea | In Development | | GitHub | — | | GitLab | — | | GoHighLevel | Token-response identity supported | | Google | — | | Intuit | — | | Kakao | — | | Keycloak | Untested: Self Hosted | | Kick | Untested: Pending Approval | | Lichess | — | | LINE | — | | Linear | — | | LinkedIn | Untested: Pending Approval | | Mastodon | — | | Mercado Libre | Untested: Region Restricted | | Mercado Pago | Untested: Region Restricted | | Microsoft Entra External ID | CIAM: tenant subdomain + tenant ID required | | Microsoft Entra ID | — | | Monday | Untested | | MyAnimeList | — | | Naver | In Development | | Notion | — | | Okta | — | | OnSpark | Contract tested | | Osu | — | | Patreon | — | | Polar | — | | Polar AccessLink | In Development | | Polar Team Pro | Untested: Paid | | Reddit | — | | Roblox | — | | Salesforce | — | | Shikimori | Untested: Region Restricted | | Slack | Untested: HTTPS Required | | Slack User | Untested: HTTPS Required | | Spotify | — | | start.gg | — | | Strava | — | | Synology | Untested: Self Hosted | | TikTok | Public Domain Only (Untested: localhost Not Supported) | | Tiltify | — | | Tumblr | — | | Twitch | — | | Twitter | Untested: Paid | | VK | Public Domain Only (Untested: localhost Not Supported) | | Withings | Contract tested; identity comes from token response | | WorkOS | In Development | | Yahoo | Untested: HTTPS Required | | Yandex | — | | Zoho | Untested | | Zoom | — |
The Microsoft providers are intentionally separate: microsoftentraid is for
workforce and personal Microsoft accounts (including Microsoft 365 APIs),
azureadb2c is the legacy policy-based B2C product, and
microsoftentraexternalid is the current ciamlogin.com customer-identity
platform. B2C and External ID resolve the authenticated identity from the OIDC
ID token rather than assuming a universal UserInfo endpoint.
Contributing
Found an issue or want to add a new provider? Please open an issue or submit a pull request.
License
Business Source License 1.1 © Alex Kahn — production use is free except offering citra as a competing hosted service (see the Additional Use Grant in LICENSE). Converts to Apache 2.0 on May 29, 2030.
Neon partner OAuth
createOAuth2Client('neon', { clientId, clientSecret, redirectUri }) uses Neon's
partner OAuth endpoints with S256 PKCE. Authorization requires a scope list and a
code verifier. Request openid for identity; request both offline and
offline_access when refresh access is needed. Select management permissions
explicitly (for example urn:neoncloud:projects:read); none are added implicitly.
Identity is sub, not an assumed email. Profile, refresh and token revocation are
available through the standard client methods.
Neon must register your partner application and exact callback URLs before live use. See Neon's OAuth integration guide. Creating a regular Neon database account does not register an OAuth application or establish a referral agreement.
