npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@winwinmbs/portal-auth-server

v2.1.0

Published

Server-side auth for WINWIN Portal — Express middleware + NestJS guard

Readme

@winwinmbs/portal-auth-server — Implementation Guide

Server-side auth for backends that integrate with WINWIN Portal. Validates incoming Bearer JWTs (or HttpOnly cookies) against the portal's /auth/me endpoint, attaches the user profile to the request, and caches it.

For AI agents: this package validates tokens that other apps already obtained — it is not a token issuer. The portal API still mints/rotates JWTs; this middleware just trusts them by re-asking the portal what user they belong to. If the question is "how do I login a user here?", the answer is in @winwinmbs/portal-auth.


1. Install

# Express-only consumer
npm install @winwinmbs/portal-auth-server express

# NestJS-only consumer
npm install @winwinmbs/portal-auth-server @nestjs/common reflect-metadata

# Both peers are declared optional — only install what you use

2. Three import paths

| Import | When | |--------|------| | @winwinmbs/portal-auth-server | Shared types, AuthService, low-level helpers | | @winwinmbs/portal-auth-server/express | Express middleware (authMiddleware, helpers) | | @winwinmbs/portal-auth-server/nestjs | NestJS factories (createAuthGuard, decorators) |

You almost never need the root path — pick the framework adapter.


3. Quickstart — Express

import express from 'express';
import {
  authMiddleware,
  requireAuth,
} from '@winwinmbs/portal-auth-server/express';

const app = express();

app.use(authMiddleware({
  baseURL: process.env.PORTAL_API_URL!,    // e.g. https://api.winwinmbs.com
  apiKey: process.env.PORTAL_API_KEY!,     // your app's API key from portal admin
  cacheTimeout: 300,                       // seconds; default 5 min
  excludePaths: ['/health', /^\/public/],  // skip auth on these
}));

app.get('/me', (req, res) => {
  // Throws if !req.user. Use this when the route MUST be authenticated.
  const { user, token } = requireAuth(req);
  res.json({ email: user.email });
});

app.get('/optional', (req, res) => {
  // Read without throwing.
  res.json({ user: req.user ?? null });   // req.user populated by middleware
});

What the middleware does, per request:

  1. Extracts the token from the configured source (Bearer header by default).
  2. Checks the local LRU cache (default 5 min TTL).
  3. On cache miss: GET <baseURL>/auth/me with the token as Bearer + your X-API-Key.
  4. Attaches req.user (a UserProfileResponseDto) and req.token (the raw JWT string).
  5. Returns 401 (or skips silently in optional: true mode) on failure.

4. Quickstart — NestJS

// auth-config.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { createAuthGuard } from '@winwinmbs/portal-auth-server/nestjs';

const PortalAuthGuard = createAuthGuard({
  baseURL: process.env.PORTAL_API_URL!,
  apiKey: process.env.PORTAL_API_KEY!,
  cacheTimeout: 300,
  excludePaths: [/^\/health/],
});

@Module({
  providers: [
    { provide: APP_GUARD, useClass: PortalAuthGuard },
  ],
})
export class AuthConfigModule {}
// in any controller
import { Controller, Get } from '@nestjs/common';
import { CurrentUser } from '@winwinmbs/portal-auth-server/nestjs';
import type { User } from '@winwinmbs/portal-auth-server';

@Controller('me')
export class MeController {
  @Get()
  me(@CurrentUser() user: User) {
    return { email: user.email };
  }
}

For routes that should be optional, mount createOptionalAuthGuard(...) instead of createAuthGuard(...) — same config, but request.user is null on missing/invalid tokens rather than throwing.


5. Configuration reference

interface MiddlewareConfig {
  /** Portal API base URL (no trailing slash) */
  baseURL: string;

  /** Your app's API key, registered in portal admin */
  apiKey: string;

  /** Header name for API key (default: 'X-API-Key') */
  apiKeyHeader?: string;

  /** Cache TTL in seconds (default 300 = 5 min) */
  cacheTimeout?: number;

  /** Skip auth on these paths (string match exact, RegExp matches pattern) */
  excludePaths?: (string | RegExp)[];

  /** Allow unauthenticated requests (req.user = null instead of 401) */
  optional?: boolean;

  /** Where to read the token from. Default 'bearer' */
  tokenStrategy?: 'bearer' | 'cookie';

