@stwrd-auth/node 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented in this file.
4
+
5
+ The format is based on Keep a Changelog 1.1.0, and this package adheres to Semantic Versioning (SemVer).
6
+
7
+ ## [0.1.0] - Unreleased
8
+
9
+ Initial release.
10
+
11
+ ### Added
12
+
13
+ - `Stwrd` and `createStwrd`, with `Stwrd.fromEnv()` reading the `STWRD_*` environment variables.
14
+ - `authRouter()`: Express router for the `/auth/sign-in`, `/auth/callback`, `/auth/sign-out`, `/auth/session`, `/auth/organizations`, `/auth/organization`, `/auth/back-channel` and `/auth/webhook` routes, with `onUserRegistered` and `onEvent` hooks.
15
+ - `attach()` middleware that resolves the session once per request into `req.stwrd`.
16
+ - Guards: `requireAuth`, `requireRole`, `requirePermission`, `requireOrg` and `apiMode`.
17
+ - `protectAll()`: opt-in fail-closed protection of an entire app by path.
18
+ - Server-side sessions, signed `__Host-` session cookie, CSRF protection and automatic token renewal.
19
+ - Organization selection when the `org` scope is requested.
20
+ - Webhook verification (`@stwrd-auth/node/webhooks`).
21
+ - Typed Management API client (`@stwrd-auth/node/management`).
22
+ - PostgreSQL session store with encrypted sessions and a refresh lease (`@stwrd-auth/node/postgres`).
23
+ - `makeHostFetch` for developing against a local identity provider that routes by `Host`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 stwrd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,244 @@
1
- # Temporary Holding Version
1
+ # @stwrd-auth/node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ stwrd SDK for Node.js and Express (backend-for-frontend profile): the `/auth/*` routes, server-side sessions, route guards and webhook verification.
4
+
5
+ ## What it is
6
+
7
+ [stwrd](https://stwrd.dev) is a multi-tenant identity provider. In the backend-for-frontend (BFF) profile the server performs the OpenID Connect flow and keeps the tokens; the browser only holds a signed session cookie. This package mounts the sign-in, callback, sign-out and session routes in an Express app and gives you guards to protect your own routes. It exposes the same routes, the same `GET /auth/session` JSON and the same environment variables as the Python SDK (`stwrd-auth` on PyPI).
8
+
9
+ Related packages:
10
+
11
+ - [`@stwrd-auth/core`](https://www.npmjs.com/package/@stwrd-auth/core): the runtime-neutral logic this package builds on (installed automatically).
12
+ - [`@stwrd-auth/react`](https://www.npmjs.com/package/@stwrd-auth/react): session hooks and components for the front end.
13
+ - [`@stwrd-auth/workers`](https://www.npmjs.com/package/@stwrd-auth/workers): the same SDK for Cloudflare Workers.
14
+
15
+ ## Installation
16
+
17
+ ```sh
18
+ npm install @stwrd-auth/node express
19
+ ```
20
+
21
+ Requires Node.js 20 or newer. `express` (`^4 || ^5`) is a peer dependency: the package uses the Express your app already has. `pg` (`^8`) is an optional peer dependency, needed only for the PostgreSQL session store. The package is ESM only and ships its own TypeScript declarations.
22
+
23
+ ## Minimal example
24
+
25
+ Set the environment variables below, then:
26
+
27
+ ```ts
28
+ import express from "express";
29
+ import { Stwrd, requireAuth, requirePermission, requireRole, safeTarget } from "@stwrd-auth/node";
30
+
31
+ const stwrd = Stwrd.fromEnv();
32
+ const app = express();
33
+
34
+ app.use(stwrd.attach()); // resolves the session once and leaves it in req.stwrd
35
+ app.use(
36
+ stwrd.authRouter({
37
+ onEvent: (event) => console.log("webhook", event.type, event.data),
38
+ }),
39
+ );
40
+
41
+ app.get("/", (req, res) => {
42
+ const user = req.stwrd?.user ?? null;
43
+ if (!user) {
44
+ res.send('<a href="/auth/sign-in?return_to=/private">Sign in</a>');
45
+ return;
46
+ }
47
+ res.send(`<p>Hello, ${user.email}.</p><a href="/private">Private area</a>`);
48
+ });
49
+
50
+ app.get("/private", requireAuth(stwrd), (req, res) => {
51
+ const { user, session } = req.stwrd!;
52
+ // Sign-out is a POST with a CSRF token; this is the one /auth/session hands out.
53
+ const csrf = stwrd.csrf(session!.id);
54
+ res.send(
55
+ `<p>Welcome, ${user!.email}. Your id is ${user!.id}.</p>` +
56
+ '<form method="post" action="/auth/sign-out">' +
57
+ `<input type="hidden" name="csrf_token" value="${csrf}"><button>Sign out</button></form>`,
58
+ );
59
+ });
60
+
61
+ app.get("/admin", requireRole(stwrd, "org:admin"), (req, res) => {
62
+ res.json({ org: req.stwrd!.organization!.display_name, roles: req.stwrd!.organization!.roles });
63
+ });
64
+
65
+ app.post("/invitations", requirePermission(stwrd, "members:invite"), (req, res) => {
66
+ res.json({ invited_by: req.stwrd!.user!.id });
67
+ });
68
+
69
+ // A redirect target that came from a query parameter goes through safeTarget:
70
+ // only relative paths of this app are accepted, anything else falls back.
71
+ app.get("/go", (req, res) => {
72
+ res.redirect(303, safeTarget(String(req.query.to ?? ""), "/"));
73
+ });
74
+
75
+ app.listen(Number(process.env.PORT ?? 3000));
76
+ ```
77
+
78
+ Register `{STWRD_BASE_URL}/auth/callback` as a redirect URI of the application in stwrd.
79
+
80
+ `authRouter` reads its own request bodies (forms for sign-out and back-channel logout, JSON for the organization switch, the raw bytes for the webhook), so you do not need `express.json()` before it. If your app mounts a body parser globally before the router, sign-out, back-channel logout and the organization switch still work, but the webhook signature cannot be verified: it is computed over the exact bytes received, so the route answers 400 `invalid_signature`. Mount the parser after the router, or give the webhook path `express.raw()`.
81
+
82
+ ## Configuration
83
+
84
+ `Stwrd.fromEnv(env = process.env, overrides = {})` reads these variables; `createStwrd(options)` takes the same values as camelCase options. A missing required variable, or a cookie secret shorter than 32 characters, throws `ConfigError` at construction.
85
+
86
+ | Variable | Required | Default | Description |
87
+ |---|---|---|---|
88
+ | `STWRD_ISSUER` | yes | | Issuer URL of the tenant. |
89
+ | `STWRD_CLIENT_ID` | yes | | OIDC client ID of the application. |
90
+ | `STWRD_CLIENT_SECRET` | yes | | Client secret of the application. |
91
+ | `STWRD_BASE_URL` | yes | | Public URL of the app. `{baseUrl}/auth/callback` is the redirect URI. |
92
+ | `STWRD_COOKIE_SECRET` | yes | | HMAC key of the session cookie, at least 32 characters. |
93
+ | `STWRD_SCOPE` | no | `openid profile email offline_access` | Requested scopes (see Scopes). |
94
+ | `STWRD_WEBHOOK_SECRET` | no | none | Without it `/auth/webhook` answers 503. |
95
+ | `STWRD_PREFIX` | no | `/auth` | Path prefix of the routes. |
96
+ | `STWRD_SESSION_TTL_S` | no | `28800` | Sliding session lifetime in seconds. |
97
+ | `STWRD_COOKIE_SECURE` | no | `true` | `Secure` attribute of the cookies. |
98
+ | `STWRD_POST_LOGIN_REDIRECT` | no | `/` | Destination when a sign-in has no `return_to`. |
99
+ | `STWRD_POST_LOGOUT_REDIRECT` | no | `/` | Destination after sign-out. |
100
+
101
+ The remaining options (cookie names, transaction lifetime, `sessions`, `fetch`) are documented in [`@stwrd-auth/core`](https://www.npmjs.com/package/@stwrd-auth/core). `STWRD_COOKIE_SECURE=false` is for local development over plain HTTP only; browsers reject `__Host-` cookies that are not `Secure`, so also set the `sessionCookie` and `txCookie` options to names without that prefix.
102
+
103
+ ### Scopes
104
+
105
+ The default scope does not include `org`; without it `organization` is `null`. Add `org` to `STWRD_SCOPE` (`openid profile email offline_access org`) to receive organization roles and permissions, and allow that scope for the application in stwrd: requesting a scope the application is not allowed makes the authorize request fail with `invalid_scope`. Individual roles and permissions are in `user.roles` and `user.permissions`; with an organization they are in `organization.roles` and `organization.permissions`.
106
+
107
+ ## API reference
108
+
109
+ ### Entry points
110
+
111
+ | Import | Contents |
112
+ |---|---|
113
+ | `@stwrd-auth/node` | `Stwrd`, `createStwrd`, guards, `safeTarget`, session helpers, webhook helpers, configuration and OIDC primitives, `makeHostFetch` |
114
+ | `@stwrd-auth/node/management` | `createManagement`: typed Management API client |
115
+ | `@stwrd-auth/node/webhooks` | `verifyWebhook`, `signWebhook`, `SeenEventIds` and related errors |
116
+ | `@stwrd-auth/node/postgres` | `PostgresSessionStore`, `SCHEMA_SQL`, `Keyring` |
117
+
118
+ ### `Stwrd`
119
+
120
+ | Member | Description |
121
+ |---|---|
122
+ | `Stwrd.fromEnv(env?, overrides?)` / `createStwrd(options)` | Build the instance once and share it. |
123
+ | `stwrd.attach()` | Middleware that resolves the session once per request and leaves `{ user, organization, consents, session }` in `req.stwrd`. |
124
+ | `stwrd.authRouter({ onUserRegistered?, onEvent? })` | Express router for the `/auth/*` routes. `onUserRegistered(user)` runs on every successful sign-in, not only the first; `onEvent(event)` runs for each accepted webhook delivery, after deduplication. |
125
+ | `stwrd.protectAll({ public })` | Opt-in fail-closed middleware: every path needs a session unless it is listed. Paths under the auth prefix are always public; `*` is a wildcard only at the end of an entry. |
126
+ | `stwrd.csrf(sessionId)` | The CSRF token of a session. |
127
+ | `stwrd.sessionFromRequest(req)`, `stwrd.resolveSessionFromRequest(req)` | Read a session outside the middleware; the second one renews expired tokens. |
128
+
129
+ ### Guards
130
+
131
+ Each guard is a factory that takes the `Stwrd` instance and returns an Express `RequestHandler`.
132
+
133
+ | Guard | If the requirement is not met |
134
+ |---|---|
135
+ | `requireAuth(stwrd)` | 303 to sign-in for a browser navigation, otherwise 401 `{"detail":"No session."}` |
136
+ | `requireRole(stwrd, role)` | 403 `{"detail":"Missing role org:admin."}` |
137
+ | `requirePermission(stwrd, permission)` | 403 `{"detail":"Missing permission members:invite."}` |
138
+ | `requireOrg(stwrd)` | 403 `{"detail":"The session has no organization."}`; throws `ConfigError` at creation if the `org` scope is not requested |
139
+ | `apiMode()` | Forces the 401 JSON instead of the 303 on this route |
140
+
141
+ Roles and permissions are checked by membership against the organization when the session has one, otherwise against the individual lists.
142
+
143
+ ### Session JSON
144
+
145
+ `GET /auth/session` answers `authenticated`, `user`, `organization`, `consents`, `csrf_token` and `account_url`. The profile has `id`, `display_name` and `avatar_url`; tokens, the identity provider's `sid` and the raw claims stay on the server. An anonymous answer has `user: null`, `organization: null`, `consents: {}` and `csrf_token: null`. `sessionContext(session)` produces the same context, and `hasRole(context, role)` and `hasPermission(context, permission)` check it.
146
+
147
+ ### Webhooks
148
+
149
+ `/auth/webhook` verifies the signature and deduplicates by event `id` (30 hours, in memory). To verify by hand, in another route or framework:
150
+
151
+ ```ts
152
+ import { verifyWebhook, SeenEventIds } from "@stwrd-auth/node/webhooks";
153
+
154
+ const seen = new SeenEventIds();
155
+ // rawBody: string | Buffer with the exact bytes received; headers: the request headers.
156
+ const event = verifyWebhook(rawBody, headers, process.env.STWRD_WEBHOOK_SECRET!, { seen });
157
+ ```
158
+
159
+ Deliveries carry `webhook-id`, `webhook-timestamp` and `webhook-signature` (`v1,` followed by the base64 HMAC-SHA256 of `id.timestamp.body`), with a 300-second tolerance. `signWebhook(secret, { eventId, timestamp, body })` produces the same signature for tests.
160
+
161
+ ### Management API
162
+
163
+ ```ts
164
+ import { createManagement } from "@stwrd-auth/node/management";
165
+
166
+ const api = createManagement({
167
+ issuer: process.env.STWRD_ISSUER!,
168
+ clientId: process.env.STWRD_MANAGEMENT_CLIENT_ID!,
169
+ clientSecret: process.env.STWRD_MANAGEMENT_CLIENT_SECRET!,
170
+ scopes: ["mgmt:users:read", "mgmt:users:write"],
171
+ });
172
+
173
+ const observed = await api.users.get({ path: { user_id: userId } });
174
+ await api.users.update({ path: { user_id: userId }, body: { name: "Ada" }, options: { ifMatch: observed.etag ?? undefined } });
175
+ for await (const user of api.users.iterate()) console.log(user.id);
176
+ ```
177
+
178
+ Server-side only. Operations, request bodies and responses are typed. Writes are never retried automatically; errors are `ManagementError` with `status`, `body` and `requestId`.
179
+
180
+ ### Sessions across several processes
181
+
182
+ `MemoryStore` (the default) keeps sessions in one process. With several processes, use the PostgreSQL store so that a refresh token is exchanged at most once and a sign-out is seen everywhere. Create the table with your own migration (`SCHEMA_SQL`; the store never creates tables), then:
183
+
184
+ ```ts
185
+ import pg from "pg";
186
+ import { Keyring, PostgresSessionStore } from "@stwrd-auth/node/postgres";
187
+
188
+ const store = new PostgresSessionStore(new pg.Pool({ connectionString }), {
189
+ namespace: `${tenantId}/${clientId}`,
190
+ keyring: new Keyring({ "2026-10": keyBytes }, "2026-10"),
191
+ });
192
+ const stwrd = createStwrd({ ...options, sessions: store });
193
+ ```
194
+
195
+ Sessions are encrypted (JWE) with the keyring, which is independent of the cookie secret, and are bound to their namespace and id. To rotate a key, deploy the new key to every process as a non-current key, switch `current`, and remove the old key only after `store.purgeExpired()` (run it periodically) has removed every session written under it. Lease expiry is judged by the database clock. Any other store can implement the `SessionStore` interface.
196
+
197
+ ### Developing against a local identity provider
198
+
199
+ The identity provider resolves the tenant from the `Host` header, and Node's global `fetch` replaces that header with the destination's. `makeHostFetch` connects to a fixed address and sends the issuer's `Host` as is:
200
+
201
+ ```ts
202
+ import { Stwrd, makeHostFetch } from "@stwrd-auth/node";
203
+
204
+ const stwrd = Stwrd.fromEnv(process.env, {
205
+ fetch: makeHostFetch({ connectHost: "127.0.0.1", connectPort: 3005, host: "idp.example.com" }),
206
+ });
207
+ ```
208
+
209
+ ## Security
210
+
211
+ - The browser holds a single signed cookie (`__Host-stwrd_session`: HMAC-signed, `HttpOnly`, `SameSite=Lax`, `Secure` unless disabled). Access, ID and refresh tokens never leave the server. The signature makes the cookie tamper-evident; it is not encrypted.
212
+ - Sign-out and the organization switch require a CSRF token (`x-csrf-token` header or `csrf_token` form field). Redirect targets taken from requests are confined to relative paths of the app (`safeTarget`).
213
+ - Authorization uses PKCE, `state` and `nonce`; ID tokens are verified for signature, issuer, audience, expiry, `nonce` and `at_hash`. Requests to the identity provider never follow redirects, and the redirect URI is built from `STWRD_BASE_URL`, never from the `Host` header.
214
+ - Webhook signatures are compared in constant time. The in-memory deduplication is a convenience; a receiver that needs a hard guarantee also deduplicates by event `id` in its own storage.
215
+ - Keep `STWRD_COOKIE_SECRET`, `STWRD_CLIENT_SECRET`, `STWRD_WEBHOOK_SECRET` and the session keyring out of source control and logs. Changing the cookie secret signs everyone out.
216
+ - Supported versions and how to report a vulnerability are described in the [security policy](https://github.com/stwrd-dev/sdk-js/security/policy).
217
+
218
+ ## Error handling and troubleshooting
219
+
220
+ | Situation | Answer |
221
+ |---|---|
222
+ | Navigation without a session | 303 to `{prefix}/sign-in?return_to=...` |
223
+ | API call without a session | 401 `{"detail":"No session."}` |
224
+ | Missing role, permission, organization or CSRF token | 403 with `detail` |
225
+ | The identity provider does not answer | 503 `The IdP did not respond.` (`Cache-Control: no-store` on `/auth/session`) |
226
+ | Rejected sign-in callback | 400, plain text |
227
+ | `/auth/webhook` | 200 `{"status":"ok"}` or `{"status":"duplicate"}`; 400 `{"error":"invalid_signature"}`; 503 `{"error":"webhook_not_configured"}` |
228
+ | Request body above the limit | 413 |
229
+
230
+ A 503 never means the person signed out: the session is kept and the next request tries again. A session that stays on 503 for one person is `uncertain` (a token renewal may have been processed without its result being stored); signing in again fixes it. The classes `ConfigError`, `OidcError`, `IdpUnavailable` and `RefreshUncertain` are exported for your own handling.
231
+
232
+ ## Compatibility
233
+
234
+ - Node.js 20 or newer; Express 4 (the version the test suite runs against) and 5 (declared in the peer range).
235
+ - ESM only. TypeScript declarations are included and resolve under `node10`, `nodenext` and `bundler` module resolution.
236
+ - PostgreSQL store: `pg` 8.
237
+
238
+ ## Versioning
239
+
240
+ This package follows Semantic Versioning (SemVer). While the version is below 1.0, minor releases may contain breaking changes. `@stwrd-auth/core` is pinned to an exact version, so the two always move together. Changes are listed in `CHANGELOG.md`.
241
+
242
+ ## License
243
+
244
+ MIT © 2026 stwrd. See `LICENSE`.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `Stwrd` / `createStwrd` — the object an app builds once and hands to
3
+ * `authRouter`/`attach`/the guards. Everything that does not touch Express
4
+ * lives in `StwrdCore` (`@stwrd-auth/core/client`).
5
+ */
6
+ import type { IncomingMessage } from "node:http";
7
+ import { StwrdCore, type StwrdOptions } from "@stwrd-auth/core/client";
8
+ import type { StwrdSession } from "@stwrd-auth/core/sessions";
9
+ import { type AuthRouterOptions, type ProtectAllOptions } from "./router.js";
10
+ import type { RequestHandler, Router } from "express";
11
+ export type { StwrdOptions };
12
+ export declare class Stwrd extends StwrdCore {
13
+ constructor(options: StwrdOptions);
14
+ /** Reads the `STWRD_*` variables from `env`, with `overrides` winning over
15
+ * the environment. */
16
+ static fromEnv(env?: NodeJS.ProcessEnv, overrides?: Partial<StwrdOptions>): Stwrd;
17
+ /** Resolves the session once per request and caches it on `req.stwrd`.
18
+ * Mount with `app.use(stwrd.attach())`, ahead of the guards and any route
19
+ * that reads `req.stwrd`. */
20
+ attach(): RequestHandler;
21
+ /** The `/auth/*` routes, mounted under `config.prefix`. */
22
+ authRouter(options?: AuthRouterOptions): Router;
23
+ /** The opt-in fail-closed guard: requires a session on every path that is
24
+ * not declared public. */
25
+ protectAll(options?: ProtectAllOptions): RequestHandler;
26
+ resolveSessionFromRequest(req: Pick<IncomingMessage, "headers">): Promise<StwrdSession | null>;
27
+ sessionFromRequest(req: Pick<IncomingMessage, "headers">): Promise<StwrdSession | null>;
28
+ }
29
+ export declare function createStwrd(options: StwrdOptions): Stwrd;
package/dist/client.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `Stwrd` / `createStwrd` — the object an app builds once and hands to
3
+ * `authRouter`/`attach`/the guards. Everything that does not touch Express
4
+ * lives in `StwrdCore` (`@stwrd-auth/core/client`).
5
+ */
6
+ import { StwrdCore, stwrdOptionsFromEnv } from "@stwrd-auth/core/client";
7
+ import { attachMiddleware } from "./guards.js";
8
+ import { readCookie } from "./http.js";
9
+ import { authRouter, protectAllMiddleware } from "./router.js";
10
+ export class Stwrd extends StwrdCore {
11
+ // The refresh tuning of `StwrdCore` is for adapters of other runtimes: the
12
+ // Node SDK uses the defaults.
13
+ constructor(options) {
14
+ super(options);
15
+ }
16
+ /** Reads the `STWRD_*` variables from `env`, with `overrides` winning over
17
+ * the environment. */
18
+ static fromEnv(env = process.env, overrides = {}) {
19
+ return new Stwrd(stwrdOptionsFromEnv(env, overrides));
20
+ }
21
+ // --- Express wiring --------------------------------------------------
22
+ /** Resolves the session once per request and caches it on `req.stwrd`.
23
+ * Mount with `app.use(stwrd.attach())`, ahead of the guards and any route
24
+ * that reads `req.stwrd`. */
25
+ attach() {
26
+ return attachMiddleware(this);
27
+ }
28
+ /** The `/auth/*` routes, mounted under `config.prefix`. */
29
+ authRouter(options = {}) {
30
+ return authRouter(this, options);
31
+ }
32
+ /** The opt-in fail-closed guard: requires a session on every path that is
33
+ * not declared public. */
34
+ protectAll(options = {}) {
35
+ return protectAllMiddleware(this, options);
36
+ }
37
+ async resolveSessionFromRequest(req) {
38
+ return this.resolveSession(readCookie(req, this.config.sessionCookie));
39
+ }
40
+ async sessionFromRequest(req) {
41
+ return this.sessionFromCookie(readCookie(req, this.config.sessionCookie));
42
+ }
43
+ }
44
+ export function createStwrd(options) {
45
+ return new Stwrd(options);
46
+ }
package/dist/env.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `configFromEnv` for Node: the core's reader with `process.env` as the
3
+ * default environment. The core has no default of its own (it also runs where
4
+ * there is no `process.env`); this wrapper keeps the public signature of
5
+ * `@stwrd-auth/node` stable.
6
+ */
7
+ import { type StwrdConfigOptions } from "@stwrd-auth/core/config";
8
+ export declare function configFromEnv(env?: NodeJS.ProcessEnv, envPrefix?: string): Partial<StwrdConfigOptions>;
package/dist/env.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `configFromEnv` for Node: the core's reader with `process.env` as the
3
+ * default environment. The core has no default of its own (it also runs where
4
+ * there is no `process.env`); this wrapper keeps the public signature of
5
+ * `@stwrd-auth/node` stable.
6
+ */
7
+ import { ENV_PREFIX, configFromEnv as readConfigFromEnv } from "@stwrd-auth/core/config";
8
+ export function configFromEnv(env = process.env, envPrefix = ENV_PREFIX) {
9
+ return readConfigFromEnv(env, envPrefix);
10
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `attach` and the Express guards — a thin Express shim over the core's
3
+ * `checkAccess` (`@stwrd-auth/core/web/access`), which holds every decision: allow,
4
+ * 401 JSON, 303 to sign-in, 403, and 503 when the IdP does not answer.
5
+ *
6
+ * Every factory below takes `stwrd` explicitly
7
+ * (`requireRole(stwrd, "org:admin")`): there is no implicit registration step
8
+ * to read it from instead.
9
+ *
10
+ * `attach()` exists because Express has no built-in per-request memoization.
11
+ * It resolves the session once and caches it on `req.stwrd`; every guard
12
+ * below reads that cache if present and falls back to resolving directly
13
+ * otherwise, so a guard works whether or not `attach()` ran first.
14
+ */
15
+ import type { Request, RequestHandler } from "express";
16
+ import { type AccessContext, type AccessRule, type Resolution, IDP_SILENT, safeTarget } from "@stwrd-auth/core/web/access";
17
+ import type { Stwrd } from "./client.js";
18
+ /** What `attach()` leaves on `req.stwrd`: the user, the organization and the
19
+ * consents as separate objects, plus the private session. */
20
+ export type StwrdContext = AccessContext;
21
+ declare global {
22
+ namespace Express {
23
+ interface Request {
24
+ stwrd?: StwrdContext;
25
+ stwrdApiMode?: boolean;
26
+ /** The IdP did not answer while resolving this request: the context
27
+ * above says `user: null` because there is nothing else to put there,
28
+ * and **not** because there is no session. The guards check it to answer
29
+ * 503 instead of sending someone who is still signed in to the login. */
30
+ stwrdIdpUnavailable?: boolean;
31
+ }
32
+ }
33
+ }
34
+ /** Resolves the request's session once and caches it on the request. */
35
+ export declare function resolveRequest(stwrd: Stwrd, req: Request): Promise<Resolution>;
36
+ /** One guard per rule: the core decides, this only carries the request in and
37
+ * the response out. */
38
+ export declare function guard(stwrd: Stwrd, rule: AccessRule): RequestHandler;
39
+ /** Resolves the session once per request and caches it on `req.stwrd`,
40
+ * ahead of route handlers and any guard below. The public entry point is the
41
+ * bound method `Stwrd.attach`; this is its implementation. */
42
+ export declare function attachMiddleware(stwrd: Stwrd): RequestHandler;
43
+ /** Forces a 401 JSON denial on this route even for a browser navigation. */
44
+ export declare function apiMode(): RequestHandler;
45
+ /** A guard with no return value beyond "must be signed in" — does not read
46
+ * the user, unlike a route handler that reads `req.stwrd.user` directly
47
+ * after this passes. */
48
+ export declare function requireAuth(stwrd: Stwrd): RequestHandler;
49
+ /** Requires a role. `org_roles` is an open registry: the check is membership,
50
+ * never set equality. */
51
+ export declare function requireRole(stwrd: Stwrd, role: string): RequestHandler;
52
+ export declare function requireOrg(stwrd: Stwrd): RequestHandler;
53
+ export declare function requirePermission(stwrd: Stwrd, permission: string): RequestHandler;
54
+ export { IDP_SILENT, safeTarget };
package/dist/guards.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `attach` and the Express guards — a thin Express shim over the core's
3
+ * `checkAccess` (`@stwrd-auth/core/web/access`), which holds every decision: allow,
4
+ * 401 JSON, 303 to sign-in, 403, and 503 when the IdP does not answer.
5
+ *
6
+ * Every factory below takes `stwrd` explicitly
7
+ * (`requireRole(stwrd, "org:admin")`): there is no implicit registration step
8
+ * to read it from instead.
9
+ *
10
+ * `attach()` exists because Express has no built-in per-request memoization.
11
+ * It resolves the session once and caches it on `req.stwrd`; every guard
12
+ * below reads that cache if present and falls back to resolving directly
13
+ * otherwise, so a guard works whether or not `attach()` ran first.
14
+ */
15
+ import { IDP_SILENT, assertOrgRuleAvailable, checkAccess, resolveAccess, safeTarget, } from "@stwrd-auth/core/web/access";
16
+ import { readCookie } from "./http.js";
17
+ import { sendWebResponse } from "./webBridge.js";
18
+ /** Resolves the request's session once and caches it on the request. */
19
+ export async function resolveRequest(stwrd, req) {
20
+ if (req.stwrd) {
21
+ return { context: req.stwrd, idpUnavailable: req.stwrdIdpUnavailable === true };
22
+ }
23
+ const resolution = await resolveAccess(stwrd, readCookie(req, stwrd.config.sessionCookie));
24
+ // Marked and NOT raised: an unguarded public route renders anyway (it has no
25
+ // session to lose) and each guard decides for itself.
26
+ if (resolution.idpUnavailable) {
27
+ req.stwrdIdpUnavailable = true;
28
+ }
29
+ req.stwrd = resolution.context;
30
+ return resolution;
31
+ }
32
+ /** One guard per rule: the core decides, this only carries the request in and
33
+ * the response out. */
34
+ export function guard(stwrd, rule) {
35
+ return (req, res, next) => {
36
+ void (async () => {
37
+ const resolved = await resolveRequest(stwrd, req);
38
+ const result = await checkAccess(stwrd, { accept: req.headers.accept, target: req.originalUrl ?? req.url, api: req.stwrdApiMode, resolved }, rule);
39
+ if (!result.ok) {
40
+ await sendWebResponse(res, result.response);
41
+ return;
42
+ }
43
+ next();
44
+ })().catch(next);
45
+ };
46
+ }
47
+ /** Resolves the session once per request and caches it on `req.stwrd`,
48
+ * ahead of route handlers and any guard below. The public entry point is the
49
+ * bound method `Stwrd.attach`; this is its implementation. */
50
+ export function attachMiddleware(stwrd) {
51
+ return (req, _res, next) => {
52
+ resolveRequest(stwrd, req)
53
+ .then(() => next())
54
+ .catch(next);
55
+ };
56
+ }
57
+ /** Forces a 401 JSON denial on this route even for a browser navigation. */
58
+ export function apiMode() {
59
+ return (req, _res, next) => {
60
+ req.stwrdApiMode = true;
61
+ next();
62
+ };
63
+ }
64
+ /** A guard with no return value beyond "must be signed in" — does not read
65
+ * the user, unlike a route handler that reads `req.stwrd.user` directly
66
+ * after this passes. */
67
+ export function requireAuth(stwrd) {
68
+ return guard(stwrd, { auth: true });
69
+ }
70
+ /** Requires a role. `org_roles` is an open registry: the check is membership,
71
+ * never set equality. */
72
+ export function requireRole(stwrd, role) {
73
+ return guard(stwrd, { role });
74
+ }
75
+ export function requireOrg(stwrd) {
76
+ assertOrgRuleAvailable(stwrd);
77
+ return guard(stwrd, { org: true });
78
+ }
79
+ export function requirePermission(stwrd, permission) {
80
+ return guard(stwrd, { permission });
81
+ }
82
+ // `safeTarget` lives in the core, next to the guards that use it; this entry
83
+ // point keeps exporting it.
84
+ export { IDP_SILENT, safeTarget };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `makeHostFetch` — a `FetchLike` that connects to a fixed address while
3
+ * sending an arbitrary `Host` header. The identity provider resolves the
4
+ * tenant by `Host`, so an app developing against one on `127.0.0.1` still has
5
+ * to talk to it as `idp.example.com`; this is how, without `/etc/hosts` or
6
+ * real DNS.
7
+ *
8
+ * Global `fetch` (undici) treats `Host` as a protected header and overwrites it
9
+ * to match the connection's own authority. `node:http.request` has no such
10
+ * restriction (`options.host`/`options.port` pick the TCP peer,
11
+ * `options.headers.Host` is sent as-is), so this wraps that instead and
12
+ * adapts the result back into a real `Response`, keeping every caller
13
+ * (`OidcClient` and the rest) working against the same Fetch-API shape.
14
+ */
15
+ import type { FetchLike } from "@stwrd-auth/core/oidc";
16
+ export interface HostFetchOptions {
17
+ /** Where the TCP connection goes — `127.0.0.1` for a local IdP. */
18
+ connectHost: string;
19
+ connectPort: number;
20
+ /** The `Host` header sent as-is — the issuer's host, what the IdP routes by. */
21
+ host: string;
22
+ }
23
+ export declare function makeHostFetch(options: HostFetchOptions): FetchLike;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `makeHostFetch` — a `FetchLike` that connects to a fixed address while
3
+ * sending an arbitrary `Host` header. The identity provider resolves the
4
+ * tenant by `Host`, so an app developing against one on `127.0.0.1` still has
5
+ * to talk to it as `idp.example.com`; this is how, without `/etc/hosts` or
6
+ * real DNS.
7
+ *
8
+ * Global `fetch` (undici) treats `Host` as a protected header and overwrites it
9
+ * to match the connection's own authority. `node:http.request` has no such
10
+ * restriction (`options.host`/`options.port` pick the TCP peer,
11
+ * `options.headers.Host` is sent as-is), so this wraps that instead and
12
+ * adapts the result back into a real `Response`, keeping every caller
13
+ * (`OidcClient` and the rest) working against the same Fetch-API shape.
14
+ */
15
+ import { request as httpRequest } from "node:http";
16
+ export function makeHostFetch(options) {
17
+ return async function hostFetch(input, init = {}) {
18
+ const url = new URL(String(input));
19
+ const method = init.method ?? "GET";
20
+ const headers = {};
21
+ new Headers(init.headers).forEach((value, key) => {
22
+ headers[key] = value;
23
+ });
24
+ headers.host = options.host;
25
+ const bodyBuffer = await toBuffer(init.body);
26
+ if (bodyBuffer && headers["content-length"] === undefined) {
27
+ headers["content-length"] = String(bodyBuffer.length);
28
+ }
29
+ return new Promise((resolve, reject) => {
30
+ const req = httpRequest({
31
+ host: options.connectHost,
32
+ port: options.connectPort,
33
+ method,
34
+ path: `${url.pathname}${url.search}`,
35
+ headers,
36
+ }, (res) => {
37
+ const chunks = [];
38
+ res.on("data", (chunk) => chunks.push(chunk));
39
+ res.on("end", () => {
40
+ const responseHeaders = new Headers();
41
+ for (const [key, value] of Object.entries(res.headers)) {
42
+ if (value === undefined) {
43
+ continue;
44
+ }
45
+ for (const one of Array.isArray(value) ? value : [value]) {
46
+ responseHeaders.append(key, one);
47
+ }
48
+ }
49
+ resolve(new Response(Buffer.concat(chunks), {
50
+ status: res.statusCode ?? 0,
51
+ statusText: res.statusMessage,
52
+ headers: responseHeaders,
53
+ }));
54
+ });
55
+ });
56
+ req.on("error", reject);
57
+ if (bodyBuffer) {
58
+ req.write(bodyBuffer);
59
+ }
60
+ req.end();
61
+ });
62
+ };
63
+ }
64
+ async function toBuffer(body) {
65
+ if (body === null || body === undefined) {
66
+ return null;
67
+ }
68
+ if (typeof body === "string") {
69
+ return Buffer.from(body, "utf-8");
70
+ }
71
+ if (body instanceof URLSearchParams) {
72
+ return Buffer.from(body.toString(), "utf-8");
73
+ }
74
+ if (Buffer.isBuffer(body)) {
75
+ return body;
76
+ }
77
+ if (body instanceof ArrayBuffer) {
78
+ return Buffer.from(body);
79
+ }
80
+ // Anything else Fetch's `BodyInit` allows (a `Blob`, a stream): none of
81
+ // the SDK's own requests need it, so this is a deliberately narrow adapter
82
+ // and not a full `BodyInit` implementation.
83
+ throw new TypeError("makeHostFetch: unsupported body (only string, Buffer, URLSearchParams or ArrayBuffer).");
84
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Small HTTP plumbing shared by `client.ts`, `router.ts` and `guards.ts`.
3
+ * Nothing here is exported from `index.ts`.
4
+ *
5
+ * Cookies are read straight off the raw `Cookie` header rather than through
6
+ * `req.cookies` (which needs the `cookie-parser` middleware): this SDK works
7
+ * whether or not an app happens to have that installed, and parsing
8
+ * `name=value; name=value` pairs is plain string splitting.
9
+ */
10
+ import type { IncomingHttpHeaders, IncomingMessage } from "node:http";
11
+ export declare function readCookie(req: Pick<IncomingMessage, "headers">, name: string): string | undefined;
12
+ /** Every header value Express-family requests can hand back, coerced to the
13
+ * plain `Record<string, string>` the webhook verifier expects. A repeated header keeps its first
14
+ * value — Standard Webhooks never sends the three signing headers more than
15
+ * once. */
16
+ export declare function headersToRecord(headers: IncomingHttpHeaders): Record<string, string>;