@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 use2. 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:
- Extracts the token from the configured source (Bearer header by default).
- Checks the local LRU cache (default 5 min TTL).
- On cache miss:
GET <baseURL>/auth/mewith the token as Bearer + yourX-API-Key. - Attaches
req.user(aUserProfileResponseDto) andreq.token(the raw JWT string). - Returns 401 (or skips silently in
optional: truemode) 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
cacheTimeoutlow (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 entryThe 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
authMiddlewareafter route handlers. Express runs in declaration order; mount it beforeapp.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.user12. 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 againstaudience='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.