  /** Cookie name when tokenStrategy='cookie' (default 'access_token') */
  cookieName?: string;

  /** Custom token extractor — overrides everything else */
  tokenExtractor?: (req: any) => string | null;
}

When to use which tokenStrategy

| Strategy | When | |----------|------| | 'bearer' | External app receiving Bearer tokens from a frontend (default — covers 95% of cases) | | 'cookie' | Same-origin SSR backend reading the portal's HttpOnly cookie directly | | tokenExtractor | Anything custom: dual-source fallback, header rename, signed cookie, etc. |


6. The cache — what to know

Every successful /auth/me response is cached by token for cacheTimeout seconds. Implications:

  • ✅ Fast: 99% of requests skip the network round trip
  • ✅ Self-purging: cache entries expire automatically
  • ⚠️ Eventually consistent: if portal admin revokes the user's session, the middleware keeps using the cached profile until the entry expires
  • ⚠️ Memory: holds N entries (where N = active token count). Default ceiling is generous; set cacheTimeout low (e.g. 60s) for security-sensitive paths

To invalidate manually (e.g. after a logout webhook):

import { clearAuthCache } from '@winwinmbs/portal-auth-server/express';

clearAuthCache();              // wipe everything
clearAuthCache(theToken);      // wipe one entry

The NestJS adapter exports clearNestAuthCache with the same signature — they're separate caches. Clear both if you use both.


7. Common scenarios

7.1 Permission-gated route (Express)

import { requireAuth } from '@winwinmbs/portal-auth-server/express';

app.get('/admin/audit', (req, res) => {
  const { user } = requireAuth(req);
  if (!user.permissions?.includes('admin:read')) {
    return res.status(403).json({ message: 'Forbidden' });
  }
  res.json({ /* ... */ });
});

7.2 Permission-gated route (NestJS)

import { CanActivate, ExecutionContext, ForbiddenException, Injectable, mixin } from '@nestjs/common';
import { CurrentUser } from '@winwinmbs/portal-auth-server/nestjs';

function HasPermission(perm: string) {
  @Injectable()
  class Guard implements CanActivate {
    canActivate(ctx: ExecutionContext) {
      const req = ctx.switchToHttp().getRequest();
      if (!req.user?.permissions?.includes(perm)) {
        throw new ForbiddenException();
      }
      return true;
    }
  }
  return mixin(Guard);
}

@UseGuards(HasPermission('admin:read'))
@Get('audit')
audit(@CurrentUser() user) {
  return { /* ... */ };
}

7.3 Reading the API key for downstream calls

app.use(authMiddleware({ /* ... */ }));

app.get('/forward', async (req, res) => {
  const { user, token } = requireAuth(req);

  // Forward the same token to another portal endpoint:
  const upstream = await fetch(`${PORTAL_URL}/api/something`, {
    headers: {
      Authorization: `Bearer ${token}`,
      'X-API-Key': process.env.PORTAL_API_KEY!,
    },
  });
  res.json(await upstream.json());
});

7.4 OAuth 2.0 token introspection (RFC 7662)

If your backend acts as a resource server for portal OAuth (not portal session auth), use the OAuth helpers instead:

import { oauthMiddleware, requireOAuth } from '@winwinmbs/portal-auth-server/express';

app.use('/oauth-resource', oauthMiddleware({
  issuerUrl: 'https://portal.winwinmbs.com',
  clientId: 'your-resource-server-client-id',
  clientSecret: process.env.OAUTH_CLIENT_SECRET!,
  cacheTimeout: 60,
}));

app.get('/oauth-resource/data', (req, res) => {
  const { tokenInfo } = requireOAuth(req);
  res.json({ scope: tokenInfo.scope });
});

This is a different code path from authMiddleware — they don't share a cache or a token format. Use oauthMiddleware only when consumers send OAuth-issued tokens (from /oauth/authorize flow), not portal-session JWTs.


8. Response contract

After successful authentication, req.user matches the portal's UserProfileResponseDto (re-exported as User from this package's root):

interface User {
  id: string;
  email: string;
  first_name: string;
  last_name: string;
  status: string;
  permissions: string[];
  roles: { id: string; name: string }[];
  positions: { id: string; name: string; code: string; type: string; is_primary: boolean }[];
  // ... more fields, see @win-portal/shared/auth.UserProfileResponseDto
}

