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

# Implement Sign in with Cativa

> Full guide: SPA with PKCE, Node/Express backend, mobile via deep link. Covers id_token validation via JWKS and error handling.

The [Quick Start](/en/get-started/quickstart-sign-in) covers the happy path in 5 steps. This guide is the deep version: **three implementation patterns** (SPA, traditional backend, native mobile), `id_token` validation via JWKS, expected error behaviors and what to do with the token when it expires.

<Info>
  Prefer learning by example? The [`cativa-sso`](https://github.com/cativalab/cativa-sso) repo has runnable reference apps. The `login-com-cativa` folder (Node + Express) implements every step in this guide end to end.
</Info>

## Scenario

You have an app — could be a React SPA on a different domain, a Rails portal with server-side sessions, or a native iOS/Android app. Your community already lives on Cativa and you want users to log into your app **with the same account** they use on the community, without creating separate credentials.

Cativa exposes a standard OIDC IdP per tenant. Any OIDC client library (`oidc-client-ts`, `auth0/spa-js`, `next-auth`, `passport-openidconnect`, `AppAuth-iOS`, `AppAuth-Android`) can talk to it from the tenant's **discovery URL**.

## Prerequisites

1. **An OAuth App registered in the Console** — go to [app.cativa.digital/admin/developers](https://app.cativa.digital/admin/developers), **OAuth Apps** tab, click **Create app**. Save the `client_id` and `client_secret` (the secret is shown **only once**).
2. **A redirect URI registered** in the same app. You can add multiple (e.g. production + staging + localhost).
3. **The `customerName`** (tenant slug) — confirm with the community admin. It's the tenant's public Cativa subdomain.

<Note>
  Cativa SSO endpoints follow OIDC and are organized per tenant: `https://apis.cativalab.digital/tenant/api/v2/sso/{customerName}/...`. The discovery document lives at `https://apis.cativalab.digital/tenant/api/v2/sso/{customerName}/.well-known/openid-configuration` and lists every endpoint and supported algorithm (`S256` for PKCE, `ES256` for `id_token`).
</Note>

## Pick the right flow

<Tabs>
  <Tab title="SPA with PKCE">
    **When to use:** your app is a static frontend (React/Vue/Svelte) served by CDN, with no trusted backend to hold the `client_secret`. PKCE (Proof Key for Code Exchange) replaces the client secret with a `verifier`/`challenge` pair generated per login.

    ### 1. Generate `code_verifier` and `code_challenge`

    The `code_verifier` is a random string that **stays in the browser**. The `code_challenge` is the SHA-256 of the verifier, base64url-encoded — that one goes into `/authorize`.

    ```js theme={null}
    function base64UrlEncode(arrayBuffer) {
      const bytes = new Uint8Array(arrayBuffer);
      let str = '';
      for (const b of bytes) str += String.fromCharCode(b);
      return btoa(str)
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
    }

    function generateRandomString(length = 64) {
      const bytes = new Uint8Array(length);
      crypto.getRandomValues(bytes);
      return base64UrlEncode(bytes);
    }

    async function sha256Base64Url(input) {
      const encoded = new TextEncoder().encode(input);
      const hash = await crypto.subtle.digest('SHA-256', encoded);
      return base64UrlEncode(hash);
    }
    ```

    ### 2. Redirect to `/authorize`

    ```js theme={null}
    const verifier = generateRandomString(64);
    const challenge = await sha256Base64Url(verifier);
    const state = crypto.randomUUID();

    sessionStorage.setItem('cativa_pkce_verifier', verifier);
    sessionStorage.setItem('cativa_oauth_state', state);

    const params = new URLSearchParams({
      client_id: import.meta.env.VITE_CATIVA_CLIENT_ID,
      redirect_uri: 'https://myapp.com/callback',
      response_type: 'code',
      scope: 'openid profile email',
      code_challenge: challenge,
      code_challenge_method: 'S256',
      state
    });

    window.location.href =
      `https://apis.cativalab.digital/tenant/api/v2/sso/${customerName}/authorize?${params}`;
    ```

    ### 3. Handle the callback

    Cativa redirects the user to `https://myapp.com/callback?code=...&state=...`. Validate `state` and exchange the `code` for tokens.

    Since SPAs cannot hold a `client_secret`, `/token` accepts PKCE as proof: you send the original `code_verifier` (not the challenge) and Cativa recomputes SHA-256 and compares with the `code_challenge` it stored from step 2.

    ```js theme={null}
    const url = new URL(window.location.href);
    const code = url.searchParams.get('code');
    const returnedState = url.searchParams.get('state');
    const expectedState = sessionStorage.getItem('cativa_oauth_state');
    const verifier = sessionStorage.getItem('cativa_pkce_verifier');

    if (!code || returnedState !== expectedState) {
      throw new Error('Invalid OAuth state');
    }

    const res = await fetch(
      `https://apis.cativalab.digital/tenant/api/v2/sso/${customerName}/token`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
          grant_type: 'authorization_code',
          code,
          client_id: import.meta.env.VITE_CATIVA_CLIENT_ID,
          client_secret: import.meta.env.VITE_CATIVA_CLIENT_SECRET,
          redirect_uri: 'https://myapp.com/callback',
          code_verifier: verifier
        })
      }
    );

    if (!res.ok) {
      const err = await res.json();
      throw new Error(`OAuth error: ${err.error_description ?? err.error}`);
    }

    const tokens = await res.json();
    sessionStorage.removeItem('cativa_pkce_verifier');
    sessionStorage.removeItem('cativa_oauth_state');
    ```

    <Warning>
      Even in a SPA, Cativa requires `client_secret` in the `/token` body. If you want a flow with truly no secret on the frontend, set up a small proxy endpoint on your backend that takes the `code` from the SPA and performs the exchange server-side (which is the **Traditional backend** pattern below).
    </Warning>

    ### 4. Fetch the profile and start the local session

    ```js theme={null}
    const userRes = await fetch(
      `https://apis.cativalab.digital/tenant/api/v2/sso/${customerName}/userinfo`,
      { headers: { Authorization: `Bearer ${tokens.access_token}` } }
    );
    const user = await userRes.json();
    // { sub, name, email, picture }

    // Persist the user in SPA state + access_token in memory.
    ```

    Don't persist the `access_token` in `localStorage` — it's vulnerable to XSS. Keep it in memory (SPA state) or `sessionStorage` if you accept losing the session across tabs.
  </Tab>

  <Tab title="Node/Express backend">
    **When to use:** your app has a backend (Express, Fastify, NestJS, Django, Rails, ASP.NET) and you'd rather keep the `client_secret` server-side and use **httpOnly session cookies**. This is the most secure pattern and the one we recommend whenever a backend is available.

    ### 1. Start the login

    ```js theme={null}
    import express from 'express';
    import crypto from 'crypto';

    const app = express();
    app.use(require('express-session')({
      secret: process.env.SESSION_SECRET,
      resave: false,
      saveUninitialized: false,
      cookie: { httpOnly: true, secure: true, sameSite: 'lax' }
    }));

    function base64UrlEncode(buf) {
      return buf.toString('base64')
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
    }

    app.get('/login', (req, res) => {
      const verifier = base64UrlEncode(crypto.randomBytes(48));
      const challenge = base64UrlEncode(
        crypto.createHash('sha256').update(verifier).digest()
      );
      const state = crypto.randomBytes(16).toString('hex');

      req.session.pkceVerifier = verifier;
      req.session.oauthState = state;

      const params = new URLSearchParams({
        client_id: process.env.CATIVA_CLIENT_ID,
        redirect_uri: process.env.CATIVA_REDIRECT_URI,
        response_type: 'code',
        scope: 'openid profile email',
        code_challenge: challenge,
        code_challenge_method: 'S256',
        state
      });

      res.redirect(
        `https://apis.cativalab.digital/tenant/api/v2/sso/${process.env.CATIVA_CUSTOMER}/authorize?${params}`
      );
    });
    ```

    ### 2. Receive the callback and swap the code

    ```js theme={null}
    app.get('/callback', async (req, res) => {
      const { code, state } = req.query;

      if (!code || state !== req.session.oauthState) {
        return res.status(400).send('Invalid OAuth state');
      }

      const tokenRes = await fetch(
        `https://apis.cativalab.digital/tenant/api/v2/sso/${process.env.CATIVA_CUSTOMER}/token`,
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
          body: new URLSearchParams({
            grant_type: 'authorization_code',
            code,
            client_id: process.env.CATIVA_CLIENT_ID,
            client_secret: process.env.CATIVA_CLIENT_SECRET,
            redirect_uri: process.env.CATIVA_REDIRECT_URI,
            code_verifier: req.session.pkceVerifier
          })
        }
      );

      if (!tokenRes.ok) {
        const err = await tokenRes.json();
        return res.status(400).send(`OAuth error: ${err.error_description}`);
      }

      const tokens = await tokenRes.json();
      // { access_token, token_type, expires_in, id_token }

      // Fetch the user profile
      const userRes = await fetch(
        `https://apis.cativalab.digital/tenant/api/v2/sso/${process.env.CATIVA_CUSTOMER}/userinfo`,
        { headers: { Authorization: `Bearer ${tokens.access_token}` } }
      );
      const user = await userRes.json();

      // Persist the user in the session
      req.session.user = user;
      req.session.cativa = {
        accessToken: tokens.access_token,
        idToken: tokens.id_token,
        expiresAt: Date.now() + tokens.expires_in * 1000
      };
      delete req.session.pkceVerifier;
      delete req.session.oauthState;

      res.redirect('/');
    });
    ```

    ### 3. Protect authenticated routes

    ```js theme={null}
    function requireAuth(req, res, next) {
      if (!req.session.user) return res.redirect('/login');
      next();
    }

    app.get('/dashboard', requireAuth, (req, res) => {
      res.render('dashboard', { user: req.session.user });
    });
    ```
  </Tab>

  <Tab title="Mobile with deep link">
    **When to use:** native iOS/Android app. The flow is the same OAuth + PKCE as the SPA, but the redirect URI is a **custom scheme** (e.g. `myapp://callback`) or an **App Link / Universal Link** (e.g. `https://myapp.com/oauth/callback`).

    ### 1. Set up the deep link

    * **iOS**: register the scheme in `Info.plist` (`CFBundleURLSchemes`), or configure Associated Domains for Universal Links.
    * **Android**: declare an `intent-filter` with `data android:scheme="myapp"` on the Activity, or an App Link with `android:autoVerify="true"`.

    Then register that URI in the **OAuth App** in the Console (`myapp://callback`).

    ### 2. Use AppAuth (recommended)

    We don't recommend implementing OAuth manually on mobile — use [AppAuth-iOS](https://github.com/openid/AppAuth-iOS) or [AppAuth-Android](https://github.com/openid/AppAuth-Android) which already handles PKCE, manages `ASWebAuthenticationSession`/Custom Tabs and validates the `id_token`.

    ### iOS (Swift)

    ```swift theme={null}
    import AppAuth

    let issuer = URL(string: "https://apis.cativalab.digital/tenant/api/v2/sso/\(customerName)")!

    OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { config, error in
      guard let config = config else { return }

      let request = OIDAuthorizationRequest(
        configuration: config,
        clientId: clientId,
        clientSecret: clientSecret,
        scopes: [OIDScopeOpenID, OIDScopeProfile, OIDScopeEmail],
        redirectURL: URL(string: "myapp://callback")!,
        responseType: OIDResponseTypeCode,
        additionalParameters: nil
      )

      // Presents ASWebAuthenticationSession and completes PKCE automatically
      currentAuthorizationFlow = OIDAuthState.authState(
        byPresenting: request,
        presenting: viewController
      ) { authState, error in
        guard let authState = authState else { return }
        let accessToken = authState.lastTokenResponse?.accessToken
        // Persist authState in the Keychain
      }
    }
    ```

    ### Android (Kotlin)

    ```kotlin theme={null}
    val issuer = Uri.parse("https://apis.cativalab.digital/tenant/api/v2/sso/$customerName")

    AuthorizationServiceConfiguration.fetchFromIssuer(issuer) { config, ex ->
      if (config == null) return@fetchFromIssuer

      val request = AuthorizationRequest.Builder(
        config,
        clientId,
        ResponseTypeValues.CODE,
        Uri.parse("myapp://callback")
      )
        .setScopes("openid", "profile", "email")
        .build()

      val authService = AuthorizationService(context)
      val intent = authService.getAuthorizationRequestIntent(request)
      startActivityForResult(intent, RC_AUTH)
    }
    ```

    Handle the callback in `onActivityResult` and swap the code for tokens via `authService.performTokenRequest`.

    <Note>
      AppAuth reads the discovery doc and configures PKCE/JWKS automatically — you only need the `issuer` (the URL `https://apis.cativalab.digital/tenant/api/v2/sso/{customerName}`) and the `clientId`/`clientSecret` registered in the Console.
    </Note>
  </Tab>
