first commit
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# middleware/context-augmentation
|
||||
|
||||
## Purpose
|
||||
|
||||
This module installs the canonical Hono `ContextVariableMap`
|
||||
augmentation that lets every consumer call `c.get("user")` and
|
||||
`c.get("session")` with full type narrowing. Without this declaration
|
||||
those calls would resolve to `unknown`, forcing every plugin to cast at
|
||||
the call site.
|
||||
|
||||
## Features
|
||||
|
||||
- Declares `user: Principal | null` and `session: BetterAuthSessionResult`
|
||||
on Hono's global `ContextVariableMap` interface.
|
||||
- Side-effect-only export — importing the module wires the augmentation
|
||||
into TypeScript's global module registry.
|
||||
- Re-exported transitively via every middleware module so consumers
|
||||
importing any auth middleware automatically inherit the augmentation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import "@suaveplan/api-plugin-auth/middleware/session";
|
||||
// or
|
||||
import "@suaveplan/api-plugin-auth";
|
||||
|
||||
// c.get("user") now narrows to Principal | null everywhere.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Combine the augmentation with `requireAuth()` so downstream handlers
|
||||
get a non-null `Principal` directly. The type system cannot prove the
|
||||
narrowing through a middleware chain, but explicit destructuring keeps
|
||||
the call site clean:
|
||||
|
||||
```ts
|
||||
import { requireAuth } from "@suaveplan/api-plugin-auth/middleware";
|
||||
|
||||
app.use("/me", requireAuth());
|
||||
app.get("/me", (c) => {
|
||||
const principal = c.get("user");
|
||||
if (principal === null) throw new Error("unreachable");
|
||||
return c.json({ id: principal.id });
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Module augmentation
|
||||
|
||||
```ts
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
user: Principal | null;
|
||||
session: BetterAuthSessionResult;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The module exports `export {}` — there are no runtime exports, only the
|
||||
ambient declaration.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The augmentation is intentionally re-exported as a side effect from
|
||||
every middleware module's barrel so a single
|
||||
`import "@suaveplan/api-plugin-auth"` is enough to enable the typed
|
||||
context everywhere — no separate "augmentation" import is needed.
|
||||
- `session` is typed as the `BetterAuthSessionResult` (a `| null`
|
||||
union) rather than the bare `BetterAuthSessionView` so anonymous
|
||||
contexts type-check without a cast.
|
||||
- Augmenting third-party type definitions is the standard pattern for
|
||||
Hono plugins; the auth plugin owns these two slots and no other
|
||||
plugin should re-declare them. Sibling plugins layer their own slots
|
||||
via additional augmentations.
|
||||
@@ -0,0 +1,70 @@
|
||||
# middleware/require-auth
|
||||
|
||||
## Purpose
|
||||
|
||||
`requireAuth()` is the canonical Hono middleware factory for gating a
|
||||
route or sub-route on an authenticated principal. It reads
|
||||
`c.get("user")` and, if the value is `null` (or absent), throws
|
||||
`AuthError(AUTH_UNAUTHENTICATED)` carrying `context.statusCode: 401`
|
||||
so the `@suaveplan/api` error-handler middleware translates it to an
|
||||
HTTP 401 response.
|
||||
|
||||
## Features
|
||||
|
||||
- Depends only on the typed `c.get("user")` slot installed by
|
||||
`sessionMiddleware` — no dynamic service lookup, no per-request
|
||||
Better-Auth call.
|
||||
- Single failure mode: `AUTH_UNAUTHENTICATED` with `statusCode: 401`
|
||||
attached to the error context.
|
||||
- Idempotent — applying the middleware twice in the same chain costs
|
||||
one type narrow + one truthy check.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { requireAuth } from "@suaveplan/api-plugin-auth/middleware/require-auth";
|
||||
|
||||
app.use("/private/*", requireAuth());
|
||||
app.get("/private/me", (c) => c.json(c.get("user")));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose `requireAuth` with `requireRole` and `requireEmailVerified` to
|
||||
build a sliding scale of access controls. Each middleware throws its
|
||||
own typed `AuthError`, so the error handler can emit specific OpenAPI
|
||||
4xx responses per stage:
|
||||
|
||||
```ts
|
||||
import {
|
||||
requireAuth,
|
||||
requireRole,
|
||||
requireEmailVerified,
|
||||
} from "@suaveplan/api-plugin-auth/middleware";
|
||||
|
||||
app.use("/admin/*", requireAuth(), requireRole("admin"));
|
||||
app.use("/billing/*", requireAuth(), requireEmailVerified());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `function requireAuth(): MiddlewareHandler`
|
||||
|
||||
Returns a Hono middleware handler. Throws
|
||||
`AuthError(AUTH_UNAUTHENTICATED, "Authentication required",
|
||||
{ context: { statusCode: 401 } })` when `c.get("user")` is null or
|
||||
undefined. Calls `next()` otherwise.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The check uses `=== null || === undefined` rather than a truthy
|
||||
check so a fully-populated `Principal` with falsy values (e.g.
|
||||
`email: ""` after the principal mapping normalised it out) still
|
||||
passes. `Principal` objects are never falsy.
|
||||
- Throwing — not returning a 401 response directly — keeps the error
|
||||
shape consistent with `requireRole` and `requireEmailVerified`; all
|
||||
three flow through the `@suaveplan/api` error-handler, which both
|
||||
serialises the error envelope and emits the OTel span event.
|
||||
- The `statusCode: 401` field is read by the error-handler middleware
|
||||
installed by `@suaveplan/api`; the auth plugin does not own status
|
||||
mapping itself.
|
||||
@@ -0,0 +1,73 @@
|
||||
# middleware/require-email-verified
|
||||
|
||||
## Purpose
|
||||
|
||||
`requireEmailVerified()` is the Hono middleware factory that gates a
|
||||
route on the principal's email having been verified. Routes that
|
||||
trigger sensitive operations (password change, billing portal,
|
||||
account deletion) use this guard so unverified accounts cannot move
|
||||
forward without first completing the verification flow.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads `c.get("user")` directly — no Better-Auth lookup per request.
|
||||
- Two failure modes: `AUTH_UNAUTHENTICATED` (401) for anonymous
|
||||
callers and `AUTH_EMAIL_NOT_VERIFIED` (403) for unverified ones.
|
||||
- Strict boolean check: `emailVerified !== true` fails, so undefined
|
||||
/ null are treated as unverified.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
requireAuth,
|
||||
requireEmailVerified,
|
||||
} from "@suaveplan/api-plugin-auth/middleware";
|
||||
|
||||
app.use("/billing/*", requireAuth(), requireEmailVerified());
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use the middleware as a precondition for routes that trigger
|
||||
high-trust workflows. Combine with `requireRole` so admin routes
|
||||
require BOTH verified email AND the admin role:
|
||||
|
||||
```ts
|
||||
import {
|
||||
requireAuth,
|
||||
requireEmailVerified,
|
||||
requireRole,
|
||||
} from "@suaveplan/api-plugin-auth/middleware";
|
||||
|
||||
app.use(
|
||||
"/admin/users/:id/delete",
|
||||
requireAuth(),
|
||||
requireEmailVerified(),
|
||||
requireRole("admin")
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `function requireEmailVerified(): MiddlewareHandler`
|
||||
|
||||
- Throws `AuthError(AUTH_UNAUTHENTICATED)` (401) when `c.get("user")`
|
||||
is null/undefined.
|
||||
- Throws `AuthError(AUTH_EMAIL_NOT_VERIFIED)` (403) when
|
||||
`principal.emailVerified !== true`.
|
||||
- Calls `next()` otherwise.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The check uses `!== true` rather than `=== false` so an explicitly
|
||||
`undefined` / `null` `emailVerified` (which the default principal
|
||||
mapping should not emit, but a custom mapping might) is treated as
|
||||
unverified. Failing closed is the right default for an auth gate.
|
||||
- The middleware does not auto-trigger a re-send of the verification
|
||||
email — that is the route handler's responsibility once the 403
|
||||
reaches the client.
|
||||
- The 403 error carries `context.statusCode: 403` for the error
|
||||
handler; no other context fields are attached because the principal
|
||||
identity does not need to be re-emitted on a 403 (it is already
|
||||
available in the trace span).
|
||||
@@ -0,0 +1,72 @@
|
||||
# middleware/require-role
|
||||
|
||||
## Purpose
|
||||
|
||||
`requireRole(...roles)` builds a Hono middleware that gates a route on
|
||||
the principal carrying at least one of the supplied roles. The factory
|
||||
throws an `AuthError(AUTH_INVALID_INPUT)` at construction time when
|
||||
called with no roles — a guard against accidentally building a
|
||||
no-op middleware.
|
||||
|
||||
## Features
|
||||
|
||||
- Constant-time membership check against an internal `Set<string>`.
|
||||
- Two failure modes — `AUTH_UNAUTHENTICATED` (401) for anonymous
|
||||
callers, `AUTH_INSUFFICIENT_ROLE` (403) for callers whose
|
||||
`principal.roles` does not intersect the required set.
|
||||
- Emits `auth.role_check_total{outcome}` for every call, with
|
||||
`outcome ∈ "allowed" | "denied"`.
|
||||
- Carries `context.required` and `context.actual` on the 403 error so
|
||||
audit logs / OpenAPI responses can show the precise mismatch.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { requireAuth, requireRole } from "@suaveplan/api-plugin-auth/middleware";
|
||||
|
||||
app.use("/admin/*", requireAuth(), requireRole("admin"));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose multiple roles for resource-level scoping. Combine with
|
||||
`AuthService` lookups to project derived roles (e.g. organisation
|
||||
membership) onto the request before the middleware runs:
|
||||
|
||||
```ts
|
||||
import { requireAuth, requireRole } from "@suaveplan/api-plugin-auth/middleware";
|
||||
|
||||
app.use("/projects/:id/edit", requireAuth(), async (c, next) => {
|
||||
const principal = c.get("user");
|
||||
if (!principal) return next();
|
||||
const isOwner = await checkOwnership(c.req.param("id"), principal.id);
|
||||
if (isOwner) {
|
||||
c.set("user", { ...principal, roles: [...principal.roles, "owner"] });
|
||||
}
|
||||
await next();
|
||||
}, requireRole("admin", "owner"));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `function requireRole(...roles: readonly string[]): MiddlewareHandler`
|
||||
|
||||
- Throws `AuthError(AUTH_INVALID_INPUT)` at construction when `roles`
|
||||
is empty.
|
||||
- Throws `AuthError(AUTH_UNAUTHENTICATED)` (401) when `c.get("user")`
|
||||
is null/undefined.
|
||||
- Throws `AuthError(AUTH_INSUFFICIENT_ROLE)` (403) when no role in
|
||||
`principal.roles` is in the required set.
|
||||
- Emits `auth.role_check_total{outcome="allowed"|"denied"}` on every
|
||||
request.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The required-roles set is constructed once per factory call. Reusing
|
||||
the same middleware across multiple routes is constant memory.
|
||||
- The error context carries the actual roles as well as the required
|
||||
ones so the 403 response can surface a "missing role: admin"
|
||||
message in operator logs without re-reading the principal.
|
||||
- The middleware never inspects `principal.tenantId` — tenant-scoped
|
||||
role gates are the responsibility of `@suaveplan/api-tenant`'s
|
||||
middleware, layered downstream.
|
||||
97
docs/api-stack/api/api-plugin-auth/src/middleware/session.md
Normal file
97
docs/api-stack/api/api-plugin-auth/src/middleware/session.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# middleware/session
|
||||
|
||||
## Purpose
|
||||
|
||||
`sessionMiddleware` is the global Hono middleware that resolves every
|
||||
incoming request's Better-Auth session and exposes a typed `Principal`
|
||||
through `c.get("user")`. It is installed by `createAuthPlugin`'s
|
||||
`onInit` against `ctx.app` so anonymous routes keep working while
|
||||
protected handlers can rely on `c.get("user")` being either a real
|
||||
`Principal` or `null` — never undefined.
|
||||
|
||||
## Features
|
||||
|
||||
- Calls `auth.api.getSession({ headers: c.req.raw.headers })` once per
|
||||
request and caches the result on the context.
|
||||
- Sets `c.set("user", principal | null)` and `c.set("session", session
|
||||
| null)` via the augmented `ContextVariableMap` (see
|
||||
`./context-augmentation.ts`).
|
||||
- Default mapping uses `mapBetterAuthUserToPrincipal`; opt-out via
|
||||
`principalMapping` for plugin-extended session shapes.
|
||||
- Records `auth.sessions_resolved_total{outcome}` and
|
||||
`auth.session_resolve_duration_ms` for every request, regardless of
|
||||
outcome.
|
||||
- Failures during session resolution set `c.get("user")` to `null`,
|
||||
log at `warn`, and let the request proceed — protected routes still
|
||||
throw `AUTH_UNAUTHENTICATED` via `requireAuth()` so the surface is
|
||||
unaffected.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { sessionMiddleware } from "@suaveplan/api-plugin-auth/middleware/session";
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
const auth = betterAuth({ /* … */ });
|
||||
app.use("*", sessionMiddleware({ auth }));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pair the middleware with a custom mapping and a host-supplied logger
|
||||
so role projection and trace correlation are wired in one place:
|
||||
|
||||
```ts
|
||||
import { sessionMiddleware } from "@suaveplan/api-plugin-auth/middleware/session";
|
||||
import { mapBetterAuthUserToPrincipal } from "@suaveplan/api-plugin-auth/principal/principal";
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
sessionMiddleware({
|
||||
auth,
|
||||
logger: ctx.logger,
|
||||
principalMapping: (view) => {
|
||||
const base = mapBetterAuthUserToPrincipal(view);
|
||||
const session = view.session as { roles?: string[] };
|
||||
return { ...base, roles: [...base.roles, ...(session.roles ?? [])] };
|
||||
},
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `function sessionMiddleware(options): MiddlewareHandler`
|
||||
|
||||
`options.auth` is required; `options.principalMapping` and
|
||||
`options.logger` are optional. The returned middleware is async and
|
||||
mutates `c`'s context variables — it never throws.
|
||||
|
||||
### `interface SessionMiddlewareOptions`
|
||||
|
||||
- `auth: Auth` — the Better-Auth instance.
|
||||
- `principalMapping?: PrincipalMapping` — defaults to
|
||||
`mapBetterAuthUserToPrincipal`.
|
||||
- `logger?: LoggerLike` — defaults to a no-op logger.
|
||||
|
||||
### `type PrincipalMapping = (view: BetterAuthSessionView) => Principal`
|
||||
|
||||
### Module augmentation
|
||||
|
||||
`./context-augmentation.ts` declares `interface ContextVariableMap {
|
||||
user: Principal | null; session: BetterAuthSessionResult }` so the
|
||||
typed `c.get("user")` lookups work without a cast in every consumer.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The middleware does NOT throw on failure — anonymous routes must keep
|
||||
working even when Better-Auth's session store is degraded. Protected
|
||||
routes are responsible for raising `AUTH_UNAUTHENTICATED` via
|
||||
`requireAuth()` only when they need an authenticated user.
|
||||
- `performance.now()` is the duration source; the value is recorded in
|
||||
milliseconds (matching the histogram's declared unit).
|
||||
- The outcome label is bounded to `"authenticated"` / `"anonymous"` /
|
||||
`"error"` so dashboards can rely on a fixed cardinality.
|
||||
- Non-`Error` throwables from Better-Auth are normalised via
|
||||
`String(err)` — the middleware does not assume Better-Auth's failure
|
||||
modes are pure `Error` instances.
|
||||
Reference in New Issue
Block a user