Setting Up OAuth Providers β
racletteJS supports 60+ OAuth providers via the Arctic library, plus any custom OAuth2-compliant service.
Built-in Providers β
The following providers are available out of the box (partial list):
| Provider | ID | PKCE | Extra Config |
|---|---|---|---|
| GitHub | git-hub | No | β |
google | Yes | β | |
| Discord | discord | No | β |
| Microsoft Entra ID | microsoft-entra-id | Yes | tenant |
| GitLab | git-lab | Yes | β |
| Apple | apple | No | β |
| Slack | slack | No | β |
| Keycloak | key-cloak | Yes | realmURL |
| Auth0 | auth0 | Yes | domain |
| Okta | okta | Yes | domain |
linked-in | Yes | β | |
| Twitter / X | twitter | Yes | β |
| Spotify | spotify | Yes | β |
The full list is discovered at runtime from Arctic's module exports. Any provider supported by Arctic v3.x works automatically.
Configuring a Provider (Workbench UI) β
- Go to Settings > Authentication in the workbench
- Click Add Provider
- Select the provider from the picker (searchable, grouped by type)
- Fill in:
- Client ID β from the provider's developer console
- Client Secret β from the provider's developer console
- Redirect URI β
https://your-domain.com/api/auth/oauth/{provider-id}/callback(see Redirect URI) - Scopes β pre-filled with sensible defaults, customize as needed
- Extra config β if the provider requires it (e.g.,
tenantfor Microsoft,realmURLfor Keycloak)
- Toggle Enabled to activate the provider
Configuring a Provider (API) β
# Create a GitHub OAuth provider
curl -X POST http://localhost:3000/auth/providers \
-H "Content-Type: application/json" \
-H "Cookie: sid=your-admin-session-cookie" \
-d '{
"providerId": "git-hub",
"type": "arctic",
"label": "GitHub",
"clientId": "your-github-client-id",
"clientSecret": "your-github-client-secret",
"redirectUri": "https://your-domain.com/api/auth/oauth/git-hub/callback",
"scopes": ["user:email"],
"enabled": true
}'Custom OAuth2 Providers β
For services not included in Arctic's built-in list, you can configure custom OAuth2 providers.
Step 1: Define the provider template in raclette.config.js β
export default {
auth: {
providers: {
custom: [
{
id: "internal-sso",
displayName: "Internal SSO",
authorizationEndpoint: "https://sso.example.com/authorize",
tokenEndpoint: "https://sso.example.com/token",
userInfoEndpoint: "https://sso.example.com/userinfo",
scopes: ["openid", "profile", "email"],
pkce: true,
},
],
},
},
}Step 2: Configure credentials via the Workbench or API β
The config file defines the provider template (endpoints, scopes). Custom entries from the app raclette.config.js appear in the workbench Add Provider picker under Custom OAuth2 when RACLETTE_APP_PATH points at the app project (set automatically when the workbench stack is started from the app).
The actual credentials (clientId, clientSecret) are configured per-instance via the workbench UI or API, and stored in MongoDB.
Register the redirect URI at your IdP as https://{host}/api/auth/oauth/{providerId}/callback. It must match the workbench Redirect URI field exactly (including /api when the app is served behind the nginx proxy).
curl -X POST http://localhost:3000/auth/providers \
-H "Content-Type: application/json" \
-H "Cookie: sid=your-admin-session-cookie" \
-d '{
"providerId": "internal-sso",
"type": "custom",
"label": "Company SSO",
"clientId": "raclette-app",
"clientSecret": "secret-from-sso-admin",
"redirectUri": "https://your-domain.com/api/auth/oauth/internal-sso/callback",
"scopes": ["openid", "profile", "email"],
"enabled": true,
"config": {
"authorizationEndpoint": "https://sso.example.com/authorize",
"tokenEndpoint": "https://sso.example.com/token",
"userInfoEndpoint": "https://sso.example.com/userinfo",
"pkce": true
}
}'See Userinfo contract for what your IdP must return from the userinfo endpoint before configuring workbench credentials.
Redirect URI β
Production apps are served with the API proxied at /api. The standard callback URL is:
https://{host}/api/auth/oauth/{providerId}/callback- Register this URL at the IdP β not legacy paths like
/core/auth/oidc/.... - The workbench Add Provider dialog pre-fills this pattern from
RACLETTE_SERVER_BASE_URL. - In local dev with
raclette dev, the app frontend proxies/apionly to the backend. Usehttp://localhost:{frontendPort}/api/auth/oauth/{providerId}/callback(not/auth/oauth/...on the frontend origin β that path is not proxied and the SPA will load instead of completing login). - Token exchange is always server-side; no HTML callback page is required.
Reverse proxy (production) β
Nginx (or your ingress) must forward /api/ to the app backend (same as Vite dev). Generated nginx.conf from raclette build includes this block. OAuth redirect URIs must use the /api prefix when the app is served from the frontend origin.
Post-login return path (POST /auth/oauth/return-to) β
The login page stores where to send the user after OAuth via an httpOnly cookie. This endpoint is hardened:
- CSRF β
GET /auth/oauth/return-to/csrfissues a token (cookie + JSON);POSTrequires headerX-Raclette-OAuth-Return-To-CSRFmatching the cookie. - Origin β only allowed frontend origins (
RACLETTE_FRONTEND_URLS, etc.) withReferer/Originfrom/login. - Path sanitization β return path must be a same-site relative path (
/dashboard, not//evil.com).
Scopes β
Scopes sent to the IdP come from the provider document in Mongo (workbench Scopes field), not from raclette.config.js after the provider is saved. An empty scopes list omits the scope query parameter (required for some providers, e.g. customer portal). Descriptor defaults in raclette.config.js apply only when creating a new provider template, not to override saved workbench values.
Userinfo contract β
After the server-side token exchange (POST to the provider's token URL), the raclette app backend (never the browser) calls userInfoEndpoint once with the access token:
Authorization: {userInfoAuthorizationScheme} {access_token}Default scheme: Bearer. Some providers (e.g. JWT-as-bearer APIs) need workbench config "userInfoAuthorizationScheme": "JWT".
If userInfoEndpoint is omitted or the request fails, login fails (redirect /?auth_error=no_email or token/profile errors). There is no JWT-only profile fallback in core.
Required response shape (core) β
JSON object. Core maps a minimal profile for user create/link:
| IdP field (any of) | Required | Raclette use |
|---|---|---|
email or mail | Yes | Match/link user; new user email. Missing β login abort |
email_verified, verified_email, verified | Recommended | Gates auto-linking to an existing account; sets email_verified on new users. Absent β treated as unverified unless trustEmailVerified is set |
id, sub, or user_id | Strongly recommended | oauthAccounts[].providerAccountId (stable per user at this provider) |
name, display_name, displayName | No | New user firstname / lastname |
avatar_url, picture, avatar | No | Passed to login handlers (ctx.profile.avatarUrl) |
Generic mapping lives in the default branch of normalizeProfile in core OAuth routes β no provider-specific cases in core for custom providers.
Example minimum userinfo (custom provider):
{
"id": 12345,
"email": "user@example.com",
"username": "jdoe"
}What is stored on the raclette user (core only) β
On successful login, core updates/creates:
email,firstname/lastname(split from the profile name),email_verified(new users)oauthAccounts:{ provider: "<providerId>", providerAccountId: "<from userinfo>" }lastLoginAt
Core does not copy arbitrary IdP fields onto the user document. The avatar URL is not stored on the user by core; it is available to login handlers via ctx.profile.avatarUrl if a plugin wants to persist it.
Provider-specific / extra fields (plugins, not core) β
The raw userinfo JSON is passed to registerOAuthLoginHandler(providerId, β¦) as ctx.userInfo. Plugins may persist integration-specific data, e.g.:
settings.<integration>on the user document- Enrichment of
persistProviderTokensblob (access token claims + userinfo)
Implement this in the app plugin backend, not in @raclettejs/core.
External server checklist (new custom provider) β
For teams implementing the IdP / resource server side:
- Authorize URL β accepts
client_id,redirect_uri,state,scope; PKCE if enabled in raclette template (code_challenge/S256). - Token URL β accepts
grant_type=authorization_code,code,redirect_uri,client_id,client_secret(+code_verifierif PKCE); returns JSON withaccess_token(and optionallyrefresh_token). - Redirect URI β register raclette callback:
https://{host}/api/auth/oauth/{providerId}/callback(must match workbench exactly). - Userinfo URL β GET (typical), returns JSON with at least
emailand a stable user id field. - Scopes β include whatever is needed for userinfo (e.g.
openid email profile); align with raclette provider config. - Optional: token contents β if userinfo is insufficient, plugins may decode JWT access tokens in a login handler; still keep core generic.
Providers with Extra Parameters β
Some providers require additional configuration beyond clientId/clientSecret:
Microsoft Entra ID (Azure AD) β
{
"providerId": "microsoft-entra-id",
"type": "arctic",
"label": "Microsoft",
"clientId": "...",
"clientSecret": "...",
"redirectUri": "https://your-domain.com/api/auth/oauth/microsoft-entra-id/callback",
"scopes": ["openid", "profile", "email"],
"enabled": true,
"config": {
"tenant": "your-tenant-id-or-common"
}
}Keycloak β
{
"providerId": "key-cloak",
"type": "arctic",
"label": "Keycloak",
"clientId": "...",
"clientSecret": "...",
"redirectUri": "https://your-domain.com/api/auth/oauth/key-cloak/callback",
"scopes": ["openid", "profile", "email"],
"enabled": true,
"config": {
"realmURL": "https://keycloak.example.com/realms/your-realm"
}
}Auth0 β
{
"providerId": "auth0",
"type": "arctic",
"label": "Auth0",
"clientId": "...",
"clientSecret": "...",
"redirectUri": "https://your-domain.com/api/auth/oauth/auth0/callback",
"scopes": ["openid", "profile", "email"],
"enabled": true,
"config": {
"domain": "your-tenant.auth0.com"
}
}OAuth Flow β
The OAuth flow is a standard BFF (Backend-for-Frontend) pattern:
- Login page fetches enabled providers via
GET /api/auth/oauth/providers(public, no secrets). - If the user opened login with
?redirect=/path, the login page callsGET /api/auth/oauth/return-to/csrfthenPOST /api/auth/oauth/return-to(CSRF + login-origin checks) to store the return path in an httpOnly cookie (defaults to/). - User clicks a provider button β browser navigates to
GET /api/auth/oauth/:provider(no query string; OAuthredirect_uriand client credentials stay server-side in Mongo). - Backend reads the return-path cookie, generates state + PKCE verifier, stores both in Valkey, redirects to the IdP.
- IdP authenticates the user, redirects to
GET /api/auth/oauth/:provider/callback(theredirectUrifrom the provider document). - Backend validates state, exchanges code for tokens server-side, fetches user profile from userinfo.
- Backend resolves or creates the user, creates a session, sets the configured session cookie (
auth.cookie.name, defaultsid_app). - User is redirected to the stored return path (authenticated).
On failure, the backend redirects to /?auth_error=<code>; the login page shows a snackbar and clears the query param.
Paths above use /api/auth/... because the frontend proxies API requests through /api. Direct backend access in dev uses the same paths without the /api prefix when RACLETTE_SERVER_BASE_URL points at the backend origin.
Account Linking β
When a user logs in via OAuth, core resolves the raclette user in this order:
- Existing OAuth link (
provider+providerAccountIdalready on the user) β login succeeds. - Email matches an existing account, and the email is verified β the OAuth account is linked to that user and login succeeds.
- Email matches an existing account, but the email is not verified β login is refused (redirect
/?auth_error=account_exists) and a short-lived pending link is stored. This prevents account takeover: an attacker who can present an unverified address at an IdP must not be able to claim an existing raclette account. - No match β a new user is created (
email_verifiedmirrors the provider signal).
When is an email "verified"? β
An email counts as verified when either:
- the userinfo response asserts it (
email_verified/verified_email/verifiedis truthy), or - Trust provider email (central SSO) is enabled on the provider in Workbench (
config.trustEmailVerified), or set via API (see below).
Trusting provider emails
Most OIDC-compliant providers (Google, Microsoft, Keycloak, Auth0, β¦) return email_verified. Only set trustEmailVerified for a provider that you control or that is guaranteed to verify email ownership (e.g. a central company SSO). Enabling it for a provider that lets users set arbitrary unverified emails re-introduces the account-takeover risk.
{
"trustEmailVerified": true
}Confirming a pending link β
The pending link from case 3 is resolved by the real owner after they authenticate with their existing method:
GET /auth/link/confirmβ check pending linkPOST /auth/link/confirmβ confirm linkDELETE /auth/link/confirmβ decline link
Because confirmation requires an authenticated session, an attacker who triggered the pending link cannot complete it.
Persisting provider tokens (upstream API access) β
By default, OAuth access and refresh tokens are discarded after userinfo is fetched. Enable this only when a plugin must call an external API on behalf of the logged-in user (BFF pattern).
In Workbench β Settings β Authentication β Add/Edit Provider, under Security & integration:
- Trust provider email (central SSO) β sets
config.trustEmailVerified. Enable only for IdPs you control that verify email ownership but do not returnemail_verifiedin userinfo. - Persist provider tokens β sets
config.persistProviderTokensand reveals the namespace / Valkey instance fields below. - Userinfo authorization scheme β
Bearer(default) orJWT.
Or set the same keys on the provider document config via API:
{
"persistProviderTokens": true,
"providerTokenNamespace": "your-plugin-key",
"providerTokenValkeyInstance": "cachePersistent",
"userInfoAuthorizationScheme": "Bearer"
}providerTokenNamespaceβ must match the plugin key; keys are{namespace}:oauth_tokens:{sid}.providerTokenValkeyInstanceβ optional; instance id from Valkey instances. If omitted, usesauth.valkeyInstances.providerTokens(default"cache").userInfoAuthorizationSchemeβ"Bearer"(default) or"JWT"for providers that requireAuthorization: JWT β¦on the userinfo endpoint.
Plugins read/write tokens only via fastify.auth.getProviderTokenBlob / setProviderTokenBlob / deleteProviderTokenBlob. Logout deletes the blob on the resolved instance.
You do not need a second Valkey unless you want provider tokens on a different persistence strategy than sessions. A single aof+rdb instance can host sessions and tokens together by setting all auth.valkeyInstances.* to "cache".
Provider-specific login hooks β
After a successful OAuth callback (user resolved, before the session cookie is set), core runs optional login handlers registered per provider id:
fastify.auth.registerOAuthLoginHandler("my-provider", async (ctx) => {
// ctx: providerId, userId, accessToken, refreshToken, expiresAt, profile, userInfo?
})Use this from a plugin backend index.ts for provider-specific side effects (for example writing settings.* on the user document or enriching the persisted provider token blob). Core stores only access, refresh, and expiresAt; plugins add integration-specific fields via fastify.auth.setProviderTokenBlob inside the handler when ctx.sid and ctx.providerTokenNamespace are set.
Handlers run on the app or workbench backend container that serves /auth/oauth/* for that stack, after the session exists and the minimal token blob is written.
Security Notes β
clientSecretis never returned in API responses (write-only)- OAuth state tokens expire after 10 minutes and are single-use (consumed atomically)
- PKCE is enabled by default for all providers that support it
- All OAuth callbacks validate the
stateparameter to prevent CSRF - The post-login
redirectis restricted to same-origin paths (absolute URLs and protocol-relative values are ignored, falling back to/), preventing open redirects - Existing accounts are only auto-linked on a verified email (see Account Linking); unverified matches require explicit confirmation