</Tabs>

## Validate the `id_token` via JWKS

The `id_token` returned by `/token` is a **JWT signed with ES256**. You **must** validate the signature before trusting any claim (`sub`, `email`, etc.) — that prevents an attacker from substituting a forged token.

The public key for validation is published at the tenant's JWKS:

```
https://apis.cativalab.digital/tenant/api/v2/sso/{customerName}/jwks
```

The discovery doc points to this JWKS — OIDC client libraries fetch and cache the key automatically.

### Node.js with `jose`

```js theme={null}
import { createRemoteJWKSet, jwtVerify } from 'jose';

const issuer = `https://apis.cativalab.digital/tenant/api/v2/sso/${customerName}`;
const JWKS = createRemoteJWKSet(new URL(`${issuer}/jwks`));

async function verifyIdToken(idToken, clientId) {
  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer,
    audience: clientId,
    algorithms: ['ES256']
  });
  return payload; // { sub, name, email, picture, iat, exp, iss, aud, ... }
}
```

### Python with `PyJWT`

```python theme={null}
import jwt
from jwt import PyJWKClient

issuer = f'https://apis.cativalab.digital/tenant/api/v2/sso/{customer_name}'
jwks_client = PyJWKClient(f'{issuer}/jwks')

def verify_id_token(id_token, client_id):
    signing_key = jwks_client.get_signing_key_from_jwt(id_token)
    return jwt.decode(
        id_token,
        signing_key.key,
        algorithms=['ES256'],
        issuer=issuer,
        audience=client_id
    )
