> ## Documentation Index
> Fetch the complete documentation index at: https://docs.prelude.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Enterprise SSO

> Implement enterprise SSO (OIDC) login with the Prelude JavaScript SDK.

This guide covers how to start an enterprise single sign-on (OIDC) login from your web application. Make sure you have [configured an enterprise connection](/session/documentation/integration-guide/enterprise-oidc/introduction) on your backend before proceeding.

## Enterprise login flow

An enterprise SSO login is the same three-step flow as [social login](/session/documentation/frontend-sdks/web/social-login):

1. **Redirect** — Your app asks the SDK to start the flow; it redirects the user to the Identity Provider.
2. **Callback** — After authenticating, the IdP redirects back and Prelude redirects to your app with a `challenge_token`.
3. **Finalize** — Your app sends the `challenge_token` to complete authentication.

The `challenge_token` is finalized exactly like social login — via `finalizeOAuthLogin`. Unlike personal social login, an enterprise connection is bound to email domains, so it never returns the `otp_required` step: it always resolves to `{ status: "logged_in" }`.

<Accordion title="How is the enterprise flow secured?" icon="shield">
  The flow is protected by [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) (Proof Key for Code Exchange). The SDK generates a unique `code_verifier`/`code_challenge` pair for each login and stores the verifier locally; the `challenge_token` can only be finalized once, by the browser that started the flow. In addition, the connection the flow launched with is pinned server-side, and the IdP-asserted email's domain must fall within that connection's allowlist.
</Accordion>

## Redirect to the Identity Provider

There are two ways to start the flow.

### By email

Resolve the connection from the user's email domain (it must match exactly one enabled connection's allowlist):

```javascript theme={null}
await client.loginWithEnterpriseOAuth({
  email: "jane@acme.com",
  redirectURI: "https://yourapp.com/callback",
});
```

### By connection

When you already know the connection, pass its `connectionId`:

```javascript theme={null}
await client.loginWithEnterpriseOAuth({
  connectionId: "ocon_01jqebhswje1ka1z7ahr9rfsgt",
  redirectURI: "https://yourapp.com/callback",
});
```

`redirectURI` is optional — it must be allowlisted for your app, and falls back to the connection's `default_redirect_uri` when omitted. The provider (Okta, Google, Microsoft) is derived server-side from the connection, so you never pass it here.

<Note>
  Both forms navigate the browser to the IdP. If you need the authorization URL
  without navigating (e.g. to open it yourself), call
  `initiateEnterpriseOAuthLogin`, which returns `{ authorization_url }`.
</Note>

## Handle the callback

When the IdP redirects back to your app, extract the `challenge_token` from the URL and finalize the login — the same call used for social login and SAML:

```javascript theme={null}
import { PrldErrors } from "@prelude.so/js-sdk";

const params = new URLSearchParams(window.location.search);
const challengeToken = params.get("challenge_token");
const error = params.get("error");

if (error) {
  // Enterprise validation or provisioning failure, e.g.
  // oauth_email_domain_not_allowed, oauth_user_not_provisioned.
  console.error(params.get("error_description") || error);
} else if (challengeToken) {
  try {
    await client.finalizeOAuthLogin(challengeToken);
    // The user is now authenticated. Enterprise challenge tokens always
    // resolve to { status: "logged_in" }.
    await client.refresh();
  } catch (err) {
    if (err instanceof PrldErrors.ExpiredChallengeToken) {
      // Challenge token has expired — restart the flow
    } else if (err instanceof PrldErrors.InvalidChallengeToken) {
      // Challenge token is invalid
    }
  }
}
```

<Accordion title="Try it" icon="flask">
  Replace `src/App.jsx` with:

  ```jsx src/App.jsx theme={null}
  import "@picocss/pico";
  import { useState, useEffect } from "react";
  import { PrldSessionClient, PrldErrors, decode } from "@prelude.so/js-sdk";

  const client = new PrldSessionClient({ domain: `${import.meta.env.VITE_APP_ID}.session.prelude.dev` });

  export default function App() {
    const [user, setUser] = useState(null);
    const [email, setEmail] = useState("");
    const [error, setError] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
      const init = async () => {
        const params = new URLSearchParams(window.location.search);
        const challengeToken = params.get("challenge_token");
        const callbackError = params.get("error");

        if (challengeToken || callbackError) {
          window.history.replaceState({}, "", window.location.pathname);
        }

        if (callbackError) {
          setError(params.get("error_description") || callbackError);
          setLoading(false);
          return;
        }

        if (challengeToken) {
          try {
            await client.finalizeOAuthLogin(challengeToken);
          } catch (err) {
            if (err instanceof PrldErrors.ExpiredChallengeToken) {
              setError("Login expired. Please try again.");
            } else if (err instanceof PrldErrors.InvalidChallengeToken) {
              setError("Invalid login. Please try again.");
            } else {
              setError("Something went wrong. Please try again.");
            }
            setLoading(false);
            return;
          }
        }

        try {
          const { user } = await client.refresh();
          setUser(user);
        } catch {
          // No active session
        }

        setLoading(false);
      };

      void init();
    }, []);

    const handleEnterpriseLogin = async () => {
      setError(null);
      try {
        await client.loginWithEnterpriseOAuth({
          email,
          redirectURI: window.location.origin + window.location.pathname,
        });
      } catch (err) {
        // The initiate endpoint rejects an unknown email domain
        // (oauth_no_connection_for_email).
        setError("No SSO connection is configured for that email domain.");
      }
    };

    return (
      <>
        <style>{`body, #root { display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; padding: 0; }`}</style>
        <main style={{ textAlign: "center", maxWidth: "600px", width: "100%" }}>
          <h1>Enterprise SSO</h1>
          {loading ? (
            <p aria-busy="true">Completing login...</p>
          ) : user ? (
            <article>
              <p>Logged in</p>
              <pre style={{ textAlign: "left", whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
                {JSON.stringify(decode(user.accessToken).claims, null, 2)}
              </pre>
              <button className="secondary" onClick={async () => {
                await client.logout();
                setUser(null);
              }}>Log Out</button>
            </article>
          ) : (
            <>
              {error && <p role="alert" style={{ color: "var(--pico-color-red-500)" }}>{error}</p>}
              <input
                type="email"
                placeholder="you@company.com"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
              />
              <button onClick={handleEnterpriseLogin} disabled={!email.trim()}>
                Sign in with SSO
              </button>
            </>
          )}
        </main>
      </>
    );
  }
  ```
</Accordion>

## What's next?

<CardGroup cols={2}>
  <Card title="Configure a connection" icon="gear" href="/session/documentation/integration-guide/enterprise-oidc/introduction">
    Set up an enterprise OIDC connection on the backend.
  </Card>

  <Card title="Social login" icon="users" href="/session/documentation/frontend-sdks/web/social-login">
    Add classic social-login buttons.
  </Card>
</CardGroup>
