@di-framework/auth
v5.3.7
Published
Authentication for di-framework — sessions, JWT/JWS bearer, OAuth2/OIDC, and WebAuthn passkeys on WebCrypto with zero runtime dependencies
Maintainers
Readme
@di-framework/auth
Authentication for di-framework — server sessions, JWT/JWS bearer tokens, OAuth2/OIDC sign-in, and WebAuthn passkeys. Built entirely on the Web Cryptography API with zero runtime dependencies, so the same code runs on Bun, Node 20+, Deno, and Cloudflare Workers.
Domain services stay on @di-framework/core; this package is the authentication and authorization integration layer. It provides a policy-neutral manager boundary, while applications or the optional @di-framework/authz companion own the policy model.
Features
- Password + server sessions: NIST SP 800-63B password policy, PBKDF2-HMAC-SHA-256 hashing, opaque session tokens stored hashed,
__Host-cookies, absolute and inactivity timeouts, regeneration on login, signed double-submit CSRF. - JWT / JWS bearer tokens: compact JWS over WebCrypto with a mandatory algorithm allowlist,
kidand JWKS publishing, overlapping key rotation, and opaque refresh tokens with rotation and reuse detection. - OAuth2 / OIDC relying party: Authorization Code with mandatory PKCE S256, discovery,
state+noncebinding, ID-token validation, and presets for Google, Microsoft Entra, GitHub, and any compliant OIDC provider. - WebAuthn passkeys: W3C WebAuthn Level 3 registration and authentication, including a CTAP2-canonical CBOR decoder and COSE key handling, with no dependencies.
- Provider pattern throughout: strategies and storage are plain interfaces with factory-function implementations. In-memory stores ship for development; a bridge over
@di-framework/repo'sStorageAdaptercovers real backends. - First-class HTTP and GraphQL guards: a typed
req.principalfor@di-framework/http, and an@Authenticated()decorator plusprotectSchema()for@di-framework/graphql. - Pluggable authorization: an
AuthorizationManagerhook with opaque policy metadata,requireAuthz()for HTTP, and@Authorize()for GraphQL. Policies remain application-owned and can live in OPA, SpiceDB, SQL, or an in-process manager.
Installation
bun add @di-framework/auth @di-framework/core
# optional integrations
bun add @di-framework/http @di-framework/graphql @di-framework/reponpm install @di-framework/auth @di-framework/coreDecorators need TypeScript 5 and experimentalDecorators. emitDecoratorMetadata is not required.
Quick Start
import { registerAuth } from '@di-framework/auth';
import { requireAuth, withAuthErrors, withAuthRoutes } from '@di-framework/auth/http';
import { TypedRouter, json } from '@di-framework/http';
const auth = registerAuth({
// At least 32 bytes. HKDF-expanded into the CSRF and cookie keys.
secret: process.env.AUTH_SECRET!,
jwt: { issuer: 'https://api.example.com', audience: 'api' },
});
await auth.passwords.createUser({
identifier: '[email protected]',
password: 'correct horse battery staple',
});
const router = TypedRouter({ catch: withAuthErrors() });
const secure = withAuthRoutes(router);
router.get('/health', () => json({ ok: true }));
// `req.principal` is typed as Principal, not any.
secure.get('/me', (req) => json({ sub: req.principal.sub }));Core Concepts
Strategies
An AuthStrategy answers one question: does this request carry a credential of my kind, and is it valid? Strategies are factory functions returning object literals, the same shape @di-framework/events uses for transports.
import { authenticated, authFailed, chain, createPrincipal, noCredential } from '@di-framework/auth';
function headerStrategy(users: UserStore): AuthStrategy {
return {
name: 'x-user',
async authenticate({ request }) {
const id = request.headers.get('x-user-id');
if (!id) return noCredential(); // not mine — try the next
const user = await users.findById(id);
if (!user) return authFailed('invalid_credentials', `No user '${id}'`); // mine, and bad — stop
return authenticated(createPrincipal({ sub: user.id, method: 'api-key' }));
},
};
}
const strategy = chain([sessionStrategy, bearerStrategy, headerStrategy(users)]);The three-state result is deliberate. no-credential means try the next strategy; failed halts the chain. Without that distinction, a forged bearer token falls through and the request ends up anonymous rather than rejected.
Principal
interface Principal {
sub: string; // stable subject id
method: AuthMethod; // how they proved it on this request
amr?: readonly string[]; // RFC 8176 methods, e.g. ['hwk', 'user', 'mfa']
acr?: string;
authTime: number; // when the original authentication happened
scope?: readonly string[];
sessionId?: string;
claims?: Record<string, unknown>;
}Authorization managers
AuthorizationManager is a policy decision point, not a role model. It receives the authenticated Principal (or undefined for an explicitly anonymous policy) and a transport context containing opaque metadata. Return { allowed: true } or { allowed: false, reason }; denial reasons are retained for logs and never sent to clients.
import type { AuthorizationManager } from '@di-framework/auth';
const authorization: AuthorizationManager = {
async authorize(principal, context) {
const response = await fetch('https://opa.example/v1/data/app/allow', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
input: { subject: principal?.sub, metadata: context.metadata },
}),
});
const decision = await response.json() as { result?: boolean };
return decision.result === true
? { allowed: true }
: { allowed: false, reason: 'remote policy denied the request' };
},
};
registerAuth({ secret, authorization });Pass manager to a guard or protectSchema() for a per-call override. Without an explicit manager, both integrations resolve auth.AuthorizationManager from DI.
HTTP
import { TypedRouter, json } from '@di-framework/http';
import {
applyAuthHeaders, createAuthRoutes, mountAuthRoutes,
requireAuth, requireAuthz, secured, securitySchemesFor, withAuthErrors, withAuthRoutes,
} from '@di-framework/auth/http';
const router = TypedRouter({
before: [requireAuth()], // or guard per route, below
catch: withAuthErrors(),
finally: [applyAuthHeaders], // applies cookies a guard queued
});Per-route protection, which types the principal:
const secure = withAuthRoutes(router);
@Controller()
export class MeController {
@Endpoint({ summary: 'Current principal', security: secured('bearerAuth') })
static get = secure.get('/me', async (req) => {
const controller = useContainer().resolve(MeController);
return json(await controller.load(req.principal.sub));
});
}Escape hatches: { auth: false } for a public route, { auth: { mode: 'optional' } } to attach a principal when present.
Add authorization after authentication with a protected-router option:
secure.get('/admin', (req) => json({ subject: req.principal.sub }), {
authorization: { metadata: { resource: 'admin', action: 'read' } },
});For RouteOptions.use, preserve the same order explicitly:
router.get('/admin', handler, {
use: [requireAuth(), requireAuthz({ metadata: { action: 'admin:read' } })],
});By default, requireAuthz() rejects a missing principal with 401. Set allowAnonymous: true only when the manager intentionally evaluates public-policy requests. A policy denial is always a generic 403 { error: "Access denied", code: "access_denied" } on the wire.
Mountable auth routes
const authRouter = createAuthRoutes({ oauth: { google: googleClient } });
mountAuthRoutes(router, authRouter, '/auth');| Method | Path | Notes |
| --- | --- | --- |
| POST | /auth/register | Creates a user and signs them in. |
| POST | /auth/login | Regenerates the session id — the fixation defence. |
| POST | /auth/logout | Idempotent; clears cookies server-side and on the client. |
| POST | /auth/refresh | Rotates the refresh token; reuse revokes the family. |
| GET | /auth/session | Whoami. Never echoes raw claims. |
| GET | /auth/csrf | A token bound to the current session. |
| POST | /auth/webauthn/{register,login}/{options,verify} | Challenge stored server-side, single-use. |
| GET | /auth/oauth/:provider/{start,callback} | PKCE and single-use state. |
OpenAPI
const spec = generateOpenAPI({
title: 'API',
securitySchemes: securitySchemesFor([auth.strategy]),
security: secured('bearerAuth'), // document-level default
});Mark an operation public with @Endpoint({ security: publicEndpoint }).
Why there is no @CurrentUser()
Handlers are static class properties invoked with itty's positional (req, ...args), not through a DI-constructed call. With emitDecoratorMetadata: false and no AsyncLocalStorage, a parameter decorator has nothing to hook into — implementing one would need a module-level "current request", which is unsound the moment two requests are in flight. Read req.principal, or getPrincipal(req) where the static type is not narrowed.
GraphQL
import { buildSemanticSchema, createGraphQLHandler } from '@di-framework/graphql';
import { Authenticated, Authorize, createAuthContext, protectSchema, requireSubject } from '@di-framework/auth/graphql';
@Portal()
class Library {
@Field(() => Book)
catalogue(): Book[] { return books; } // public
@Authenticated()
@Authorize({ resource: 'loan', action: 'create' })
@Action(() => Loan)
borrow(@Arg('id') id: string, @Ctx() ctx: AuthGraphQLContext): Loan {
return lend(id, requireSubject(ctx));
}
}
const api = protectSchema(buildSemanticSchema());
const handler = createGraphQLHandler(api, {
context: createAuthContext({ strategy: auth.strategy }),
});@Authenticated({ amr: ['mfa'], maxAge: 300 }) requires a stronger or more recent authentication — still authentication, not authorization.
@Authorize(metadata) applies to types, fields, actions, and subscriptions. It invokes the registered manager after any @Authenticated() check. Use @Authorize(metadata, { allowAnonymous: true }) only for a manager that deliberately makes decisions about anonymous callers. Policy denials use extensions.code === 'FORBIDDEN'; internal reasons remain redacted.
Authentication rejections surface as errors[0].extensions.code === 'UNAUTHENTICATED', and the message on the wire is always the generic public text.
Note: printSDL renders from the type graph rather than the executable schema, so @authenticated does not appear in the printed SDL. The SDL describes the shape; the executable schema enforces access.
Storage providers
import { inMemoryAuthStores } from '@di-framework/auth';
import { repoSessionStore, repoUserStore } from '@di-framework/auth/repo';
registerAuth({
secret,
stores: {
users: repoUserStore({ adapter: userAdapter }),
sessions: repoSessionStore({ adapter: sessionAdapter }),
},
});Interfaces: UserStore, SessionStore, CredentialStore, StateStore, RefreshTokenStore, KeyStore, LoginThrottle.
Three methods must be a compare-and-swap: StateStore.consume, RefreshTokenStore.rotate, and CredentialStore.updateSignCount. They are the replay defences for OAuth state, refresh tokens, and WebAuthn sign counters. StorageAdapter has no conditional write, so repoStateStore and repoRefreshTokenStore require an explicit atomic hook and throw at construction without one rather than shipping a defence that silently does nothing.
Security notes
Choosing a password hasher. The default is PBKDF2-HMAC-SHA-256 at 600,000 iterations (NIST SP 800-132; OWASP's 2024 figure), because it is the only password KDF the Web Cryptography API offers and this package carries no dependencies. PBKDF2 is memory-cheap, so a GPU attacker gets better value against it than against Argon2id. If you run on Bun or Node, supply a stronger hasher:
registerAuth({ secret, hasher: bunPasswordHasher() }); // Argon2id via Bun.passwordStored hashes record their own parameters, and login re-hashes transparently when they are below the current setting — so upgrading is a one-line change with no migration.
Login throttling is on by default and is not optional in spirit: SP 800-63B §5.2.2 requires it, and PBKDF2 at 600,000 iterations is expensive enough to be a denial-of-service vector without it. The in-memory throttle is per-process; use a shared one behind a load balancer.
Cookies. Defaults are __Host- prefixed: Secure, HttpOnly, Path=/, no Domain, SameSite=Lax. Setting a Domain downgrades the name to __Secure- and warns, because browsers reject a __Host- cookie carrying a Domain outright. A __Secure- cookie is writable by every subdomain, so if you need cross-subdomain SSO, pair it with strict CSRF checking.
Cloudflare Workers. Session and bearer verification are cheap and run fine at the edge. PBKDF2 at 600,000 iterations may exceed the Workers CPU limit — verify passwords at your origin.
Timing. Login reports the same error and burns the same work whether the user is missing, has no password, or typed the wrong one.
API Reference
| Export | Description |
| --- | --- |
| registerAuth(options) | Build and register the runtime with the DI container. |
| Auth(options) | Class-decorator sugar over registerAuth. |
| chain(strategies) | Compose strategies, first match wins. |
| sessionCookieStrategy / bearerTokenStrategy / apiKeyStrategy | Built-in strategies. |
| sessionManager(options) | Session issuance, resolution, regeneration, revocation. |
| passwordService(options) | NIST-compliant password policy, login, and rehashing. |
| csrfGuard(options) | Session-bound signed double-submit tokens. |
| signJwt / verifyJwt / signJws / verifyJws | Token primitives with a required algorithm allowlist. |
| keyService(options) / remoteJwks(uri) | Key rotation and JWKS publishing/consumption. |
| refreshService(options) | Rotation with reuse detection. |
| webAuthnService(options) | @di-framework/auth/webauthn — the two ceremonies. |
| oauthClient(provider, deps) | @di-framework/auth/oauth — the relying party. |
| inMemoryAuthStores() | Development and test storage. |
| repo*Store(options) | @di-framework/auth/repo — StorageAdapter bridge. |
| withAuthRoutes / protect / requireAuth | @di-framework/auth/http — guards. |
| AuthorizationManager / AUTHORIZATION_MANAGER | Application-owned policy decision point and its DI token. |
| requireAuthz / authorize | @di-framework/auth/http — authorization guards. |
| createAuthRoutes(options) | @di-framework/auth/http — mountable protocol endpoints. |
| protectSchema / Authenticated / Authorize / createAuthContext | @di-framework/auth/graphql. |
Non-goals (v1)
- An authorization policy model. The package supplies the
AuthorizationManagerextension point and transport plumbing, but no roles, permissions, RBAC/ABAC DSL, SpEL expressions, or@Roles(). Applications and remote policy agents define the metadata vocabulary and make every allow/deny decision. - Full WebAuthn attestation verification.
noneand self-attestedpackedare verified; anything else needs FIDO Metadata Service integration. The extension point isWebAuthnConfig.verifyAttestation. Known gap:fido-u2f, which older security keys still emit. - Being an authorization server. This is a relying party — no
/authorize, no/token, no consent screen, no client registration. - Multi-factor orchestration (TOTP, SMS, magic links, step-up state machines).
amrandacrare recorded so you can build it. - Redis, KV, and SQL stores. Interfaces, in-memory implementations, and the
StorageAdapterbridge only. - Account recovery and email delivery. Tokens are provided; sending them is yours.
- An
@authenticateddirective in the printed SDL. - SAML, LDAP, Kerberos, and multi-tenant modelling.
Example
https://github.com/di-framework/di-framework/tree/main/examples/packages/auth
License
Licensed under either MIT or Apache-2.0, at your option.
