ObjectStackObjectStack

SSO — social and enterprise sign-in setup

Enable Google sign-in in open source, and extend auth providers through packages.

ObjectStack open source ships two built-in social sign-in implementations, both wired by os serve from deployment env vars:

  • Google OAuth: configure in Setup → Authentication, or from GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET deployment env vars
  • GitHub OAuth: configure from GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET deployment env vars

Additional providers should be contributed by product or enterprise packages through the auth:configure hook. Those packages can add better-auth socialProviders or OIDC/generic OAuth providers without forking @objectstack/plugin-auth.

The Console login and registration pages automatically render Continue with ... buttons for every enabled provider returned by /api/v1/auth/config.


Social Providers

Google

  1. Go to Google Cloud Console → APIs & Services → Credentials.
  2. Click Create Credentials → OAuth 2.0 Client ID → Web application.
  3. Add an Authorized redirect URI:
    https://<your-domain>/api/v1/auth/callback/google
    For local development: http://localhost:3000/api/v1/auth/callback/google
  4. Copy the Client ID and Client Secret.

Then either save the values in Setup → Authentication → Social sign-in or provide them as deployment environment variables:

GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

# Optional settings env override. UI settings are lower precedence than env.
# OS_AUTH_GOOGLE_ENABLED=false

GitHub

  1. Go to GitHub Developer Settings → OAuth Apps → New OAuth App.
  2. Set Authorization callback URL:
    https://<your-domain>/api/v1/auth/callback/github
  3. Copy the Client ID and generate a Client Secret.

Provide them as deployment environment variables:

GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret

Extension Packages

The providers below are examples for enterprise or product packages, not built-in open-source settings. A package can register them by listening to auth:configure and mutating the draft auth config.

The env-var names shown below (e.g. MICROSOFT_CLIENT_ID, APPLE_CLIENT_ID) are illustrative for an extension package's own reader code — unlike GOOGLE_* and GITHUB_*, the framework does not read them out of the box, so an extension must wire them in its auth:configure handler.

Microsoft (Azure AD / Entra ID)

  1. Go to Azure Portal → Azure Active Directory → App registrations → New registration.
  2. Add a redirect URI (Web):
    https://<your-domain>/api/v1/auth/callback/microsoft
  3. Under Certificates & secrets, create a new client secret.
MICROSOFT_CLIENT_ID=your-azure-app-client-id
MICROSOFT_CLIENT_SECRET=your-azure-client-secret

# Optional: restrict to a single tenant (default: "common" — all Microsoft accounts)
# MICROSOFT_TENANT_ID=your-tenant-id

Apple

  1. Go to Apple Developer Console → Certificates, IDs & Profiles → Identifiers → register a Services ID.
  2. Enable Sign In with Apple and configure the redirect URL:
    https://<your-domain>/api/v1/auth/callback/apple
  3. Generate a private key under Keys.
APPLE_CLIENT_ID=your-apple-services-id
APPLE_CLIENT_SECRET=your-apple-private-key-jwt

Apple requires the client secret to be a signed JWT derived from the private key. See better-auth Apple docs for details.


Enterprise SSO (OIDC)

Admin-managed OIDC SSO ships in the open framework: @objectstack/plugin-auth registers external IdPs through @better-auth/sso, and admins add them without code from Setup → SSO Providers (see the ADR-0069 note below). The oidcProviders config shown here is the in-process path for framework or product packages that prefer wiring providers in code, or contributing them through auth:configure.

Admin-managed external IdP (ADR-0069). Recent releases add a per-environment external-IdP path built on @better-auth/sso and surface an sso flag in the public /auth/config (features.sso) so a client can show an enterprise-login button when SSO is configured. Admins register providers without code from Setup → SSO Providers — both OIDC (Okta, Entra, Auth0, Keycloak, …) and SAML 2.0 (see SAML 2.0 below). The oidcProviders extension shown here remains the in-process path for framework/enterprise packages that prefer wiring providers in code.

