Skip to content

Generic OAuth / OIDC

The built-in providers cover well-known services. For anything else — your company IdP, Keycloak, Auth0, Okta, Authentik, Zitadel, a self-hosted GitLab — use GenericOAuthConfig. It builds an ordinary OAuthProvider from an OIDC discovery document or explicit endpoints, so everything on the social sign-on page (flows, linking, tokens, state) applies unchanged.

Discovery is performed once, at startup, from operator-supplied configuration — never from request input. Resolve the configuration, then register the provider:

use crate::auth_schema::AppAuthSchema;
use alibi::plugins::OAuthPlugin;
use alibi::plugins::oauth::GenericOAuthConfig;
use alibi::sqlx::SqlxStore;
use alibi::{AuthConfig, AuthError, AuthResult, BetterAuth};
async fn build_auth(
config: AuthConfig,
store: SqlxStore<AppAuthSchema>,
) -> AuthResult<BetterAuth<AppAuthSchema>> {
let mut keycloak = GenericOAuthConfig::new("my-client-id", "my-client-secret");
keycloak.discovery_url =
Some("https://sso.example.com/realms/acme/.well-known/openid-configuration".into());
keycloak.provider.scopes = vec!["openid".into(), "profile".into(), "email".into()];
let resolved = keycloak
.resolve()
.await
.map_err(|error| AuthError::config(error.to_string()))?
.ok_or_else(|| AuthError::config("Keycloak discovery returned no usable endpoints"))?;
BetterAuth::<AppAuthSchema>::new(config)
.store(store)
.plugin(OAuthPlugin::new().add_provider("keycloak", resolved.provider))
.build()
.await
}

The redirect URI to register at the IdP is {base_url}/api/auth/callback/keycloak (the id you pass to add_provider). Users start with POST /sign-in/social and {"provider":"keycloak"}.

resolve() returns:

Result Meaning
Ok(Some(resolved)) A usable provider, plus resolved.metadata (issuer, JWKS URL, signing algorithms, end-session endpoint)
Ok(None) Discovery was configured but yielded no authorization or token endpoint — skip the provider
Err(error) Contradictory configuration, for example required ID-token verification without JWKS metadata, or secretless public-client auth with a secret set

If discovery fails to download, the explicit endpoints you configured are used as a fallback.

GenericOAuthConfig field Purpose
provider The underlying OAuthProvider: scopes, authorization_params, disable_sign_up, disable_implicit_sign_up, require_email_verification, …
discovery_url, discovery_headers OIDC discovery document and any headers it needs
authorization_url, token_url, user_info_url Explicit endpoints. Any value you set overrides discovery
end_session_endpoint, post_logout_redirect_uri, disable_provider_logout Provider logout (below)
require_id_token_verification Fail at startup unless discovery supplies keys to verify ID tokens
disable_id_token_nonce_binding Do not bind the ID token to a per-request nonce
map_profile Arc<dyn OAuthProfileMapper>: map the raw profile onto your user/additional fields
access_token_expires_in Fallback lifetime (seconds) when the token response has no expires_in
account_key Application resolver for the stable account identifier

Behavior you get automatically with discovery metadata:

  • ID tokens from the code grant are verified against the provider’s JWKS (issuer, audience = your client id, allowed algorithms) before the profile is admitted, and a nonce binds the response to the browser that started the flow.
  • openid is added to the scopes when the provider advertises OIDC signing algorithms.
  • The account identifier is the sub claim for OIDC providers and the profile id for plain OAuth 2.0.
  • PKCE is used by default.
use alibi::plugins::oauth::GenericOAuthConfig;
fn custom_oauth() -> GenericOAuthConfig {
let mut custom = GenericOAuthConfig::new("client-id", "client-secret");
custom.authorization_url = Some("https://auth.example.com/oauth/authorize".into());
custom.token_url = Some("https://auth.example.com/oauth/token".into());
custom.user_info_url = Some("https://api.example.com/v1/me".into());
custom.provider.scopes = vec!["profile".into(), "email".into()];
custom.access_token_expires_in = Some(3600.0);
custom
}

Without an ID token, the profile comes from user_info_url (a bearer-authenticated GET). The default mapping reads id, email, name, image and email_verified; use map_profile for anything else.

Token endpoint authentication and extra parameters

Section titled “Token endpoint authentication and extra parameters”

Fine-grained transport options live on the provider’s authorization policy:

use alibi::plugins::oauth::{GenericOAuthConfig, OAuthTokenEndpointAuth};
fn tuned(mut custom: GenericOAuthConfig) -> GenericOAuthConfig {
if let Some(policy) = custom.provider.authorization.as_mut() {
// How the client authenticates at the token endpoint.
policy.token_endpoint_auth = Some(OAuthTokenEndpointAuth::ClientSecretBasic);
// Extra form fields on the authorization-code exchange.
policy
.authorization_code_params
.insert("audience".into(), "https://api.example.com".into());
// Extra form fields on every refresh.
policy
.refresh_token_params
.insert("audience".into(), "https://api.example.com".into());
// Sent on the authorization request.
policy.fixed_authorization_params.push(("prompt".into(), "select_account".into()));
}
custom
}

OAuthTokenEndpointAuth supports ClientSecretBasic, ClientSecretPost, PrivateKeyJwt and None (public clients). Use PrivateKeyJwt with an OAuthClientAssertion getter (or OAuthPrivateKeyJwtOptions) that returns a freshly signed assertion for each token request. Refresh parameters can also be computed per request with OAuthRefreshTokenParamsResolver — validate tenant and scope entitlements before forwarding anything derived from request data.

When discovery advertises an end_session_endpoint (or you set one), POST /sign-out for a session created through this provider answers with the provider’s logout URL after clearing the local session:

Terminal window
curl -b cookies.txt -X POST http://localhost:3000/api/auth/sign-out \
-H 'Content-Type: application/json' -H 'Origin: http://localhost:3000' \
-d '{"callbackURL":"https://app.example.com/signed-out"}'
# {"success":true,"url":"https://sso.example.com/realms/acme/protocol/openid-connect/logout?id_token_hint=…&post_logout_redirect_uri=…","redirect":true}

Pass "disableRedirect": true to receive the URL without a redirect. post_logout_redirect_uri sets a default return URI; disable_provider_logout keeps sign-out local. The callbackURL must be a trusted redirect target.

Provider Discovery URL
Keycloak https://<host>/realms/<realm>/.well-known/openid-configuration
Auth0 https://<tenant>.auth0.com/.well-known/openid-configuration
Okta https://<org>.okta.com/.well-known/openid-configuration (custom auth server: /oauth2/<id>/…)
Authentik https://<host>/application/o/<slug>/.well-known/openid-configuration
Google Workspace (custom) https://accounts.google.com/.well-known/openid-configuration

Register one provider id per IdP: add_provider("acme-sso", …), add_provider("partner-okta", …).

The official client talks to the same /sign-in/social endpoint. For the typed signIn.oauth2 helper of the TypeScript generic-oauth plugin, call signIn.social({ provider: "<id>" }) instead; the server has no separate /sign-in/oauth2 route. See the official Generic OAuth guide for client-side usage.