From 8f236e85d0953daa70deef8c4952f26248e789f4 Mon Sep 17 00:00:00 2001 From: webdevelopersrinu Date: Sat, 11 Jul 2026 11:12:09 +0530 Subject: [PATCH 1/2] Add NestJS SDK (@thunderid/nestjs) --- README.md | 1 + packages/nestjs/.editorconfig | 1 + packages/nestjs/.gitignore | 141 +++++++++++++++ packages/nestjs/.prettierignore | 4 + packages/nestjs/README.md | 101 +++++++++++ packages/nestjs/eslint.config.js | 27 +++ packages/nestjs/package.json | 75 ++++++++ packages/nestjs/prettier.config.js | 21 +++ packages/nestjs/rolldown.config.js | 61 +++++++ packages/nestjs/src/ThunderIDModule.ts | 55 ++++++ packages/nestjs/src/ThunderIDService.ts | 148 +++++++++++++++ .../src/__tests__/ThunderIDGuard.test.ts | 65 +++++++ .../nestjs/src/constants/InjectionTokens.ts | 20 +++ packages/nestjs/src/decorators/CurrentUser.ts | 34 ++++ packages/nestjs/src/guards/ThunderIDGuard.ts | 55 ++++++ packages/nestjs/src/index.ts | 38 ++++ packages/nestjs/src/models/config.ts | 41 +++++ packages/nestjs/tsconfig.eslint.json | 4 + packages/nestjs/tsconfig.json | 35 ++++ packages/nestjs/tsconfig.lib.json | 22 +++ packages/nestjs/tsconfig.spec.json | 18 ++ packages/nestjs/vitest.config.ts | 23 +++ pnpm-lock.yaml | 170 ++++++++++++++++++ 23 files changed, 1160 insertions(+) create mode 100644 packages/nestjs/.editorconfig create mode 100644 packages/nestjs/.gitignore create mode 100644 packages/nestjs/.prettierignore create mode 100644 packages/nestjs/README.md create mode 100644 packages/nestjs/eslint.config.js create mode 100644 packages/nestjs/package.json create mode 100644 packages/nestjs/prettier.config.js create mode 100644 packages/nestjs/rolldown.config.js create mode 100644 packages/nestjs/src/ThunderIDModule.ts create mode 100644 packages/nestjs/src/ThunderIDService.ts create mode 100644 packages/nestjs/src/__tests__/ThunderIDGuard.test.ts create mode 100644 packages/nestjs/src/constants/InjectionTokens.ts create mode 100644 packages/nestjs/src/decorators/CurrentUser.ts create mode 100644 packages/nestjs/src/guards/ThunderIDGuard.ts create mode 100644 packages/nestjs/src/index.ts create mode 100644 packages/nestjs/src/models/config.ts create mode 100644 packages/nestjs/tsconfig.eslint.json create mode 100644 packages/nestjs/tsconfig.json create mode 100644 packages/nestjs/tsconfig.lib.json create mode 100644 packages/nestjs/tsconfig.spec.json create mode 100644 packages/nestjs/vitest.config.ts diff --git a/README.md b/README.md index 122bc4e..bf778f5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ JavaScript SDKs for ThunderID. Provides authentication and user management for b | [`@thunderid/vue`](packages/vue) | ![npm](https://img.shields.io/npm/v/@thunderid/vue) | Vue SDK | | [`@thunderid/nuxt`](packages/nuxt) | ![npm](https://img.shields.io/npm/v/@thunderid/nuxt) | Nuxt SDK | | [`@thunderid/express`](packages/express) | ![npm](https://img.shields.io/npm/v/@thunderid/express) | Express.js SDK | +| [`@thunderid/nestjs`](packages/nestjs) | ![npm](https://img.shields.io/npm/v/@thunderid/nestjs) | NestJS SDK | | [`@thunderid/tanstack-router`](packages/tanstack-router) | ![npm](https://img.shields.io/npm/v/@thunderid/tanstack-router) | TanStack Router integration | ## License diff --git a/packages/nestjs/.editorconfig b/packages/nestjs/.editorconfig new file mode 100644 index 0000000..54a1611 --- /dev/null +++ b/packages/nestjs/.editorconfig @@ -0,0 +1 @@ +../../.editorconfig diff --git a/packages/nestjs/.gitignore b/packages/nestjs/.gitignore new file mode 100644 index 0000000..0ccb8df --- /dev/null +++ b/packages/nestjs/.gitignore @@ -0,0 +1,141 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ diff --git a/packages/nestjs/.prettierignore b/packages/nestjs/.prettierignore new file mode 100644 index 0000000..99b0b51 --- /dev/null +++ b/packages/nestjs/.prettierignore @@ -0,0 +1,4 @@ +/dist +/build +/node_modules +/coverage diff --git a/packages/nestjs/README.md b/packages/nestjs/README.md new file mode 100644 index 0000000..c56290a --- /dev/null +++ b/packages/nestjs/README.md @@ -0,0 +1,101 @@ +# @thunderid/nestjs + +NestJS SDK for [ThunderID](https://github.com/thunder-id/thunderid). Built on top of [`@thunderid/express`](../express) +— works with NestJS applications running on the default Express platform. + +## Installation + +```bash +npm install @thunderid/nestjs cookie-parser +``` + +## Setup + +Register `cookie-parser` and the ThunderID module: + +```ts +// main.ts +import cookieParser from 'cookie-parser'; + +const app = await NestFactory.create(AppModule); +app.use(cookieParser()); +``` + +```ts +// app.module.ts +import {ThunderIDModule} from '@thunderid/nestjs'; + +@Module({ + imports: [ + ThunderIDModule.forRoot({ + clientId: process.env.THUNDERID_CLIENT_ID, + baseUrl: process.env.THUNDERID_BASE_URL, + }), + ], +}) +export class AppModule {} +``` + +The module is global — `ThunderIDService` and `ThunderIDGuard` are injectable everywhere. + +## Sign-in / sign-out routes + +```ts +import {Request, Response} from 'express'; +import {ThunderIDService} from '@thunderid/nestjs'; + +@Controller() +export class AuthController { + constructor(private readonly thunderID: ThunderIDService) {} + + @Get('login') + async login(@Req() req: Request, @Res() res: Response) { + const tokens = await this.thunderID.signIn(req, res); + if (tokens.accessToken || tokens.idToken) { + res.redirect('/'); + } + } + + @Get('logout') + async logout(@Req() req: Request, @Res() res: Response) { + if (this.thunderID.isSignOutSuccess(req)) { + res.redirect('/'); + return; + } + await this.thunderID.signOut(req, res); + } +} +``` + +## Protecting routes + +```ts +import {CurrentUser, ThunderIDGuard, User} from '@thunderid/nestjs'; + +@Controller() +export class ProfileController { + @UseGuards(ThunderIDGuard) + @Get('profile') + profile(@CurrentUser() user: User) { + return user; + } +} +``` + +`ThunderIDGuard` returns `401 Unauthorized` for requests without a valid session. `@CurrentUser()` resolves the +authenticated user attached by the guard. + +## API + +| Export | Description | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `ThunderIDModule.forRoot(config)` | Global module; accepts `ThunderIDNestConfig` (same shape as the Node SDK config) | +| `ThunderIDService` | `signIn(req, res)`, `signOut(req, res)`, `isSignOutSuccess(req)`, `isSignedIn(req)`, `getUser(req)`, `getClient()` | +| `ThunderIDGuard` | Route guard that blocks unauthenticated requests | +| `@CurrentUser()` | Param decorator resolving the authenticated `User` (requires `ThunderIDGuard`) | + +Everything from `@thunderid/express` (and transitively `@thunderid/node` / `@thunderid/javascript`) is re-exported. + +## License + +Apache-2.0 diff --git a/packages/nestjs/eslint.config.js b/packages/nestjs/eslint.config.js new file mode 100644 index 0000000..7de9261 --- /dev/null +++ b/packages/nestjs/eslint.config.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import thunderIdPlugin from '@thunderid/eslint-plugin'; + +export default [ + { + ignores: ['dist/**', 'build/**', 'node_modules/**', 'coverage/**'], + }, + ...thunderIdPlugin.configs.typescript, + ...thunderIdPlugin.configs.vitest, +]; diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json new file mode 100644 index 0000000..321115f --- /dev/null +++ b/packages/nestjs/package.json @@ -0,0 +1,75 @@ +{ + "name": "@thunderid/nestjs", + "version": "0.1.0", + "description": "NestJS SDK for ThunderID", + "keywords": [ + "thunderid", + "nestjs", + "server" + ], + "homepage": "https://github.com/thunder-id/javascript-sdks/tree/main/packages/nestjs#readme", + "bugs": { + "url": "https://github.com/thunder-id/thunderid/issues" + }, + "author": "WSO2", + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "commonjs": "dist/cjs/index.cjs", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/cjs/index.cjs" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/thunder-id/javascript-sdks", + "directory": "packages/nestjs" + }, + "scripts": { + "build": "pnpm clean:dist && rolldown -c rolldown.config.js && tsc -p tsconfig.lib.json --emitDeclarationOnly --outDir dist", + "clean": "pnpm clean:node_modules && pnpm clean:dist", + "clean:dist": "rimraf dist", + "clean:node_modules": "rimraf node_modules", + "format:check": "prettier --check --cache .", + "format:fix": "prettier --write --cache .", + "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.cjs,.mjs", + "lint:fix": "eslint . --fix --ext .js,.jsx,.ts,.tsx,.cjs,.mjs", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.lib.json" + }, + "devDependencies": { + "@nestjs/common": "^11.0.0", + "@thunderid/eslint-plugin": "catalog:", + "@thunderid/prettier-config": "catalog:", + "@types/cookie-parser": "^1.4.8", + "@types/express": "^5.0.6", + "@types/node": "22.15.3", + "eslint": "catalog:", + "prettier": "catalog:", + "reflect-metadata": "^0.2.2", + "rimraf": "catalog:", + "rolldown": "catalog:", + "rxjs": "^7.8.1", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "dependencies": { + "@thunderid/express": "workspace:^" + }, + "peerDependencies": { + "@nestjs/common": ">=10.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/nestjs/prettier.config.js b/packages/nestjs/prettier.config.js new file mode 100644 index 0000000..7e978e9 --- /dev/null +++ b/packages/nestjs/prettier.config.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import config from '@thunderid/prettier-config'; + +export default config; diff --git a/packages/nestjs/rolldown.config.js b/packages/nestjs/rolldown.config.js new file mode 100644 index 0000000..fb38ec1 --- /dev/null +++ b/packages/nestjs/rolldown.config.js @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {readFileSync} from 'fs'; +import {join} from 'path'; +import {defineConfig} from 'rolldown'; + +const pkg = JSON.parse(readFileSync('./package.json', 'utf8')); + +const external = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})]; + +const commonOptions = { + input: [join('src', 'index.ts')], + preserveModules: true, + external, + platform: 'node', + target: 'es2020', + sourcemap: true, + transform: { + decorator: { + legacy: true, + }, + }, +}; + +export default defineConfig([ + // ESM build + { + ...commonOptions, + output: { + dir: 'dist', + format: 'esm', + preserveModulesRoot: 'src', + }, + }, + // CommonJS build + { + ...commonOptions, + output: { + dir: join('dist', 'cjs'), + entryFileNames: '[name].cjs', + format: 'cjs', + preserveModulesRoot: 'src', + }, + }, +]); diff --git a/packages/nestjs/src/ThunderIDModule.ts b/packages/nestjs/src/ThunderIDModule.ts new file mode 100644 index 0000000..fe522c5 --- /dev/null +++ b/packages/nestjs/src/ThunderIDModule.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {DynamicModule, Module} from '@nestjs/common'; +import {THUNDERID_CONFIG} from './constants/InjectionTokens'; +import ThunderIDGuard from './guards/ThunderIDGuard'; +import {ThunderIDNestConfig} from './models/config'; +import ThunderIDService from './ThunderIDService'; + +/** + * ThunderID module for NestJS. Register once in the root module: + * + * ```ts + * @Module({ + * imports: [ + * ThunderIDModule.forRoot({ + * clientId: process.env.THUNDERID_CLIENT_ID, + * baseUrl: process.env.THUNDERID_BASE_URL, + * }), + * ], + * }) + * export class AppModule {} + * ``` + * + * The module is global — `ThunderIDService` and `ThunderIDGuard` are + * injectable everywhere without re-importing. + */ +@Module({}) +class ThunderIDModule { + public static forRoot(config: ThunderIDNestConfig): DynamicModule { + return { + exports: [ThunderIDService, ThunderIDGuard], + global: true, + module: ThunderIDModule, + providers: [{provide: THUNDERID_CONFIG, useValue: config}, ThunderIDService, ThunderIDGuard], + }; + } +} + +export default ThunderIDModule; diff --git a/packages/nestjs/src/ThunderIDService.ts b/packages/nestjs/src/ThunderIDService.ts new file mode 100644 index 0000000..86c8c00 --- /dev/null +++ b/packages/nestjs/src/ThunderIDService.ts @@ -0,0 +1,148 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {Inject, Injectable} from '@nestjs/common'; +import { + SESSION_COOKIE_NAME, + ThunderIDExpressClient, + ThunderIDRuntimeError, + TokenResponse, + User, +} from '@thunderid/express'; +import type express from 'express'; +import {THUNDERID_CONFIG} from './constants/InjectionTokens'; +import {ThunderIDNestConfig} from './models/config'; + +/** + * Injectable service exposing ThunderID authentication to NestJS applications. + * + * Register it once with `ThunderIDModule.forRoot(config)` and inject it into + * controllers: + * + * ```ts + * @Controller() + * export class AuthController { + * constructor(private readonly thunderID: ThunderIDService) {} + * + * @Get('login') + * async login(@Req() req: Request, @Res() res: Response) { + * const tokens = await this.thunderID.signIn(req, res); + * if (tokens.accessToken || tokens.idToken) res.redirect('/'); + * } + * } + * ``` + * + * Requires `cookie-parser` middleware to be registered on the application. + */ +@Injectable() +class ThunderIDService { + private readonly client: ThunderIDExpressClient = new ThunderIDExpressClient(); + private initPromise: Promise | undefined; + + public constructor(@Inject(THUNDERID_CONFIG) private readonly config: ThunderIDNestConfig) {} + + /** The underlying `ThunderIDExpressClient` for advanced use cases. */ + public getClient(): ThunderIDExpressClient { + return this.client; + } + + private static getSessionId(req: express.Request): string | undefined { + const cookies = req.cookies as Record | undefined; + return cookies?.[SESSION_COOKIE_NAME]; + } + + private ensureInitialized(req: express.Request): Promise { + if (this.initPromise === undefined) { + const origin = `${req.protocol}://${req.get('host')}`; + this.initPromise = this.client.initialize({ + ...this.config, + afterSignInUrl: this.config.afterSignInUrl ?? `${origin}/login`, + afterSignOutUrl: this.config.afterSignOutUrl ?? `${origin}/logout`, + }); + } + return this.initPromise; + } + + /** + * Handles the sign-in path. + * + * - If the request has no `?code` query param, initiates the OAuth 2.0 + * redirect (the response is sent by the SDK — do not write to it after). + * - If the request has `?code`, exchanges the authorization code for tokens, + * sets the session cookie, and returns the token response. + */ + public async signIn( + req: express.Request, + res: express.Response, + signInOptions?: Record, + ): Promise { + await this.ensureInitialized(req); + return this.client.signIn(req, res, (): void => undefined, signInOptions ?? this.config.signInOptions); + } + + /** + * Handles the sign-out path: clears the session cookie and redirects the + * response to the identity provider's end-session endpoint. + * + * Check {@link isSignOutSuccess} first to detect the identity provider's + * redirect back after a completed sign-out. + * + * @throws ThunderIDRuntimeError when the request has no session cookie. + */ + public async signOut(req: express.Request, res: express.Response): Promise { + await this.ensureInitialized(req); + + const sessionId: string | undefined = ThunderIDService.getSessionId(req); + + if (!sessionId) { + throw new ThunderIDRuntimeError('No cookie found in the request', 'NESTJS-SERVICE-SO-NF01', 'nestjs'); + } + + const signOutURL: string = await this.client.signOut(sessionId); + + if (signOutURL) { + res.cookie(SESSION_COOKIE_NAME, '', {maxAge: 0}); + res.redirect(signOutURL); + } + } + + /** Whether the request is the identity provider's redirect back after a completed sign-out. */ + public isSignOutSuccess(req: express.Request): boolean { + return req.query['state'] === 'sign_out_success'; + } + + /** Whether the request carries a valid ThunderID session. */ + public async isSignedIn(req: express.Request): Promise { + await this.ensureInitialized(req); + const sessionId: string | undefined = ThunderIDService.getSessionId(req); + + if (!sessionId) { + return false; + } + + return (await this.client.isSignedIn(sessionId)) ?? false; + } + + /** Returns the authenticated user for the request's session, or `undefined`. */ + public async getUser(req: express.Request): Promise { + await this.ensureInitialized(req); + return this.client.getUserFromRequest(req); + } +} + +export default ThunderIDService; diff --git a/packages/nestjs/src/__tests__/ThunderIDGuard.test.ts b/packages/nestjs/src/__tests__/ThunderIDGuard.test.ts new file mode 100644 index 0000000..f2d5e66 --- /dev/null +++ b/packages/nestjs/src/__tests__/ThunderIDGuard.test.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import 'reflect-metadata'; +import {ExecutionContext, UnauthorizedException} from '@nestjs/common'; +import {User} from '@thunderid/express'; +import {describe, expect, it, vi} from 'vitest'; +import ThunderIDGuard from '../guards/ThunderIDGuard'; +import {ThunderIDNestConfig, ThunderIDRequest} from '../models/config'; +import ThunderIDModule from '../ThunderIDModule'; +import ThunderIDService from '../ThunderIDService'; + +const contextFor = (req: Partial): ExecutionContext => + ({ + switchToHttp: () => ({getRequest: () => req}), + }) as unknown as ExecutionContext; + +describe('ThunderIDGuard', () => { + it('rejects unauthenticated requests with UnauthorizedException', async () => { + const service = {getUser: vi.fn(), isSignedIn: vi.fn().mockResolvedValue(false)} as unknown as ThunderIDService; + const guard = new ThunderIDGuard(service); + + await expect(guard.canActivate(contextFor({}))).rejects.toThrow(UnauthorizedException); + }); + + it('allows authenticated requests and attaches the user', async () => { + const user = {id: 'user-1'} as unknown as User; + const service = { + getUser: vi.fn().mockResolvedValue(user), + isSignedIn: vi.fn().mockResolvedValue(true), + } as unknown as ThunderIDService; + const guard = new ThunderIDGuard(service); + const req: Partial = {}; + + await expect(guard.canActivate(contextFor(req))).resolves.toBe(true); + expect(req.thunderIDUser).toBe(user); + }); +}); + +describe('ThunderIDModule', () => { + it('forRoot registers the config provider, service, and guard globally', () => { + const config = {clientId: 'client-1'} as unknown as ThunderIDNestConfig; + const dynamicModule = ThunderIDModule.forRoot(config); + + expect(dynamicModule.global).toBe(true); + expect(dynamicModule.providers).toContainEqual({provide: 'THUNDERID_CONFIG', useValue: config}); + expect(dynamicModule.providers).toContain(ThunderIDService); + expect(dynamicModule.providers).toContain(ThunderIDGuard); + }); +}); diff --git a/packages/nestjs/src/constants/InjectionTokens.ts b/packages/nestjs/src/constants/InjectionTokens.ts new file mode 100644 index 0000000..4321820 --- /dev/null +++ b/packages/nestjs/src/constants/InjectionTokens.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** Injection token for the ThunderID configuration passed to `ThunderIDModule.forRoot()`. */ +export const THUNDERID_CONFIG = 'THUNDERID_CONFIG'; diff --git a/packages/nestjs/src/decorators/CurrentUser.ts b/packages/nestjs/src/decorators/CurrentUser.ts new file mode 100644 index 0000000..244b6fa --- /dev/null +++ b/packages/nestjs/src/decorators/CurrentUser.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {ExecutionContext, createParamDecorator} from '@nestjs/common'; +import {User} from '@thunderid/express'; +import {ThunderIDRequest} from '../models/config'; + +/** + * Param decorator that resolves the authenticated ThunderID user. + * + * Requires `ThunderIDGuard` on the route — the guard attaches the user to the + * request; without it this resolves to `undefined`. + */ +const CurrentUser = createParamDecorator( + (data: unknown, context: ExecutionContext): User | undefined => + context.switchToHttp().getRequest().thunderIDUser, +); + +export default CurrentUser; diff --git a/packages/nestjs/src/guards/ThunderIDGuard.ts b/packages/nestjs/src/guards/ThunderIDGuard.ts new file mode 100644 index 0000000..08edcfc --- /dev/null +++ b/packages/nestjs/src/guards/ThunderIDGuard.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException} from '@nestjs/common'; +import {ThunderIDRequest} from '../models/config'; +import ThunderIDService from '../ThunderIDService'; + +/** + * Guard that blocks unauthenticated requests — the NestJS equivalent of the + * Express SDK's `protect()` middleware. + * + * On success, the authenticated user is attached to the request and can be + * read with the `@CurrentUser()` param decorator. + * + * ```ts + * @UseGuards(ThunderIDGuard) + * @Get('profile') + * profile(@CurrentUser() user: User) { + * return user; + * } + * ``` + */ +@Injectable() +class ThunderIDGuard implements CanActivate { + public constructor(@Inject(ThunderIDService) private readonly thunderID: ThunderIDService) {} + + public async canActivate(context: ExecutionContext): Promise { + const req: ThunderIDRequest = context.switchToHttp().getRequest(); + + if (!(await this.thunderID.isSignedIn(req))) { + throw new UnauthorizedException(); + } + + req.thunderIDUser = await this.thunderID.getUser(req); + + return true; + } +} + +export default ThunderIDGuard; diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts new file mode 100644 index 0000000..8054b35 --- /dev/null +++ b/packages/nestjs/src/index.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Module +export {default as ThunderIDModule} from './ThunderIDModule'; + +// Service +export {default as ThunderIDService} from './ThunderIDService'; + +// Guards +export {default as ThunderIDGuard} from './guards/ThunderIDGuard'; + +// Decorators +export {default as CurrentUser} from './decorators/CurrentUser'; + +// Models +export type {ThunderIDNestConfig, ThunderIDRequest} from './models/config'; + +// Constants +export {THUNDERID_CONFIG} from './constants/InjectionTokens'; + +// Re-export everything from the Express SDK (includes the Node and JavaScript SDK re-exports) +export * from '@thunderid/express'; diff --git a/packages/nestjs/src/models/config.ts b/packages/nestjs/src/models/config.ts new file mode 100644 index 0000000..2bbb880 --- /dev/null +++ b/packages/nestjs/src/models/config.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {ThunderIDNodeConfig, User} from '@thunderid/express'; +import type express from 'express'; + +/** + * Configuration type for the ThunderID NestJS SDK. + * + * `afterSignInUrl` and `afterSignOutUrl` are optional. When omitted, the SDK + * infers them from the first incoming request's origin combined with + * `/login` and `/logout` respectively. + * + * Unlike the Express SDK, response handling hooks (`onSignIn`, `onError`, …) + * are not part of the config — in NestJS the controller that calls + * `ThunderIDService` decides how to respond. + */ +export type ThunderIDNestConfig = ThunderIDNodeConfig; + +/** + * Express request extended with the ThunderID user attached by `ThunderIDGuard`. + */ +export interface ThunderIDRequest extends express.Request { + /** Authenticated user attached by `ThunderIDGuard`; read it with `@CurrentUser()`. */ + thunderIDUser?: User; +} diff --git a/packages/nestjs/tsconfig.eslint.json b/packages/nestjs/tsconfig.eslint.json new file mode 100644 index 0000000..d5eefbb --- /dev/null +++ b/packages/nestjs/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["**/.*.js", "**/.*.cjs", "**/.*.ts", "**/*.js", "**/*.cjs", "**/*.ts"] +} diff --git a/packages/nestjs/tsconfig.json b/packages/nestjs/tsconfig.json new file mode 100644 index 0000000..9b3b3ad --- /dev/null +++ b/packages/nestjs/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "declaration": false, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "importHelpers": true, + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "node", + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "sourceMap": true, + "target": "ESNext", + "forceConsistentCasingInFileNames": true, + "strict": false, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "strictNullChecks": true + }, + "exclude": ["node_modules", "tmp", "dist"], + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/nestjs/tsconfig.lib.json b/packages/nestjs/tsconfig.lib.json new file mode 100644 index 0000000..209682b --- /dev/null +++ b/packages/nestjs/tsconfig.lib.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "declarationDir": "dist", + "types": ["node", "express", "cookie-parser"] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx", + "src/__tests__/**" + ], + "include": ["src/**/*.js", "src/**/*.ts", "types/**/*.d.ts"] +} diff --git a/packages/nestjs/tsconfig.spec.json b/packages/nestjs/tsconfig.spec.json new file mode 100644 index 0000000..d39f597 --- /dev/null +++ b/packages/nestjs/tsconfig.spec.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "test-configs", + "vitest.config.ts", + "jest.config.js", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.test.js", + "**/*.spec.js", + "**/*.d.ts" + ] +} diff --git a/packages/nestjs/vitest.config.ts b/packages/nestjs/vitest.config.ts new file mode 100644 index 0000000..87215b0 --- /dev/null +++ b/packages/nestjs/vitest.config.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {defineConfig} from 'vitest/config'; + +export default defineConfig({ + test: {}, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebd0696..3079a70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -490,6 +490,55 @@ importers: specifier: 'catalog:' version: 4.1.8(@types/node@24.7.2)(@vitest/browser-playwright@4.1.8)(jsdom@27.0.1)(vite@7.3.5(@types/node@24.7.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + packages/nestjs: + dependencies: + '@thunderid/express': + specifier: workspace:^ + version: link:../express + devDependencies: + '@nestjs/common': + specifier: ^11.0.0 + version: 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@thunderid/eslint-plugin': + specifier: 'catalog:' + version: 0.0.2(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)(vitest@4.1.8)(vue-eslint-parser@10.4.1(eslint@9.39.4(jiti@2.7.0))) + '@thunderid/prettier-config': + specifier: 'catalog:' + version: 0.0.2 + '@types/cookie-parser': + specifier: ^1.4.8 + version: 1.4.10(@types/express@5.0.6) + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: 22.15.3 + version: 22.15.3 + eslint: + specifier: 'catalog:' + version: 9.39.4(jiti@2.7.0) + prettier: + specifier: 'catalog:' + version: 3.6.2 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rimraf: + specifier: 'catalog:' + version: 6.1.3 + rolldown: + specifier: 'catalog:' + version: 1.0.0-beta.45(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.1) + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.8(@types/node@22.15.3)(@vitest/browser-playwright@4.1.8)(jsdom@27.0.1)(vite@7.3.5(@types/node@22.15.3)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + packages/nextjs: dependencies: '@thunderid/node': @@ -1167,6 +1216,9 @@ packages: commander: optional: true + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@clack/core@1.4.2': resolution: {integrity: sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==} engines: {node: '>= 20.12.0'} @@ -2007,6 +2059,10 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + '@mapbox/node-pre-gyp@2.0.3': resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} engines: {node: '>=18'} @@ -2018,6 +2074,19 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nestjs/common@11.1.28': + resolution: {integrity: sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + '@next/env@15.5.18': resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} @@ -3125,6 +3194,13 @@ packages: '@thunderid/prettier-config@0.0.2': resolution: {integrity: sha512-O9FDBMx82G0ACdzlhNzIQTJ5QNJhe8NTIfTSo9wstGT0Am0XeUE993J9PzIbc+tlnVNrzyoMmC3ck8CYfUq4MQ==} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@turbo/darwin-64@2.10.2': resolution: {integrity: sha512-wBM3ObqOWnKUDmg7QfUFDkDHPFUAJmrYlYqmEM8jMPAPA/I6wRJIbWimeQUqhOiQ8xPKhzyWM+xaiUP0wz8FEQ==} cpu: [x64] @@ -4779,6 +4855,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -5282,6 +5362,10 @@ packages: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -5400,6 +5484,10 @@ packages: resolution: {integrity: sha512-kfz4C0OrC6IpaVMtYDJtf6PFjurxe9NBBoDAh/o2p587INryFOO4DQ9OetbCdDrWFt1m1CJKvYrzkGsuPHw8nQ==} hasBin: true + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + local-pkg@1.2.1: resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} engines: {node: '>=14'} @@ -6258,6 +6346,9 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -6358,6 +6449,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -6639,6 +6733,10 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + structured-clone-es@1.0.0: resolution: {integrity: sha512-FL8EeKFFyNQv5cMnXI31CIMCsFarSVI2bF0U0ImeNE3g/F1IvJQyqzOXxPBRXiwQfyBTlbNe88jh1jFW0O/jiQ==} @@ -6763,6 +6861,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -6852,6 +6954,14 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} @@ -7696,6 +7806,8 @@ snapshots: cac: 6.7.14 citty: 0.2.2 + '@borewit/text-codec@0.2.2': {} + '@clack/core@1.4.2': dependencies: fast-wrap-ansi: 0.2.2 @@ -8290,6 +8402,8 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} + '@lukeed/csprng@1.1.0': {} + '@mapbox/node-pre-gyp@2.0.3': dependencies: consola: 3.4.2 @@ -8317,6 +8431,18 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + '@next/env@15.5.18': {} '@next/swc-darwin-arm64@15.5.18': @@ -9549,6 +9675,15 @@ snapshots: dependencies: tslib: 2.8.1 + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@turbo/darwin-64@2.10.2': optional: true @@ -11655,6 +11790,15 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -12163,6 +12307,8 @@ snapshots: isexe@4.0.0: {} + iterare@1.2.1: {} + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -12307,6 +12453,8 @@ snapshots: transitivePeerDependencies: - srvx + load-esm@1.0.3: {} + local-pkg@1.2.1: dependencies: mlly: 1.8.2 @@ -13613,6 +13761,8 @@ snapshots: dependencies: redis-errors: 1.2.0 + reflect-metadata@0.2.2: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -13799,6 +13949,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -14173,6 +14327,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + structured-clone-es@1.0.0: {} structured-clone-es@2.0.0: {} @@ -14290,6 +14448,12 @@ snapshots: toidentifier@1.0.1: {} + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + totalist@3.0.1: {} tough-cookie@6.0.1: @@ -14390,6 +14554,12 @@ snapshots: ufo@1.6.4: {} + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + ultrahtml@1.6.0: {} unbox-primitive@1.1.0: From df3e225795a7302e07b01ff319a121a20f2e9186 Mon Sep 17 00:00:00 2001 From: webdevelopersrinu Date: Sat, 11 Jul 2026 15:15:53 +0530 Subject: [PATCH 2/2] Address CodeRabbit review feedback - Reset cached init promise on failure so transient startup errors recover - Handle array/missing state query values in isSignOutSuccess (Express 5) - Replace Jest types and drop dead include entries in tsconfig.spec.json - Clarify sign-in redirect behavior in README and service docs --- packages/nestjs/README.md | 2 ++ packages/nestjs/src/ThunderIDService.ts | 22 ++++++++++++++----- .../src/__tests__/ThunderIDGuard.test.ts | 11 ++++++++++ packages/nestjs/tsconfig.spec.json | 13 ++--------- 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/packages/nestjs/README.md b/packages/nestjs/README.md index c56290a..daeb304 100644 --- a/packages/nestjs/README.md +++ b/packages/nestjs/README.md @@ -54,6 +54,8 @@ export class AuthController { if (tokens.accessToken || tokens.idToken) { res.redirect('/'); } + // If no tokens were returned, signIn has already redirected the + // response to the ThunderID sign-in page. } @Get('logout') diff --git a/packages/nestjs/src/ThunderIDService.ts b/packages/nestjs/src/ThunderIDService.ts index 86c8c00..02ada0f 100644 --- a/packages/nestjs/src/ThunderIDService.ts +++ b/packages/nestjs/src/ThunderIDService.ts @@ -43,6 +43,8 @@ import {ThunderIDNestConfig} from './models/config'; * async login(@Req() req: Request, @Res() res: Response) { * const tokens = await this.thunderID.signIn(req, res); * if (tokens.accessToken || tokens.idToken) res.redirect('/'); + * // If no tokens were returned, signIn has already redirected the + * // response to the ThunderID sign-in page. * } * } * ``` @@ -69,11 +71,18 @@ class ThunderIDService { private ensureInitialized(req: express.Request): Promise { if (this.initPromise === undefined) { const origin = `${req.protocol}://${req.get('host')}`; - this.initPromise = this.client.initialize({ - ...this.config, - afterSignInUrl: this.config.afterSignInUrl ?? `${origin}/login`, - afterSignOutUrl: this.config.afterSignOutUrl ?? `${origin}/logout`, - }); + this.initPromise = this.client + .initialize({ + ...this.config, + afterSignInUrl: this.config.afterSignInUrl ?? `${origin}/login`, + afterSignOutUrl: this.config.afterSignOutUrl ?? `${origin}/logout`, + }) + .catch((error: unknown) => { + // Clear the cached promise so a transient startup failure doesn't + // break the service until restart. + this.initPromise = undefined; + throw error; + }); } return this.initPromise; } @@ -123,7 +132,8 @@ class ThunderIDService { /** Whether the request is the identity provider's redirect back after a completed sign-out. */ public isSignOutSuccess(req: express.Request): boolean { - return req.query['state'] === 'sign_out_success'; + const state: unknown = req.query['state']; + return typeof state === 'string' && state === 'sign_out_success'; } /** Whether the request carries a valid ThunderID session. */ diff --git a/packages/nestjs/src/__tests__/ThunderIDGuard.test.ts b/packages/nestjs/src/__tests__/ThunderIDGuard.test.ts index f2d5e66..fc96baa 100644 --- a/packages/nestjs/src/__tests__/ThunderIDGuard.test.ts +++ b/packages/nestjs/src/__tests__/ThunderIDGuard.test.ts @@ -52,6 +52,17 @@ describe('ThunderIDGuard', () => { }); }); +describe('ThunderIDService.isSignOutSuccess', () => { + it('handles string, array, and missing state query values', () => { + const service = Object.create(ThunderIDService.prototype) as ThunderIDService; + const requestWithState = (state: unknown): ThunderIDRequest => ({query: {state}}) as unknown as ThunderIDRequest; + + expect(service.isSignOutSuccess(requestWithState('sign_out_success'))).toBe(true); + expect(service.isSignOutSuccess(requestWithState(['sign_out_success', 'other']))).toBe(false); + expect(service.isSignOutSuccess(requestWithState(undefined))).toBe(false); + }); +}); + describe('ThunderIDModule', () => { it('forRoot registers the config provider, service, and guard globally', () => { const config = {clientId: 'client-1'} as unknown as ThunderIDNestConfig; diff --git a/packages/nestjs/tsconfig.spec.json b/packages/nestjs/tsconfig.spec.json index d39f597..6fb2e99 100644 --- a/packages/nestjs/tsconfig.spec.json +++ b/packages/nestjs/tsconfig.spec.json @@ -3,16 +3,7 @@ "compilerOptions": { "outDir": "dist", "module": "commonjs", - "types": ["jest", "node"] + "types": ["node"] }, - "include": [ - "test-configs", - "vitest.config.ts", - "jest.config.js", - "**/*.test.ts", - "**/*.spec.ts", - "**/*.test.js", - "**/*.spec.js", - "**/*.d.ts" - ] + "include": ["vitest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] }