IdRa

Nuxt

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

A Nuxt app with server routes, 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

.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

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

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;
}
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);
});
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, '/');
});
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);
});
server/api/me.get.ts
export default defineEventHandler(async (event) => {
  const { user } = (await getAppSession(event)).data;
  return { user: user ?? null };
});
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

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.

ClaimID tokenUserInfoScope
subYesYesopenid
preferred_username (the email), updated_atYesYesprofile
name, given_name, family_nameNoYesprofile
email, email_verifiedNoYesemail
groups (group names, always including Everyone)YesYesgroups

Add offline_access to the scopes to receive a refresh token. Access tokens last an hour. See OpenID Connect for the rest.

Troubleshooting

What you seeCauseFix
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 uriThe 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_scopeThe 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 clientThe 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 Headeropenid-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_postThe library sent the secret in the body.Send it as HTTP Basic, or change Client authentication on Sign On.
invalid_grant: redirect_uri mismatchThe token request's redirect URI differs from the authorize request's.Use the same value in both.
invalid_grant: code verification failedThe PKCE verifier doesn't match the challenge.Keep the verifier from the authorize step, per sign-in.
404 on discoveryThe 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 pageNo 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.

On this page