@gaballahtech/seba 0.0.0-stage → 0.2.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.
Files changed (86) hide show
  1. package/ARCHITECTURE.md +242 -0
  2. package/BUILD_REPORT.md +195 -0
  3. package/CHANGELOG.md +59 -0
  4. package/LICENSE +21 -0
  5. package/README.md +282 -2
  6. package/RESEARCH.md +180 -0
  7. package/SECURITY.md +224 -0
  8. package/VALIDATION_REPORT.md +187 -0
  9. package/dist/browser/index.d.ts +9 -0
  10. package/dist/browser/index.d.ts.map +1 -0
  11. package/dist/browser/index.js +9 -0
  12. package/dist/browser/index.js.map +1 -0
  13. package/dist/browser/platform.d.ts +14 -0
  14. package/dist/browser/platform.d.ts.map +1 -0
  15. package/dist/browser/platform.js +60 -0
  16. package/dist/browser/platform.js.map +1 -0
  17. package/dist/browser/provider-modal.d.ts +15 -0
  18. package/dist/browser/provider-modal.d.ts.map +1 -0
  19. package/dist/browser/provider-modal.js +126 -0
  20. package/dist/browser/provider-modal.js.map +1 -0
  21. package/dist/browser/storage.d.ts +32 -0
  22. package/dist/browser/storage.d.ts.map +1 -0
  23. package/dist/browser/storage.js +121 -0
  24. package/dist/browser/storage.js.map +1 -0
  25. package/dist/core/client.d.ts +49 -0
  26. package/dist/core/client.d.ts.map +1 -0
  27. package/dist/core/client.js +371 -0
  28. package/dist/core/client.js.map +1 -0
  29. package/dist/core/discovery.d.ts +82 -0
  30. package/dist/core/discovery.d.ts.map +1 -0
  31. package/dist/core/discovery.js +243 -0
  32. package/dist/core/discovery.js.map +1 -0
  33. package/dist/core/dpop.d.ts +67 -0
  34. package/dist/core/dpop.d.ts.map +1 -0
  35. package/dist/core/dpop.js +176 -0
  36. package/dist/core/dpop.js.map +1 -0
  37. package/dist/core/errors.d.ts +86 -0
  38. package/dist/core/errors.d.ts.map +1 -0
  39. package/dist/core/errors.js +104 -0
  40. package/dist/core/errors.js.map +1 -0
  41. package/dist/core/http.d.ts +68 -0
  42. package/dist/core/http.d.ts.map +1 -0
  43. package/dist/core/http.js +128 -0
  44. package/dist/core/http.js.map +1 -0
  45. package/dist/core/identity.d.ts +145 -0
  46. package/dist/core/identity.d.ts.map +1 -0
  47. package/dist/core/identity.js +735 -0
  48. package/dist/core/identity.js.map +1 -0
  49. package/dist/core/oidc.d.ts +175 -0
  50. package/dist/core/oidc.d.ts.map +1 -0
  51. package/dist/core/oidc.js +413 -0
  52. package/dist/core/oidc.js.map +1 -0
  53. package/dist/core/session.d.ts +116 -0
  54. package/dist/core/session.d.ts.map +1 -0
  55. package/dist/core/session.js +350 -0
  56. package/dist/core/session.js.map +1 -0
  57. package/dist/core/storage.d.ts +22 -0
  58. package/dist/core/storage.d.ts.map +1 -0
  59. package/dist/core/storage.js +48 -0
  60. package/dist/core/storage.js.map +1 -0
  61. package/dist/index.d.ts +18 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +3 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/node/index.d.ts +9 -0
  66. package/dist/node/index.d.ts.map +1 -0
  67. package/dist/node/index.js +9 -0
  68. package/dist/node/index.js.map +1 -0
  69. package/dist/node/platform.d.ts +25 -0
  70. package/dist/node/platform.d.ts.map +1 -0
  71. package/dist/node/platform.js +161 -0
  72. package/dist/node/platform.js.map +1 -0
  73. package/dist/seba.d.ts +39 -0
  74. package/dist/seba.d.ts.map +1 -0
  75. package/dist/seba.js +58 -0
  76. package/dist/seba.js.map +1 -0
  77. package/dist/types.d.ts +279 -0
  78. package/dist/types.d.ts.map +1 -0
  79. package/dist/types.js +8 -0
  80. package/dist/types.js.map +1 -0
  81. package/examples/node/index.ts +61 -0
  82. package/examples/react/App.tsx +127 -0
  83. package/examples/react/index.html +12 -0
  84. package/examples/react/main.tsx +14 -0
  85. package/examples/react/vite.config.ts +27 -0
  86. package/package.json +83 -4
