@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/dist/http.js ADDED
@@ -0,0 +1,27 @@
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 { parseCookies } from "@stwrd-auth/core/web/cookies";
11
+ export function readCookie(req, name) {
12
+ return parseCookies(req.headers.cookie)[name];
13
+ }
14
+ /** Every header value Express-family requests can hand back, coerced to the
15
+ * plain `Record<string, string>` the webhook verifier expects. A repeated header keeps its first
16
+ * value — Standard Webhooks never sends the three signing headers more than
17
+ * once. */
18
+ export function headersToRecord(headers) {
19
+ const out = {};
20
+ for (const [key, value] of Object.entries(headers)) {
21
+ if (value === undefined) {
22
+ continue;
23
+ }
24
+ out[key] = Array.isArray(value) ? (value[0] ?? "") : value;
25
+ }
26
+ return out;
27
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `@stwrd-auth/node` — the Node SDK (BFF profile).
3
+ *
4
+ * This module's exports are the public surface; every name here is part of
5
+ * the package's API.
6
+ */
7
+ export declare const VERSION = "0.1.0";
8
+ export { COOKIE_SECRET_MIN_LEN, ConfigError, ENV_PREFIX, REQUIRED_ENV, resolveConfig, } from "@stwrd-auth/core/config";
9
+ export { configFromEnv } from "./env.js";
10
+ export type { OidcParams, ResolvedConfig, StwrdConfigOptions } from "@stwrd-auth/core/config";
11
+ export { csrfToken, sign, unsign } from "@stwrd-auth/core/crypto";
12
+ export { MemoryStore, accessExpiry, hasPermission, hasRole, userFromClaims, sessionContext, sessionResponse, contextFromClaims } from "@stwrd-auth/core/sessions";
13
+ export type { Claims, SessionResponse, SessionContext, StwrdOrganization, ConsentState, RefreshClaim, RefreshPhase, SessionStore, StwrdSession, StwrdUser, Tokens, } from "@stwrd-auth/core/sessions";
14
+ export { JWKS_DEFAULT_TTL_S, JWKS_TTL_CEILING_S, JWKS_TTL_FLOOR_S, JWKS_UNKNOWN_KID_REFETCH_FLOOR_S, IdpUnavailable, RefreshUncertain, LOGOUT_EVENT_URI, OidcClient, OidcError, RETRY_STATUSES, challengeS256, generateVerifier, halfHash, newAuthorizationState, newNonce, newState, } from "@stwrd-auth/core/oidc";
15
+ export type { AuthorizationState, Awaitable, Discovery, FetchLike, Transaction } from "@stwrd-auth/core/oidc";
16
+ export { DEDUP_WINDOW_S, HEADER_ID, HEADER_SIGNATURE, HEADER_TIMESTAMP, SIGNATURE_PREFIX, TOLERANCE_S, DuplicateEventError, InvalidSignatureError, SeenEventIds, safeEqual, signWebhook, verifyWebhook, verifyWebhookSignature, } from "./webhooks.js";
17
+ export type { SeenStore, VerifyWebhookOptions, WebhookEvent } from "./webhooks.js";
18
+ export { Stwrd, createStwrd } from "./client.js";
19
+ export { makeHostFetch } from "./hostFetch.js";
20
+ export type { HostFetchOptions } from "./hostFetch.js";
21
+ export type { StwrdOptions } from "./client.js";
22
+ export { apiMode, requireAuth, requireOrg, requirePermission, requireRole, safeTarget, } from "./guards.js";
23
+ export type { StwrdContext } from "./guards.js";
24
+ export type { AuthRouterOptions, ProtectAllOptions } from "./router.js";
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `@stwrd-auth/node` — the Node SDK (BFF profile).
3
+ *
4
+ * This module's exports are the public surface; every name here is part of
5
+ * the package's API.
6
+ */
7
+ export const VERSION = "0.1.0";
8
+ // --- config ------------------------------------------------------------
9
+ export { COOKIE_SECRET_MIN_LEN, ConfigError, ENV_PREFIX, REQUIRED_ENV, resolveConfig, } from "@stwrd-auth/core/config";
10
+ export { configFromEnv } from "./env.js";
11
+ // --- crypto / session primitives ---------------------------------------
12
+ export { csrfToken, sign, unsign } from "@stwrd-auth/core/crypto";
13
+ // --- sessions ------------------------------------------------------------
14
+ export { MemoryStore, accessExpiry, hasPermission, hasRole, userFromClaims, sessionContext, sessionResponse, contextFromClaims } from "@stwrd-auth/core/sessions";
15
+ // --- OIDC ------------------------------------------------------------
16
+ export { JWKS_DEFAULT_TTL_S, JWKS_TTL_CEILING_S, JWKS_TTL_FLOOR_S, JWKS_UNKNOWN_KID_REFETCH_FLOOR_S, IdpUnavailable, RefreshUncertain, LOGOUT_EVENT_URI, OidcClient, OidcError, RETRY_STATUSES, challengeS256, generateVerifier, halfHash, newAuthorizationState, newNonce, newState, } from "@stwrd-auth/core/oidc";
17
+ // --- webhooks ------------------------------------------------------------
18
+ export { DEDUP_WINDOW_S, HEADER_ID, HEADER_SIGNATURE, HEADER_TIMESTAMP, SIGNATURE_PREFIX, TOLERANCE_S, DuplicateEventError, InvalidSignatureError, SeenEventIds, safeEqual, signWebhook, verifyWebhook, verifyWebhookSignature, } from "./webhooks.js";
19
+ // --- the Stwrd instance --------------------------------------------------
20
+ export { Stwrd, createStwrd } from "./client.js";
21
+ // A `FetchLike` for developing against a local IdP that routes by `Host`.
22
+ export { makeHostFetch } from "./hostFetch.js";
23
+ // --- Express wiring: guards ------------------------------------------------
24
+ export { apiMode, requireAuth, requireOrg, requirePermission, requireRole, safeTarget, } from "./guards.js";
@@ -0,0 +1 @@
1
+ export * from "@stwrd-auth/core/management";
@@ -0,0 +1 @@
1
+ export * from "@stwrd-auth/core/management";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A shared `SessionStore` on PostgreSQL, for deployments with several workers.
3
+ *
4
+ * Sessions are encrypted as JWE (`dir` + `A256GCM`, via jose) with a keyring
5
+ * independent of the cookie secret. The authenticated payload also carries the
6
+ * namespace (tenant/client), the session id and the purpose, so a row copied
7
+ * to another namespace or id is rejected on read. Lease phase, fence and
8
+ * expiry stay in plain columns because coordination needs them in SQL and they
9
+ * hold no secret; lease expiry is judged by the database clock. The schema is
10
+ * explicit: run `SCHEMA_SQL` from your own migration, the store never creates
11
+ * tables. A namespace belongs to one SDK language: the payload shape is not
12
+ * shared with the Python store.
13
+ */
14
+ import { Keyring } from "@stwrd-auth/core/keyring";
15
+ import type { RefreshClaim, SessionStore, StwrdSession, Tokens } from "@stwrd-auth/core/sessions";
16
+ export declare const SCHEMA_SQL = "\nCREATE SEQUENCE stwrd_sdk_fence_seq;\nCREATE TABLE stwrd_sdk_sessions (\n namespace text NOT NULL,\n session_id text NOT NULL,\n payload text NOT NULL,\n expires_at double precision NOT NULL,\n fence bigint NOT NULL DEFAULT nextval('stwrd_sdk_fence_seq'),\n lease_owner text,\n lease_phase text CHECK (lease_phase IN ('acquired', 'sent', 'hydrating', 'uncertain')),\n lease_expires_at timestamptz,\n fresh_id_token boolean NOT NULL DEFAULT false,\n PRIMARY KEY (namespace, session_id),\n CHECK ((lease_phase IS NULL) = (lease_owner IS NULL))\n);\nCREATE INDEX stwrd_sdk_sessions_expiry ON stwrd_sdk_sessions (namespace, expires_at);\n";
17
+ export { Keyring };
18
+ /** The slice of `pg.Pool` the store uses, so `pg` stays optional. */
19
+ export interface PgClient {
20
+ query(text: string, values?: unknown[]): Promise<{
21
+ rows: Array<Record<string, unknown>>;
22
+ }>;
23
+ release(error?: Error | boolean): void;
24
+ }
25
+ export interface PgPool {
26
+ connect(): Promise<PgClient>;
27
+ }
28
+ export declare class PostgresSessionStore implements SessionStore {
29
+ private readonly pool;
30
+ private readonly options;
31
+ constructor(pool: PgPool, options: {
32
+ namespace: string;
33
+ keyring: Keyring;
34
+ onUnreadable?: (error: unknown) => void;
35
+ });
36
+ private get namespace();
37
+ private seal;
38
+ private open;
39
+ private withClient;
40
+ private transaction;
41
+ get(sessionId: string): Promise<StwrdSession | null>;
42
+ set(session: StwrdSession): Promise<void>;
43
+ delete(sessionId: string): Promise<void>;
44
+ /** Delete this namespace's sessions past their local expiry (and with them
45
+ * their stored refresh tokens). Run it periodically. */
46
+ purgeExpired(): Promise<number>;
47
+ replaceSession(oldId: string, session: StwrdSession): Promise<boolean>;
48
+ claimRefresh(sessionId: string, owner: string, leaseS: number): Promise<RefreshClaim>;
49
+ markRefreshSent(sessionId: string, fence: number): Promise<boolean>;
50
+ checkpointRefresh(sessionId: string, fence: number, tokens: Tokens, freshIdToken: boolean): Promise<boolean>;
51
+ completeRefresh(sessionId: string, fence: number, session: StwrdSession): Promise<boolean>;
52
+ releaseRefresh(sessionId: string, fence: number, sent: boolean): Promise<void>;
53
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * A shared `SessionStore` on PostgreSQL, for deployments with several workers.
3
+ *
4
+ * Sessions are encrypted as JWE (`dir` + `A256GCM`, via jose) with a keyring
5
+ * independent of the cookie secret. The authenticated payload also carries the
6
+ * namespace (tenant/client), the session id and the purpose, so a row copied
7
+ * to another namespace or id is rejected on read. Lease phase, fence and
8
+ * expiry stay in plain columns because coordination needs them in SQL and they
9
+ * hold no secret; lease expiry is judged by the database clock. The schema is
10
+ * explicit: run `SCHEMA_SQL` from your own migration, the store never creates
11
+ * tables. A namespace belongs to one SDK language: the payload shape is not
12
+ * shared with the Python store.
13
+ */
14
+ import { Keyring } from "@stwrd-auth/core/keyring";
15
+ import { withoutCapabilities } from "@stwrd-auth/core/sessions";
16
+ export const SCHEMA_SQL = `
17
+ CREATE SEQUENCE stwrd_sdk_fence_seq;
18
+ CREATE TABLE stwrd_sdk_sessions (
19
+ namespace text NOT NULL,
20
+ session_id text NOT NULL,
21
+ payload text NOT NULL,
22
+ expires_at double precision NOT NULL,
23
+ fence bigint NOT NULL DEFAULT nextval('stwrd_sdk_fence_seq'),
24
+ lease_owner text,
25
+ lease_phase text CHECK (lease_phase IN ('acquired', 'sent', 'hydrating', 'uncertain')),
26
+ lease_expires_at timestamptz,
27
+ fresh_id_token boolean NOT NULL DEFAULT false,
28
+ PRIMARY KEY (namespace, session_id),
29
+ CHECK ((lease_phase IS NULL) = (lease_owner IS NULL))
30
+ );
31
+ CREATE INDEX stwrd_sdk_sessions_expiry ON stwrd_sdk_sessions (namespace, expires_at);
32
+ `;
33
+ // `Keyring` lives in the core (the Durable Object store of `@stwrd-auth/workers` needs
34
+ // it too); this entry point keeps exporting it.
35
+ export { Keyring };
36
+ const TABLE = "stwrd_sdk_sessions";
37
+ function defaultUnreadable(error) {
38
+ console.warn("stwrd: unreadable session row", error);
39
+ }
40
+ export class PostgresSessionStore {
41
+ pool;
42
+ options;
43
+ constructor(pool, options) {
44
+ this.pool = pool;
45
+ this.options = options;
46
+ if (!options.namespace)
47
+ throw new Error("A namespace (tenant/client) is required.");
48
+ }
49
+ get namespace() {
50
+ return this.options.namespace;
51
+ }
52
+ async seal(session) {
53
+ return this.options.keyring.encrypt(JSON.stringify({ ns: this.namespace, sid: session.id, purpose: "session", session }));
54
+ }
55
+ async open(sessionId, payload) {
56
+ try {
57
+ const body = JSON.parse(await this.options.keyring.decrypt(payload));
58
+ if (body.ns !== this.namespace || body.sid !== sessionId || body.purpose !== "session") {
59
+ throw new Error("Row context does not match its location.");
60
+ }
61
+ return body.session;
62
+ }
63
+ catch (error) {
64
+ // Fail closed: a tampered, relocated or unreadable row is no session.
65
+ (this.options.onUnreadable ?? defaultUnreadable)(error);
66
+ return null;
67
+ }
68
+ }
69
+ async withClient(run) {
70
+ const client = await this.pool.connect();
71
+ let broken = false;
72
+ try {
73
+ return await run(client);
74
+ }
75
+ catch (error) {
76
+ broken = error.brokenConnection === true;
77
+ throw error;
78
+ }
79
+ finally {
80
+ // A connection whose ROLLBACK failed is destroyed, never reused.
81
+ client.release(broken);
82
+ }
83
+ }
84
+ async transaction(run) {
85
+ return this.withClient(async (client) => {
86
+ await client.query("BEGIN");
87
+ try {
88
+ const result = await run(client);
89
+ await client.query("COMMIT");
90
+ return result;
91
+ }
92
+ catch (error) {
93
+ try {
94
+ await client.query("ROLLBACK");
95
+ }
96
+ catch {
97
+ throw Object.assign(error, { brokenConnection: true });
98
+ }
99
+ throw error;
100
+ }
101
+ });
102
+ }
103
+ async get(sessionId) {
104
+ const { rows } = await this.withClient(client => client.query(`SELECT payload FROM ${TABLE} WHERE namespace = $1 AND session_id = $2`, [this.namespace, sessionId]));
105
+ return rows.length ? this.open(sessionId, rows[0].payload) : null;
106
+ }
107
+ async set(session) {
108
+ const payload = await this.seal(session);
109
+ await this.withClient(client => client.query(`INSERT INTO ${TABLE} (namespace, session_id, payload, expires_at) VALUES ($1, $2, $3, $4)
110
+ ON CONFLICT (namespace, session_id) DO UPDATE SET
111
+ payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at,
112
+ fence = nextval('stwrd_sdk_fence_seq'), lease_owner = NULL, lease_phase = NULL,
113
+ lease_expires_at = NULL, fresh_id_token = false`, [this.namespace, session.id, payload, session.expiresAt]));
114
+ }
115
+ async delete(sessionId) {
116
+ await this.withClient(client => client.query(`DELETE FROM ${TABLE} WHERE namespace = $1 AND session_id = $2`, [this.namespace, sessionId]));
117
+ }
118
+ /** Delete this namespace's sessions past their local expiry (and with them
119
+ * their stored refresh tokens). Run it periodically. */
120
+ async purgeExpired() {
121
+ const result = await this.withClient(client => client.query(`DELETE FROM ${TABLE} WHERE namespace = $1 AND expires_at < $2 RETURNING 1`, [this.namespace, Date.now() / 1000]));
122
+ return result.rows.length;
123
+ }
124
+ async replaceSession(oldId, session) {
125
+ const payload = await this.seal(session);
126
+ return this.transaction(async (client) => {
127
+ const removed = await client.query(`DELETE FROM ${TABLE} WHERE namespace = $1 AND session_id = $2 RETURNING 1`, [this.namespace, oldId]);
128
+ if (!removed.rows.length)
129
+ return false;
130
+ await client.query(`INSERT INTO ${TABLE} (namespace, session_id, payload, expires_at) VALUES ($1, $2, $3, $4)
131
+ ON CONFLICT (namespace, session_id) DO UPDATE SET
132
+ payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at,
133
+ fence = nextval('stwrd_sdk_fence_seq'), lease_owner = NULL, lease_phase = NULL,
134
+ lease_expires_at = NULL, fresh_id_token = false`, [this.namespace, session.id, payload, session.expiresAt]);
135
+ return true;
136
+ });
137
+ }
138
+ async claimRefresh(sessionId, owner, leaseS) {
139
+ return this.transaction(async (client) => {
140
+ const { rows } = await client.query(`SELECT payload, lease_phase, fresh_id_token,
141
+ coalesce(lease_expires_at <= clock_timestamp(), false) AS expired
142
+ FROM ${TABLE} WHERE namespace = $1 AND session_id = $2 FOR UPDATE`, [this.namespace, sessionId]);
143
+ if (!rows.length)
144
+ return { status: "gone" };
145
+ const row = rows[0];
146
+ const session = await this.open(sessionId, row.payload);
147
+ if (!session)
148
+ return { status: "gone" };
149
+ const phase = row.lease_phase;
150
+ if (phase === "uncertain")
151
+ return { status: "uncertain" };
152
+ if (phase !== null && !row.expired)
153
+ return { status: "busy" };
154
+ if (phase === "sent") {
155
+ await client.query(`UPDATE ${TABLE} SET lease_phase = 'uncertain' WHERE namespace = $1 AND session_id = $2`, [this.namespace, sessionId]);
156
+ return { status: "uncertain" };
157
+ }
158
+ const granted = phase === "hydrating" ? "hydrating" : "acquired";
159
+ const updated = await client.query(`UPDATE ${TABLE} SET fence = nextval('stwrd_sdk_fence_seq'), lease_owner = $1, lease_phase = $2,
160
+ lease_expires_at = clock_timestamp() + make_interval(secs => $3)
161
+ WHERE namespace = $4 AND session_id = $5 RETURNING fence`, [owner, granted, leaseS, this.namespace, sessionId]);
162
+ return { status: "granted", session, fence: Number(updated.rows[0].fence), phase: granted, freshIdToken: Boolean(row.fresh_id_token) };
163
+ });
164
+ }
165
+ async markRefreshSent(sessionId, fence) {
166
+ const { rows } = await this.withClient(client => client.query(`UPDATE ${TABLE} SET lease_phase = 'sent'
167
+ WHERE namespace = $1 AND session_id = $2 AND fence = $3 AND lease_phase = 'acquired' RETURNING 1`, [this.namespace, sessionId, fence]));
168
+ return rows.length > 0;
169
+ }
170
+ async checkpointRefresh(sessionId, fence, tokens, freshIdToken) {
171
+ return this.transaction(async (client) => {
172
+ const { rows } = await client.query(`SELECT payload FROM ${TABLE} WHERE namespace = $1 AND session_id = $2 AND fence = $3 AND lease_phase = 'sent' FOR UPDATE`, [this.namespace, sessionId, fence]);
173
+ const current = rows.length ? await this.open(sessionId, rows[0].payload) : null;
174
+ if (!current)
175
+ return false;
176
+ const payload = await this.seal({ ...current, claims: withoutCapabilities(current.claims), tokens });
177
+ await client.query(`UPDATE ${TABLE} SET payload = $1, lease_phase = 'hydrating', fresh_id_token = $2 WHERE namespace = $3 AND session_id = $4`, [payload, freshIdToken, this.namespace, sessionId]);
178
+ return true;
179
+ });
180
+ }
181
+ async completeRefresh(sessionId, fence, session) {
182
+ if (session.id !== sessionId)
183
+ return false;
184
+ const payload = await this.seal(session);
185
+ const { rows } = await this.withClient(client => client.query(`UPDATE ${TABLE} SET payload = $1, expires_at = $2, lease_owner = NULL, lease_phase = NULL,
186
+ lease_expires_at = NULL, fresh_id_token = false
187
+ WHERE namespace = $3 AND session_id = $4 AND fence = $5
188
+ AND lease_phase IS NOT NULL AND lease_phase <> 'uncertain' RETURNING 1`, [payload, session.expiresAt, this.namespace, sessionId, fence]));
189
+ return rows.length > 0;
190
+ }
191
+ async releaseRefresh(sessionId, fence, sent) {
192
+ await this.withClient(client => client.query(`UPDATE ${TABLE} SET
193
+ lease_phase = CASE
194
+ WHEN lease_phase = 'sent' AND $1::boolean THEN 'uncertain'
195
+ WHEN lease_phase = 'hydrating' THEN 'hydrating'
196
+ ELSE NULL END,
197
+ lease_owner = CASE
198
+ WHEN lease_phase = 'hydrating' OR (lease_phase = 'sent' AND $1::boolean) THEN lease_owner
199
+ ELSE NULL END,
200
+ lease_expires_at = CASE
201
+ WHEN lease_phase = 'hydrating' THEN clock_timestamp()
202
+ WHEN lease_phase = 'sent' AND $1::boolean THEN lease_expires_at
203
+ ELSE NULL END
204
+ WHERE namespace = $2 AND session_id = $3 AND fence = $4
205
+ AND lease_phase IS NOT NULL AND lease_phase <> 'uncertain'`, [sent, this.namespace, sessionId, fence]));
206
+ }
207
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `authRouter` and `protectAll` — a thin Express shim over the core's
3
+ * `authHandler` (`@stwrd-auth/core/web/authHandler`), which serves the routes
4
+ * under `stwrd.config.prefix`, and over `checkAccess` for the opt-in
5
+ * fail-closed middleware. The Python SDK's FastAPI integration behaves the
6
+ * same way.
7
+ */
8
+ import { type RequestHandler, type Router } from "express";
9
+ import type { StwrdUser } from "@stwrd-auth/core/sessions";
10
+ import type { WebhookEvent } from "@stwrd-auth/core/webhooks";
11
+ import type { Stwrd } from "./client.js";
12
+ export interface AuthRouterOptions {
13
+ onUserRegistered?: (user: StwrdUser) => unknown | Promise<unknown>;
14
+ onEvent?: (event: WebhookEvent) => unknown | Promise<unknown>;
15
+ }
16
+ /**
17
+ * Serves the `/auth/*` routes under `stwrd.config.prefix`: every
18
+ * request goes to the core's handler, and one it does not serve (another path,
19
+ * a method the route does not take) falls through to the app.
20
+ *
21
+ * `onUserRegistered(user)` runs once per successful `/auth/callback`.
22
+ * Whether this is the very first login for that `sub` is the app's own
23
+ * question, so the hook fires on every login and an idempotent upsert on
24
+ * the app's side is what makes "registered" mean the first one.
25
+ * `onEvent(event)` runs once per accepted `/auth/webhook` delivery, after
26
+ * dedup.
27
+ */
28
+ export declare function authRouter(stwrd: Stwrd, options?: AuthRouterOptions): Router;
29
+ export interface ProtectAllOptions {
30
+ public?: Iterable<string>;
31
+ }
32
+ /** Fail-closed mode is opt-in (`protectAll({public})`): it requires a session
33
+ * on every path that is not declared public. Implementation behind
34
+ * `Stwrd.protectAll`. */
35
+ export declare function protectAllMiddleware(stwrd: Stwrd, options?: ProtectAllOptions): RequestHandler;
package/dist/router.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `authRouter` and `protectAll` — a thin Express shim over the core's
3
+ * `authHandler` (`@stwrd-auth/core/web/authHandler`), which serves the routes
4
+ * under `stwrd.config.prefix`, and over `checkAccess` for the opt-in
5
+ * fail-closed middleware. The Python SDK's FastAPI integration behaves the
6
+ * same way.
7
+ */
8
+ import express from "express";
9
+ import { authHandler } from "@stwrd-auth/core/web/authHandler";
10
+ import { checkAccess, isPublicPath } from "@stwrd-auth/core/web/access";
11
+ import { readCookie } from "./http.js";
12
+ import { sendWebResponse, toWebRequest } from "./webBridge.js";
13
+ import { resolveRequest } from "./guards.js";
14
+ /**
15
+ * Serves the `/auth/*` routes under `stwrd.config.prefix`: every
16
+ * request goes to the core's handler, and one it does not serve (another path,
17
+ * a method the route does not take) falls through to the app.
18
+ *
19
+ * `onUserRegistered(user)` runs once per successful `/auth/callback`.
20
+ * Whether this is the very first login for that `sub` is the app's own
21
+ * question, so the hook fires on every login and an idempotent upsert on
22
+ * the app's side is what makes "registered" mean the first one.
23
+ * `onEvent(event)` runs once per accepted `/auth/webhook` delivery, after
24
+ * dedup.
25
+ */
26
+ export function authRouter(stwrd, options = {}) {
27
+ const handle = authHandler(stwrd, options);
28
+ const prefix = stwrd.config.prefix.replace(/\/$/, "").toLowerCase();
29
+ const router = express.Router();
30
+ router.use((req, res, next) => {
31
+ // Cheap exit for everything outside the prefix: no `Request` is built and
32
+ // the app's own body is left alone.
33
+ if (!req.path.toLowerCase().startsWith(`${prefix}/`)) {
34
+ next();
35
+ return;
36
+ }
37
+ void (async () => {
38
+ const webRequest = toWebRequest(req, stwrd);
39
+ const response = webRequest === null ? null : await handle(webRequest);
40
+ if (response === null) {
41
+ next();
42
+ return;
43
+ }
44
+ await sendWebResponse(res, response);
45
+ })().catch(next);
46
+ });
47
+ return router;
48
+ }
49
+ /** Fail-closed mode is opt-in (`protectAll({public})`): it requires a session
50
+ * on every path that is not declared public. Implementation behind
51
+ * `Stwrd.protectAll`. */
52
+ export function protectAllMiddleware(stwrd, options = {}) {
53
+ const publicList = [...(options.public ?? [])];
54
+ return (req, res, next) => {
55
+ void (async () => {
56
+ if (isPublicPath(req.path, publicList, stwrd.config.prefix)) {
57
+ next();
58
+ return;
59
+ }
60
+ const resolved = await resolveRequest(stwrd, req);
61
+ const result = await checkAccess(stwrd, { cookie: readCookie(req, stwrd.config.sessionCookie), accept: req.headers.accept, target: req.originalUrl ?? req.url, api: req.stwrdApiMode, resolved }, { auth: true });
62
+ if (!result.ok) {
63
+ // A silent IdP is a 503, not the 401 or the redirect to sign-in: both
64
+ // tell someone with a live session that they lost it.
65
+ await sendWebResponse(res, result.response);
66
+ return;
67
+ }
68
+ next();
69
+ })().catch(next);
70
+ };
71
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The seam between Express and the core's `Request → Response` handlers:
3
+ * `toWebRequest` builds the Fetch `Request` the core reads, `sendWebResponse`
4
+ * writes the core's `Response` back. Nothing here decides anything about
5
+ * authentication; it only translates. Internal — not exported from `index.ts`.
6
+ *
7
+ * Two rules make the translation safe:
8
+ *
9
+ * - The URL is relative to where the router is mounted (`req.url`, not
10
+ * `originalUrl`: `app.use("/api", stwrd.authRouter())` still serves
11
+ * `/api/auth/sign-in`) and its origin comes from `config.baseUrl`, never from
12
+ * the `Host` header: a spoofed `Host` cannot move a `redirect_uri`.
13
+ * - A body is only read when the core asks for it, so a request this router
14
+ * does not serve reaches the app's own handlers with its body intact.
15
+ */
16
+ import type { Response as ExpressResponse, Request as ExpressRequest } from "express";
17
+ import type { StwrdCore } from "@stwrd-auth/core/client";
18
+ /** The Fetch `Request` for an Express one, or `null` when the URL parser would
19
+ * rewrite its path (`/auth/x/../session`, `/auth/x/..\\session`): the router
20
+ * does not serve such a path, and answering it would let a request slip past a
21
+ * WAF or CDN rule on the exact path. The app gets it as it came. */
22
+ export declare function toWebRequest(req: ExpressRequest, stwrd: StwrdCore): globalThis.Request | null;
23
+ /** Status, headers and every `Set-Cookie` of the core's response, then its body. */
24
+ export declare function sendWebResponse(res: ExpressResponse, response: globalThis.Response): Promise<void>;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The seam between Express and the core's `Request → Response` handlers:
3
+ * `toWebRequest` builds the Fetch `Request` the core reads, `sendWebResponse`
4
+ * writes the core's `Response` back. Nothing here decides anything about
5
+ * authentication; it only translates. Internal — not exported from `index.ts`.
6
+ *
7
+ * Two rules make the translation safe:
8
+ *
9
+ * - The URL is relative to where the router is mounted (`req.url`, not
10
+ * `originalUrl`: `app.use("/api", stwrd.authRouter())` still serves
11
+ * `/api/auth/sign-in`) and its origin comes from `config.baseUrl`, never from
12
+ * the `Host` header: a spoofed `Host` cannot move a `redirect_uri`.
13
+ * - A body is only read when the core asks for it, so a request this router
14
+ * does not serve reaches the app's own handlers with its body intact.
15
+ */
16
+ /** A body the app's own global parser (`express.json()`, `express.urlencoded()`,
17
+ * `express.raw()`) already consumed: the stream is gone, `req.body` is what is left. */
18
+ function wasConsumed(req) {
19
+ return req._body === true || req.readableDidRead === true || req.readableEnded;
20
+ }
21
+ function isWebhookPath(pathname, prefix) {
22
+ const path = pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
23
+ return path.toLowerCase() === `${prefix.replace(/\/$/, "")}/webhook`.toLowerCase();
24
+ }
25
+ /** Puts back on the wire what a global parser took off it, by content type, so
26
+ * the routes that read a form or JSON (sign-out, organization, back-channel)
27
+ * still see it. The webhook is the exception: it verifies a signature over the
28
+ * exact bytes, which a parsed object cannot give back, so it
29
+ * only accepts a raw `Buffer` body and otherwise reads as empty (400
30
+ * `invalid_signature`). */
31
+ function reserialize(req, webhook) {
32
+ const body = req.body;
33
+ if (Buffer.isBuffer(body)) {
34
+ return { body };
35
+ }
36
+ if (webhook || body === undefined || body === null) {
37
+ return { body: null };
38
+ }
39
+ if (typeof body === "string") {
40
+ return { body };
41
+ }
42
+ const type = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
43
+ if (type === "application/x-www-form-urlencoded" && typeof body === "object") {
44
+ const params = new URLSearchParams();
45
+ for (const [key, value] of Object.entries(body)) {
46
+ for (const item of Array.isArray(value) ? value : [value]) {
47
+ if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") {
48
+ params.append(key, String(item));
49
+ }
50
+ }
51
+ }
52
+ return { body: params.toString() };
53
+ }
54
+ if (type === "application/json") {
55
+ return { body: JSON.stringify(body) };
56
+ }
57
+ return { body: null };
58
+ }
59
+ /** The request stream as a Fetch body that attaches to the Node stream only on
60
+ * the first read, and drains (instead of destroying the connection) when the
61
+ * core stops reading because a ceiling was crossed: the 413 still has a socket
62
+ * to travel on. */
63
+ function lazyBody(req) {
64
+ let started = false;
65
+ let cancelled = false;
66
+ return new ReadableStream({
67
+ pull(controller) {
68
+ if (started) {
69
+ req.resume();
70
+ return;
71
+ }
72
+ started = true;
73
+ req.on("data", (chunk) => {
74
+ if (cancelled)
75
+ return;
76
+ controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
77
+ if ((controller.desiredSize ?? 0) <= 0)
78
+ req.pause();
79
+ });
80
+ req.on("end", () => {
81
+ if (!cancelled)
82
+ controller.close();
83
+ });
84
+ req.on("error", (error) => {
85
+ if (!cancelled)
86
+ controller.error(error);
87
+ });
88
+ req.on("close", () => {
89
+ if (!cancelled && !req.complete)
90
+ controller.error(new Error("The request was aborted."));
91
+ });
92
+ },
93
+ cancel() {
94
+ cancelled = true;
95
+ req.resume();
96
+ },
97
+ }, { highWaterMark: 0 });
98
+ }
99
+ /** The Fetch `Request` for an Express one, or `null` when the URL parser would
100
+ * rewrite its path (`/auth/x/../session`, `/auth/x/..\\session`): the router
101
+ * does not serve such a path, and answering it would let a request slip past a
102
+ * WAF or CDN rule on the exact path. The app gets it as it came. */
103
+ export function toWebRequest(req, stwrd) {
104
+ const target = req.url.startsWith("/") ? req.url : `/${req.url}`;
105
+ const url = new URL(new URL(stwrd.config.baseUrl).origin + target);
106
+ if (url.pathname !== target.split(/[?#]/, 1)[0]) {
107
+ return null;
108
+ }
109
+ const headers = new Headers();
110
+ for (const [name, value] of Object.entries(req.headers)) {
111
+ if (value === undefined || name.startsWith(":"))
112
+ continue;
113
+ for (const item of Array.isArray(value) ? value : [value])
114
+ headers.append(name, item);
115
+ }
116
+ if (req.method === "GET" || req.method === "HEAD") {
117
+ return new Request(url, { method: req.method, headers });
118
+ }
119
+ if (wasConsumed(req)) {
120
+ const { body } = reserialize(req, isWebhookPath(url.pathname, stwrd.config.prefix));
121
+ // The bytes put back are not the ones the headers describe.
122
+ headers.delete("content-length");
123
+ headers.delete("transfer-encoding");
124
+ return new Request(url, { method: req.method, headers, body: body });
125
+ }
126
+ return new Request(url, { method: req.method, headers, body: lazyBody(req), duplex: "half" });
127
+ }
128
+ /** Status, headers and every `Set-Cookie` of the core's response, then its body. */
129
+ export async function sendWebResponse(res, response) {
130
+ res.status(response.status);
131
+ response.headers.forEach((value, name) => {
132
+ if (name.toLowerCase() !== "set-cookie")
133
+ res.setHeader(name, value);
134
+ });
135
+ for (const cookie of response.headers.getSetCookie()) {
136
+ res.append("Set-Cookie", cookie);
137
+ }
138
+ if (response.body === null) {
139
+ res.end();
140
+ return;
141
+ }
142
+ res.end(Buffer.from(await response.arrayBuffer()));
143
+ }
@@ -0,0 +1 @@
1
+ export * from "@stwrd-auth/core/webhooks";
@@ -0,0 +1 @@
1
+ export * from "@stwrd-auth/core/webhooks";