@stwrd-auth/core 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 +24 -0
- package/LICENSE +21 -0
- package/README.md +172 -2
- package/dist/backchannel.d.ts +28 -0
- package/dist/backchannel.js +41 -0
- package/dist/client.d.ts +101 -0
- package/dist/client.js +321 -0
- package/dist/config.d.ts +86 -0
- package/dist/config.js +223 -0
- package/dist/crypto.d.ts +13 -0
- package/dist/crypto.js +21 -0
- package/dist/keyring.d.ts +16 -0
- package/dist/keyring.js +35 -0
- package/dist/management.d.ts +4077 -0
- package/dist/management.js +609 -0
- package/dist/managementTransport.d.ts +52 -0
- package/dist/managementTransport.js +202 -0
- package/dist/oidc.d.ts +228 -0
- package/dist/oidc.js +498 -0
- package/dist/organizations.d.ts +16 -0
- package/dist/organizations.js +73 -0
- package/dist/sessions.d.ts +145 -0
- package/dist/sessions.js +163 -0
- package/dist/web/access.d.ts +91 -0
- package/dist/web/access.js +129 -0
- package/dist/web/authHandler.d.ts +30 -0
- package/dist/web/authHandler.js +434 -0
- package/dist/web/body.d.ts +26 -0
- package/dist/web/body.js +117 -0
- package/dist/web/cookies.d.ts +26 -0
- package/dist/web/cookies.js +62 -0
- package/dist/web/response.d.ts +9 -0
- package/dist/web/response.js +32 -0
- package/dist/webhooks.d.ts +98 -0
- package/dist/webhooks.js +226 -0
- package/package.json +71 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
- Configuration resolution (`resolveConfig`, `configFromEnv`, `stwrdOptionsFromEnv`) with fail-closed validation and `ConfigError`.
|
|
14
|
+
- `StwrdCore`: session resolution with expiry and token renewal, HMAC-signed cookie sealing, CSRF tokens and webhook verification, independent of any web framework or runtime.
|
|
15
|
+
- OpenID Connect relying-party client (`OidcClient`): discovery, JWKS caching, authorization-code flow with PKCE, ID token and logout token validation, refresh-token exchange, userinfo. Redirects are never followed.
|
|
16
|
+
- Distinct errors for a rejection by the identity provider (`OidcError`) and for no answer (`IdpUnavailable`, `RefreshUncertain`).
|
|
17
|
+
- Session store contract (`SessionStore`) with a refresh lease and fencing, so a refresh token is exchanged at most once across processes, plus the in-memory `MemoryStore`.
|
|
18
|
+
- `Keyring` for encrypting stored sessions (JWE `dir` + `A256GCM`) with key rotation.
|
|
19
|
+
- `authHandler`: the `/auth/*` routes (sign-in, callback, sign-out, session, organizations, back-channel logout, webhook) as a `Request` to `Response` function.
|
|
20
|
+
- Access guards on `Request` and `Response` (`checkAccess`, `resolveAccess`, `safeTarget`, `isPublicPath`).
|
|
21
|
+
- Back-channel logout bookkeeping (`BackChannelSink`, `MemoryBackChannelSink`).
|
|
22
|
+
- Standard Webhooks signature verification and signing (`verifyWebhook`, `signWebhook`) with replay window and `SeenEventIds` deduplication.
|
|
23
|
+
- Typed Management API client (`createManagement`) with discovery validation, token caching and cursor iteration.
|
|
24
|
+
- Cookie, bounded request-body and response helpers.
|
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,173 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @stwrd-auth/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The runtime-neutral core shared by the stwrd JavaScript SDKs: configuration, an OpenID Connect relying-party client, server-side sessions with a refresh lease, signed cookies, webhook verification and the Management API transport.
|
|
4
|
+
|
|
5
|
+
## What it is
|
|
6
|
+
|
|
7
|
+
[stwrd](https://stwrd.dev) is a multi-tenant identity provider. This package holds the logic that does not depend on a web framework or a runtime. It has no HTTP server and no framework integration of its own: applications normally install an adapter, and the adapter builds on this package.
|
|
8
|
+
|
|
9
|
+
| Package | Use it for |
|
|
10
|
+
|---|---|
|
|
11
|
+
| [`@stwrd-auth/node`](https://www.npmjs.com/package/@stwrd-auth/node) | Express apps |
|
|
12
|
+
| [`@stwrd-auth/workers`](https://www.npmjs.com/package/@stwrd-auth/workers) | Cloudflare Workers |
|
|
13
|
+
| [`@stwrd-auth/react`](https://www.npmjs.com/package/@stwrd-auth/react) | React front ends that talk to one of the two above |
|
|
14
|
+
|
|
15
|
+
Install this package directly to write an adapter for another runtime, to verify stwrd webhooks on their own, or to use the typed Management client without an adapter.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install @stwrd-auth/core
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Node.js 18 or newer, or any runtime that provides the Fetch API (`Request`, `Response`, `Headers`) and `node:crypto` (Cloudflare Workers with the `nodejs_compat` compatibility flag). The only runtime dependency is [`jose`](https://www.npmjs.com/package/jose) (`^6`). The package is ESM only and ships its own TypeScript declarations.
|
|
24
|
+
|
|
25
|
+
Each module is a subpath export; there is no root entry point (`import ... from "@stwrd-auth/core/client"`). The adapters pin this package to an exact version, so install both from the same release.
|
|
26
|
+
|
|
27
|
+
## Minimal example
|
|
28
|
+
|
|
29
|
+
A server for any runtime that speaks `Request` and `Response` (Bun, Deno, Workers). The handler serves the `/auth/*` routes and returns `null` for any other path:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { StwrdCore, stwrdOptionsFromEnv } from "@stwrd-auth/core/client";
|
|
33
|
+
import { authHandler } from "@stwrd-auth/core/web/authHandler";
|
|
34
|
+
|
|
35
|
+
const stwrd = new StwrdCore(stwrdOptionsFromEnv(process.env));
|
|
36
|
+
const handle = authHandler(stwrd);
|
|
37
|
+
|
|
38
|
+
export default {
|
|
39
|
+
async fetch(request: Request): Promise<Response> {
|
|
40
|
+
return (await handle(request)) ?? new Response("Not found", { status: 404 });
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Without a `sessions` option the sessions live in process memory (`MemoryStore`), which suits development and a single process. For several processes, pass a shared store that implements `SessionStore`; `@stwrd-auth/node/postgres` and `@stwrd-auth/workers` ship two.
|
|
46
|
+
|
|
47
|
+
Verifying a webhook delivery, with the raw request body and the request headers:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { SeenEventIds, verifyWebhook } from "@stwrd-auth/core/webhooks";
|
|
51
|
+
|
|
52
|
+
const seen = new SeenEventIds();
|
|
53
|
+
|
|
54
|
+
export function receive(rawBody: string | Buffer, headers: Record<string, string>) {
|
|
55
|
+
// Throws InvalidSignatureError or DuplicateEventError.
|
|
56
|
+
return verifyWebhook(rawBody, headers, process.env.STWRD_WEBHOOK_SECRET!, { seen });
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
`new StwrdCore(options)` and `resolveConfig(options)` validate the options and throw `ConfigError` when one is missing or invalid. `stwrdOptionsFromEnv(env, overrides?)` reads them from an environment object; the core never reads a global environment of its own.
|
|
63
|
+
|
|
64
|
+
| Option | Environment variable | Required | Default | Description |
|
|
65
|
+
|---|---|---|---|---|
|
|
66
|
+
| `issuer` | `STWRD_ISSUER` | yes | | Issuer URL of the tenant. |
|
|
67
|
+
| `clientId` | `STWRD_CLIENT_ID` | yes | | OIDC client ID of the application. |
|
|
68
|
+
| `clientSecret` | `STWRD_CLIENT_SECRET` | yes | | Client secret of the application. |
|
|
69
|
+
| `baseUrl` | `STWRD_BASE_URL` | yes | | Public URL of the application. `{baseUrl}{prefix}/callback` is the redirect URI. |
|
|
70
|
+
| `cookieSecret` | `STWRD_COOKIE_SECRET` | yes | | HMAC key of the session cookie, at least 32 characters. |
|
|
71
|
+
| `scope` | `STWRD_SCOPE` | no | `openid profile email offline_access` | Requested scopes. Add `org` to enable organizations. |
|
|
72
|
+
| `webhookSecret` | `STWRD_WEBHOOK_SECRET` | no | none | Without it `/auth/webhook` answers 503. |
|
|
73
|
+
| `prefix` | `STWRD_PREFIX` | no | `/auth` | Path prefix of the routes. |
|
|
74
|
+
| `sessionTtlS` | `STWRD_SESSION_TTL_S` | no | `28800` | Sliding session lifetime in seconds. |
|
|
75
|
+
| `cookieSecure` | `STWRD_COOKIE_SECURE` | no | `true` | `Secure` attribute of the cookies. |
|
|
76
|
+
| `postLoginRedirect` | `STWRD_POST_LOGIN_REDIRECT` | no | `/` | Destination when a sign-in has no `return_to`. |
|
|
77
|
+
| `postLogoutRedirect` | `STWRD_POST_LOGOUT_REDIRECT` | no | `/` | Destination after sign-out. |
|
|
78
|
+
| `sessionCookie` | | no | `__Host-stwrd_session` | Name of the session cookie. |
|
|
79
|
+
| `txCookie` | | no | `__Host-stwrd_tx` | Name of the sign-in transaction cookie. |
|
|
80
|
+
| `transactionTtlS` | | no | `600` | Lifetime of a pending sign-in, in seconds. |
|
|
81
|
+
| `sessions` | | no | `MemoryStore` | A `SessionStore`. |
|
|
82
|
+
| `fetch` | | no | `globalThis.fetch` | The `fetch` used for every request to the identity provider. |
|
|
83
|
+
|
|
84
|
+
## API reference
|
|
85
|
+
|
|
86
|
+
| Subpath | Exports |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `@stwrd-auth/core/client` | `StwrdCore` (sessions, cookie sealing, CSRF, webhook verification, organization listing), `stwrdOptionsFromEnv`, `RefreshTuning`, `KeepAlive` |
|
|
89
|
+
| `@stwrd-auth/core/config` | `resolveConfig`, `configFromEnv`, `stwrdConfigFromEnv`, `ConfigError`, defaults and option types |
|
|
90
|
+
| `@stwrd-auth/core/oidc` | `OidcClient`, `OidcError`, `IdpUnavailable`, `RefreshUncertain`, PKCE and `state` helpers |
|
|
91
|
+
| `@stwrd-auth/core/sessions` | `SessionStore`, `MemoryStore`, `StwrdSession`, `StwrdUser`, `StwrdOrganization`, `sessionContext`, `sessionResponse`, `hasRole`, `hasPermission` |
|
|
92
|
+
| `@stwrd-auth/core/keyring` | `Keyring`: named 32-byte keys that encrypt stored sessions (JWE `dir` + `A256GCM`) |
|
|
93
|
+
| `@stwrd-auth/core/backchannel` | `BackChannelSink`, `MemoryBackChannelSink` |
|
|
94
|
+
| `@stwrd-auth/core/webhooks` | `verifyWebhook`, `verifyWebhookSignature`, `signWebhook`, `SeenEventIds`, `InvalidSignatureError`, `DuplicateEventError` |
|
|
95
|
+
| `@stwrd-auth/core/management` | `createManagement`, `ManagementClient`, `ManagementError`: typed Management API client |
|
|
96
|
+
| `@stwrd-auth/core/crypto` | `sign`, `unsign`, `csrfToken` |
|
|
97
|
+
| `@stwrd-auth/core/web/authHandler` | `authHandler(stwrd, options?)`: the `/auth/*` routes as `(Request) => Promise<Response \| null>` |
|
|
98
|
+
| `@stwrd-auth/core/web/access` | `checkAccess`, `resolveAccess`, `safeTarget`, `isPublicPath`: the access guards on `Request` and `Response` |
|
|
99
|
+
| `@stwrd-auth/core/web/cookies`, `body`, `response` | Cookie, bounded body-reading and response helpers |
|
|
100
|
+
|
|
101
|
+
### Routes served by `authHandler`
|
|
102
|
+
|
|
103
|
+
| Route | Behavior |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `GET {prefix}/sign-in` | Starts the authorization-code flow with PKCE; accepts `return_to`; other query parameters are ignored. |
|
|
106
|
+
| `GET {prefix}/callback` | Completes the flow, creates the session and sets the session cookie. |
|
|
107
|
+
| `POST {prefix}/sign-out` | Ends the session. Requires the CSRF token (`x-csrf-token` header or `csrf_token` form field). |
|
|
108
|
+
| `GET {prefix}/session` | JSON: `authenticated`, `user`, `organization`, `consents`, `csrf_token`, `account_url`. |
|
|
109
|
+
| `GET {prefix}/organizations`, `POST {prefix}/organization` | List the person's organizations and switch the active one (requires the `org` scope). |
|
|
110
|
+
| `POST {prefix}/back-channel` | Receives OIDC back-channel logout notices. |
|
|
111
|
+
| `POST {prefix}/webhook` | Receives and verifies webhook deliveries. |
|
|
112
|
+
|
|
113
|
+
### Management client
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { createManagement } from "@stwrd-auth/core/management";
|
|
117
|
+
|
|
118
|
+
const api = createManagement({
|
|
119
|
+
issuer: process.env.STWRD_ISSUER!,
|
|
120
|
+
clientId: process.env.STWRD_MANAGEMENT_CLIENT_ID!,
|
|
121
|
+
clientSecret: process.env.STWRD_MANAGEMENT_CLIENT_SECRET!,
|
|
122
|
+
scopes: ["mgmt:users:read"],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
for await (const user of api.users.iterate()) {
|
|
126
|
+
console.log(user.id);
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Use it on a server only; never ship Management credentials to a browser. The client validates the discovery document before it sends any credential, refuses redirects, caches access tokens per validated destination and never retries a write on its own. Errors are `ManagementError` instances with `status`, `body` and `requestId`.
|
|
131
|
+
|
|
132
|
+
## Security
|
|
133
|
+
|
|
134
|
+
- Session cookies are HMAC-signed (tamper-evident, not encrypted) and carry only an opaque session identifier. Access, ID and refresh tokens stay in the server-side store and are never sent to the browser.
|
|
135
|
+
- By default the cookies use the `__Host-` prefix and are `HttpOnly`, `Secure` and `SameSite=Lax`.
|
|
136
|
+
- Authorization-code flows use PKCE (`S256`), `state` and `nonce`. ID tokens are verified for signature (`RS256` or `EdDSA`), issuer, audience, expiry, `nonce` and `at_hash`. The algorithm list is fixed and never read from the token.
|
|
137
|
+
- Requests to the identity provider never follow redirects, so a redirect cannot carry a secret to another host. The redirect URI is built from `baseUrl`, never from the `Host` header.
|
|
138
|
+
- State-changing routes (`sign-out`, organization switch) require a CSRF token derived from the session. Redirect targets taken from requests go through `safeTarget`: only paths of the same application are allowed.
|
|
139
|
+
- Webhook signatures are compared in constant time, with a 300-second replay window. Deduplication by event `id` is process-local; a receiver that needs a hard guarantee must also deduplicate in its own storage.
|
|
140
|
+
- Request bodies are read with a size ceiling measured on the stream (100 KiB for forms and JSON, 5 MiB for webhooks).
|
|
141
|
+
- Across the processes that share a session store, a refresh token is exchanged at most once (a lease with fencing). When a refresh may have been processed but its result was not stored, the session becomes `uncertain` and the person signs in again; the consumed token is never replayed.
|
|
142
|
+
|
|
143
|
+
Keep `cookieSecret`, `clientSecret`, `webhookSecret` and any session keyring out of source control and out of logs.
|
|
144
|
+
|
|
145
|
+
Supported versions and how to report a vulnerability are described in the [security policy](https://github.com/stwrd-dev/sdk-js/security/policy).
|
|
146
|
+
|
|
147
|
+
## Error handling and troubleshooting
|
|
148
|
+
|
|
149
|
+
| Error | Meaning | What to do |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `ConfigError` | A required option is missing or invalid, or an option has the wrong type. | Fix the configuration; the process should not start. |
|
|
152
|
+
| `OidcError` | The identity provider answered and rejected the request (bad code, invalid token). | Treat the sign-in or the session as invalid. |
|
|
153
|
+
| `IdpUnavailable` | The identity provider, or the session store, did not answer. `requestSent` says whether a request may have been processed. | Answer 503 and keep the session; retry later. |
|
|
154
|
+
| `RefreshUncertain` | A refresh may have been processed without its result being stored. | Send the person through `/auth/sign-in`. |
|
|
155
|
+
| `InvalidSignatureError` | The webhook headers, timestamp or signature are not valid. | Answer 400. |
|
|
156
|
+
| `DuplicateEventError` | The webhook event `id` was already processed. | Answer 200 without reprocessing. |
|
|
157
|
+
| `ManagementError` | The Management API answered with an error status. | Read `status`, `body` and `requestId`. |
|
|
158
|
+
|
|
159
|
+
When the identity provider does not answer, the routes and guards answer 503 and never 401, so a person with a live session is not told that they signed out.
|
|
160
|
+
|
|
161
|
+
## Compatibility
|
|
162
|
+
|
|
163
|
+
- Node.js 18 or newer; Cloudflare Workers with `nodejs_compat`. The test suite runs on both Node and workerd.
|
|
164
|
+
- ESM only. TypeScript declarations are included; they resolve under `node10`, `nodenext` and `bundler` module resolution.
|
|
165
|
+
- Browsers are not supported: this package holds server-side secrets.
|
|
166
|
+
|
|
167
|
+
## Versioning
|
|
168
|
+
|
|
169
|
+
This package follows Semantic Versioning (SemVer). While the version is below 1.0, minor releases may contain breaking changes. Changes are listed in `CHANGELOG.md`.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT © 2026 stwrd. See `LICENSE`.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Back-channel logout bookkeeping: ending a local session and remembering the
|
|
3
|
+
* notice's `jti`, so the same notice is never accepted twice.
|
|
4
|
+
*
|
|
5
|
+
* The order is the contract: the session is deleted FIRST and the `jti` is
|
|
6
|
+
* marked SECOND. The IdP retries a 5xx and does not retry a 4xx; if the `jti`
|
|
7
|
+
* were marked first and the deletion then failed, the retry would find a seen
|
|
8
|
+
* `jti` and answer 400 "replay", and the local session would stay alive for
|
|
9
|
+
* good. A sink that cannot do both (a store that is down) throws and marks
|
|
10
|
+
* nothing.
|
|
11
|
+
*/
|
|
12
|
+
import type { SessionStore } from "./sessions.js";
|
|
13
|
+
export interface BackChannelSink {
|
|
14
|
+
/** Ends `sessionId` (when the notice names a session) and marks `jti` as
|
|
15
|
+
* seen until `untilS` (epoch seconds, the notice's own `exp`: afterwards it
|
|
16
|
+
* cannot pass validation anyway). `"replay"` when `jti` was already seen; in
|
|
17
|
+
* that case nothing is deleted. Throws when the store cannot be reached. */
|
|
18
|
+
terminate(sessionId: string | null, jti: string, untilS: number): Promise<"terminated" | "replay">;
|
|
19
|
+
}
|
|
20
|
+
/** The default sink: process-local `jti` memory in front of a `SessionStore`.
|
|
21
|
+
* With several processes each one remembers its own; a sink that spans them
|
|
22
|
+
* (one transaction in the store) is the adapter's to provide. */
|
|
23
|
+
export declare class MemoryBackChannelSink implements BackChannelSink {
|
|
24
|
+
private readonly sessions;
|
|
25
|
+
private readonly seen;
|
|
26
|
+
constructor(sessions: SessionStore);
|
|
27
|
+
terminate(sessionId: string | null, jti: string, untilS: number): Promise<"terminated" | "replay">;
|
|
28
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Back-channel logout bookkeeping: ending a local session and remembering the
|
|
3
|
+
* notice's `jti`, so the same notice is never accepted twice.
|
|
4
|
+
*
|
|
5
|
+
* The order is the contract: the session is deleted FIRST and the `jti` is
|
|
6
|
+
* marked SECOND. The IdP retries a 5xx and does not retry a 4xx; if the `jti`
|
|
7
|
+
* were marked first and the deletion then failed, the retry would find a seen
|
|
8
|
+
* `jti` and answer 400 "replay", and the local session would stay alive for
|
|
9
|
+
* good. A sink that cannot do both (a store that is down) throws and marks
|
|
10
|
+
* nothing.
|
|
11
|
+
*/
|
|
12
|
+
/** The default sink: process-local `jti` memory in front of a `SessionStore`.
|
|
13
|
+
* With several processes each one remembers its own; a sink that spans them
|
|
14
|
+
* (one transaction in the store) is the adapter's to provide. */
|
|
15
|
+
export class MemoryBackChannelSink {
|
|
16
|
+
sessions;
|
|
17
|
+
seen = new Map();
|
|
18
|
+
constructor(sessions) {
|
|
19
|
+
this.sessions = sessions;
|
|
20
|
+
}
|
|
21
|
+
async terminate(sessionId, jti, untilS) {
|
|
22
|
+
const now = Date.now() / 1000;
|
|
23
|
+
for (const [seenJti, until] of this.seen) {
|
|
24
|
+
if (until <= now) {
|
|
25
|
+
this.seen.delete(seenJti);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (this.seen.has(jti)) {
|
|
29
|
+
return "replay";
|
|
30
|
+
}
|
|
31
|
+
// The store is keyed by the BFF's own id and the notice only carries
|
|
32
|
+
// `sid`, so the id was derived (`sessionIdForSid`) and the deletion is the
|
|
33
|
+
// plain `delete(id)` of the store contract: every store, third-party ones
|
|
34
|
+
// included, terminates sessions correctly with no extra method.
|
|
35
|
+
if (sessionId) {
|
|
36
|
+
await this.sessions.delete(sessionId);
|
|
37
|
+
}
|
|
38
|
+
this.seen.set(jti, untilS);
|
|
39
|
+
return "terminated";
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `StwrdCore` — the runtime-neutral object behind every JavaScript adapter:
|
|
3
|
+
* configuration, OIDC client, session store, cookie sealing, session
|
|
4
|
+
* resolution with the refresh lease, organization listing and webhook
|
|
5
|
+
* verification. It knows nothing about Express, Node's `http` or a Worker's
|
|
6
|
+
* `env`: `@stwrd-auth/node` extends it with the Express wiring. The Python SDK
|
|
7
|
+
* implements the same session logic.
|
|
8
|
+
*/
|
|
9
|
+
import { type EnvLike, type ResolvedConfig, type StwrdConfigOptions } from "./config.js";
|
|
10
|
+
import { type FetchLike, OidcClient } from "./oidc.js";
|
|
11
|
+
import { type OwnOrganization } from "./organizations.js";
|
|
12
|
+
import { type SessionStore, type StwrdSession } from "./sessions.js";
|
|
13
|
+
import { SeenEventIds, type WebhookEvent } from "./webhooks.js";
|
|
14
|
+
/** How long the IdP gets to answer the discovery probe (`probeBeforeExchange`). */
|
|
15
|
+
export declare const DISCOVERY_PROBE_TIMEOUT_MS = 5000;
|
|
16
|
+
/** How a runtime waits for, and checks the IdP before, a refresh. The defaults
|
|
17
|
+
* suit Node; an adapter for a runtime where each store call is expensive (Workers: one Durable Object
|
|
18
|
+
* call per poll) or where a failed exchange cannot be told apart from a lost
|
|
19
|
+
* one passes its own. */
|
|
20
|
+
export interface RefreshTuning {
|
|
21
|
+
/** Seconds a request waits for another owner's refresh of the same session
|
|
22
|
+
* to finish, before answering "the IdP is unavailable". */
|
|
23
|
+
waitS: number;
|
|
24
|
+
/** Milliseconds to wait before polling the store again, given how many polls
|
|
25
|
+
* (0 for the first) already found the lease busy. */
|
|
26
|
+
pollDelayMs(attempt: number): number;
|
|
27
|
+
/** Ask the IdP for its discovery document again, with a
|
|
28
|
+
* `DISCOVERY_PROBE_TIMEOUT_MS` timeout, just BEFORE the exchange is marked as
|
|
29
|
+
* sent. If the IdP does not answer, nothing was marked and nothing sent: the
|
|
30
|
+
* lease is released and the next request tries again. Where a failure of the
|
|
31
|
+
* exchange itself can never be proven "not sent" (the session would go
|
|
32
|
+
* `uncertain`), this is what keeps an IdP that is down from costing a session. */
|
|
33
|
+
probeBeforeExchange: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare const DEFAULT_REFRESH_TUNING: RefreshTuning;
|
|
36
|
+
/** How a runtime keeps a renewal alive past the request that started it: given
|
|
37
|
+
* the work (a promise that never rejects), it must not let the runtime cut it
|
|
38
|
+
* short. On Workers this is `ctx.waitUntil`; where nothing cuts a request off
|
|
39
|
+
* (Node) there is none, and a renewal runs inline. The caller still awaits the
|
|
40
|
+
* renewal itself; this only stops "the person closed the tab" from killing it
|
|
41
|
+
* between "marked sent" and "saved". */
|
|
42
|
+
export type KeepAlive = (work: Promise<void>) => void;
|
|
43
|
+
export interface StwrdOptions extends StwrdConfigOptions {
|
|
44
|
+
sessions?: SessionStore;
|
|
45
|
+
fetch?: FetchLike;
|
|
46
|
+
}
|
|
47
|
+
export declare class StwrdCore {
|
|
48
|
+
readonly config: ResolvedConfig;
|
|
49
|
+
readonly oidc: OidcClient;
|
|
50
|
+
readonly sessions: SessionStore;
|
|
51
|
+
readonly seenWebhookIds: SeenEventIds;
|
|
52
|
+
private readonly fetchImpl;
|
|
53
|
+
private readonly tuning;
|
|
54
|
+
constructor(options: StwrdOptions, tuning?: RefreshTuning);
|
|
55
|
+
/** HMAC-signed, base64url cookie value. Not encryption — nothing sealed
|
|
56
|
+
* carries a secret the browser must not read; what this buys is
|
|
57
|
+
* tamper-evidence, so a forged cookie unseals to nothing. */
|
|
58
|
+
seal(value: unknown): string;
|
|
59
|
+
/** `null` on anything wrong — missing cookie, bad signature, bad JSON. */
|
|
60
|
+
unseal(cookie: string | null | undefined): unknown;
|
|
61
|
+
csrf(sessionId: string): string;
|
|
62
|
+
/** The local session id for an IdP session — derived, never random. It is
|
|
63
|
+
* what lets back-channel logout reach a session with only the IdP's `sid`,
|
|
64
|
+
* through `SessionStore.delete(id)` and no secondary index. */
|
|
65
|
+
sessionIdForSid(sid: string): string;
|
|
66
|
+
/** Organization selection needs the `org` scope to be requested
|
|
67
|
+
* explicitly; it is never added automatically. */
|
|
68
|
+
organizationSelectionEnabled(): boolean;
|
|
69
|
+
/** The person's own usable organizations, read with their access token. */
|
|
70
|
+
listOrganizations(session: StwrdSession): Promise<OwnOrganization[]>;
|
|
71
|
+
/** The session ready to authorize with — renews the tokens if the session
|
|
72
|
+
* holds a refresh token and the access token has expired. */
|
|
73
|
+
resolveSession(cookie: string | null | undefined, keepAlive?: KeepAlive): Promise<StwrdSession | null>;
|
|
74
|
+
/** Raw store read: unseals the cookie and looks the session up, with no
|
|
75
|
+
* renewal and no expiry side effects. */
|
|
76
|
+
sessionFromCookie(cookie: string | null | undefined): Promise<StwrdSession | null>;
|
|
77
|
+
/** Refresh `session` under the store's lease: across every worker the
|
|
78
|
+
* refresh token is exchanged at most once. `null` when the IdP REJECTED the
|
|
79
|
+
* renewal or the session was deleted meanwhile (`resolveSession` then ends
|
|
80
|
+
* the local session). `IdpUnavailable` propagates when nothing can be
|
|
81
|
+
* vouched for right now: the IdP is silent, another worker holds the lease
|
|
82
|
+
* too long, or an earlier exchange may have rotated the token without its
|
|
83
|
+
* result stored (`uncertain`: permanent for that session, the consumed token
|
|
84
|
+
* is never replayed). With `keepAlive`, the owned renewal (marked sent →
|
|
85
|
+
* exchange → saved) is handed to the runtime to finish even if the request
|
|
86
|
+
* ends first. */
|
|
87
|
+
private renew;
|
|
88
|
+
/** A lost lease is not a rejection: only a session that no longer exists
|
|
89
|
+
* ends (logout). One that is still there, possibly re-created by a new
|
|
90
|
+
* sign-in, must not be deleted by a stale owner. */
|
|
91
|
+
private leaseLost;
|
|
92
|
+
private renewOwned;
|
|
93
|
+
/** Verifies a webhook delivery and deduplicates it against
|
|
94
|
+
* `this.seenWebhookIds`. Throws `ConfigError` if no `webhookSecret` is
|
|
95
|
+
* configured — the `/auth/webhook` route turns that into a 503. */
|
|
96
|
+
verifyWebhook(body: string | Buffer, headers: Record<string, string>): WebhookEvent;
|
|
97
|
+
}
|
|
98
|
+
/** Reads `STWRD_*` variables from `env`, with `overrides` winning over them,
|
|
99
|
+
* and fails closed (`ConfigError`) when a required one is missing from both.
|
|
100
|
+
* There is no default `env` here: `@stwrd-auth/node` passes `process.env`. */
|
|
101
|
+
export declare function stwrdOptionsFromEnv(env: EnvLike, overrides?: Partial<StwrdOptions>): StwrdOptions;
|