req.token is the raw JWT (use it for forwarding only — don't decode it for identity claims; trust the cached profile).


9. Error handling

The middleware sends 401 with a structured body:

{
  "statusCode": 401,
  "message": "Authentication required",
  "error": "Unauthorized"
}

When the portal returns 401 (token expired/revoked), the body is forwarded verbatim with the upstream message. When optional: true, no response is sent — req.user and req.token are null and the next handler runs.

For NestJS the equivalent is a thrown UnauthorizedException (which the default exception filter formats consistently with the rest of your app).


10. Don'ts

  • Don't validate the JWT signature in your middleware. That's the portal's job. Validating both means you ship the JWT secret to your app, which defeats the purpose of token rotation.
  • Don't cache forever. A revoked session keeps working until the cache entry expires. 5 min is the right default; 24 h is wrong.
  • Don't write to req.user. It's read-only after the middleware. Mutating it (e.g. attaching extra org context) leads to confused debugging.
  • Don't put authMiddleware after route handlers. Express runs in declaration order; mount it before app.use(routes).
  • Don't share the API key across apps. Each integrating backend should have its own portal API key — that's what gates which app the token is valid for. Sharing breaks the multi-app membership model.

11. Cross-package integration

| Combine with | Why | |--------------|-----| | @winwinmbs/portal-auth | The frontend SDK that mints the tokens this middleware validates | | @winwinmbs/portal-auth-react | React bindings used by the frontend; not used by this server package | | @winwinmbs/portal-api | If your backend also acts as a portal API consumer (admin-only stuff), use this client + a service-to-service token |

Typical request lifecycle across packages:

Browser (portal-auth-react)
  │  Authorization: Bearer <api_client JWT>
  ▼
Your backend (portal-auth-server middleware)
  │  cache hit? ─yes─▶ req.user populated, handler runs
  │       │ no
  │       ▼
  │  GET <portal>/auth/me   ───▶ Portal API (validates aud='api_client')
  │       │
  ◀───────┘  cache the response, populate req.user
  ▼
Your route handler runs with req.user

12. Test recipes

The package ships a Jest spec at src/express/middleware.spec.ts that demonstrates the full middleware contract under axios mocking. Pattern to copy:

jest.mock('axios', () => {
  const g = globalThis as any;
  g.__mockGet = jest.fn(async () => {
    if (g.__nextError) throw g.__nextError;
    return g.__nextResponse;
  });
  const stub = {
    get: g.__mockGet,
    defaults: { headers: { common: {} } },
    interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
  };
  return { __esModule: true, default: { create: () => stub }, create: () => stub };
});

import { authMiddleware } from '@winwinmbs/portal-auth-server/express';

beforeEach(() => {
  (globalThis as any).__nextResponse = null;
  (globalThis as any).__nextError = null;
});

test('populates req.user on /auth/me 200', async () => {
  (globalThis as any).__nextResponse = {
    data: { success: true, data: { id: 'u1', email: 'a@b' } },
  };
  // ...
});

The hoisted-globalThis pattern is the workaround for jest.mock's factory running before module-level let initializers.


13. Where to look in source

| File | Contents | |------|----------| | src/express/middleware.ts | authMiddleware, getAuth, requireAuth, clearAuthCache | | src/express/oauth-middleware.ts | OAuth 7662 introspection middleware | | src/nestjs/guard.ts | createAuthGuard, createOptionalAuthGuard | | src/nestjs/oauth-guard.ts | OAuth equivalents | | src/nestjs/decorators.ts | @CurrentUser, @CurrentToken | | src/shared/auth-service.ts | The HTTP client behind the middleware (/auth/me call) | | src/shared/token-extractor.ts | Strategy implementations (bearer/cookie/custom) | | src/shared/user-cache.ts | LRU cache |


14. Reference: standardization

This middleware sends Bearer tokens to the portal's /auth/me endpoint. The portal's JwtStrategy routes the audience check based on how the token arrived (cookie vs header), so:

  • A token your app received from the browser as Authorization: Bearer ... validates against audience='api_client' (the SDK side mints exactly that audience). ✅
  • The same token replayed in a cookie header is rejected as a cross-context replay attempt. ✅

You don't need to think about audiences at all — the portal does the right thing. The only thing you need to get right is forwarding the token verbatim, which authMiddleware already does.

Full server-side rules: docs/plans/2026-05-05-session-standardization-design.md.