N

JavaScript / Node.js

Sign in with NIA — NestJS

Add “Sign in with NIA” to a NestJS app with openid-client and @nestjs/passport, using the standard OpenID Connect Authorization Code flow with PKCE.

~15 minMediumopenid-client + passport
Try OAuth Flow
Integration progress
0/16

01Overview

This guide adds “Sign in with NIA” to a NestJS application using the standard OpenID Connect flow. NIA is a conformant OIDC provider, so you use openid-client wired up as a @nestjs/passport strategy — no NIA-specific SDK required.

NestJS runs as a confidential client: the browser is redirected to NIA to sign in, and your Nest server exchanges the returned code for tokens using your client secret. openid-client handles PKCE, state, and ID-token signature validation for you.

  • Flow: Authorization Code + PKCE (S256)
  • Tokens: RS256 access & ID tokens, opaque refresh token
  • You write: a client provider, an OIDC passport strategy, and an auth controller

02Requirements

  • Node 18+ and NestJS 10+
  • A NIA account with access to the client dashboard
  • A registered OAuth client (client_id + client_secret)
  • Your app's redirect URI registered on that client
  • A session store enabled via express-session

03Installation

Install openid-client for the OIDC handshake plus Passport and its NestJS bindings for the strategy/guard plumbing.

npm install openid-client passport @nestjs/passport express-session
If you use TypeScript strict mode, also add @types/passport and @types/express-session as dev dependencies.

04Required OAuth library

Discover NIA's issuer once and provide the resulting Client for injection. The discovery document is fetched from NIA_ISSUER and configures every endpoint, the JWKS URL, and supported algorithms automatically.

src/auth/nia-client.provider.ts
import { Issuer, Client } from "openid-client";

export const NIA_CLIENT = "NIA_CLIENT";

export const niaClientProvider = {
  provide: NIA_CLIENT,
  useFactory: async (): Promise<Client> => {
    const nia = await Issuer.discover(process.env.NIA_ISSUER as string);

    return new nia.Client({
      client_id: process.env.NIA_CLIENT_ID as string,
      client_secret: process.env.NIA_CLIENT_SECRET as string,
      redirect_uris: ["http://localhost:3000/auth/callback"],
      response_types: ["code"],
    });
  },
};

Register the client as a Passport strategy named oidc. usePKCE: "S256" tells openid-client to generate and verify the code verifier/challenge for you.

src/auth/oidc.strategy.ts
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy, Client, TokenSet } from "openid-client";
import { Inject } from "@nestjs/common";
import { NIA_CLIENT } from "./nia-client.provider";

@Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, "oidc") {
  constructor(@Inject(NIA_CLIENT) client: Client) {
    super({
      client,
      params: { scope: "openid email profile" },
      usePKCE: "S256",
    });
  }

  async validate(tokenset: TokenSet) {
    return {
      claims: tokenset.claims(),
      tokens: {
        access_token: tokenset.access_token,
        refresh_token: tokenset.refresh_token,
      },
    };
  }
}
Add offline_access to the scope only if you need refresh tokens (see step 13).

05Environment Variables

Never hard-code secrets. Keep them in a .env file (git-ignored) and load them via @nestjs/config or dotenv at bootstrap.

.env
NIA_ISSUER=https://dev-api.niaauth.com
NIA_CLIENT_ID=your-client-id
NIA_CLIENT_SECRET=your-client-secret
SESSION_SECRET=replace-with-a-long-random-value

06Register OAuth Client

In the NIA dashboard, create the client your NestJS app will authenticate as:

  • Open Dashboard → Clients → New client
  • Client type: Web (confidential)
  • Allowed scopes: openid email profile (add offline_access for refresh)
  • Add your redirect URI (next step)
  • Copy the client_id and client_secret — the secret is shown once

07Redirect URI

NIA matches redirect URIs exactly — scheme, host, port, and path must be identical, with no trailing-slash difference. Register one URI per environment.

redirect URIs
# Development
http://localhost:3000/auth/callback

# Production
https://app.example.com/auth/callback
A mismatch is the #1 integration error. http://localhost:3000/auth/callback/ (trailing slash) will be rejected.

08Issuer

The issuer is the single URL every OIDC library needs. Its discovery document advertises all endpoints below.

issuer & endpoints
Issuer:        https://dev-api.niaauth.com
Discovery:     https://dev-api.niaauth.com/.well-known/openid-configuration
Authorization: https://dev-api.niaauth.com/oauth/authorize
Token:         https://dev-api.niaauth.com/oauth/token
UserInfo:      https://dev-api.niaauth.com/oauth/userinfo
JWKS:          https://dev-api.niaauth.com/oauth/jwks.json

09Authorization Flow

Wire up an AuthController with two guarded routes. AuthGuard("oidc") on /auth/login redirects the browser to NIA; the same guard on /auth/callback runs the exchange and populates req.user.

src/auth/auth.controller.ts
import { Controller, Get, Req, UseGuards } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { Request } from "express";

@Controller("auth")
export class AuthController {
  @Get("login")
  @UseGuards(AuthGuard("oidc"))
  login() {
    // Passport redirects to NIA's authorize endpoint; nothing to return.
  }

  @Get("callback")
  @UseGuards(AuthGuard("oidc"))
  callback(@Req() req: Request) {
    return req.user;
  }
}
src/auth/auth.module.ts
import { Module } from "@nestjs/common";
import { PassportModule } from "@nestjs/passport";
import { AuthController } from "./auth.controller";
import { OidcStrategy } from "./oidc.strategy";
import { niaClientProvider } from "./nia-client.provider";

