@partechpartners/auth
v4.4.1
Published
Server-side helpers for Partech authentication. Install `@partechpartners/auth` and `axios`; Express apps also need `express` and `cookie-parser`.
Readme
@partechpartners/auth
Server-side helpers for Partech authentication. Install @partechpartners/auth and axios; Express apps also need express and cookie-parser.
This package is available on the public npm registry.
Existing internal services
import {
createAuthApi,
createAuthExpressMiddleware,
handleExpressSSO,
} from '@partechpartners/auth';
const authApi = createAuthApi({
internalServiceUrl: 'http://auth-v2-internal',
publicServiceUrl: 'https://auth.partechpartners.com',
service: 'my-service',
});
const authenticated = createAuthExpressMiddleware({
authApi,
applicationUrl: 'https://my-service.partechpartners.com',
});
app.use(cookieParser());
app.get('/private', authenticated({ includeUser: true }), (req, res) => {
res.json({ email: req.user?.email });
});
app.get('/login', handleExpressSSO({ authApi }));For return URLs, pass allowedRedirectOrigins to handleExpressSSO; it rejects unlisted origins. applicationUrl gives the middleware a trusted origin for automatic post-login redirects.
External app mode
An external app uses an app token issued by auth-v2. Store the token only in server environment variables. Never use a NEXT_PUBLIC_ variable, render the token into HTML, pass it as a browser header, or log the Axios request. The backend binds the token to one registered app; the SDK rejects requests for another service.
import { createAuthApi } from '@partechpartners/auth';
const authApi = createAuthApi({
mode: 'external',
externalAuthUrl: process.env.AUTH_BACKEND_URL!,
appToken: process.env.AUTH_APP_TOKEN!,
publicAuthUrl: process.env.AUTH_PUBLIC_URL!,
});
// Load once before using synchronous getSsoUrl() directly.
const config = await authApi.service.config();
// config.service_name and config.cookie_name come from the app token.
const ssoUrl = authApi.getSsoUrl();The external backend supports config, session validation, and logout. authenticateSessionToken and disableSessionToken keep their existing signatures. Session validation uses the backend's normal refresh behavior, so the app makes no separate refresh call. Other auth API methods, including app-token authentication and Microsoft on-behalf-of calls, reject clearly in external mode. A valid session without the app's required roles returns 403. A rejected app token or unreachable backend fails closed with a service error; it does not start another SSO cycle.
Logout invalidates the shared production-group session, so it signs the user out of other apps sharing that cookie. Show that consequence in the app's sign-out UI.
Vercel Next.js starter
Register a routed app at the environment's shared app origin: https://apps.dev.partechpartners.com/<app> in dev, https://apps.staging.partechpartners.com/<app> in staging, or https://apps.partechpartners.com/<app> in production. The app's routing team still determines ownership and access policy, while its catalog classification determines menu grouping. Each app has its own auth-v2 service in the shared production group, which uses the p_production cookie. The app does not create its own session cookie. Existing direct-domain services may keep their registered URLs.
In Vercel, set these server-only environment variables for the selected deployment. The shared app host points to the app router, which forwards the app path to the Vercel target origin:
| Variable | Production value |
| --- | --- |
| AUTH_BACKEND_URL | https://auth.partechpartners.com/v2 |
| AUTH_PUBLIC_URL | https://auth.partechpartners.com/v2 |
| AUTH_APP_TOKEN | The one-time token value issued for this registered app by auth-v2 |
Use the corresponding auth-v2 base URL and token in other environments. Both URLs include /v2: the SDK appends /api/external-auth/* for backend calls and /oidc/{service_name} for browser SSO. Keep AUTH_APP_TOKEN out of NEXT_PUBLIC_ variables, browser code, response headers, logs, and build-time public assets. The SDK sends it only from the server to auth-v2. The registered app can require roles; auth-v2 enforces those roles during session validation, so a signed-in user without access receives 403.
// middleware.ts — executes on the server/edge, never in a Client Component
import { NextResponse, type NextRequest } from 'next/server';
import { authNextMiddleware } from '@partechpartners/auth/next';
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
const result = await authNextMiddleware(request, response, {
AuthApiParams: {
mode: 'external',
externalAuthUrl: process.env.AUTH_BACKEND_URL!,
appToken: process.env.AUTH_APP_TOKEN!,
publicAuthUrl: process.env.AUTH_PUBLIC_URL!,
},
applicationUrl: 'https://apps.partechpartners.com/my-app',
publicRoutes: [],
});
return result ?? response;
}
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] };The auth middleware handles /auth/logout, /auth/switch-account, and /auth/insufficient-permissions. Keep them inside the matcher. The public app URL must match the registered URL; avoid preview-host redirects for the shared production cookie. On Vercel, use applicationUrl so redirects cannot derive an origin from a request header.
For a routed app at https://apps.dev.partechpartners.com/expenses, build Next.js with basePath: '/expenses' and pass the full public URL as applicationUrl. Next.js removes the base path from request.nextUrl.pathname in middleware; the SDK uses the trusted public URL for login, logout, and return redirects and forwards it for server-side URL reconstruction. basePath may also be passed explicitly, but it must match the path in applicationUrl. The router must preserve /expenses when forwarding to the target origin. Keep the matcher on all pages and APIs. If target ownership verification is required, exempt only the exact /.well-known/partech-app-verification pathname and serve the registry's challenge as plain text there.
authNextMiddleware creates an auth client for each invocation, so its service config may be fetched on every request. The 15-minute config cache applies only within one createAuthApi instance; it is not a shared cache across Next.js requests.
The sign-out control can link to /auth/logout. Tell users that this invalidates the shared session for other apps in the production group too. The middleware never forwards AUTH_APP_TOKEN to the browser; it forwards only validated user identity to the protected application request.
Express external app
import cookieParser from 'cookie-parser';
import {
createAuthApi,
createAuthExpressMiddleware,
handleExpressSSO,
} from '@partechpartners/auth';
const authApi = createAuthApi({
mode: 'external',
externalAuthUrl: process.env.AUTH_BACKEND_URL!,
appToken: process.env.AUTH_APP_TOKEN!,
publicAuthUrl: process.env.AUTH_PUBLIC_URL!,
});
const authenticated = createAuthExpressMiddleware({
authApi,
applicationUrl: 'https://apps.partechpartners.com/my-app',
token: false,
});
app.use(cookieParser());
app.get('/login', handleExpressSSO({ authApi }));
app.get('/private', authenticated({ includeUser: true, redirectToSSOIfNotAuthenticated: true }), (req, res) => {
res.json({ email: req.user?.email });
});MCP Resource Server
Use the explicit MCP subpath for MCP Resource Server wiring:
import {
createNextMcpHandlers,
defineMcpTool,
mountExpressMcpResourceServer,
type McpPrincipal,
} from '@partechpartners/auth/mcp';Do not import MCP helpers from @partechpartners/auth or @partechpartners/auth/next.
The MCP subpath is ESM-only because the MCP SDK is ESM-first. Root
@partechpartners/auth and @partechpartners/auth/next stay separate so non-MCP consumers do
not load MCP SDK runtime or type constraints.
MCP projects must install the MCP SDK peer:
yarn add @partechpartners/auth @modelcontextprotocol/sdkIf the tool schema uses Zod, also install your app's Zod version:
yarn add zodImport safety
@partechpartners/auth/mcp is safe for Next App Router projects and ESM-compatible
Express projects:
- The
./mcppackage export has its owntypesandimportentry. - The generated MCP declarations do not import Express types; Next projects do
not need
expressinstalled just to import@partechpartners/auth/mcp. - The root
.and./nextexports do not re-export MCP helpers, so regular auth consumers do not inherit MCP SDK constraints. - Because
./mcpintentionally has norequirecondition, CommonJSrequire('@partechpartners/auth/mcp')is not supported. CommonJS Express apps must load it with dynamicimport()during startup or move the MCP entrypoint to ESM.
Express example
import express from 'express';
import { createAuthApi } from '@partechpartners/auth';
import {
defineMcpTool,
mountExpressMcpResourceServer,
} from '@partechpartners/auth/mcp';
import { z } from 'zod';
const authApi = createAuthApi({
internalServiceUrl: process.env.AUTH_INTERNAL_URL!,
publicServiceUrl: process.env.AUTH_PUBLIC_URL!,
service: process.env.AUTH_SERVICE_NAME!,
});
type SearchDocumentsInput = {
q?: string;
limit?: number;
includes_metadata?: boolean;
includes_facets?: boolean;
};
const searchDocuments = defineMcpTool<SearchDocumentsInput>({
name: 'search_documents',
title: 'Search Documents',
description: 'Search documents using permission-aware filters.',
requiredScopes: ['edm:documents:search'],
inputSchema: {
q: z.string().optional(),
limit: z.coerce.number().int().min(1).max(25).default(10),
includes_metadata: z.boolean().default(true),
includes_facets: z.boolean().default(false),
},
handler: async ({ input, principal }) => {
return {
documents: [],
found: 0,
has_more: false,
user_id: principal.userId,
query: input.q ?? null,
limit: input.limit ?? 10,
};
},
});
const app = express();
app.use(express.json());
mountExpressMcpResourceServer(app, {
enabled: process.env.MCP_ENABLED !== 'false',
path: process.env.MCP_PATH ?? '/mcp',
resourceUrl: process.env.MCP_PUBLIC_RESOURCE_URL!,
resourceName: process.env.MCP_RESOURCE_NAME ?? 'Documents MCP',
issuerUrl: process.env.AUTH_PUBLIC_URL!,
authApi,
requiredRoles: ['EDM', 'ADMIN_EDM', 'SUPER_ADMIN'],
server: {
name: 'documents-mcp',
version: '1.0.0',
},
tools: [searchDocuments],
});The helper mounts:
GET /.well-known/oauth-protected-resource/<mcp path>GET /.well-known/oauth-authorization-serverPOST <mcp path>- authenticated
GETandDELETE <mcp path>method-not-allowed responses
When the service is published through mcp-edge, keep path as the internal
mount path, usually /mcp, but set resourceUrl to the public edge resource,
for example https://mcp.dev.partechpartners.com/edm. The auth-v2 MCP Server
registry row must use the same generated resource URL and an upstream_url
pointing to the internal MCP endpoint.
Next App Router example
Create shared MCP handlers in server-only code:
// src/lib/mcp.ts
import { createAuthApi } from '@partechpartners/auth';
import {
createNextMcpHandlers,
defineMcpTool,
} from '@partechpartners/auth/mcp';
import { z } from 'zod';
const authApi = createAuthApi({
internalServiceUrl: process.env.AUTH_INTERNAL_URL!,
publicServiceUrl: process.env.AUTH_PUBLIC_URL!,
service: process.env.AUTH_SERVICE_NAME!,
});
type SearchDocumentsInput = {
q?: string;
limit?: number;
includes_metadata?: boolean;
includes_facets?: boolean;
};
const searchDocuments = defineMcpTool<SearchDocumentsInput>({
name: 'search_documents',
title: 'Search Documents',
description: 'Search documents using permission-aware filters.',
requiredScopes: ['edm:documents:search'],
inputSchema: {
q: z.string().optional(),
limit: z.coerce.number().int().min(1).max(25).default(10),
includes_metadata: z.boolean().default(true),
includes_facets: z.boolean().default(false),
},
handler: async ({ input, principal }) => {
return {
documents: [],
found: 0,
has_more: false,
user_id: principal.userId,
query: input.q ?? null,
limit: input.limit ?? 10,
};
},
});
export const mcpHandlers = createNextMcpHandlers({
enabled: process.env.MCP_ENABLED !== 'false',
path: process.env.MCP_PATH ?? '/mcp',
resourceUrl: process.env.MCP_PUBLIC_RESOURCE_URL!,
resourceName: process.env.MCP_RESOURCE_NAME ?? 'Documents MCP',
issuerUrl: process.env.AUTH_PUBLIC_URL!,
authApi,
requiredRoles: ['EDM', 'ADMIN_EDM', 'SUPER_ADMIN'],
server: {
name: 'documents-mcp',
version: '1.0.0',
},
tools: [searchDocuments],
});Expose the MCP route:
// src/app/mcp/route.ts
import { mcpHandlers } from '@/lib/mcp';
export const GET = mcpHandlers.GET;
export const POST = mcpHandlers.POST;
export const DELETE = mcpHandlers.DELETE;Expose protected-resource metadata for /mcp:
// src/app/.well-known/oauth-protected-resource/mcp/route.ts
import { mcpHandlers } from '@/lib/mcp';
export const GET = mcpHandlers.metadata.GET;Expose OAuth server metadata:
// src/app/.well-known/oauth-authorization-server/route.ts
import { mcpHandlers } from '@/lib/mcp';
export const GET = mcpHandlers.metadata.GET;For a resource URL such as https://api.example.com/mcp, the protected
resource metadata URL is:
https://api.example.com/.well-known/oauth-protected-resource/mcpThe resourceUrl value must match the MCP Server registry row in auth-v2. With
mcp-edge, that value is the public edge URL, not the internal upstream URL.
