From eeebebd2dc4b3fc81e3d7983394ad40cbdc6f6bc Mon Sep 17 00:00:00 2001 From: mohiit1502 Date: Sun, 28 Dec 2025 19:00:00 +0530 Subject: [PATCH] First commit --- .DS_Store | Bin 0 -> 6148 bytes Jenkinsfile | 7 ++ README.md | 143 +++++++++++++++++++++++++++++++ package.json | 60 +++++++++++++ src/express/index.ts | 200 +++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 41 +++++++++ src/types.ts | 83 ++++++++++++++++++ src/verifier.ts | 154 +++++++++++++++++++++++++++++++++ tsconfig.json | 17 ++++ tsup.config.ts | 14 +++ 10 files changed, 719 insertions(+) create mode 100644 .DS_Store create mode 100644 Jenkinsfile create mode 100644 README.md create mode 100644 package.json create mode 100644 src/express/index.ts create mode 100644 src/index.ts create mode 100644 src/types.ts create mode 100644 src/verifier.ts create mode 100644 tsconfig.json create mode 100644 tsup.config.ts diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f86ce94727a10d1fa934dfad5b3b93dbab633a79 GIT binary patch literal 6148 zcmeHKJx{|h5WQ8j#`i7{a~Oz$Sxatg+pU=c~5a^(^vNLwNRr zu4qGd^q4>Y{<+IFZCx+7?NVaptSy0arlIY*=}w=)Ei83b+E_3SfT-(twp=R7_ik zbORZJFMu%dahCuF!^$u!3M0mvDyXS!R}9v4i3gik8Ae4-Cr*`R9jp9xdEr!D;vr2Z zt`xm@1zdq#fuRmZIR9_tlj*(W=P5pN1zdrDrT`c9yq?KR+1YyXdN^x?w1qSn=0!MS m;%7erGQd7^o=D{be3@4nMn$ZGcuE)aAAux@cdo!MDDVj!0Ye`E literal 0 HcmV?d00001 diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..13fd29d --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,7 @@ +@Library('jenkins-shared') _ + +kanikoPipeline( + repoName: 'iam-server-sdk', + branch: env.BRANCH_NAME ?: 'main', + isNpmLib: true +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a9b3f5 --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# @armco/iam-server + +Server-side JWT validation and middleware for IAM. + +## Installation + +```bash +npm install @armco/iam-server +``` + +## Quick Start + +### Standalone Verifier + +```typescript +import { createIAMVerifier } from '@armco/iam-server'; + +const verifier = createIAMVerifier({ + issuer: 'http://localhost:5000', + audience: 'my-api', +}); + +// Verify a token +const result = await verifier.verify(token); +if (result.valid) { + console.log('User ID:', result.payload.sub); + console.log('Email:', result.payload.email); + console.log('Roles:', result.payload.roles); +} + +// Or authenticate and get structured user info +const user = await verifier.authenticate(token); +if (user) { + console.log(user.id, user.email, user.roles, user.scopes); +} +``` + +### Express Middleware + +```typescript +import express from 'express'; +import { createAuthMiddleware, requireRole } from '@armco/iam-server/express'; + +const app = express(); + +// Create auth middleware +const auth = createAuthMiddleware({ + issuer: 'http://localhost:5000', + audience: 'my-api', +}); + +// Protect all /api routes +app.use('/api', auth()); + +// Access user in handlers +app.get('/api/profile', (req, res) => { + res.json({ + id: req.user.id, + email: req.user.email, + roles: req.user.roles, + }); +}); + +// Require specific role +app.get('/api/admin', auth({ roles: ['admin'] }), (req, res) => { + res.json({ message: 'Admin access granted' }); +}); + +// Require specific scope +app.get('/api/data', auth({ scopes: ['read:data'] }), (req, res) => { + res.json({ data: '...' }); +}); + +// Or use standalone middleware +app.delete('/api/users/:id', auth(), requireRole('admin'), handler); +``` + +## Configuration + +```typescript +interface IAMServerConfig { + /** IAM server base URL */ + issuer: string; + /** Expected audience (your app's client_id) */ + audience: string; + /** Cache JWKS keys (default: true) */ + cacheKeys?: boolean; + /** JWKS cache TTL in seconds (default: 3600) */ + cacheTTL?: number; + /** Required scopes for all requests */ + requiredScopes?: string[]; + /** Custom claim to extract user ID from (default: 'sub') */ + userIdClaim?: string; +} +``` + +## API Reference + +### IAMVerifier + +| Method | Description | +|--------|-------------| +| `verify(token)` | Verify JWT, returns `{ valid, payload?, error? }` | +| `authenticate(token)` | Verify and return `AuthenticatedUser` or `null` | +| `hasRole(user, roles)` | Check if user has any of the roles | +| `hasAllRoles(user, roles)` | Check if user has all roles | +| `hasScope(user, scopes)` | Check if user has any of the scopes | +| `hasAllScopes(user, scopes)` | Check if user has all scopes | +| `clearCache()` | Force JWKS refresh on next verify | + +### Express Middleware + +| Function | Description | +|----------|-------------| +| `createAuthMiddleware(config)` | Create auth middleware factory | +| `auth(options?)` | Middleware that requires valid token | +| `requireRole(roles)` | Middleware to check roles (use after auth) | +| `requireScope(scopes)` | Middleware to check scopes (use after auth) | +| `createOptionalAuthMiddleware(config)` | Attaches user if token present, doesn't require it | + +### AuthenticatedUser + +```typescript +interface AuthenticatedUser { + id: string; // Global identity ID (from 'sub') + userId?: string; // Tenant-specific user ID + tenantId?: string; // Tenant ID + email?: string; + username?: string; + roles: string[]; + scopes: string[]; + claims: JWTPayload; // Raw JWT payload +} +``` + +## Development + +```bash +cd packages/iam-server +npm install +npm run build +npm run dev # Watch mode +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..319760f --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "@armco/iam-server", + "version": "0.1.0", + "description": "Server-side JWT validation and middleware for IAM", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./express": { + "import": "./dist/express.mjs", + "require": "./dist/express.js", + "types": "./dist/express.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "lint": "eslint src --ext .ts", + "test": "vitest run", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "iam", + "jwt", + "authentication", + "middleware", + "armco", + "express" + ], + "author": "Armco", + "license": "MIT", + "peerDependencies": { + "express": ">=4.0.0" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + } + }, + "dependencies": { + "jose": "^5.2.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "express": "^4.18.2", + "tsup": "^8.0.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0" + } +} diff --git a/src/express/index.ts b/src/express/index.ts new file mode 100644 index 0000000..d6def58 --- /dev/null +++ b/src/express/index.ts @@ -0,0 +1,200 @@ +/** + * Express middleware for IAM authentication + */ + +import type { Request, Response, NextFunction } from 'express'; +import { IAMVerifier, createIAMVerifier } from '../verifier'; +import type { IAMServerConfig, AuthenticatedUser, AuthOptions } from '../types'; + +// Extend Express Request to include user +declare global { + namespace Express { + interface Request { + user?: AuthenticatedUser; + } + } +} + +/** + * Extract Bearer token from Authorization header + */ +function extractToken(req: Request): string | null { + const authHeader = req.headers.authorization; + if (!authHeader) return null; + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') { + return null; + } + + return parts[1]; +} + +/** + * Create authentication middleware + * + * @example + * ```ts + * import { createAuthMiddleware } from '@armco/iam-server/express'; + * + * const auth = createAuthMiddleware({ + * issuer: 'http://localhost:5000', + * audience: 'my-api', + * }); + * + * // Protect all routes + * app.use('/api', auth()); + * + * // Or specific routes + * app.get('/api/users', auth(), (req, res) => { + * console.log(req.user); // AuthenticatedUser + * }); + * ``` + */ +export function createAuthMiddleware(config: IAMServerConfig) { + const verifier = createIAMVerifier(config); + + /** + * Authentication middleware factory + */ + return function auth(options: AuthOptions = {}) { + return async (req: Request, res: Response, next: NextFunction) => { + const token = extractToken(req); + + if (!token) { + return res.status(401).json({ + error: 'unauthorized', + message: 'Missing or invalid Authorization header', + }); + } + + const user = await verifier.authenticate(token); + + if (!user) { + return res.status(401).json({ + error: 'unauthorized', + message: 'Invalid or expired token', + }); + } + + // Check required scopes + if (options.scopes && options.scopes.length > 0) { + const hasScope = options.requireAllScopes + ? verifier.hasAllScopes(user, options.scopes) + : verifier.hasScope(user, options.scopes); + + if (!hasScope) { + return res.status(403).json({ + error: 'forbidden', + message: `Insufficient scope. Required: ${options.scopes.join(', ')}`, + }); + } + } + + // Check required roles + if (options.roles && options.roles.length > 0) { + const hasRole = options.requireAllRoles + ? verifier.hasAllRoles(user, options.roles) + : verifier.hasRole(user, options.roles); + + if (!hasRole) { + return res.status(403).json({ + error: 'forbidden', + message: `Insufficient permissions. Required role: ${options.roles.join(' or ')}`, + }); + } + } + + // Attach user to request + req.user = user; + next(); + }; + }; +} + +/** + * Create role-checking middleware (use after auth middleware) + * + * @example + * ```ts + * app.get('/admin', auth(), requireRole('admin'), handler); + * app.get('/editor', auth(), requireRole(['admin', 'editor']), handler); + * ``` + */ +export function requireRole(roles: string | string[]) { + const required = Array.isArray(roles) ? roles : [roles]; + + return (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { + return res.status(401).json({ + error: 'unauthorized', + message: 'Authentication required', + }); + } + + const hasRole = required.some(role => req.user!.roles.includes(role)); + if (!hasRole) { + return res.status(403).json({ + error: 'forbidden', + message: `Required role: ${required.join(' or ')}`, + }); + } + + next(); + }; +} + +/** + * Create scope-checking middleware (use after auth middleware) + * + * @example + * ```ts + * app.get('/data', auth(), requireScope('read:data'), handler); + * ``` + */ +export function requireScope(scopes: string | string[]) { + const required = Array.isArray(scopes) ? scopes : [scopes]; + + return (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { + return res.status(401).json({ + error: 'unauthorized', + message: 'Authentication required', + }); + } + + const hasScope = required.some(scope => req.user!.scopes.includes(scope)); + if (!hasScope) { + return res.status(403).json({ + error: 'forbidden', + message: `Required scope: ${required.join(' or ')}`, + }); + } + + next(); + }; +} + +/** + * Optional auth - attaches user if token present, but doesn't require it + */ +export function createOptionalAuthMiddleware(config: IAMServerConfig) { + const verifier = createIAMVerifier(config); + + return async (req: Request, res: Response, next: NextFunction) => { + const token = extractToken(req); + + if (token) { + const user = await verifier.authenticate(token); + if (user) { + req.user = user; + } + } + + next(); + }; +} + +// Re-export types +export type { IAMServerConfig, AuthenticatedUser, AuthOptions } from '../types'; +export { IAMVerifier, createIAMVerifier } from '../verifier'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..01325e9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,41 @@ +/** + * @armco/iam-server + * + * Server-side JWT validation and utilities for IAM. + * + * @example + * ```ts + * import { createIAMVerifier } from '@armco/iam-server'; + * + * const verifier = createIAMVerifier({ + * issuer: 'http://localhost:5000', + * audience: 'my-api', + * }); + * + * // Verify a token + * const result = await verifier.verify(token); + * if (result.valid) { + * console.log(result.payload); + * } + * + * // Or authenticate and get user info + * const user = await verifier.authenticate(token); + * if (user) { + * console.log(user.email, user.roles); + * } + * ``` + * + * For Express middleware, use: + * ```ts + * import { createAuthMiddleware } from '@armco/iam-server/express'; + * ``` + */ + +export { IAMVerifier, createIAMVerifier } from './verifier'; +export type { + IAMServerConfig, + JWTPayload, + VerifyResult, + AuthenticatedUser, + AuthOptions, +} from './types'; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a8e68da --- /dev/null +++ b/src/types.ts @@ -0,0 +1,83 @@ +/** + * @armco/iam-server Types + */ + +export interface IAMServerConfig { + /** IAM server base URL (e.g., http://localhost:5000) */ + issuer: string; + /** Expected audience (your app's client_id) */ + audience: string; + /** Cache JWKS keys (default: true) */ + cacheKeys?: boolean; + /** JWKS cache TTL in seconds (default: 3600) */ + cacheTTL?: number; + /** Required scopes for all requests (optional) */ + requiredScopes?: string[]; + /** Custom claim to extract user ID from (default: 'sub') */ + userIdClaim?: string; +} + +export interface JWTPayload { + /** Subject - global identity ID */ + sub: string; + /** Issuer */ + iss: string; + /** Audience */ + aud: string | string[]; + /** Issued at */ + iat: number; + /** Expiration */ + exp: number; + /** User's email */ + email?: string; + /** Email verified */ + email_verified?: boolean; + /** Username */ + username?: string; + /** Tenant ID */ + tenantId?: string; + /** User ID (membership ID) */ + userId?: string; + /** Roles */ + roles?: string[]; + /** Scopes */ + scope?: string; + /** Additional claims */ + [key: string]: unknown; +} + +export interface VerifyResult { + valid: boolean; + payload?: JWTPayload; + error?: string; +} + +export interface AuthenticatedUser { + /** Global identity ID (from 'sub' claim) */ + id: string; + /** Tenant-specific user ID */ + userId?: string; + /** Tenant ID */ + tenantId?: string; + /** Email */ + email?: string; + /** Username */ + username?: string; + /** Roles */ + roles: string[]; + /** Scopes */ + scopes: string[]; + /** Raw JWT payload */ + claims: JWTPayload; +} + +export interface AuthOptions { + /** Required scopes (any of these) */ + scopes?: string[]; + /** Required roles (any of these) */ + roles?: string[]; + /** Require all specified scopes (default: false - any match) */ + requireAllScopes?: boolean; + /** Require all specified roles (default: false - any match) */ + requireAllRoles?: boolean; +} diff --git a/src/verifier.ts b/src/verifier.ts new file mode 100644 index 0000000..7992b24 --- /dev/null +++ b/src/verifier.ts @@ -0,0 +1,154 @@ +/** + * JWT Verifier using jose library + * Fetches JWKS from IAM and validates tokens + */ + +import { createRemoteJWKSet, jwtVerify, type JWTVerifyResult } from 'jose'; +import type { IAMServerConfig, JWTPayload, VerifyResult, AuthenticatedUser } from './types'; + +export class IAMVerifier { + private config: Required; + private jwks: ReturnType | null = null; + private jwksCreatedAt: number = 0; + + constructor(config: IAMServerConfig) { + this.config = { + issuer: config.issuer.replace(/\/$/, ''), + audience: config.audience, + cacheKeys: config.cacheKeys ?? true, + cacheTTL: config.cacheTTL ?? 3600, + requiredScopes: config.requiredScopes ?? [], + userIdClaim: config.userIdClaim ?? 'sub', + }; + } + + /** + * Get or create JWKS + */ + private getJWKS(): ReturnType { + const now = Date.now(); + const ttlMs = this.config.cacheTTL * 1000; + + // Return cached JWKS if still valid + if (this.jwks && this.config.cacheKeys && (now - this.jwksCreatedAt) < ttlMs) { + return this.jwks; + } + + // Create new JWKS + const jwksUrl = new URL(`${this.config.issuer}/.well-known/jwks.json`); + this.jwks = createRemoteJWKSet(jwksUrl); + this.jwksCreatedAt = now; + + return this.jwks; + } + + /** + * Verify a JWT token + */ + async verify(token: string): Promise { + try { + const jwks = this.getJWKS(); + + const result: JWTVerifyResult = await jwtVerify(token, jwks, { + issuer: this.config.issuer, + audience: this.config.audience, + }); + + const payload = result.payload as unknown as JWTPayload; + + // Check required scopes if configured + if (this.config.requiredScopes.length > 0) { + const tokenScopes = (payload.scope || '').split(' ').filter(Boolean); + const hasRequiredScope = this.config.requiredScopes.some(s => tokenScopes.includes(s)); + + if (!hasRequiredScope) { + return { + valid: false, + error: `Missing required scope. Required: ${this.config.requiredScopes.join(', ')}`, + }; + } + } + + return { + valid: true, + payload, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Token verification failed'; + return { + valid: false, + error: message, + }; + } + } + + /** + * Verify token and extract user info + */ + async authenticate(token: string): Promise { + const result = await this.verify(token); + + if (!result.valid || !result.payload) { + return null; + } + + const payload = result.payload; + const scopes = (payload.scope || '').split(' ').filter(Boolean); + + return { + id: payload[this.config.userIdClaim] as string || payload.sub, + userId: payload.userId, + tenantId: payload.tenantId, + email: payload.email, + username: payload.username, + roles: payload.roles || [], + scopes, + claims: payload, + }; + } + + /** + * Check if user has any of the specified scopes + */ + hasScope(user: AuthenticatedUser, scopes: string | string[]): boolean { + const required = Array.isArray(scopes) ? scopes : [scopes]; + return required.some(scope => user.scopes.includes(scope)); + } + + /** + * Check if user has all of the specified scopes + */ + hasAllScopes(user: AuthenticatedUser, scopes: string[]): boolean { + return scopes.every(scope => user.scopes.includes(scope)); + } + + /** + * Check if user has any of the specified roles + */ + hasRole(user: AuthenticatedUser, roles: string | string[]): boolean { + const required = Array.isArray(roles) ? roles : [roles]; + return required.some(role => user.roles.includes(role)); + } + + /** + * Check if user has all of the specified roles + */ + hasAllRoles(user: AuthenticatedUser, roles: string[]): boolean { + return roles.every(role => user.roles.includes(role)); + } + + /** + * Clear JWKS cache (force refresh on next verify) + */ + clearCache(): void { + this.jwks = null; + this.jwksCreatedAt = 0; + } +} + +/** + * Create a new IAM verifier instance + */ +export function createIAMVerifier(config: IAMServerConfig): IAMVerifier { + return new IAMVerifier(config); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..863d9ba --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..ee32860 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: { + index: 'src/index.ts', + express: 'src/express/index.ts', + }, + format: ['cjs', 'esm'], + dts: true, + clean: true, + splitting: false, + sourcemap: true, + external: ['express'], +})