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:
- Under Connect your app, copy the Issuer and Client ID. The
.envsnippet there has the same values, namedOIDC_*; this quickstart names themIDRA_*. - 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
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/nodeCode
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;
}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);
});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, '/');
});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);
});export default defineEventHandler(async (event) => {
const { user } = (await getAppSession(event)).data;
return { user: user ?? null };
});<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 devVerify sign-in and sign-out
- Open
http://localhost:3000and select Sign in with IdRa. You land on IdRa's sign-in page. - Sign in, with MFA if your policies ask for it. You come back to a Signed in page showing your claims.
- 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 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.