```

## Session and token refresh

The `access_token` returned by Cativa SSO is short-lived (configured per OAuth App, default 1h). Pick the strategy that fits:

1. **Refresh with `refresh_token` (recommended)** — every `authorization_code` exchange already returns a `refresh_token` (opaque). Swap it for a fresh `access_token` when the current one expires:

   ```bash theme={null}
   curl -X POST https://apis.cativalab.digital/tenant/api/v2/sso/{customerName}/token \
     -H "Content-Type: application/x-www-form-urlencoded" \
     -d "grant_type=refresh_token" \
     -d "refresh_token=YOUR_REFRESH_TOKEN" \
     -d "client_id=YOUR_CLIENT_ID" \
     -d "client_secret=YOUR_CLIENT_SECRET"
   ```

   The response carries a **new** `access_token` **and** a **new** `refresh_token` — tokens are rotated, so persist the new refresh token and discard the old one (it is revoked on use).
2. **Silent re-login when it expires** — when `expires_in` reaches 0, redirect the user back through `/authorize`. If they still have an active Cativa session, the IdP returns a fresh code without prompting for a password again (single sign-on).
3. **Treat the token as a session bound to your app** — store only the identity (`sub`, `email`) on your session and issue your own JWT/cookie. The Cativa `access_token` is used only at login time.

## Logout

There is currently **no dedicated OIDC `end_session` endpoint** for external OAuth apps. The recommended flow:

1. **On your side**: drop the local session (delete cookie/session/Keychain), redirect the user to a "logged out" destination.
2. **Optional, if you also want to log them out of the Cativa community**: redirect them to `https://{customerName}.cativa.digital/logout` (replacing `{customerName}` with the tenant subdomain).