Setup → SSO Providers → Register Provider posts to the env-side bridge at POST /api/v1/auth/admin/sso/register, the OIDC counterpart of the SAML bridge below. It takes the flat form fields of the OIDC provider fields table, reshapes them for @better-auth/sso, and is gated on a platform admin (ADR-0068 D4) before it delegates — an organization owner or admin is not sufficient, because registering an identity provider decides how the whole environment authenticates.

This is an ObjectStack mount, distinct from @better-auth/sso's own POST /api/v1/auth/sso/register. The bridge exists so the no-code Setup form can post flat fields; both doors apply the platform-admin rule.

Quick start — Okta

const oidcProviders = [
  {
    "providerId": "okta",
    "name": "Okta",
    "discoveryUrl": "https://your-org.okta.com/.well-known/openid-configuration",
    "clientId": "your-okta-client-id",
    "clientSecret": "your-okta-client-secret",
    "scopes": ["openid", "email", "profile"]
  }
];

Quick start — Azure AD (OIDC)

const oidcProviders = [
  {
    "providerId": "azure-ad",
    "name": "Azure AD",
    "discoveryUrl": "https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration",
    "clientId": "your-azure-client-id",
    "clientSecret": "your-azure-client-secret"
  }
];

Quick start — Keycloak

const oidcProviders = [
  {
    "providerId": "keycloak",
    "name": "Keycloak",
    "discoveryUrl": "https://keycloak.example.com/realms/your-realm/.well-known/openid-configuration",
    "clientId": "your-client-id",
    "clientSecret": "your-client-secret",
    "pkce": true
  }
];

Multiple enterprise providers

const oidcProviders = [
  {
    "providerId": "okta",
    "name": "Okta SSO",
    "discoveryUrl": "https://your-org.okta.com/.well-known/openid-configuration",
    "clientId": "...",
    "clientSecret": "..."
  },
  {
    "providerId": "azure-ad",
    "name": "Microsoft SSO",
    "discoveryUrl": "https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration",
    "clientId": "...",
    "clientSecret": "..."
  }
];

OIDC provider fields

FieldRequiredDescription
providerId✅Unique identifier used internally (e.g. okta, azure-ad)
name—Display name shown on the button (defaults to providerId)
discoveryUrl✅*OIDC discovery URL — auto-fetches all endpoints
authorizationUrl✅*OAuth2 authorization endpoint (if no discoveryUrl)
tokenUrl✅*OAuth2 token endpoint (if no discoveryUrl)
userInfoUrl—OAuth2 userinfo endpoint
issuer—Expected issuer for token validation
clientId✅OAuth2 client ID
clientSecret✅OAuth2 client secret
scopes—Requested scopes (default: openid email profile)
pkce—Enable PKCE — recommended for public clients

*Either discoveryUrl or authorizationUrl + tokenUrl must be provided.

Domain verification (opt-in)

A provider can claim an email domain, so that anyone signing in with an address at that domain is routed to it. Proving the claim is opt-in per environment (ADR-0135 D6) and off by default:

# Off by default. Turn on to require a DNS proof before a domain claim counts.
OS_SSO_DOMAIN_VERIFICATION=true

With it on, Setup → SSO Providers exposes a two-step flow, one route per step. Both are platform-admin gated (ADR-0068 D4) and both take the provider the domain is being claimed for:

  1. POST /api/v1/auth/admin/sso/request-domain-verification — returns a DNS TXT record to publish on the domain. Copy it into your DNS zone.
  2. POST /api/v1/auth/admin/sso/verify-domain — call once the record has propagated. It re-checks DNS and marks the domain verified, or reports why it could not.