package/SECURITY.md ADDED
@@ -0,0 +1,224 @@
1
+ # SECURITY
2
+
3
+ ## 1. Threat model
4
+
5
+ | Threat | Assets at risk | Mitigation |
6
+ |---|---|---|
7
+ | Authorization code interception / mix-up | Access + refresh tokens | PKCE S256, random `state`, RFC 9207 `iss` checked against the issuer the request was built for. |
8
+ | ID token forgery or substitution | User identity (`WebID`) | Signature verified with the provider's JWKS; `iss`, `aud`, `azp`, `exp`, `iat`, `nonce` validated; algorithm taken from the key metadata, not the token's preference. |
9
+ | DPoP proof replay | Pod access | A new proof (new `jti`, new signature) for every attempt; no proof cache, no TTL, no reuse option. |
10
+ | Token theft from the browser profile | Refresh token, DPoP key | Only the refresh token and key are persisted (IndexedDB); access token stays in memory; no `localStorage`; no cookies set by the library. |
11
+ | XSS on the application origin | Everything the origin can reach | Out of scope to *prevent*; the library minimises the window (no tokens in DOM, no globals, no logging) and documents the residual risk. |
12
+ | Credential leak via redirects | Access token, DPoP proof | `redirect: "manual"`; cross-origin redirect responses are returned to the caller, never re-issued with credentials. |
13
+ | Duplicate side effects from retries | Application state | Automatic replay is limited to replayable requests (`GET`/`HEAD`/`OPTIONS`, or a re-readable body), at most one nonce retry and one refresh retry. |
14
+ | Malicious/compromised provider metadata | Redirection of tokens | Issuer must match the discovery document exactly; endpoints must be `https` (loopback `http` only); redirect URI must be published verbatim by the Client ID Document. |
15
+ | Secret leakage through logs | Tokens, codes, keys | Debug logging is structured and excludes credentials by construction; errors carry codes and messages, never payloads. |
16
+ | Stolen database copy | Refresh token, key | IndexedDB is origin-bound and not encrypted at rest by SEBA; at-rest protection is the OS/browser profile's job (documented limitation). |
17
+
18
+ Trust boundaries: (1) the browser page and its origin, (2) the provider's HTTPS endpoints,
19
+ (3) the Solid resource server, (4) the published Client ID Document. Everything crossing
20
+ those boundaries is validated; nothing is trusted because it "looks like" a token.
21
+
22
+ ## 2. Token storage policy
23
+
24
+ | Value | Where | Lifetime |
25
+ |---|---|---|
26
+ | Access token | A private field in `SebaClient` (memory) | Until refresh, logout, or process end |
27
+ | Refresh token | IndexedDB (`seba`/`session`, record key `current`) or memory | Until logout / rejection |
28
+ | DPoP private key | IndexedDB, structured-cloned `CryptoKey` (non-extractable) or exported JWK fallback | Same as the refresh token |
29
+ | DPoP public JWK + thumbprint | With the session record | Non-secret |
30
+ | `state`, `nonce`, `code_verifier` | `sessionStorage` (browser) / memory (Node), single transaction record | Deleted after the callback, stale after 10 minutes |
31
+ | ID token | Memory, inside the validated token set | Session lifetime in memory |
32
+ | Authorization code | Memory, only between callback parsing and exchange | Seconds |
33
+
34
+ `PersistedSession` has **no access-token field** — the type makes accidental persistence a
35
+ compile error. `localStorage`, cookies, `window.name` and URL fragments are never used for
36
+ secrets. If IndexedDB is unavailable and `storage` was left at its default, SEBA throws
37
+ `SebaConfigError`; it never silently downgrades to a weaker store.
38
+
39
+ ## 3. DPoP implementation requirements (RFC 9449)
40
+
41
+ - `typ` is `dpop+jwt`, `alg` is `ES256`; the `jwk` header contains only `kty`, `crv`, `x`,
42
+ `y` — never `d`.
43
+ - `jti`: 32 bytes from `crypto.getRandomValues`, base64url encoded. Unique per proof.
44
+ - `htm`: uppercase HTTP method actually sent.
45
+ - `htu`: the request URL with query and fragment removed, normalized by `URL`
46
+ (lowercased scheme/host, default port removed).
47
+ - `iat`: seconds since the epoch, from the local clock at signing time.
48
+ - `ath`: `base64url(SHA-256(ASCII(access_token)))`, **only** on protected-resource requests.
49
+ Token-endpoint requests carry no `ath` (there is no access token being presented).
50
+ - `nonce`: included only when the server supplied a `DPoP-Nonce`; the nonce is cached per
51
+ endpoint, the proof is always regenerated.
52
+ - One proof per HTTP attempt. Retries — nonce challenge, token refresh, redirect hop —
53
+ each call `createDPoPProof()` again.
54
+
55
+ `createDPoPProof()` is a pure function of `(key, method, url, accessToken?, nonce?)`
56
+ returning a compact JWS. There is no module-level proof state, so no code path can return
57
+ a previously signed proof. `tests/security/dpop-security.test.ts` asserts this directly:
58
+ 100 sequential proofs for an identical `(htm, htu)` yield 100 distinct JWTs and 100
59
+ distinct `jti` values, and a recorded proof never reappears.
60
+
61
+ ## 4. Key management
62
+
63
+ - Keys are generated with `jose.generateKeyPair("ES256", { extractable: false })` and
64
+ never leave the runtime.
65
+ - `structuredClone(privateKey)` is probed once at login. When it succeeds, the
66
+ non-extractable `CryptoKey` is stored and the key material is never exportable
67
+ (`exportKey` throws for the private key). The public JWK is stored alongside it because a
68
+ non-extractable private key cannot reproduce its public half later.
69
+ - When the engine cannot clone `CryptoKey`s, SEBA generates an extractable key, exports the
70
+ private JWK and stores it. This is a documented downgrade: the key is readable by
71
+ same-origin script either way, but it is no longer protected from export. The probe
72
+ result is not configurable, and index-key persistence is never used as a silent
73
+ substitute.
74
+ - Only one key pair exists per stored session. A new login replaces the record atomically;
75
+ logout deletes it. Keys are never logged, never returned from the public API and never
76
+ included in error messages.
77
+ - The public JWK's SHA-256 thumbprint (`jkt`) is what the ID token's `cnf.jkt` is compared
78
+ against; the access token is therefore bound to the same key used for its proofs.
79
+
80
+ ## 5. PKCE and OAuth state
81
+
82
+ - `code_verifier`: 43 characters from the RFC 7636 alphabet, each from `crypto.getRandomValues`
83
+ (~258 bits of entropy), never shorter than the RFC minimum.
84
+ - `code_challenge`: `base64url(SHA-256(ASCII(verifier)))`, `code_challenge_method=S256`.
85
+ `plain` is never sent or accepted.
86
+ - `state` and `nonce`: 32 random bytes each, base64url, independent values.
87
+ - All three are stored **before** the navigation and consumed exactly once. A callback
88
+ without a pending transaction, with a different `state`, or older than 10 minutes is
89
+ rejected with `SebaAuthError` before the code is exchanged.
90
+ - The authorization response's `iss` (RFC 9207) must equal the issuer the pending
91
+ transaction was created for; otherwise `SebaAuthError` (mix-up protection).
92
+ - The code is exchanged exactly once. Pending state is removed as soon as the exchange has
93
+ succeeded, so a replayed callback finds no transaction and fails closed. A response that
94
+ fails validation *before* the exchange leaves the transaction in place (until the
95
+ 10-minute expiry) so the user can retry; the code itself is never exchanged twice.
96
+
97
+ ## 6. ID token validation
98
+
99
+ Performed by `validateIdToken()` before any identity value is read:
100
+
101
+ 1. Compact JWS parsed; signature verified with `createRemoteJWKSet(jwksUri)` via `jose`,
102
+ restricted to asymmetric algorithms (`ES256/384/512`, `RS256/384/512`, `PS256/384/512`).
103
+ 2. `iss` must equal the discovered issuer (exact string).
104
+ 3. `aud` must contain `client_id`; `azp`, when present, must equal `client_id`; `azp` is
105
+ required when `aud` has multiple values.
106
+ 4. When the provider advertises the `webid` scope, `aud` must also contain `"solid"`.
107
+ 5. `exp` must be in the future (60 s clock tolerance), `iat` must not be in the future
108
+ (60 s tolerance), `nbf` is honoured when present.
109
+ 6. `nonce` must equal the pending transaction's nonce (login only).
110
+ 7. `sub` is required. `webid`, when present, must be an `https` URL (loopback `http`
111
+ allowed for development); otherwise `sub` is used when it is an `https` URL.
112
+ 8. `cnf.jkt`, when present, must equal the local DPoP key thumbprint, and is **required**
113
+ when the provider advertises `webid`.
114
+ 9. The resulting WebID must equal `options.webId` when the caller supplied one
115
+ (`SebaAuthError` on mismatch).
116
+
117
+ Decoding without verification is never used to make a decision. Failed validation is a
118
+ hard `SebaAuthError`; there is no "accept anyway" path.
119
+
120
+ ## 7. Refresh-token handling
121
+
122
+ - Refresh requests use `grant_type=refresh_token`, `client_id`, the stored refresh token and
123
+ a freshly signed DPoP proof for the token endpoint (no `ath`).
124
+ - The response must be `token_type: "DPoP"`; anything else is discarded (`SebaAuthError`)
125
+ and the previous record is left untouched.
126
+ - Rotation: a returned `refresh_token` replaces the stored one in a single IndexedDB
127
+ `put`. If the provider omits it, the existing token is kept (RFC 6749 §6 allows both).
128
+ - `invalid_grant` / `invalid_token` / `invalid_request` / `unauthorized_client` → storage
129
+ cleared, then `SebaSessionExpiredError`. The application learns the user must sign in
130
+ again. Other OAuth errors surface as `SebaAuthError` and the record is kept.
131
+ - Network failures and 5xx responses → `SebaNetworkError`, record **kept**, so a provider
132
+ outage is never reported as a logout.
133
+ - An ID token returned by a refresh is validated (signature, `iss`, `aud`, lifetime) and
134
+ its WebID must match the stored one; a nonce is not expected on refresh.
135
+ - Concurrent refreshes are collapsed into one in-flight promise to avoid rotation races.
136
+
137
+ ## 8. Replay protection
138
+
139
+ - DPoP: see §3 — no cache, no TTL, fresh `jti` per attempt.
140
+ - OAuth: `state` is single-use and removed before the token request; `nonce` is single-use
141
+ per transaction; PKCE ties the code to this client instance.
142
+ - Retries in `Seba.fetch`: at most one `use_dpop_nonce` retry and at most one
143
+ refresh-then-retry, never nested unbounded. Each retry signs a new proof.
144
+ - Redirects: at most 5 same-origin hops. `303` switches to `GET`; `301`/`302` on a `POST`
145
+ switch to `GET`; `307`/`308` keep the method and body; other method/status combinations
146
+ are only followed when the body is replayable. Cross-origin targets are never followed
147
+ with credentials; the redirect response is returned instead.
148
+ - Requests with a non-replayable body (`ReadableStream`) are never retried after a 401:
149
+ the challenge response is returned unchanged.
150
+
151
+ ## 9. Redirect and request security
152
+
153
+ - The configured `redirectUri` must appear **verbatim** in the Client ID Document's
154
+ `redirect_uris`; no scheme/host/port/trailing-slash normalization is applied to either
155
+ side, so `https://app.example/cb` never matches `https://app.example/cb/`.
156
+ - Node.js callback servers bind only to loopback (`127.0.0.1`, `::1`, `localhost`) and only
157
+ answer the registered path with the expected `state`; anything else gets `404`/`400`. The
158
+ listener is fully closed before `login()` settles, and after a five-minute timeout.
159
+ - The authorization URL is built from the discovered endpoints only, over `https`.
160
+ - `Authorization`/`DPoP` headers are attached only to requests the caller initiated (plus
161
+ same-origin redirect hops). They are never attached to a different origin, and token
162
+ endpoint requests never carry `Authorization`.
163
+ - The provider selection UI validates user input as an `https` URL before use and never
164
+ collects credentials; there is no password field anywhere in the library.
165
+
166
+ ## 10. Logging restrictions
167
+
168
+ `debug: true` logs only: operation names, issuer/client identifiers, endpoint URLs
169
+ (without query strings), status codes, retry reasons and error codes. It never logs access
170
+ tokens, refresh tokens, ID tokens, authorization codes, PKCE verifiers/challenges, `state`,
171
+ `nonce`, public or private JWKs, or DPoP proofs. Errors contain codes and human-readable
172
+ messages; `cause` objects are the original network/JOSE errors and contain no credentials.
173
+
174
+ ## 11. Known limitations
175
+
176
+ 1. **XSS defeats browser storage.** Same-origin script can use the stored refresh token and
177
+ key while the page is open. SEBA reduces exposure but cannot prevent this.
178
+ 2. **IndexedDB is not encrypted at rest** by SEBA. Anyone with the browser profile can read
179
+ the refresh token and (fallback mode) the exported key.
180
+ 3. **Opaque browser redirects.** With `redirect: "manual"`, browsers hide `Location` and the
181
+ status of a redirect, so SEBA returns the redirect response instead of following it.
182
+ 4. **No token introspection.** SEBA validates ID tokens and relies on the resource server
183
+ for access-token validity; revocation is best-effort.
184
+ 5. **Clock skew tolerance (60 s)** is fixed; a badly skewed device can fail login and
185
+ receives an actionable `SebaAuthError`.
186
+ 6. **No live-provider verification.** Only the hand-written mock provider and unit-level
187
+ protocol tests were executed in this environment.
188
+ 7. **`cnf.jkt` requirement gated on advertised `webid` scope.** A provider that omits the
189
+ scope but issues unbound tokens will pass ID token validation; the resource server still
190
+ rejects unbound tokens.
191
+ 8. **IndexedDB at-rest exposure.** Anyone with the browser profile can read the refresh
192
+ token and — on engines that cannot structured-clone `CryptoKey` — the exported private
193
+ JWK. Mitigation available **today**: pass `storage: "memory"` to keep the refresh token
194
+ and key out of IndexedDB for the lifetime of the page; the session then does not survive
195
+ a reload. No `sessionStorage` session backend is implemented, so it is not offered as a
196
+ mitigation here.
197
+
198
+ ## 12. External Validation Findings (v0.2.0)
199
+
200
+ Audit of `src/core/dpop.ts`, `src/core/session.ts` and `src/core/client.ts`, plus the real
201
+ provider run documented in `VALIDATION_REPORT.md`.
202
+
203
+ | ID | Severity | Finding | Resolution |
204
+ |---|---|---|---|
205
+ | F1 | Medium (interop) | Real providers (solidcommunity.net, solidweb.me, CSS 7.3.0) publish a path-less issuer with a trailing slash. Exact string comparison rejected the discovery document when the caller typed the host without `/`. | **Fixed.** `issuerEquivalent()` accepts only the empty-path canonical variant and keeps the provider's spelling for every later comparison; paths/ports/schemes must still match exactly. Unit-tested. |
206
+ | F2 | Medium | A refresh whose ID token failed validation (e.g. `webid` for a different user) left the unusable grant in storage and threw `SebaAuthError`, so every subsequent restore repeated the failure. | **Fixed, stricter.** `SessionManager` now clears storage and throws `SebaSessionExpiredError` when refresh-time ID token validation fails. Transport/JWKS errors still keep the record. Regression test added. |
207
+ | F3 | Low (accepted) | A callback with a mismatched `state` throws `SebaAuthError` but leaves the pending transaction intact until its 10-minute expiry. | **Accepted.** The code is never exchanged, and the legitimate transaction stays usable. Clearing it would allow a page that can navigate the user to cancel an in-flight login. No token or identity is exposed either way. |
208
+ | F4 | Informational | CSS 7.3.0 issues an ID token with string `aud` (no `"solid"`) and no `cnf`, contrary to Solid-OIDC §8.1. | **Not worked around.** Relaxing the audience or `cnf` requirement would remove Solid-specific audience restriction and proof-of-possession binding. The provider is non-conformant; SEBA refuses the session. |
209
+
210
+ Audit checks that passed, with the mechanism:
211
+
212
+ | Check | Result |
213
+ |---|---|
214
+ | New proof for every attempt | `SebaClient.fetch()` calls `createProof()` inside the attempt loop; `requestTokenResponse()` signs again on its nonce retry. No proof object is stored. |
215
+ | `ath` only with an access token | `createDPoPProof` adds `ath` only when `input.accessToken !== undefined`; token-endpoint and revocation proofs omit it. |
216
+ | `jti` uniqueness | 32 bytes from `crypto.getRandomValues` (256 bits), base64url, per proof. |
217
+ | `state` validated before exchange | `parseAuthorizationResponse()` runs before any token request; mismatch throws before the exchange. |
218
+ | `use_dpop_nonce` retry signs fresh | `requestTokenResponse()` loops and calls `signer.createProof({ nonce })` again; no cached proof. |
219
+ | Refresh-token rotation | Returned refresh token overwrites the stored record with one IndexedDB `put`; concurrent refreshes collapse into one in-flight promise. |
220
+ | `restoreSession()` fails closed on identity mismatch | Now clears storage and throws `SebaSessionExpiredError` (F2). |
221
+ | No secrets in logs | `grep -rn "console\." src/` returns only `core/client.ts`'s `console.debug` of caller-supplied diagnostic messages (issuer, endpoint, WebID after success) and doc comments. No tokens, codes, verifiers, keys or proofs are logged. |
222
+
223
+ Residual risk unchanged: XSS on the application origin can use the stored refresh token and
224
+ key while the page is open; SEBA cannot prevent that.
@@ -0,0 +1,187 @@
1
+ # VALIDATION_REPORT — SEBA v0.2.0
2
+
3
+ Environment: Linux (container), Node.js v26.12.1, npm 12.2.1, Chrome 141 binary present
4
+ (`/usr/bin/google-chrome-stable`), no Firefox/Safari binary. Network access to npm and the
5
+ public Solid providers was available.
6
+
7
+ This report records what was executed, not what was intended. Where a gate could not run,
8
+ it is marked **BLOCKED** with the concrete reason.
9
+
10
+ ## Phase A — real provider validation
11
+
12
+ ### A.1 Local Community Solid Server 7.3.0
13
+
14
+ Commands:
15
+
16
+ ```bash
17
+ npx @solid/community-server@7.3.0 -c @css:config/file.json -f ./css-data -p 3000
18
+ node scripts/validation/css-setup.mjs # account + password login + pod
19
+ CSS_EMAIL=… CSS_PASSWORD=… WEBID=… node scripts/validation/css-flow.mjs
20
+ ```
21
+
22
+ The Client ID Document is served by the validation script at
23
+ `http://localhost:8443/client-id.jsonld` with `redirect_uris:
24
+ ["http://localhost:3001/auth/callback"]`. Plain loopback HTTP is used instead of the
25
+ suggested `mkcert` HTTPS endpoint because SEBA's Node callback server intentionally binds
26
+ plain HTTP on loopback only; the document shape is otherwise the requested one (`@context`,
27
+ `client_id`, `redirect_uris`, `client_name`, `scope`, `grant_types`).
28
+
29
+ What passed:
30
+
31
+ | Step | Result |
32
+ |---|---|
33
+ | Discovery | `GET /.well-known/openid-configuration` → 200; issuer `http://localhost:3000/` |
34
+ | Client ID Document fetch by CSS | Accepted; authorization request reached the account interaction |
35
+ | PKCE S256, `state`, `nonce` in authorization request | Accepted by CSS |
36
+ | Interactive login / WebID selection / consent (driven through CSS's account JSON API) | Completed; CSS redirected to the callback with `code`, `state`, `iss=http://localhost:3000/` |
37
+ | SEBA callback server | Received the callback, returned HTTP 200 |
38
+ | Token request with DPoP proof | Accepted by CSS (no `invalid_dpop_proof`, no nonce challenge needed) |
39
+ | Token response | `token_type: "DPoP"`, access token, refresh token, ID token |
40
+
41
+ The ID token CSS actually issued (non-secret claims only, captured by the validation
42
+ script):
43
+
44
+ ```json
45
+ {
46
+ "iss": "http://localhost:3000/",
47
+ "aud": "http://localhost:8443/client-id.jsonld",
48
+ "azp": "http://localhost:8443/client-id.jsonld",
49
+ "webid": "http://localhost:3000/seba018213/profile/card#me",
50
+ "sub": "http://localhost:3000/seba018213/profile/card#me"
51
+ }
52
+ ```
53
+
54
+ SEBA then failed the login with:
55
+
56
+ ```
57
+ SebaAuthError: The ID token audience does not include "solid".
58
+ Solid-OIDC requires it when the provider advertises the webid scope.
59
+ ```
60
+
61
+ Root cause: CSS 7.3.0 deviates from [Solid-OIDC §8.1](https://solidproject.org/TR/oidc).
62
+ With the `webid` scope (which CSS itself advertises in `scopes_supported`), the spec
63
+ requires:
64
+
65
+ - `aud` to be an **array** that includes `azp` and the string `"solid"`;
66
+ - a `cnf` claim with `jkt`.
67
+
68
+ CSS emits a string `aud` containing only the client ID and emits no `cnf`. The same
69
+ observation was reported to the Solid authentication panel long ago
70
+ (solid/authentication-panel#135 discusses the strict audience requirement), but the current
71
+ normative text has not changed.
72
+
73
+ Decision: **do not relax the check.** Accepting an ID token without `"solid"` and `cnf`
74
+ would remove the Solid-specific audience restriction and the proof-of-possession binding
75
+ this library exists to enforce. The failure is in the provider, not in SEBA. Gate A.1 is
76
+ therefore **BLOCKED** at the last step (post-token, pre-session) by provider
77
+ non-conformance. Everything up to and including the DPoP token exchange was exercised
78
+ against real CSS.
79
+
80
+ Transcript files from the run: `/tmp/css-flow.out`, `/tmp/css-flow-noslash.out` (ephemeral,
81
+ environment-local).
82
+
83
+ ### A.2 Public providers
84
+
85
+ No account could be created on `solidcommunity.net`, `solidweb.org` or `solidweb.me`
86
+ without an interactive browser session, an email address and (on some deployments) a
87
+ CAPTCHA. No credentials were available to this environment, so the full login → fetch →
88
+ restore → logout cycle did **not** run against any public provider. It is not marked as
89
+ tested anywhere in this repository.
90
+
91
+ What was executed instead, through SEBA's real discovery code
92
+ (`node scripts/validation/public-discovery.mjs`):
93
+
94
+ | Requested issuer | Discovery | Issuer returned | Revocation endpoint | `webid` advertised |
95
+ |---|---|---|---|---|
96
+ | `https://solidcommunity.net` | 200 | `https://solidcommunity.net/` | yes | yes |
97
+ | `https://solidcommunity.net/` | 200 | `https://solidcommunity.net/` | yes | yes |
98
+ | `https://solidweb.org` | 200 | `https://solidweb.org` | **no** | yes |
99
+ | `https://solidweb.me` | 200 | `https://solidweb.me/` | yes | yes |
100
+ | `http://localhost:3000` | 200 | `http://localhost:3000/` | yes | yes |
101
+
102
+ This surfaced a real interoperability problem: two of the three providers publish an issuer
103
+ with a trailing slash while users and documentation usually type the host without one, and
104
+ SEBA's exact-string comparison rejected the document. Fixed — see Phase D finding F1.
105
+
106
+ Gate A.2: **BLOCKED** (no public account; full cycle not executed). Discovery compatibility
107
+ is proven, not authentication.
108
+
109
+ ## Phase B — browser validation
110
+
111
+ - Chrome is installed, but Firefox and Safari are not present in this environment.
112
+ - A browser end-to-end login against a real provider is blocked by A.1/A.2: the only local
113
+ real provider (CSS) fails ID token validation, and no public provider account exists.
114
+ - No Playwright/Puppeteer run was performed. The built React example (Vite) and the browser
115
+ storage adapter (`fake-indexeddb` unit tests) are the only browser-adjacent evidence.
116
+
117
+ Gate B.1: **BLOCKED** — Chrome + Firefox 5-step matrix not executed. Safari availability
118
+ was never reached.
119
+
120
+ ### B.2 IndexedDB edge cases
121
+
122
+ Not executed in a real browser. What the code does today, verified by unit tests or
123
+ inspection:
124
+
125
+ | Case | Behaviour | Evidence |
126
+ |---|---|---|
127
+ | Storage quota exceeded | `storage.save()` rejection is caught in `SessionManager.adopt` and only logged; the in-memory session stays usable. Refresh-rotation save failure is likewise non-fatal. | `tests/unit/session.test.ts` "keeps the session usable when storage fails" |
128
+ | Corrupted pending-authorization JSON | Removed and treated as no pending login (`SebaAuthError` when a callback arrives). | `tests/unit/browser-storage.test.ts` |
129
+ | Stored session from another client/version | Cleared, then `SebaAuthError`. | `tests/unit/session.test.ts` |
130
+ | Unusable stored DPoP key | Cleared, then `SebaSessionExpiredError`. | `tests/unit/session.test.ts` |
131
+ | Private/incognito IndexedDB failure | `openDB` rejection propagates while saving; `adopt` downgrades it to a debug message and keeps the memory session. A later `restoreSession()` sees nothing. No hang, but no typed error is surfaced to the caller because storage failure is intentionally non-fatal. | code inspection; not browser-executed |
132
+
133
+ Gate B.2: **NOT EXECUTED in a browser**; no crash/hang was found by inspection or unit
134
+ tests, but this gate is not claimed green.
135
+
136
+ ## Phase C — examples
137
+
138
+ - `tsconfig.examples.json` added; `npm run typecheck` now runs both projects.
139
+ - `npm run typecheck:examples` → 0 errors.
140
+ - `npm run example:react:build` (`vite build`) → success, 79 modules.
141
+ - Node example is env-driven and documented; React example has `index.html`, `main.tsx` and
142
+ `vite.config.ts`, with `clientId`/`redirectUri` documented in its comments and README.
143
+ - Runtime against CSS remains blocked by A.1.
144
+
145
+ Gate C.1: **PASS** (typechecked). Gate C.2: **PARTIAL** — build command works; the Node
146
+ example cannot complete a login against CSS for the provider reason above.
147
+
148
+ ## Phase D — security audit
149
+
150
+ See `SECURITY.md` §12 for the full table and accepted risks. Summary:
151
+
152
+ - **F1 (real-provider incompatibility, fixed):** issuer with/without trailing slash.
153
+ `parseProviderConfiguration` now accepts only the empty-path canonical form and adopts the
154
+ provider's spelling; paths, ports and schemes still require exact equality. Not a
155
+ security weakening: the exact declared issuer is what is stored and compared afterwards.
156
+ - **F2 (audit finding, fixed, stricter):** a refresh whose ID token fails validation (e.g.
157
+ different WebID) previously left the unusable grant in storage and threw `SebaAuthError`.
158
+ It now clears storage and throws `SebaSessionExpiredError`. New regression test added.
159
+ - **F3 (audit finding, accepted):** a callback with a mismatched `state` throws
160
+ `SebaAuthError` without clearing the pending transaction. The code is never exchanged and
161
+ the legitimate transaction remains usable until its 10-minute expiry. Clearing it would
162
+ let any page that can navigate the user cancel an in-flight login; both behaviours fail
163
+ closed. Documented.
164
+ - No proof reuse, no cached proofs, fresh `jti` per attempt, `ath` only on resource
165
+ requests, no tokens/keys logged.
166
+
167
+ Gate D.1: zero unresolved high-severity findings; one medium fixed, one low accepted.
168
+
169
+ ## Phase E — release
170
+
171
+ See `BUILD_REPORT.md` for the pasted gate output and package contents. Version 0.2.0 is a
172
+ validation-and-hardening release, **not** a claim of production readiness: the public
173
+ provider and browser gates are blocked, and no document in this repository says otherwise.
174
+
175
+ ## Definition-of-done checklist
176
+
177
+ | Item | Status |
178
+ |---|---|
179
+ | Local CSS full cycle with transcript | **BLOCKED** — every step through DPoP token exchange succeeded; CSS 7.3.0 fails Solid-OIDC §8.1 ID token requirements |
180
+ | At least one public provider full cycle | **BLOCKED** — no account could be created; discovery verified for three providers |
181
+ | Chrome and Firefox 5-step browser check | **BLOCKED** — Firefox absent; real-provider login blocked; no browser automation run |
182
+ | IndexedDB edge cases typed errors | **PARTIAL** — unit-covered; browser execution not performed |
183
+ | Examples typechecked and documented | **PASS** |
184
+ | Manual security audit of `dpop.ts`, `session.ts`, `client.ts` | **PASS** (findings in SECURITY.md §12) |
185
+ | No document claims untested capability | **PASS** |
186
+ | `npm pack --dry-run` output pasted in BUILD_REPORT | **PASS** |
187
+ | `CHANGELOG.md` honest about validation | **PASS** |
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Browser entry point (`seba/browser`).
3
+ *
4
+ * Identical to the root entry point; browsers resolve the platform themselves. The
5
+ * subpath exists so bundlers can target a browser-only module graph.
6
+ */
7
+ export * from "../index.js";
8
+ export { createBrowserPlatform } from "./platform.js";
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/browser/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,cAAc,aAAa,CAAC;AAC5B,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Browser entry point (`seba/browser`).
3
+ *
4
+ * Identical to the root entry point; browsers resolve the platform themselves. The
5
+ * subpath exists so bundlers can target a browser-only module graph.
6
+ */
7
+ export * from "../index.js";
8
+ export { createBrowserPlatform } from "./platform.js";
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/browser/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,cAAc,aAAa,CAAC;AAC5B,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Browser platform: navigation-based login, IDB session storage, `sessionStorage`
3
+ * authorization state.
4
+ */
5
+ import type { LoginOptions, Platform } from "../types.js";
6
+ /**
7
+ * Create the platform used by `Seba` in browsers.
8
+ *
9
+ * @throws {@link SebaConfigError} when no browser `window`/`document` exists.
10
+ */
11
+ export declare function createBrowserPlatform(): Platform;
12
+ /** Re-exported for {@link Seba} type inference in browser-only builds. */
13
+ export type { LoginOptions };
14
+ //# sourceMappingURL=platform.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"platform.d.ts","sourceRoot":"","sources":["../../src/browser/platform.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,OAAO,KAAK,EAA2B,YAAY,EAAE,QAAQ,EAAsC,MAAM,aAAa,CAAC;AAUvH;;;;GAIG;AACH,wBAAgB,qBAAqB,IAAI,QAAQ,CAqDhD;AAED,0EAA0E;AAC1E,YAAY,EAAE,YAAY,EAAE,CAAC"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Browser platform: navigation-based login, IDB session storage, `sessionStorage`
3
+ * authorization state.
4
+ */
5
+ import { SebaConfigError } from "../core/errors.js";
6
+ import { memorySessionStorage } from "../core/storage.js";
7
+ import { askForIdentity } from "./provider-modal.js";
8
+ import { createIndexedDbStorage, createSessionStoragePendingAuthStore } from "./storage.js";
9
+ /** Query parameters produced by an authorization response. */
10
+ const CALLBACK_PARAMS = ["code", "state", "iss", "error", "error_description", "error_uri", "session_state"];
11
+ /** How long a browser waits for navigation before reporting that it did not happen. */
12
+ const NAVIGATION_GRACE_MS = 1_500;
13
+ /**
14
+ * Create the platform used by `Seba` in browsers.
15
+ *
16
+ * @throws {@link SebaConfigError} when no browser `window`/`document` exists.
17
+ */
18
+ export function createBrowserPlatform() {
19
+ if (typeof window === "undefined" || typeof document === "undefined") {
20
+ throw new SebaConfigError("The browser entry point requires a DOM. Import \"seba\" in a browser or \"seba/node\" in Node.js.");
21
+ }
22
+ return {
23
+ kind: "browser",
24
+ defaultStorage: "indexeddb",
25
+ fetch: (input, init) => globalThis.fetch(input, init),
26
+ currentUrl: () => new URL(window.location.href),
27
+ pendingAuth: createSessionStoragePendingAuthStore(),
28
+ selectIdentity: () => askForIdentity(),
29
+ createStorage(kind) {
30
+ if (kind === "memory") {
31
+ return memorySessionStorage();
32
+ }
33
+ return createIndexedDbStorage();
34
+ },
35
+ async beginInteractiveLogin(request) {
36
+ const target = new URL(request.redirectUri);
37
+ const before = window.location.href;
38
+ window.location.assign(request.authorizationUrl);
39
+ return new Promise((_resolve, reject) => {
40
+ if (before.startsWith(target.origin) && window.location.href !== before) {
41
+ // Navigation already happened; the page is unloading.
42
+ return;
43
+ }
44
+ window.setTimeout(() => {
45
+ reject(new SebaConfigError("The browser did not navigate to the identity provider. " +
46
+ "A popup blocker or a sandboxed iframe can prevent navigation; open the page in a top-level tab."));
47
+ }, NAVIGATION_GRACE_MS);
48
+ });
49
+ },
50
+ clearCallback() {
51
+ const url = new URL(window.location.href);
52
+ for (const name of CALLBACK_PARAMS) {
53
+ url.searchParams.delete(name);
54
+ }
55
+ const query = url.searchParams.toString();
56
+ window.history.replaceState({}, document.title, `${url.pathname}${query === "" ? "" : `?${query}`}${url.hash}`);
57
+ },
58
+ };
59
+ }
60
+ //# sourceMappingURL=platform.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"platform.js","sourceRoot":"","sources":["../../src/browser/platform.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAE1D,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,sBAAsB,EAAE,oCAAoC,EAAE,MAAM,cAAc,CAAC;AAE5F,8DAA8D;AAC9D,MAAM,eAAe,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,mBAAmB,EAAE,WAAW,EAAE,eAAe,CAAC,CAAC;AAE7G,uFAAuF;AACvF,MAAM,mBAAmB,GAAG,KAAK,CAAC;AAElC;;;;GAIG;AACH,MAAM,UAAU,qBAAqB;IACnC,IAAI,OAAO,MAAM,KAAK,WAAW,IAAI,OAAO,QAAQ,KAAK,WAAW,EAAE,CAAC;QACrE,MAAM,IAAI,eAAe,CACvB,mGAAmG,CACpG,CAAC;IACJ,CAAC;IAED,OAAO;QACL,IAAI,EAAE,SAAS;QACf,cAAc,EAAE,WAAW;QAC3B,KAAK,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,UAAU,CAAC,KAAK,CAAC,KAAK,EAAE,IAAI,CAAC;QACrD,UAAU,EAAE,GAAG,EAAE,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC;QAC/C,WAAW,EAAE,oCAAoC,EAAE;QACnD,cAAc,EAAE,GAAG,EAAE,CAAC,cAAc,EAAE;QACtC,aAAa,CAAC,IAAiB;YAC7B,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACtB,OAAO,oBAAoB,EAAE,CAAC;YAChC,CAAC;YACD,OAAO,sBAAsB,EAAE,CAAC;QAClC,CAAC;QACD,KAAK,CAAC,qBAAqB,CAAC,OAAgC;YAC1D,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;YAC5C,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC;YACpC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC;YAEjD,OAAO,IAAI,OAAO,CAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE;gBAC3C,IAAI,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;oBACxE,sDAAsD;oBACtD,OAAO;gBACT,CAAC;gBACD,MAAM,CAAC,UAAU,CAAC,GAAG,EAAE;oBACrB,MAAM,CACJ,IAAI,eAAe,CACjB,yDAAyD;wBACvD,iGAAiG,CACpG,CACF,CAAC;gBACJ,CAAC,EAAE,mBAAmB,CAAC,CAAC;YAC1B,CAAC,CAAC,CAAC;QACL,CAAC;QACD,aAAa;YACX,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;YAC1C,KAAK,MAAM,IAAI,IAAI,eAAe,EAAE,CAAC;gBACnC,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YAChC,CAAC;YACD,MAAM,KAAK,GAAG,GAAG,CAAC,YAAY,CAAC,QAAQ,EAAE,CAAC;YAC1C,MAAM,CAAC,OAAO,CAAC,YAAY,CACzB,EAAE,EACF,QAAQ,CAAC,KAAK,EACd,GAAG,GAAG,CAAC,QAAQ,GAAG,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,EAAE,GAAG,GAAG,CAAC,IAAI,EAAE,CAC/D,CAAC;QACJ,CAAC;KACiB,CAAC;AACvB,CAAC"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Minimal, dependency-free identity prompt for browsers.
3
+ *
4
+ * The dialog asks for a WebID by default and offers an identity-provider URL instead.
5
+ * It is only a hint: whatever the user enters is validated and discovered by the core.
6
+ */
7
+ import type { LoginOptions } from "../types.js";
8
+ /**
9
+ * Ask the user which WebID or identity provider to use.
10
+ *
11
+ * @returns Login options collected from the dialog.
12
+ * @throws {@link SebaAuthError} when the user cancels or the dialog cannot be shown.
13
+ */
14
+ export declare function askForIdentity(): Promise<LoginOptions>;
15
+ //# sourceMappingURL=provider-modal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider-modal.d.ts","sourceRoot":"","sources":["../../src/browser/provider-modal.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD;;;;;GAKG;AACH,wBAAgB,cAAc,IAAI,OAAO,CAAC,YAAY,CAAC,CA4HtD"}