# Nuxt (https://docs.idra.zone/docs/quickstarts/nuxt)

Add IdRa sign-in to a Nuxt app with server routes and openid-client.

A Nuxt app with server routes, [`openid-client`](https://github.com/panva/openid-client) and the encrypted cookie session built into Nuxt's server. Tested with Nuxt 4.6 and Node.js 24.

## 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:** **Web Application**
- **App integration name:** any name
- **Sign-in redirect URIs:** `http://localhost:3000/callback`

Leave **Assign this app to me so I can test it** on, then select **Create**. Copy the client secret, shown **once**, and select **I've saved it**.

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 `IDRA_*`.
2. Add `http://localhost:3000/` 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"
IDRA_ISSUER=https://<your-org>.idra.team/api/auth
IDRA_CLIENT_ID=<Client ID>
IDRA_CLIENT_SECRET=<client secret>
APP_URL=http://localhost:3000
SESSION_SECRET=<output of: openssl rand -hex 32>
```

`nuxt dev` reads `.env`. Everywhere else, set these in the environment.

## Install

```sh
npx nuxi@4.0.1 init idra-nuxt --template minimal
cd idra-nuxt
npm install nuxt@4.6.3 openid-client@6.8.8
npm install -D @types/node
```

## Code

```ts title="server/utils/idra.ts"
import type { H3Event } from 'h3';
import * as oidc from 'openid-client';

type Session = {
  flow?: { verifier: string; state: string; nonce: string };
  user?: oidc.UserInfoResponse;
  idToken?: string;
};

export const appUrl = () => process.env.APP_URL!;

// An encrypted cookie. h3 ships with Nuxt.
export const getAppSession = (event: H3Event) =>
  useSession<Session>(event, {
    name: 'app_session',
    password: process.env.SESSION_SECRET!,
    cookie: { httpOnly: true, sameSite: 'lax', secure: appUrl().startsWith('https://') },
  });

let config: Promise<oidc.Configuration> | undefined;

export function getConfig() {
  const issuer = new URL(process.env.IDRA_ISSUER!);
  config ??= oidc.discovery(
    issuer,
    process.env.IDRA_CLIENT_ID!,
    undefined,
    oidc.ClientSecretBasic(process.env.IDRA_CLIENT_SECRET!),
    // Only for an IdRa you run locally on http. Never needed for an https issuer.
    issuer.protocol === 'http:' ? { execute: [oidc.allowInsecureRequests] } : undefined,
  );
  return config;
}
```

```ts title="server/routes/login.get.ts"
import * as oidc from 'openid-client';

export default defineEventHandler(async (event) => {
  const session = await getAppSession(event);
  const verifier = oidc.randomPKCECodeVerifier();
  const flow = { verifier, state: oidc.randomState(), nonce: oidc.randomNonce() };
  await session.update({ flow });
  const url = oidc.buildAuthorizationUrl(await getConfig(), {
    redirect_uri: `${appUrl()}/callback`,
    scope: 'openid profile email groups',
    code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
    code_challenge_method: 'S256',
    state: flow.state,
    nonce: flow.nonce,
  });
  return sendRedirect(event, url.href);
});
```

```ts title="server/routes/callback.get.ts"
import * as oidc from 'openid-client';

export default defineEventHandler(async (event) => {
  const session = await getAppSession(event);
  const { flow } = session.data;
  if (!flow) return sendRedirect(event, '/login');
  try {
    const config = await getConfig();
    const tokens = await oidc.authorizationCodeGrant(config, new URL(event.path, appUrl()), {
      pkceCodeVerifier: flow.verifier,
      expectedState: flow.state,
      expectedNonce: flow.nonce,
    });
    // The ID token has no email or name: they come from UserInfo.
    const user = await oidc.fetchUserInfo(config, tokens.access_token, tokens.claims()!.sub);
    await session.update({ flow: undefined, user, idToken: tokens.id_token });
  } catch (e) {
    console.error(e);
    setResponseStatus(event, 400);
    return `Sign-in failed: ${(e as Error).message}`;
  }
  return sendRedirect(event, '/');
});
```

```ts title="server/routes/logout.get.ts"
import * as oidc from 'openid-client';

export default defineEventHandler(async (event) => {
  const session = await getAppSession(event);
  const { idToken } = session.data;
  await session.clear();
  if (!idToken) return sendRedirect(event, '/');
  const url = oidc.buildEndSessionUrl(await getConfig(), {
    id_token_hint: idToken,
    post_logout_redirect_uri: `${appUrl()}/`,
  });
  return sendRedirect(event, url.href);
});
```

```ts title="server/api/me.get.ts"
export default defineEventHandler(async (event) => {
  const { user } = (await getAppSession(event)).data;
  return { user: user ?? null };
});
```

```vue title="app/app.vue"
<script setup lang="ts">
const { data } = await useFetch('/api/me');
</script>

<template>
  <main>
    <template v-if="data?.user">
      <h1>Signed in</h1>
      <pre>{{ JSON.stringify(data.user, null, 2) }}</pre>
      <a href="/logout">Sign out</a>
    </template>
    <template v-else>
      <h1>Signed out</h1>
      <!-- A plain link, not NuxtLink: this route redirects to another site. -->
      <a href="/login">Sign in with IdRa</a>
    </template>
  </main>
</template>
```

The `allowInsecureRequests` line only applies to an `http` issuer, such as an IdRa on your own machine.

## Run

```sh
npm run dev
```

## Verify sign-in and sign-out

1. Open `http://localhost:3000` 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.

| 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:3000/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`. |
| `Sign-in failed: server responded with a challenge in the WWW-Authenticate HTTP Header` | `openid-client`'s message for a wrong secret, or one that was rotated. | Use **Generate new secret** on **Sign On**. |
| `invalid_client`: `client registered for client_secret_basic cannot use client_secret_post` | The library sent the secret in the body. | Send it as HTTP Basic, or change **Client authentication** on **Sign On**. |
| `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.