// Step 1 — ask for the TXT record to publish.
// Body: { providerId, domain? }. `domain` only shapes the record NAME shown back
// to you; omit it and you get the bare label to place on the zone yourself.
const req = await fetch('http://localhost:3000/api/v1/auth/admin/sso/request-domain-verification', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  body: JSON.stringify({ providerId: 'okta', domain: 'acme.example' })
});
// → { success: true, data: { providerId, domain, token,
//                            dnsRecordType: 'TXT', dnsRecordName, dnsRecordValue } }

// …publish dnsRecordName / dnsRecordValue, wait for DNS to propagate, then:

// Step 2 — verify the claim. Body: { providerId }.
const done = await fetch('http://localhost:3000/api/v1/auth/admin/sso/verify-domain', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  body: JSON.stringify({ providerId: 'okta' })
});
// → { success: true, data: { providerId, verified: true, message } }

Both take providerId and refuse with 400 INVALID_REQUEST when it is missing. Step 2 reports NO_PENDING_VERIFICATION if you call it before step 1, and DOMAIN_VERIFICATION_FAILED when the TXT record is not visible yet — retry after DNS propagates.

The mounts are unconditional; the switch controls the endpoint behind them. Both routes exist whether or not OS_SSO_DOMAIN_VERIFICATION is set — and with it unset there is no endpoint behind them, so both steps answer 400 DOMAIN_VERIFICATION_DISABLED. With it on, either step answers an unknown providerId with 404 RESOURCE_NOT_FOUND; step 2's NO_PENDING_VERIFICATION is a 404 too, so match on the code rather than the status. Either way an anonymous caller gets 401 UNAUTHENTICATED and a signed-in non-platform-admin 403 PERMISSION_DENIED — identity is answered before capability, so a stranger cannot use these routes to probe which features an environment has enabled.


Enterprise SSO (SAML 2.0)

SAML 2.0 is provided natively by @better-auth/sso (the same package behind the OIDC path) — no custom plugin or extension is needed. Admins register a SAML IdP from Setup → SSO Providers → Register SAML Provider, which posts to the env-side bridge at POST /api/v1/auth/admin/sso/register-saml.

Register a SAML provider

Fill in the IdP details collected by the Register SAML Provider action:

FieldRequiredDescription
providerId✅Stable identifier, e.g. acme-saml.
issuer✅The IdP's SAML EntityID (issuer), e.g. https://saml.acme.com/entityid.
domain✅Users with this email domain are routed to this IdP, e.g. acme.com.
entryPoint✅The IdP's SAML single sign-on (redirect) URL that receives the SAMLRequest.
cert✅The IdP's X.509 signing certificate (PEM body) — used to verify assertion signatures.
identifierFormat—Requested SAML NameID format (defaults to the IdP's configured format), e.g. urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress.

On success the response returns the two Service Provider (SP) URLs you configure on the IdP side:

{
  "success": true,
  "data": { "providerId": "acme-saml" },
  "acsUrl": "https://<your-domain>/api/v1/auth/sso/saml2/sp/acs/acme-saml",
  "spMetadataUrl": "https://<your-domain>/api/v1/auth/sso/saml2/sp/metadata?providerId=acme-saml"
}
  • ACS (Assertion Consumer Service) URL — register this as the SP ACS / reply URL on the IdP.
  • SP metadata URL — serves the SP EntityDescriptor/SPSSODescriptor XML (many IdPs can import it directly to auto-configure the SP).

SAML sign-in flow

  1. User enters their work email (or clicks the enterprise-login button).
  2. Client calls POST /api/v1/auth/sign-in/sso with { email, callbackURL }.
  3. The email domain is matched to the registered provider; the response is a redirect to the IdP's entryPoint carrying a signed SAMLRequest.
  4. The IdP authenticates the user and POSTs a SAML assertion back to the SP ACS URL; @better-auth/sso (samlify) validates the signature/timestamp, then creates a session and redirects to callbackURL.

Signature, timestamp, and replay validation are handled by @better-auth/sso (samlify). You only supply the IdP's signing certificate at registration time.


Verify configuration

Restart the server (pnpm dev), then:

curl http://localhost:3000/api/v1/auth/config

Expected response (the endpoint wraps getPublicConfig() in a { success, data } envelope; abbreviated — getPublicConfig() also returns disableSignUp/requireEmailVerification on emailPassword and additional features keys such as multiOrgEnabled, oidcProvider, deviceAuthorization, and admin):

{
  "success": true,
  "data": {
    "emailPassword": { "enabled": true },
    "socialProviders": [
      { "id": "google", "name": "Google", "enabled": true, "type": "social" },
      { "id": "github", "name": "GitHub", "enabled": true, "type": "social" },
      { "id": "okta",   "name": "Okta SSO", "enabled": true, "type": "oidc" }
    ],
    "features": { "twoFactor": false, "organization": true }
  }
}

The Console /login and /register pages will now show a button for each enabled provider.


OAuth flow

Social providers (Google, GitHub, Microsoft, Apple):

  1. User clicks Continue with Google (or similar).
  2. Client calls POST /api/v1/auth/sign-in/social with { provider: "google", callbackURL }.
  3. Browser redirects to the provider's consent screen.
  4. Provider redirects to /api/v1/auth/callback/google.
  5. better-auth creates a session and redirects to callbackURL.

OIDC/enterprise providers (oidcProviders): better-auth's generic-OAuth plugin registers each one as a social provider and adds no endpoints of its own, so the flow uses the same two routes as above.

  1. User clicks Continue with Okta SSO.
  2. Client calls POST /api/v1/auth/sign-in/social with { provider: "okta", callbackURL }, where provider is the entry's providerId.
  3. Browser redirects to the provider's authorization endpoint.
  4. Provider redirects to /api/v1/auth/callback/okta. better-auth exchanges the code, reads the user's profile from the ID token or userInfoUrl, creates a session and redirects to callbackURL.

Linking to an existing account

A social, OIDC or enterprise SSO sign-in may arrive with an email that already belongs to a local account, one that is not linked to this provider yet. The platform then links the identity to that account automatically (an implicit link) only under these rules:

  • The local account's email must be verified. If it is not, no link is written and the callback redirects with error=account_not_linked. The user signs in with their existing method and links the provider from account settings, or verifies their email first.
  • The platform identity provider is the exception. objectstack-cloud links to an unverified local account, because it creates the environment owner's account itself, without a mailbox round-trip.
  • An unlink is honoured. After a user unlinks a provider, signing in with that provider no longer re-links it, for any provider, objectstack-cloud included. The user links it again explicitly from account settings (POST /api/v1/auth/link-social while signed in), which also ends the refusal. If the unlink cannot be recorded, the unlink itself fails and the provider stays linked.

An explicit link from account settings does not depend on whether the signed-in user's email is verified. It still requires the provider to vouch for the email (a trusted provider or an email_verified claim) and the email to match the account's.

account.accountLinking.requireLocalEmailVerified in the auth plugin config changes the first rule. Unset (the default) gives the rules above. true applies the verified-email requirement to every provider, objectstack-cloud included. false turns the requirement off, which the library warns is an account-takeover risk; the unlink rule still applies. account.accountLinking.trustedProviders relaxes only the provider-side email_verified claim and never the local requirement.


Notes

  • OS_AUTH_SECRET must be set to a random string in production (a long random value of 32+ characters is recommended). AUTH_SECRET and BETTER_AUTH_SECRET are accepted as legacy aliases (read silently, without a deprecation warning). If no secret is set, the auth plugin is skipped in production.
  • OS_AUTH_URL (or OS_BASE_URL as a fallback) must match the domain registered with each provider; it determines the base URL used to build OAuth callback URLs.
  • Never commit client secrets to source control — use a secrets manager in production.
  • The redirect URI registered with each provider must match exactly: https://<your-domain>/api/v1/auth/callback/<provider-id>.

On this page