# React SPA (PKCE) (https://docs.idra.zone/docs/quickstarts/react-spa)

Add IdRa sign-in to a React single-page app with oidc-client-ts and PKCE, no client secret.

A browser-only app: a public client using the authorization code flow with PKCE, built with Vite and [`oidc-client-ts`](https://github.com/authts/oidc-client-ts). It has no secret. IdRa's token, UserInfo, JWKS and discovery endpoints answer cross-origin requests from any origin, without cookies.

Sample repository (being opened soon): [IdRaHQ/idra-sample-react-spa](https://github.com/IdRaHQ/idra-sample-react-spa). It has this quickstart's code plus a protected page.

## Register the app

Use one app for local development and production. Open your organization's admin console at `https://<your-org>.idra.team/admin` (or `/admin` on your own IdRa). Go to **Applications → Create App Integration**, choose **OIDC - OpenID Connect**, and set:

- **Application type:** **Single-Page Application**
- **App integration name:** any name
- **Sign-in redirect URIs:** `http://localhost:5173/callback`

Leave **Assign this app to me so I can test it** on, then select **Create**.

The app's **Sign On** tab opens:

1. Under **Connect your app**, copy the **Issuer** and **Client ID**. The `.env` snippet there has the same values, named `OIDC_*`; this quickstart names them `VITE_IDRA_*`.
2. Add `http://localhost:5173/` to **Sign-out redirect URIs** and select **Save**.

To let other people sign in, assign them or their groups on **Assignments**.

**Local redirects and production**

  `http` redirects work on `localhost`, `127.0.0.1` or `[::1]`, with a port, and match exactly. PKCE stays required while the app has one. For production, add your `https` URIs (such as `https://app.example.com/callback` and `https://app.example.com/`) to the same app, then remove the local ones.

## Configure

```sh title=".env"
VITE_IDRA_ISSUER=https://<your-org>.idra.team/api/auth
VITE_IDRA_CLIENT_ID=<Client ID>
```

## Install

```sh
npm create vite@latest idra-react -- --template react
cd idra-react
npm install oidc-client-ts
```

## Code

Replace the generated file:

```jsx title="src/main.jsx"
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
import { createRoot } from 'react-dom/client';

const auth = new UserManager({
  authority: import.meta.env.VITE_IDRA_ISSUER,
  client_id: import.meta.env.VITE_IDRA_CLIENT_ID,
  redirect_uri: `${location.origin}/callback`,
  post_logout_redirect_uri: `${location.origin}/`,
  scope: 'openid profile email groups',
  // The ID token has no email or name: this reads them from UserInfo.
  loadUserInfo: true,
  userStore: new WebStorageStateStore({ store: sessionStorage }),
});

// Before React renders, so the code is exchanged once, whatever React does with effects.
async function currentUser() {
  if (location.pathname !== '/callback') return auth.getUser();
  const user = await auth.signinCallback();
  history.replaceState(null, '', '/');
  return user;
}

function App({ user, error }) {
  if (error) return <p role="alert">Sign-in failed: {error}</p>;
  if (!user || user.expired)
    return (
      <main>
        <h1>Signed out</h1>
        <button onClick={() => auth.signinRedirect()}>Sign in with IdRa</button>
      </main>
    );
  return (
    <main>
      <h1>Signed in</h1>
      <pre>{JSON.stringify(user.profile, null, 2)}</pre>
      <button onClick={() => auth.signoutRedirect()}>Sign out</button>
    </main>
  );
}

const root = createRoot(document.getElementById('root'));
currentUser().then(
  (user) => root.render(<App user={user} />),
  (e) => root.render(<App error={e.message} />),
);
```

Your production host must serve `index.html` for `/callback`. Tokens stay in `sessionStorage`, where any script on the page can read them, so keep the page free of third-party scripts you don't trust.

## Run

```sh
npm run dev
```

Vite serves on port 5173, which must match the registered URI exactly.

## Verify sign-in and sign-out

1. Open `http://localhost:5173` and select **Sign in with IdRa**. You land on IdRa's sign-in page.
2. Sign in, with MFA if your policies ask for it. You come back to a **Signed in** page showing your claims.
3. Select **Sign out**. You return to **Signed out**. This also ends the IdRa session, so the next sign-in asks for your credentials again.

**Reports → System Log** in the admin console shows the sign-in.

## What the app receives

The ID token doesn't carry `email` or `name`. Read them from UserInfo, as this sample does with `loadUserInfo`.

| Claim | ID token | UserInfo | Scope |
| --- | --- | --- | --- |
| `sub` | Yes | Yes | `openid` |
| `preferred_username` (the email), `updated_at` | Yes | Yes | `profile` |
| `name`, `given_name`, `family_name` | No | Yes | `profile` |
| `email`, `email_verified` | No | Yes | `email` |
| `groups` (group names, always including `Everyone`) | Yes | Yes | `groups` |

Add `offline_access` to the scopes to receive a refresh token. Access tokens last an hour. See [OpenID Connect](https://docs.idra.zone/docs/admin/oidc) for the rest.

## Troubleshooting

| What you see | Cause | Fix |
| --- | --- | --- |
| `Use https on a public host, or for local development http on localhost, 127.0.0.1 or [::1] with a port (…)` | The redirect URI is `http` on another host or without a port, or `https` on a loopback host. | Use `http://localhost:5173/callback` locally and `https` on a public host in production. |
| **Sign-in error**: `invalid redirect uri` | The app sent a redirect URI that isn't registered. Matching is exact, including port and path. | Make it match **Sign-in redirect URIs** on the **Sign On** tab. |
| **Access to … denied**: `You are not assigned to this application.` | The person isn't assigned. | Assign them on **Assignments**. |
| **Access to … denied**: `This application is currently inactive.` | The app is deactivated. | Activate it on the app's page. |
| **Access denied**: `This application is not registered with your organization.` | Wrong client ID, or a client ID from another organization. | Copy the **Client ID** from **Sign On**. |
| **Access denied**: `Your organization's sign-on policy does not allow this access.`, URL has `code=invalid_scope` | The app asked for a scope that doesn't exist. | Use `openid`, `profile`, `email`, `groups`, `offline_access` or a scope defined under **Security → API**. |
| `pkce is required for this client` | The authorize request has no PKCE challenge. | Send `code_challenge` with `code_challenge_method=S256`. IdRa accepts only `S256`. |
| `Forbidden (403)`: `Invalid origin`, code `INVALID_ORIGIN` | A cross-origin call to the token endpoint sent cookies. | Don't set `credentials: 'include'` on calls to IdRa. `oidc-client-ts` doesn't by default. |
| `invalid_grant`: `redirect_uri mismatch` | The token request's redirect URI differs from the authorize request's. | Use the same value in both. |
| `invalid_grant`: `code verification failed` | The PKCE verifier doesn't match the challenge. | Keep the verifier from the authorize step, per sign-in. |
| `404` on discovery | The issuer is missing `/api/auth`. | Use the **Issuer** from **Sign On**: `https://<your-org>.idra.team/api/auth`. |
| Page says `Logged out. The requested post-logout redirect was not registered.` | The person was signed out, but the return URL isn't registered. | Add it to **Sign-out redirect URIs**. |
| **Confirm logout** page | No `id_token_hint`, or it's for a different session than the browser's. | Send the ID token as the hint. Without one, the page is the expected safeguard. |

**Reports → System Log** records refused access as `app.access` with the reason.