- 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
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.
- 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.
bun install citranpm install citrayarn add citraImport 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.
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.
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()
}
});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
});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);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);
}Citra’s TypeScript definitions let you configure and consume OAuth2 providers with full type safety.
-
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;
-
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>; };
-
Conditional types for narrowing providers by feature:
-
PKCEProvider
Providers withPKCEMethod: 'S256' | 'plain' -
OIDCProvider
Providers whereisOIDC === true -
RefreshableProvider
Providers whereisRefreshable === true -
ProfileProviderProviders definingprofileRequest -
RevocableProvider
Providers definingrevocationRequest -
ScopeRequiredProvider
Providers wherescopeRequired === true
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;
-
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);
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 } => {
/* ... */
};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.
| 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 |
| — | |
| Figma | — |
| Gitea | In Development |
| GitHub | — |
| GitLab | — |
| GoHighLevel | Token-response identity supported |
| — | |
| Intuit | — |
| Kakao | — |
| Keycloak | Untested: Self Hosted |
| Kick | Untested: Pending Approval |
| Lichess | — |
| LINE | — |
| Linear | — |
| 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 |
| — | |
| 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 | — |
| 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.
Found an issue or want to add a new provider? Please open an issue or submit a pull request.
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.