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.
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@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.
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.
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,
},
};
}
}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.
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-value06Register 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:
openidemailprofile(addoffline_accessfor refresh) - Add your redirect URI (next step)
- Copy the
client_idandclient_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.
# Development
http://localhost:3000/auth/callback
# Production
https://app.example.com/auth/callbackhttp://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: 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.json09Authorization 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.
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;
}
}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.
@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.
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 } };
}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.
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.
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_urisin the client provider to the production callback URL - Enable secure cookies:
cookie.secure = truebehind HTTPS/a trusted proxy - Use a persistent session store (Redis, DB) behind multiple instances — the default in-memory
express-sessionstore does not scale horizontally
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 whatopenid-clientsends. - invalid_grant / PKCE failed — the session was lost between login and callback. Ensure
express-sessionis registered before Passport and cookies usesameSite: "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.