diff --git a/README.md b/README.md index cdbd91e..b9e1104 100644 --- a/README.md +++ b/README.md @@ -898,8 +898,6 @@ curl -i -H "Authorization: Bearer $TOKEN" https:///writer-only - πŸ› Report bugs via [GitHub Issues](https://github.com/davidcrowe/gatewaystack/issues) - ⭐ Star the repo if this helps you! -## License -MIT License - see [LICENSE](LICENSE) for details. Built by [reducibl applied AI studio](https://reducibl.com) \ No newline at end of file diff --git a/apps/gateway-server/src/app.ts b/apps/gateway-server/src/app.ts index 02410c8..95c48b1 100644 --- a/apps/gateway-server/src/app.ts +++ b/apps/gateway-server/src/app.ts @@ -13,6 +13,8 @@ import { explicablRouter } from "@gatewaystack/explicabl"; import { testEchoRoutes } from "./routes/testEcho"; +import rateLimit from "express-rate-limit"; + /** * GatewayStack reference server * @@ -57,6 +59,12 @@ export function buildApp(env: NodeJS.ProcessEnv) { const windowMs = +(process.env.RATE_LIMIT_WINDOW_MS ?? 60_000); const limit = +(process.env.RATE_LIMIT_MAX ?? 10); + // Very loose limiter for PRM metadata (satisfies CodeQL, effectively no impact) + const prmLimiter = rateLimit({ + windowMs, + max: limit * 100, // extremely generous + }); + // ----------------------------- // Core Express setup // ----------------------------- @@ -90,7 +98,17 @@ export function buildApp(env: NodeJS.ProcessEnv) { // Layer 3: Mount validatabl’s PRM router early for public metadata // validatabl is added as added via requireScope() in the protected area below // ----------------------------- + // app.use( + // protectedResourceRouter({ + // issuer: OAUTH_ISSUER.replace(/\/+$/, ""), + // audience: OAUTH_AUDIENCE, + // scopes: OAUTH_SCOPES, + // }) as unknown as RequestHandler + // ); + app.use( + "/prm", + prmLimiter, protectedResourceRouter({ issuer: OAUTH_ISSUER.replace(/\/+$/, ""), audience: OAUTH_AUDIENCE, @@ -98,6 +116,7 @@ export function buildApp(env: NodeJS.ProcessEnv) { }) as unknown as RequestHandler ); + // ----------------------------- // Layer 4: limitabl (per-identity rate limiting) // ----------------------------- @@ -109,9 +128,9 @@ export function buildApp(env: NodeJS.ProcessEnv) { // ----------------------------- app.use( "/protected", + limitablMiddleware, identifiablMiddleware, - transformablMiddleware, - limitablMiddleware + transformablMiddleware ); // READ example (no extra scope) diff --git a/package-lock.json b/package-lock.json index c7e2ca1..efccc95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -917,10 +917,6 @@ "resolved": "packages/identifiabl", "link": true }, - "node_modules/@gatewaystack/identifiabl-core": { - "resolved": "packages/identifiabl-core", - "link": true - }, "node_modules/@gatewaystack/limitabl": { "resolved": "packages/limitabl", "link": true @@ -3532,6 +3528,10 @@ "url": "https://opencollective.com/express" } }, + "node_modules/identifiabl": { + "resolved": "packages/identifiabl-core", + "link": true + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -6282,13 +6282,30 @@ }, "packages/identifiabl": { "name": "@gatewaystack/identifiabl", - "version": "0.0.1" + "version": "0.0.1", + "dependencies": { + "identifiabl": "0.1.0", + "jose": "^5.0.0" + }, + "peerDependencies": { + "express": "^4.0.0 || ^5.0.0" + } }, "packages/identifiabl-core": { - "name": "@gatewaystack/identifiabl-core", + "name": "identifiabl", "version": "0.1.0", + "license": "MIT", "dependencies": { - "@gatewaystack/request-context": "0.1.0" + "jose": "^6.1.0" + } + }, + "packages/identifiabl-core/node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" } }, "packages/limitabl": { diff --git a/packages/identifiabl-core/README.md b/packages/identifiabl-core/README.md new file mode 100644 index 0000000..40c1309 --- /dev/null +++ b/packages/identifiabl-core/README.md @@ -0,0 +1,345 @@ +# @gatewaystack/identifiabl-core + +> **Experimental** – core identity verification helper used by GatewayStack. +> Verifies RS256 JWTs against a JWKS endpoint and maps them into a normalized `GatewayIdentity` object. + +`@gatewaystack/identifiabl-core` is a small, framework-agnostic helper for: + +- Verifying RS256 JWTs with `jose` and a remote JWKS URL +- Enforcing `iss` (issuer) and `aud` (audience) +- Normalizing identity claims (`sub`, `email`, `name`, tenant, roles, scopes, plan) +- Returning a consistent `VerifyResult` you can plug into Gateways, middlewares, or Firebase Functions + +--- + +`@gatewaystack/identifiabl-core` is the low-level verifier behind +[identifiabl](https://www.npmjs.com/package/identifiabl) and the broader +[GatewayStack](https://gatewaystack.com) modules. + +If you want an opinionated HTTP/middleware layer for Apps SDK / MCP backends, +start with `identifiabl`. If you just need a small, framework-agnostic verifier +that returns a normalized identity object, use `@gatewaystack/identifiabl-core` directly. + +## Installation + +```bash +npm install @gatewaystack/identifiabl-core jose +# or +yarn add @gatewaystack/identifiabl-core jose +# or +pnpm add @gatewaystack/identifiabl-core jose +``` + +## Quick Start + +```typescript +import { createIdentifiablVerifier } from "@gatewaystack/identifiabl-core"; + +const verify = createIdentifiablVerifier({ + issuer: "https://dev-xxxxx.us.auth0.com/", + audience: "https://inner.app/api", + // optional mappings: + source: "auth0", + tenantClaim: "https://inner.app/tenant_id", + roleClaim: "https://inner.app/roles", + scopeClaim: "scope", + planClaim: "https://inner.app/plan" +}); + +async function handleRequest(bearerToken: string) { + const token = bearerToken.replace(/^Bearer\s+/i, ""); + + const result = await verify(token); + + if (!result.ok) { + console.error("JWT verification failed:", result.error, result.detail); + // return 401 / throw / etc. + return; + } + + const { identity, payload } = result; + + console.log("Verified user:", identity.sub); + console.log("Tenant:", identity.tenantId); + console.log("Roles:", identity.roles); + console.log("Scopes:", identity.scopes); +} +``` + +## API + +### createIdentifiablVerifier(config) + +```typescript +import { createIdentifiablVerifier } from "@gatewaystack/identifiabl-core"; + +const verify = createIdentifiablVerifier(config); +const result = await verify(token); +``` + +### IdentifiablCoreConfig + +```typescript +interface IdentifiablCoreConfig { + issuer: string; // Expected issuer (e.g. Auth0 domain) + audience: string; // Expected audience / API identifier + jwksUri?: string; // Optional override; defaults to `${issuer}/.well-known/jwks.json` + source?: string; // Optional identity source label (e.g. "auth0", "stytch", "cognito") + + tenantClaim?: string; // Claim name for tenant / org id + roleClaim?: string; // Claim name for roles array + scopeClaim?: string; // Claim name for space-separated scopes string + planClaim?: string; // Claim name for plan / subscription tier +} +``` + +#### issuer + +Used both to: + +- Build a default JWKS URL (`${issuer}/.well-known/jwks.json` after trimming trailing `/`) +- Validate the `iss` claim. Trailing slashes are tolerated (e.g. `https://foo/` equals `https://foo`). + +#### audience + +Passed directly to `jwtVerify` to enforce the `aud` claim. + +#### jwksUri (optional) + +Override if your JWKS lives somewhere else. + +#### Claim mapping fields + +Let you adapt to different identity providers without changing code: + +- `tenantClaim` β†’ mapped to `identity.tenantId` +- `roleClaim` β†’ mapped to `identity.roles: string[]` +- `scopeClaim` β†’ split on spaces into `identity.scopes: string[]` +- `planClaim` β†’ mapped to `identity.plan` + +### VerifyResult + +```typescript +type VerifyResult = + | { + ok: true; + identity: GatewayIdentity; + payload: JWTPayload; + } + | { + ok: false; + error: string; + detail?: string; + }; +``` + +#### On success + +`identity` is a normalized view of the user: + +- `sub`: subject (required) +- `issuer`: normalized issuer (no trailing `/`) +- `email`, `name` (if present) +- `tenantId`, `roles`, `scopes`, `plan` (based on your config) +- `source`: identity provider label (defaults to "auth0") +- `raw`: the full decoded JWT payload + +#### On failure + +- `error` is a short code (currently `invalid_token`) +- `detail` is the underlying error message from jose when available + +## Example: Firebase Callable Function + +```typescript +import * as functions from "firebase-functions/v2/https"; +import { createIdentifiablVerifier } from "@gatewaystack/identifiabl-core"; + +const verify = createIdentifiablVerifier({ + issuer: "https://dev-xxxxx.us.auth0.com/", + audience: "https://inner.app/api", + tenantClaim: "https://inner.app/tenant_id" +}); + +export const myProtectedFunction = functions.onCall(async (req) => { + const token = req.rawRequest.headers.authorization?.replace(/^Bearer\s+/i, ""); + if (!token) { + throw new functions.HttpsError("unauthenticated", "Missing bearer token"); + } + + const result = await verify(token); + if (!result.ok) { + throw new functions.HttpsError("unauthenticated", "Invalid or expired token"); + } + + const { identity } = result; + // Use identity.sub / identity.tenantId / etc. + return { ok: true, user: identity.sub, tenantId: identity.tenantId }; +}); +``` + +## what identifiabl replaces under the hood +You **do not** need to write any of the code in this section if you're using +`@gatewaystack/identifiabl-core`. This is an example of the kind of hand-rolled +JWT/JWKS + identity plumbing that `createIdentifiablVerifier()` is designed to replace. + +**1. `verifyToken` β€” verify oidc / apps sdk identity tokens** +validates rs256 jwts, audiences, issuers, expirations, and nonce. + +a minimal typescript implementation: +```ts +// auth/verifyToken.ts +import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose'; + +const ISSUER = process.env.AUTH_ISSUER!; +const AUDIENCE = process.env.AUTH_AUDIENCE!; +const JWKS_URI = process.env.AUTH_JWKS_URI!; + +const jwks = createRemoteJWKSet(new URL(JWKS_URI)); + +export type VerifiedIdentity = { + user_id: string; + org_id?: string; + tenant?: string; + roles: string[]; + scopes: string[]; + raw: JWTPayload; +}; + +export async function verifyToken(authorizationHeader: string | undefined): Promise { + if (!authorizationHeader?.startsWith('Bearer ')) { + throw new Error('missing or invalid bearer token'); + } + + const token = authorizationHeader.slice('Bearer '.length).trim(); + + const { payload } = await jwtVerify(token, jwks, { + issuer: ISSUER, + audience: AUDIENCE, + }); + + return extractIdentity(payload); +} + +function extractIdentity(payload: JWTPayload): VerifiedIdentity { + const scopes = + typeof payload.scope === 'string' + ? payload.scope.split(' ').filter(Boolean) + : Array.isArray(payload.scope) + ? payload.scope.map(String) + : []; + + return { + user_id: String(payload.sub ?? ''), + org_id: (payload['org_id'] as string) ?? undefined, + tenant: (payload['tenant'] as string) ?? undefined, + roles: (payload['roles'] as string[]) ?? [], + scopes, + raw: payload, + }; +} +``` + +**2. `extractIdentity` β€” normalize user/org/tenant metadata** +implemented inside `verifyToken` above, it returns a canonical structure: +```ts +{ user_id, org_id, tenant, roles, scopes } +``` + +**3. `attachIdentity` β€” bind identity to model request metadata** +injects identity into headers or context fields for downstream modules. +```ts +// middleware/attachIdentity.ts +import type { Request, Response, NextFunction } from 'express'; +import { verifyToken, VerifiedIdentity } from '../auth/verifyToken'; + +declare module 'express-serve-static-core' { + interface Request { + identity?: VerifiedIdentity; + } +} + +export async function attachIdentity(req: Request, res: Response, next: NextFunction) { + try { + const authHeader = req.headers['authorization'] as string | undefined; + + const identity = await verifyToken(authHeader); + + // attach to request for downstream handlers / modules + req.identity = identity; + + // inject normalized identity headers for downstream services / proxies + req.headers['x-user-id'] = identity.user_id; + if (identity.org_id) req.headers['x-org-id'] = identity.org_id; + if (identity.tenant) req.headers['x-tenant'] = identity.tenant; + req.headers['x-user-scopes'] = identity.scopes.join(' '); + + return next(); + } catch (err) { + return res.status(401).json({ + error: 'unauthorized', + reason: (err as Error).message ?? 'token validation failed', + }); + } +} +``` + +then wire it up in your gateway server: +```ts +import express from 'express'; +import { attachIdentity } from './middleware/attachIdentity'; + +const app = express(); + +app.use(attachIdentity); + +// all downstream routes now see req.identity and identity headers +``` + +**4. `assertIdentity` β€” enforce presence of user identity** +guarantees that no anonymous or shared-key requests pass through. +```ts +// middleware/assertIdentity.ts +import type { Request, Response, NextFunction } from 'express'; + +export function assertIdentity(req: Request, res: Response, next: NextFunction) { + if (!req.identity) { + return res.status(401).json({ error: 'unauthorized', reason: 'missing user identity' }); + } + return next(); +} +``` + +**5. `logIdentity` β€” produce identity-level audit events** +emits structured logs for compliance, analytics, and debugging. +```ts +// inside your request pipeline +logger.info('identity_event', { + user_id: req.identity?.user_id, + org_id: req.identity?.org_id, + scopes: req.identity?.scopes, + path: req.path, + action: 'model_request', +}); +``` + +## Implementation Notes + +Uses `jose` under the hood: + +- `createRemoteJWKSet` to fetch and cache keys +- `jwtVerify` with `algorithms: ["RS256"]` +- `clockTolerance: "60s"` to allow for small clock skew + +Currently focused on RS256 JWTs; other algorithms are intentionally not allowed. + +## Status + +This package is pre-1.0 and may change as GatewayStack evolves. If you try it and run into issues, please open an issue or PR in the main GatewayStack repo. + +## Related + +- High-level identity gateway for Apps SDK / MCP backends: + [`identifiabl` on npm](https://www.npmjs.com/package/identifiabl) / [identifiabl.com](https://identifiabl.com) +- GatewayStack (user-scoped trust & governance gateway): + https://github.com/davidcrowe/gatewaystack diff --git a/packages/identifiabl-core/package.json b/packages/identifiabl-core/package.json index 2abc94e..b61a30b 100644 --- a/packages/identifiabl-core/package.json +++ b/packages/identifiabl-core/package.json @@ -1,10 +1,23 @@ { - "name": "@gatewaystack/identifiabl-core", + "name": "identifiabl", "version": "0.1.0", + "description": "RS256 JWT verification and identity mapping for GatewayStack.", "main": "dist/index.js", "types": "dist/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/davidcrowe/gatewaystack.git", + "directory": "packages/identifiabl-core" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "prepublishOnly": "npm run build" + }, "dependencies": { - "@gatewaystack/request-context": "0.1.0" + "jose": "^6.1.0" } } - diff --git a/packages/identifiabl-core/src/index.ts b/packages/identifiabl-core/src/index.ts index ef461ff..c142a3d 100644 --- a/packages/identifiabl-core/src/index.ts +++ b/packages/identifiabl-core/src/index.ts @@ -4,10 +4,33 @@ import { type JWTPayload, } from "jose"; -import type { - GatewayIdentity, - IdentitySource, -} from "@gatewaystack/request-context"; +// import type { +// GatewayIdentity, +// IdentitySource, +// } from "@gatewaystack/request-context"; + +// remove this: +// import type { GatewayIdentity, IdentitySource } from "@gatewaystack/request-context"; + +export type IdentitySource = + | "auth0" + | "stytch" + | "cognito" + | "custom" + | string; + +export interface GatewayIdentity { + sub: string; + issuer: string; + tenantId?: string; + email?: string; + name?: string; + roles?: string[]; + scopes?: string[]; + plan?: string; + source: IdentitySource; + raw: Record; +} export interface IdentifiablCoreConfig { issuer: string; @@ -34,20 +57,22 @@ export interface VerifyFailure { export type VerifyResult = VerifySuccess | VerifyFailure; -/** - * Escape a string for safe use inside a RegExp literal. - */ -function escapeForRegex(input: string): string { - return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} +// /** +// * Escape a string for safe use inside a RegExp literal. +// */ +// function escapeForRegex(input: string): string { +// return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +// } + +// /** +// * Build a pattern that matches the issuer with or without a trailing slash. +// */ +// function buildIssuerPattern(issuer: string): RegExp { +// const issuerNoSlash = issuer.replace(/\/+$/, ""); +// return new RegExp(`^${escapeForRegex(issuerNoSlash)}\\/?$`); +// } + -/** - * Build a pattern that matches the issuer with or without a trailing slash. - */ -function buildIssuerPattern(issuer: string): RegExp { - const issuerNoSlash = issuer.replace(/\/+$/, ""); - return new RegExp(`^${escapeForRegex(issuerNoSlash)}\\/?$`); -} function mapPayloadToGatewayIdentity( payload: JWTPayload, @@ -110,14 +135,21 @@ function mapPayloadToGatewayIdentity( }; } +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value[end - 1] === "/") { + end--; + } + return value.slice(0, end); +} + /** * Factory that returns a token verifier you can use in any environment. */ export function createIdentifiablVerifier( config: IdentifiablCoreConfig ): (token: string) => Promise { - const issuerNoSlash = config.issuer.replace(/\/+$/, ""); - const issuerPattern = buildIssuerPattern(config.issuer); + const issuerNoSlash = trimTrailingSlashes(config.issuer); const audience = config.audience; const jwksUri = config.jwksUri || `${issuerNoSlash}/.well-known/jwks.json`; @@ -126,10 +158,15 @@ export function createIdentifiablVerifier( return async (token: string): Promise => { try { - const { payload } = await jwtVerify(token, JWKS, { audience }); + const { payload } = await jwtVerify(token, JWKS, { + audience, + algorithms: ["RS256"], + clockTolerance: "60s" + }); const iss = String(payload.iss || ""); - if (!issuerPattern.test(iss)) { + const issNoSlash = trimTrailingSlashes(iss); + if (issNoSlash !== issuerNoSlash) { return { ok: false, error: "invalid_token", @@ -156,4 +193,4 @@ export function createIdentifiablVerifier( }; } }; -} +} \ No newline at end of file diff --git a/packages/identifiabl/package.json b/packages/identifiabl/package.json index f7645ad..c450406 100644 --- a/packages/identifiabl/package.json +++ b/packages/identifiabl/package.json @@ -2,5 +2,12 @@ "name": "@gatewaystack/identifiabl", "version": "0.0.1", "main": "dist/index.js", - "types": "dist/index.d.ts" + "types": "dist/index.d.ts", + "dependencies": { + "jose": "^5.0.0", + "identifiabl": "0.1.0" + }, + "peerDependencies": { + "express": "^4.0.0 || ^5.0.0" + } } \ No newline at end of file diff --git a/packages/identifiabl/src/index.ts b/packages/identifiabl/src/index.ts index a475464..440cd24 100644 --- a/packages/identifiabl/src/index.ts +++ b/packages/identifiabl/src/index.ts @@ -40,44 +40,26 @@ function buildIssuerPattern(issuer: string): RegExp { * - Checks audience and issuer * - Attaches the JWT payload to req.user */ -export function identifiabl(config: IdentifiablConfig): RequestHandler { - const issuerNoSlash = config.issuer.replace(/\/+$/, ""); - const issuerPattern = buildIssuerPattern(config.issuer); - const audience = config.audience; - const jwksUri = - config.jwksUri || `${issuerNoSlash}/.well-known/jwks.json`; +import { createIdentifiablVerifier } from "identifiabl"; - const JWKS = createRemoteJWKSet(new URL(jwksUri)); +export function identifiabl(config: IdentifiablConfig): RequestHandler { + const verify = createIdentifiablVerifier(config); const middleware: RequestHandler = async (req: any, res, next) => { - try { - const auth = req.headers.authorization || ""; - const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; - - if (!token) { - return res.status(401).json({ error: "missing_bearer" }); - } + const auth = req.headers.authorization || ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; - // Verify signature & audience - const { payload } = await jwtVerify(token, JWKS, { audience }); - - // Enforce expected issuer (with or without trailing slash) - const iss = String(payload.iss || ""); - if (!issuerPattern.test(iss)) { - return res.status(401).json({ - error: "invalid_token", - detail: `unexpected "iss" claim value: ${iss}`, - }); - } + if (!token) { + return res.status(401).json({ error: "missing_bearer" }); + } - // Attach identity to the request - req.user = payload; - return next(); - } catch (e: any) { - return res - .status(401) - .json({ error: "invalid_token", detail: e?.message }); + const result = await verify(token); + if (!result.ok) { + return res.status(401).json(result); } + + req.user = result.identity; + return next(); }; return middleware; diff --git a/packages/identifiabl/tsconfig.json b/packages/identifiabl/tsconfig.json index d23549d..38dbd4d 100644 --- a/packages/identifiabl/tsconfig.json +++ b/packages/identifiabl/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "src", - "outDir": "../../dist/identifiabl", + "outDir": "../../dist", // changed from "outDir": "../../dist/identifiabl", "composite": true }, "include": ["src"]