IdRa

Next.js

Add IdRa sign-in to a Next.js App Router app with route handlers and openid-client.

An App Router app with route handlers, openid-client and an encrypted cookie session from iron-session.

Sample repository (being opened soon): IdRaHQ/idra-sample-nextjs. 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: 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.local
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>

Install

npx create-next-app@latest idra-next --ts --app --no-src-dir --no-tailwind --import-alias '@/*'
cd idra-next
npm install openid-client iron-session

Code

lib/idra.ts
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
import * as oidc from 'openid-client';

export const appUrl = process.env.APP_URL!;
export const redirectUri = `${appUrl}/callback`;

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

export const getSession = async () =>
  getIronSession<Session>(await cookies(), {
    cookieName: 'app_session',
    password: process.env.SESSION_SECRET!,
    cookieOptions: { 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!);
  const secret = process.env.IDRA_CLIENT_SECRET;
  // With a secret: client_secret_basic. Without one: a public client.
  config ??= oidc.discovery(
    issuer,
    process.env.IDRA_CLIENT_ID!,
    undefined,
    secret ? oidc.ClientSecretBasic(secret) : oidc.None(),
    // Only for an IdRa you run locally on http. Never needed for an https issuer.
    issuer.protocol === 'http:' ? { execute: [oidc.allowInsecureRequests] } : undefined,
  );
  return config;
}
app/login/route.ts
import { redirect } from 'next/navigation';
import * as oidc from 'openid-client';
import { getConfig, getSession, redirectUri } from '@/lib/idra';

export async function GET() {
  const session = await getSession();
  const verifier = oidc.randomPKCECodeVerifier();
  const flow = { verifier, state: oidc.randomState(), nonce: oidc.randomNonce() };
  session.flow = flow;
  await session.save();
  const url = oidc.buildAuthorizationUrl(await getConfig(), {
    redirect_uri: redirectUri,
    scope: 'openid profile email groups',
    code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
    code_challenge_method: 'S256',
    state: flow.state,
    nonce: flow.nonce,
  });
  redirect(url.href);
}
app/callback/route.ts
import { redirect } from 'next/navigation';
import type { NextRequest } from 'next/server';
import * as oidc from 'openid-client';
import { appUrl, getConfig, getSession } from '@/lib/idra';

export async function GET(req: NextRequest) {
  const session = await getSession();
  const { flow } = session;
  if (!flow) redirect('/login');
  session.flow = undefined;
  try {
    const config = await getConfig();
    const tokens = await oidc.authorizationCodeGrant(
      config,
      new URL(`${req.nextUrl.pathname}${req.nextUrl.search}`, appUrl),
      { pkceCodeVerifier: flow.verifier, expectedState: flow.state, expectedNonce: flow.nonce },
    );
    // The ID token has no email or name: they come from UserInfo.
    session.user = await oidc.fetchUserInfo(config, tokens.access_token, tokens.claims()!.sub);
    session.idToken = tokens.id_token;
    await session.save();
  } catch (e) {
    console.error(e);
    return new Response(`Sign-in failed: ${(e as Error).message}`, { status: 400 });
  }
  redirect('/');
}
app/logout/route.ts
import { redirect } from 'next/navigation';
import * as oidc from 'openid-client';
import { appUrl, getConfig, getSession } from '@/lib/idra';

export async function GET() {
  const session = await getSession();
  const idTokenHint = session.idToken;
  session.destroy();
  if (!idTokenHint) redirect('/');
  const url = oidc.buildEndSessionUrl(await getConfig(), {
    id_token_hint: idTokenHint,
    post_logout_redirect_uri: `${appUrl}/`,
  });
  redirect(url.href);
}
app/page.tsx
import { getSession } from '@/lib/idra';

export default async function Home() {
  const { user } = await getSession();
  if (!user)
    return (
      <main>
        <h1>Signed out</h1>
        {/* A plain link, not next/link: these routes redirect to another site. */}
        <a href="/login">Sign in with IdRa</a>
      </main>
    );
  return (
    <main>
      <h1>Signed in</h1>
      <pre>{JSON.stringify(user, null, 2)}</pre>
      <a href="/logout">Sign out</a>
    </main>
  );
}

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.
invalid_client: invalid client_secretWrong secret, or it was rotated.Use Generate new secret on Sign On.
invalid_client: client registered for client_secret_basic cannot use noneA Web Application is running without its secret.Set IDRA_CLIENT_SECRET.
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