├── .npmrc ├── .eslintignore ├── .prettierignore ├── .gitignore ├── .prettierrc ├── .editorconfig ├── .eslintrc.yml ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── package.json ├── README.md ├── tsconfig.json └── src └── index.ts /.npmrc: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | .DS_Store 5 | *.log 6 | .vscode 7 | .idea 8 | dist 9 | compiled 10 | .awcache 11 | .rpt2_cache 12 | docs 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "arrowParens": "avoid", 5 | "endOfLine": "lf", 6 | "printWidth": 120, 7 | "semi": true 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | #root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | max_line_length = 100 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | extends: 5 | - 'eslint:recommended' 6 | - 'plugin:@typescript-eslint/recommended' 7 | - 'prettier/@typescript-eslint' # Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 8 | - 'plugin:prettier/recommended' 9 | parser: '@typescript-eslint/parser' 10 | parserOptions: 11 | ecmaVersion: 12 12 | sourceType: module 13 | plugins: 14 | - '@typescript-eslint' 15 | rules: {} 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ["iad-os"] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: 'Setup node' 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: 16 19 | 20 | - name: 'install deps' 21 | run: npm ci --no-audit 22 | - run: npm run lint --if-present 23 | - name: build 24 | run: npm run build --if-present 25 | env: 26 | CI: true 27 | publish: 28 | name: publish 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | - uses: actions/checkout@v2 33 | - name: 'Setup node' 34 | uses: actions/setup-node@v2 35 | with: 36 | node-version: 16 37 | registry-url: https://registry.npmjs.org 38 | 39 | - name: install deps, tests and build 40 | run: | 41 | npm ci --no-audit 42 | npm run build --if-present 43 | env: 44 | CI: true 45 | 46 | - name: publish to npm 47 | run: npm publish --access=public 48 | env: 49 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@iad-os/aemon-oidc-introspect", 3 | "version": "0.2.14", 4 | "description": "Aemon Oidc Introspect", 5 | "maintainers": [ 6 | { 7 | "name": "Daniele Fiungo", 8 | "email": "daniele.fiungo@iad2.it" 9 | }, 10 | { 11 | "name": "Nicola Vurchio", 12 | "email": "nicola.vurchio@iad2.it" 13 | } 14 | ], 15 | "author": "Daniele Fiungo ", 16 | "keywords": [ 17 | "node", 18 | "middlewares" 19 | ], 20 | "main": "dist/lib/index.js", 21 | "types": "dist/types/index.d.ts", 22 | "files": [ 23 | "dist" 24 | ], 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/iad-os/aemon-oidc-introspect.git" 28 | }, 29 | "publishConfig": { 30 | "access": "public" 31 | }, 32 | "private": false, 33 | "license": "MIT", 34 | "engines": { 35 | "node": ">=14.0.0" 36 | }, 37 | "scripts": { 38 | "lint": "eslint . --ext .ts,.tsx", 39 | "prebuild": "rimraf dist", 40 | "build": "tsc" 41 | }, 42 | "devDependencies": { 43 | "@types/express": "^4.17.14", 44 | "@types/jws": "^3.2.4", 45 | "@types/lodash.reduce": "^4.6.7", 46 | "@types/node": "^14.14.6", 47 | "@typescript-eslint/eslint-plugin": "^4.6.1", 48 | "@typescript-eslint/parser": "^4.6.1", 49 | "cross-env": "^7.0.3", 50 | "eslint": "^7.32.0", 51 | "eslint-config-prettier": "^6.15.0", 52 | "eslint-plugin-prettier": "^3.1.4", 53 | "prettier": "^2.8.0", 54 | "rimraf": "^3.0.2", 55 | "ts-node": "^10.9.1", 56 | "typedoc": "^0.23.21", 57 | "typescript": "^4.0.5" 58 | }, 59 | "dependencies": { 60 | "http-status": "^1.5.3", 61 | "jws": "^4.0.0", 62 | "lodash.reduce": "^4.6.0", 63 | "lru-cache": "^7.14.1", 64 | "qs": "^6.11.0" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OIDC Smart Introspect middleware 2 | 3 | ### Add dependency to project 4 | 5 | ```sh 6 | npm i @iad-os/aemon-oidc-introspect 7 | ``` 8 | 9 | ## Configure 10 | 11 | ```typescript 12 | import aemonOidcIntrospect from '@iad-os/aemon-oidc-introspect'; 13 | 14 | //... 15 | 16 | const expressApp = express() 17 | // other middleware ... 18 | .use( 19 | aemonOidcIntrospect({ 20 | issuers: [ 21 | { 22 | issuer: 'https://XXX/auth/realms/YYY', 23 | introspection_endpoint: 24 | 'https://XXX/auth/realms/YYY/protocol/openid-connect/token/introspect', 25 | client: { 26 | client_id: 'client_id', 27 | client_secret: 'aClientSecret', 28 | }, 29 | }, 30 | ], 31 | extractToken: (req: Request): string => { 32 | 33 | // Bearer token example (Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c) 34 | 35 | const authorization = req.headers['authorization']; 36 | if (!authorization) return; 37 | const [, token] = authorization.split(' '); 38 | return token; 39 | }, 40 | doPost: async ( 41 | req: Request, 42 | url: string, 43 | queryString: string, 44 | options?: { headers: Record } 45 | ) => { 46 | return await axios.create().post(url, queryString, options); 47 | }, 48 | logger: (req: Request, level: string, msg: string, payload?: any) => 49 | logger[level](msg, payload), 50 | }) 51 | ); 52 | ``` 53 | 54 | ## Usage 55 | 56 | ```typescript 57 | const myMiddleware: RequestHandler = function (req, res, next) { 58 | // ... code here 59 | if (!req.uid) { 60 | logger.info('User IDentity not found.'); 61 | return next(null); 62 | } 63 | 64 | sendEmail(req.uid.email) 65 | }; 66 | ``` 67 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | // "resolveJsonModule": true, 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2020", 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "lib": ["ES2020.Promise"] /* Specify library files to be included in the compilation. */, 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | "declaration": true /* Generates corresponding '.d.ts' file. */, 14 | "declarationDir": "dist/types", 15 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 16 | //"plugins": "dist/types", 17 | "sourceMap": true /* Generates corresponding '.map' file. */, 18 | // "outFile": "./", /* Concatenate and emit output to single file. */ 19 | "outDir": "dist/lib" /* Redirect output structure to the directory. */, 20 | // "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 21 | // "composite": true, /* Enable project compilation */ 22 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 23 | // "removeComments": true, /* Do not emit comments to output. */ 24 | // "noEmit": true, /* Do not emit outputs. */ 25 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 26 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 27 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 28 | 29 | /* Strict Type-Checking Options */ 30 | "strict": true /* Enable all strict type-checking options. */, 31 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 32 | // "strictNullChecks": true, /* Enable strict null checks. */ 33 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 34 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 35 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 36 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 37 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 38 | 39 | /* Additional Checks */ 40 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 41 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 42 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 43 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 44 | 45 | /* Module Resolution Options */ 46 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | "rootDir": "./src" /* List of root folders whose combined content represents the structure of the project at runtime. */, 50 | "typeRoots": ["./node_modules/@types"] /* List of folders to include type definitions from. */, 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, 53 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 65 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 66 | 67 | /* Advanced Options */ 68 | "skipLibCheck": true /* Skip type checking of declaration files. */, 69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import type { Request, RequestHandler } from 'express'; 3 | import httpStatus from 'http-status'; 4 | import jwt from 'jws'; 5 | import reduce from 'lodash.reduce'; 6 | import LRUCache from 'lru-cache'; 7 | import query from 'qs'; 8 | 9 | export type Identity = { 10 | subject: string; 11 | issuer: string; 12 | email: string; 13 | familyName: string; 14 | givenName: string; 15 | emailVerified: boolean; 16 | token: string; 17 | roles: string[]; 18 | allRoles: string[]; 19 | audience: string; 20 | expires: number; 21 | issuedAt: number; 22 | active: boolean; 23 | clientId: string; 24 | expiresIn: number; 25 | name: string; 26 | username: string; 27 | }; 28 | 29 | export type TokenData = { 30 | active: string; 31 | aud: string; 32 | iss: string; 33 | sub: string; 34 | email: string; 35 | email_verified: string; 36 | realm_access: string; 37 | resource_access: string; 38 | exp: string; 39 | iat: string; 40 | clientId: string; 41 | family_name: string; 42 | given_name: string; 43 | }; 44 | 45 | export type doPostHandler = ( 46 | req: Request, 47 | url: string, 48 | queryString: string, 49 | options?: { 50 | headers: Record; 51 | } 52 | ) => Promise<{ data: TokenData }>; 53 | 54 | export type loggerHandler = ( 55 | req: Request, 56 | level: 'info' | 'debug' | 'error' | 'trace' | 'warn', 57 | msg: string, 58 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 59 | payload?: any 60 | ) => void; 61 | 62 | export type AemonOption = { 63 | issuers: IssuerEndpoints[]; 64 | extractToken: (req: Request) => string | undefined; 65 | doPost: doPostHandler; 66 | logger: loggerHandler; 67 | devMode?: DevModeConfig; 68 | }; 69 | 70 | declare module 'http' { 71 | interface IncomingMessage { 72 | uid: Identity; 73 | } 74 | } 75 | 76 | export interface IssuerEndpoints { 77 | issuer: string; 78 | introspection_endpoint: string; 79 | client: { 80 | client_secret: string; 81 | client_id: string; 82 | }; 83 | } 84 | 85 | export async function checkIntrospectCredentials(req: Request, options: AemonOption): Promise { 86 | if (options?.devMode?.enabled) return; 87 | const checks = await Promise.allSettled( 88 | options.issuers.map(issuer => introspectToken(issuer, 'dontcaretoken', options.doPost, req)) 89 | ); 90 | 91 | const errors = checks 92 | .map((check, i) => ({ check, issuer: options.issuers[i] })) 93 | .filter(({ check }) => check.status === 'rejected') 94 | .map(({ check, issuer }) => { 95 | const { reason } = check as PromiseRejectedResult; 96 | return { msg: 'TEST-INTROSPECT - KO', reason: reason.message, issuer }; 97 | }); 98 | 99 | if (errors.length) { 100 | throw errors; 101 | } 102 | return; 103 | } 104 | 105 | export interface DevModeConfig { 106 | enabled: boolean; 107 | fakeUid: Identity; 108 | } 109 | 110 | export type AemonOidcIntrospectFunction = (options: AemonOption) => RequestHandler; 111 | 112 | const aemonOidcIntrospect: AemonOidcIntrospectFunction = (options): RequestHandler => { 113 | const { issuers } = options; 114 | 115 | const IntrospectCache = new LRUCache({ 116 | max: 1000, 117 | maxAge: 300, 118 | }); 119 | 120 | if (options?.devMode?.enabled) { 121 | return (req, res, next) => { 122 | req.uid = { ...(options?.devMode?.fakeUid as Identity), issuedAt: new Date().getTime() }; 123 | Math.random() > 0.98 || options.logger(req, 'error', '---- DEV-MODE: ENABLED ----'); 124 | next(); 125 | }; 126 | } 127 | 128 | return function (req, res, next) { 129 | const tokenInfo = extractToken(options.extractToken(req)); 130 | 131 | if (tokenInfo === undefined) { 132 | res.status(httpStatus.UNAUTHORIZED).send(); 133 | return; 134 | } 135 | const { token, signature } = tokenInfo; 136 | const cacheKey = keyFor(signature); 137 | 138 | // Search UID in cache 139 | const uid = IntrospectCache.get(cacheKey) as Identity; 140 | 141 | if (uid) { 142 | options.logger(req, 'trace', 'INTROSPECT-CACHE-HIT', { ...uid }); 143 | // Also cache active:false token 144 | if (uid.active) { 145 | req.uid = uid; 146 | return next(); 147 | } else { 148 | res.status(httpStatus.UNAUTHORIZED).send(); 149 | return; 150 | } 151 | } 152 | const { payload } = decodeToken(token) || {}; 153 | if (!payload) { 154 | options.logger(req, 'warn', 'INTROSPECT-INVALID-TOKEN-HIT', { payload }); 155 | IntrospectCache.set(cacheKey, { active: false }, {}); 156 | res.status(httpStatus.UNAUTHORIZED).send(); 157 | return; 158 | } 159 | const issuer = issuers.find(({ issuer }) => issuer === payload?.iss); 160 | if (!issuer) { 161 | options.logger(req, 'warn', 'INTROSPECT-UNKNOWN-ISSUER-HIT', { payload }); 162 | IntrospectCache.set(signature, { active: false }, { ttl: 1800 }); 163 | res.status(httpStatus.UNAUTHORIZED).send(); 164 | return; 165 | } 166 | introspectToken(issuer, token, options.doPost, req) 167 | .then(({ data }) => { 168 | const { active } = data; 169 | 170 | if (!active) { 171 | options.logger(req, 'warn', 'INTROSPECT-CACHE-PUT - ACTIVE:FALSE', { active: false, issuer }); 172 | IntrospectCache.set(signature, { active: false }, { ttl: 1800 }); 173 | res.status(httpStatus.UNAUTHORIZED).send(); 174 | return; 175 | } 176 | 177 | req.uid = buildUid(data, issuer, token); 178 | 179 | const exp = req.uid.expires - Date.now() / 1000 || 1800; 180 | options.logger(req, 'debug', 'INTROSPECT-CACHE-PUT - ACTIVE:TRUE', { 181 | req: { subject: req.uid.subject }, 182 | exp, 183 | uid: req.uid, 184 | }); 185 | IntrospectCache.set(signature, req.uid, { ttl: exp }); 186 | next(); 187 | }) 188 | .catch(err => { 189 | next(err); 190 | }); 191 | }; 192 | 193 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 194 | function buildUid(data: any, issuer: IssuerEndpoints, token: string): Identity { 195 | const { 196 | active, 197 | aud, 198 | iss, 199 | sub, 200 | email, 201 | email_verified, 202 | realm_access, 203 | resource_access, 204 | exp, 205 | iat, 206 | client_id, 207 | family_name, 208 | given_name, 209 | name, 210 | preferred_username, 211 | } = data; 212 | 213 | const allRoles = extractAllRoles(realm_access, resource_access); 214 | const roles = extractSelfRoles(resource_access, issuer); 215 | return { 216 | active, 217 | issuer: iss, 218 | audience: aud, 219 | subject: sub, 220 | email: email, 221 | familyName: family_name, 222 | givenName: given_name, 223 | emailVerified: email_verified, 224 | token, 225 | roles, 226 | allRoles, 227 | expires: exp, 228 | expiresIn: expiresIn(exp), 229 | issuedAt: iat, 230 | clientId: client_id, 231 | name, 232 | username: preferred_username, 233 | }; 234 | } 235 | }; 236 | 237 | function expiresIn(exp: number): number { 238 | const value = Math.max.apply(null, [exp * 1000 - Date.now(), 0]); 239 | return Math.floor(value / 1000); 240 | } 241 | 242 | function introspectToken(issuer: IssuerEndpoints, token: string, doPost: doPostHandler, req: Request) { 243 | return doPost(req, issuer.introspection_endpoint, query.stringify({ token }), { 244 | headers: { 245 | authorization: toBasic(issuer.client.client_id, issuer.client.client_secret), 246 | 'Content-Type': 'application/x-www-form-urlencoded', 247 | }, 248 | }); 249 | } 250 | 251 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 252 | function extractSelfRoles(resource_access: any, { client }: IssuerEndpoints) { 253 | return (resource_access && resource_access[client.client_id] && resource_access[client.client_id].roles) || []; 254 | } 255 | 256 | function extractAllRoles(realm_access: { roles: string[] }, resource_access: { [key: string]: { roles: string[] } }) { 257 | return [ 258 | ...(realm_access?.roles || []), 259 | ...reduce( 260 | resource_access, 261 | (acc, curr, key) => { 262 | return [...acc, ...curr.roles.map(role => `${key}:${role}`)]; 263 | }, 264 | [] as string[] 265 | ), 266 | ]; 267 | } 268 | 269 | function toBasic(username: string, password: string) { 270 | const toEncode = Buffer.from(`${username}:${password}`); 271 | return `Basic ${toEncode.toString('base64')}`; 272 | } 273 | 274 | function decodeToken(token: string): jwt.Signature { 275 | const tokenContent = jwt.decode(token); 276 | return tokenContent; 277 | } 278 | 279 | function extractToken(token: string | undefined): { signature: string; token: string } | undefined { 280 | if (!token) return; 281 | const [, , signature] = token.split('.'); 282 | return { signature, token }; 283 | } 284 | 285 | function keyFor(text: string) { 286 | return crypto.createHash('md5').update(text).digest('hex'); 287 | } 288 | 289 | export { aemonOidcIntrospect }; 290 | 291 | export default aemonOidcIntrospect; 292 | --------------------------------------------------------------------------------