@easy-sso/node
v0.0.0
Published
Provider-agnostic SSO framework for Node.js, with Microsoft Entra ID as the first provider.
Maintainers
Readme
@easy-sso/node
Provider-agnostic SSO for Node.js. Microsoft Entra ID today; Google, Okta, Auth0, Keycloak tomorrow — with zero public-API changes.
@easy-sso/node validates OAuth2 / OIDC bearer tokens and gives your routes a
typed, normalized user — without tying your codebase to any one identity
provider. The core knows only an AuthProvider interface; Microsoft Entra ID is
the first concrete implementation.
Docs: Getting started · Adding a provider
import { microsoftAuth, requireAuth, requireRole } from "@easy-sso/node";
app.use(microsoftAuth({ clientId: process.env.CLIENT_ID, tenantId: "common" }));
app.get("/profile", requireAuth(), (req, res) => res.json(req.user));
app.get("/admin", requireAuth(), requireRole("Admin"), (req, res) => res.json({ ok: true }));Why
- Provider-agnostic by design. Your app depends on
AuthProvider/AuthUser, never on Microsoft. Swapping or adding a provider doesn't touch your route code. - Secure defaults, non-negotiable. Signature validation is never
disable-able. Issuer, audience,
exp, andnbfare always checked. OnlyRS256is accepted (alg: noneand HMAC-confusion are impossible). - Production-ready JWKS handling. In-memory cache with TTL, background refresh, key-rotation retry, and single-flight dedup so a burst of requests triggers exactly one key fetch.
- Strict TypeScript, zero
any. Fully typed public surface,.d.tsemitted, dual ESM + CJS builds.
Install
npm install @easy-sso/node
# express is an optional peer dependency if you use the middleware
npm install expressQuick start
import express from "express";
import { microsoftAuth, requireAuth, requireRole, AuthError } from "@easy-sso/node";
const app = express();
// Validates a bearer token if present and attaches a typed `req.user`.
app.use(microsoftAuth({ clientId: process.env.CLIENT_ID!, tenantId: "common" }));
app.get("/me", requireAuth(), (req, res) => res.json(req.user));
app.get("/admin", requireAuth(), requireRole("Admin"), (req, res) => res.json({ ok: true }));
// The framework's errors carry `statusCode` and a structured `toJSON()` body.
app.use((err, _req, res, next) => {
if (AuthError.isAuthError(err)) return res.status(err.statusCode).json(err.toJSON());
next(err);
});A request with no token continues as anonymous (so public routes work);
requireAuth() enforces 401, requireRole() enforces 403. A request with an
invalid token is always rejected.
Architecture
┌───────────────────────────┐
HTTP request ──────► │ middleware (Express-shaped) │
│ microsoftAuth / requireAuth │
└──────────────┬────────────┘
│ depends only on
▼
┌───────────────────────────┐
│ core: AuthProvider │ ◄── provider-agnostic seam
│ AuthUser · AuthContext │
└──────────────┬────────────┘
│ implemented by
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
MicrosoftProvider (GoogleProvider) (OktaProvider)
├─ token-validator …same interface, …
├─ jwks-client zero API change
└─ claims-mapper
│
▼
cache: MemoryKeyCache (TTL · single-flight · rotation)core/—AuthProvider,AuthUser,AuthContext, claim types. Imports nothing fromproviders/.providers/microsoft/— the only place that knows Entra issuer formats, JWKS endpoints, and claim names.middleware/— genericcreateAuthMiddleware(provider)plus themicrosoftAuthconvenience and therequireAuth/requireRoleguards.cache/— value-generic JWKS cache.errors/,config/,utils/— typed errors, fail-fast config validation, bearer parsing.
Microsoft / Entra setup
- Register an application in the Entra admin center → App registrations → New registration. Note the Application (client) ID.
- Pick your tenant mode:
common— any Microsoft account (work/school and personal).organizations— any work/school tenant.consumers— personal Microsoft accounts only.<tenant-guid>— a single specific tenant.
- Expose an API / set the audience. Access tokens for your API will carry
aud = <client-id>orapi://<client-id>. Both are accepted by default; passaudienceto override. - (Optional) App roles. Define roles under App roles; assigned roles
appear in the
rolesclaim and driverequireRole().
microsoftAuth({
clientId: process.env.CLIENT_ID!,
tenantId: "organizations",
audience: [`api://${process.env.CLIENT_ID}`], // optional override
allowedTenants: ["<tenant-guid-1>", "<tenant-guid-2>"], // optional allow-list
});The issuer is resolved dynamically from the token's real tid and version —
correct for common/organizations where the issuer is not a fixed string —
then validated cryptographically.
Google setup
Google ships as a second built-in provider via googleAuth (or GoogleProvider
createAuthMiddleware). It validates Google-issued OIDC ID tokens against Google's live JWKS (https://www.googleapis.com/oauth2/v3/certs), issuerhttps://accounts.google.com, andaud = <your OAuth client id>.
import { googleAuth, requireAuth } from "@easy-sso/node";
app.use(
googleAuth({
clientId: process.env.GOOGLE_CLIENT_ID!, // expected `aud`
audience: ["web-client-id", "ios-client-id"], // optional: several client IDs
hostedDomains: ["yourcompany.com"], // optional: restrict to a Workspace domain (hd)
}),
);
app.get("/me", requireAuth(), (req, res) => res.json(req.user));Google ID tokens carry no application roles, so roles defaults to empty (map
roles from your own store). The Workspace hd claim is surfaced as
AuthUser.tenantId. GoogleAuthConfig: { clientId, audience?, hostedDomains?,
clockToleranceSec?, claims?, jwks?, fetch?, clock? }.
API reference
Middleware
| Export | Signature | Description |
| --- | --- | --- |
| microsoftAuth | (config, options?) => RequestHandler | Entra provider + middleware in one. |
| googleAuth | (config, options?) => RequestHandler | Google provider + middleware in one. |
| createAuthMiddleware | (provider, options?) => RequestHandler | Generic middleware over any AuthProvider. |
| requireAuth | (options?) => RequestHandler | 401 unless req.user is set. |
| requireRole | (role, options?) => RequestHandler | 403 unless the user has role. |
| requireRoles | (roles, options?) => RequestHandler | mode: "any" (default) or "all". |
| defaultErrorHandler | AuthErrorHandler | JSON + status + WWW-Authenticate. |
AuthMiddlewareOptions: { credentialsRequired?, tokenExtractor?, onError? }.
Config (MicrosoftAuthConfig)
| Field | Default | Notes |
| --- | --- | --- |
| clientId | — (required) | Application (client) ID. |
| tenantId | "common" | common / organizations / consumers / GUID. |
| audience | [clientId, "api://"+clientId] | string or string[]. |
| allowedTenants | [] | extra GUID allow-list. |
| acceptedVersions | ["1.0","2.0"] | accepted token versions. |
| clockToleranceSec | 60 | skew tolerance for exp/nbf. |
| claims | Microsoft defaults | custom claim mapping (see below). |
| jwks | { ttlMs: 3600000 } | { ttlMs?, refreshIntervalMs?, uri? }. |
| authorityHost / v1IssuerHost | public cloud | override for sovereign clouds. |
Misconfiguration throws a descriptive ConfigurationError at startup.
Custom claim mapping
microsoftAuth({
clientId,
claims: {
email: ["email", "preferred_username", "upn"], // first present wins
name: "name",
roles: ["roles", "groups"], // arrays or space-delimited strings
},
});AuthUser
interface AuthUser {
readonly id: string; // oid → sub
readonly email?: string;
readonly name?: string;
readonly roles: readonly string[];
readonly scopes: readonly string[];
readonly tenantId?: string; // tid
readonly provider: string; // "microsoft"
readonly claims: Readonly<RawClaims>;
}Errors
AuthError (base) → AuthenticationError · MissingTokenError ·
TokenExpiredError · TokenNotYetValidError · InvalidAudienceError ·
InvalidIssuerError · InvalidSignatureError · InvalidTokenError ·
AuthorizationError (403) · ConfigurationError (500). Each has code,
message, statusCode, and toJSON().
Adding a provider
See docs/adding-a-provider.md for a worked
example against the same AuthProvider interface.
License
MIT