@Module({
  imports: [PassportModule.register({ session: true })],
  controllers: [AuthController],
  providers: [niaClientProvider, OidcStrategy],
})
export class AuthModule {}

10Token Exchange

You do not call the token endpoint yourself. AuthGuard("oidc") on /auth/callback triggers Passport, which hands off to openid-client: it verifies state, sends the PKCE verifier, exchanges the code, and validates the ID token — all before your callback handler runs.

src/auth/auth.controller.ts (excerpt)
@Get("callback")
@UseGuards(AuthGuard("oidc"))
callback(@Req() req: Request) {
  // req.user === OidcStrategy#validate()'s return value:
  // { claims, tokens: { access_token, refresh_token } }
  const session = req.session as any;
  session.user = req.user;
  return { ok: true };
}

11JWKS Validation

NIA signs ID tokens with RS256. openid-client fetches the public keys from the JWKS endpoint (discovered automatically via Issuer.discover), caches them, and verifies the signature, iss, aud, exp, and nonce as part of the Passport strategy's callback handling. You do not verify manually.

verify (illustrative)
async validate(tokenset: TokenSet) {
  const claims = tokenset.claims();

  // signature (RS256, via JWKS), exp and nonce are already verified
  // by openid-client before validate() is ever called.
  if (claims.iss !== process.env.NIA_ISSUER) throw new Error("bad issuer");
  if (claims.aud !== process.env.NIA_CLIENT_ID) throw new Error("bad audience");

  return { claims, tokens: { access_token: tokenset.access_token, refresh_token: tokenset.refresh_token } };
}
Keys rotate. Because openid-client refetches JWKS by kid, rotation needs no code change on your side.

12Session Management

Enable express-session and Passport's session middleware in main.ts, then guard routes with a small SessionAuthGuard.

import { NestFactory } from "@nestjs/core";
import * as session from "express-session";
import * as passport from "passport";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.use(
    session({
      secret: process.env.SESSION_SECRET as string,
      resave: false,
      saveUninitialized: false,
      cookie: { httpOnly: true, sameSite: "lax" },
    }),
  );
  app.use(passport.initialize());
  app.use(passport.session());

  passport.serializeUser((user, done) => done(null, user));
  passport.deserializeUser((user: any, done) => done(null, user));

  await app.listen(3000);
}
bootstrap();

13Refresh Tokens

If you requested offline_access, NIA returns a refresh token. Call client.refresh() to exchange it for a fresh access token. NIA rotates refresh tokens, so always persist the new one.

src/auth/refresh.ts
import { Client } from "openid-client";

export async function refreshSession(client: Client, session: any) {
  const refreshToken = session.user?.tokens?.refresh_token;
  if (!refreshToken) return null;

  const tokenset = await client.refresh(refreshToken);
  // NIA rotates the refresh token — persist the replacement.
  session.user.tokens.access_token = tokenset.access_token;
  session.user.tokens.refresh_token = tokenset.refresh_token ?? refreshToken;
  return tokenset;
}

14Logout

Destroy the Express session and send the user to NIA's end-session endpoint so they are signed out of NIA too. Register your post_logout_redirect_uri on the client.

src/auth/auth.controller.ts (excerpt)
import { Get, Inject, Req, Res, UseGuards } from "@nestjs/common";
import { Request, Response } from "express";
import { Client } from "openid-client";
import { NIA_CLIENT } from "./nia-client.provider";

@Get("logout")
@UseGuards(SessionAuthGuard)
logout(@Req() req: Request, @Res() res: Response, @Inject(NIA_CLIENT) client: Client) {
  const idToken = (req.session as any)?.user?.tokens?.id_token;

  req.session.destroy(() => {
    const url = client.endSessionUrl({
      id_token_hint: idToken,
      post_logout_redirect_uri: "http://localhost:3000/",
    });
    res.redirect(url);
  });
}

15Deployment

Before going live:

  • Register the production redirect URI (HTTPS) on the client
  • Set all env vars in your host — never commit .env
  • Set redirect_uris in the client provider to the production callback URL
  • Enable secure cookies: cookie.secure = true behind HTTPS/a trusted proxy
  • Use a persistent session store (Redis, DB) behind multiple instances — the default in-memory express-session store does not scale horizontally
src/main.ts (production excerpt)
app.set("trust proxy", 1);
app.use(
  session({
    secret: process.env.SESSION_SECRET as string,
    resave: false,
    saveUninitialized: false,
    store: redisStore, // e.g. connect-redis
    cookie: { httpOnly: true, secure: true, sameSite: "lax" },
  }),
);

16Troubleshooting

  • redirect_uri_mismatch — the URI must match the registered one exactly (no trailing slash, correct scheme/port).
  • invalid_client — wrong client_secret, or the client's token-auth method differs from what openid-client sends.
  • invalid_grant / PKCE failed — the session was lost between login and callback. Ensure express-session is registered before Passport and cookies use sameSite: "lax".
  • mismatching_state — same root cause: the session cookie isn't surviving the redirect. Check cookie domain/secure settings.
  • invalid_scope — the scope isn't in the client's allowed_scopes; add it in the dashboard.
  • ID-token signature — usually server clock skew; sync time via NTP.