<Note>
  A standard OIDC `end_session` endpoint for external partners is on our roadmap. When available, this page will be updated with cURL and a `post_logout_redirect_uri` example.
</Note>

## Common errors

<AccordionGroup>
  <Accordion title="`error: invalid_request, error_description: redirect_uri not authorized`">
    The `redirect_uri` sent to `/authorize` (and re-confirmed at `/token`) must be **identical** to one of the URIs registered on the OAuth App in the Console. Compare carefully: trailing slash, http vs https, and case in paths matter.
  </Accordion>

  <Accordion title="`error: invalid_grant, error_description: Authorization code expired`">
    The `code` returned by `/authorize` has a short TTL (a few minutes). If your exchange takes a while — because the backend redirects to another route first, or because you're debugging by hand — the code expires. Always exchange it **immediately** in the callback.
  </Accordion>

  <Accordion title="`error: invalid_grant, error_description: Authorization code already used`">
    The `code` is **single-use**. If your callback is hit twice (e.g. user reloaded the callback page), the second exchange fails with this error. That's expected — just redirect to the SPA home when you already have tokens in session.
  </Accordion>

  <Accordion title="`error: invalid_client, error_description: invalid client_secret`">
    Check:

    * You didn't swap `client_id` and `client_secret`.
    * The secret hasn't been rotated in the Console (generating a new one invalidates the old).
    * There's no extra whitespace/newline in the env var (this happens with `cat .env` when the file came from Windows).
  </Accordion>

  <Accordion title="`error: invalid_grant, error_description: PKCE verification failed`">
    The `code_verifier` sent to `/token` doesn't match the `code_challenge` sent to `/authorize`. Common causes: you generated a new verifier before the callback (lost the original), you're SHA-256-ing different bytes (UTF-8 vs ASCII), or you're applying standard base64 instead of **base64url**.
  </Accordion>

  <Accordion title="`401 Unauthorized` on /userinfo">
    The `access_token` expired or is malformed. Check `Authorization: Bearer <access_token>` (don't use the `id_token` here — `/userinfo` validates the `access_token`). If the token is fresh and you still get 401, validate the `iss` of the `access_token` (must be `https://apis.cativalab.digital/tenant/api/v2/sso/{customerName}`).
  </Accordion>

  <Accordion title="The user can sign in but the `id_token` fails my JWKS validation">
    Confirm:

    * `algorithms: ['ES256']` (Cativa signs with ES256, not RS256).
    * `issuer` exactly `https://apis.cativalab.digital/tenant/api/v2/sso/{customerName}` (no trailing slash).
    * `audience` is your `client_id`.
    * Your library is fetching JWKS from the right issuer (and caching it, so you don't hit the endpoint on every login).
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Badges as Permissions" icon="shield-check" href="/en/concepts/badges-as-permissions">
    How the `sub` from id\_token shows up in the community and how badges control what that user can access.
  </Card>

  <Card title="Quick Start: API Key" icon="terminal" href="/en/get-started/quickstart-api-key">
    For server-to-server calls (no interactive user), use an API Key instead of OAuth.
  </Card>

  <Card title="Example app (GitHub)" icon="github" href="https://github.com/cativalab/cativa-sso">
    Runnable reference implementations (Node + Express). The `login-com-cativa` folder implements this exact flow: discovery, PKCE, token exchange and JWKS validation.
  </Card>
</CardGroup>
