@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.
- package/ARCHITECTURE.md +242 -0
- package/BUILD_REPORT.md +195 -0
- package/CHANGELOG.md +59 -0
- package/LICENSE +21 -0
- package/README.md +282 -2
- package/RESEARCH.md +180 -0
- package/SECURITY.md +224 -0
- package/VALIDATION_REPORT.md +187 -0
- package/dist/browser/index.d.ts +9 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +9 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/platform.d.ts +14 -0
- package/dist/browser/platform.d.ts.map +1 -0
- package/dist/browser/platform.js +60 -0
- package/dist/browser/platform.js.map +1 -0
- package/dist/browser/provider-modal.d.ts +15 -0
- package/dist/browser/provider-modal.d.ts.map +1 -0
- package/dist/browser/provider-modal.js +126 -0
- package/dist/browser/provider-modal.js.map +1 -0
- package/dist/browser/storage.d.ts +32 -0
- package/dist/browser/storage.d.ts.map +1 -0
- package/dist/browser/storage.js +121 -0
- package/dist/browser/storage.js.map +1 -0
- package/dist/core/client.d.ts +49 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +371 -0
- package/dist/core/client.js.map +1 -0
- package/dist/core/discovery.d.ts +82 -0
- package/dist/core/discovery.d.ts.map +1 -0
- package/dist/core/discovery.js +243 -0
- package/dist/core/discovery.js.map +1 -0
- package/dist/core/dpop.d.ts +67 -0
- package/dist/core/dpop.d.ts.map +1 -0
- package/dist/core/dpop.js +176 -0
- package/dist/core/dpop.js.map +1 -0
- package/dist/core/errors.d.ts +86 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +104 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/http.d.ts +68 -0
- package/dist/core/http.d.ts.map +1 -0
- package/dist/core/http.js +128 -0
- package/dist/core/http.js.map +1 -0
- package/dist/core/identity.d.ts +145 -0
- package/dist/core/identity.d.ts.map +1 -0
- package/dist/core/identity.js +735 -0
- package/dist/core/identity.js.map +1 -0
- package/dist/core/oidc.d.ts +175 -0
- package/dist/core/oidc.d.ts.map +1 -0
- package/dist/core/oidc.js +413 -0
- package/dist/core/oidc.js.map +1 -0
- package/dist/core/session.d.ts +116 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/session.js +350 -0
- package/dist/core/session.js.map +1 -0
- package/dist/core/storage.d.ts +22 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/storage.js +48 -0
- package/dist/core/storage.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/node/index.d.ts +9 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +9 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/platform.d.ts +25 -0
- package/dist/node/platform.d.ts.map +1 -0
- package/dist/node/platform.js +161 -0
- package/dist/node/platform.js.map +1 -0
- package/dist/seba.d.ts +39 -0
- package/dist/seba.d.ts.map +1 -0
- package/dist/seba.js +58 -0
- package/dist/seba.js.map +1 -0
- package/dist/types.d.ts +279 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/examples/node/index.ts +61 -0
- package/examples/react/App.tsx +127 -0
- package/examples/react/index.html +12 -0
- package/examples/react/main.tsx +14 -0
- package/examples/react/vite.config.ts +27 -0
- package/package.json +83 -4
package/README.md
CHANGED
|
@@ -1,3 +1,283 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SEBA
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Solid-OIDC authentication in four calls: `login`, `restoreSession`, `fetch`, `logout`.
|
|
4
|
+
|
|
5
|
+
SEBA implements the Solid-OIDC authorization-code flow with PKCE, DPoP-bound tokens and a
|
|
6
|
+
static Client ID Document. It runs in browsers (IndexedDB session storage, redirect-based
|
|
7
|
+
login) and in Node.js (in-memory storage, real loopback callback server). The public API is
|
|
8
|
+
the same in both.
|
|
9
|
+
|
|
10
|
+
- No dynamic client registration. Your published Client ID Document is the `client_id`.
|
|
11
|
+
- Access tokens live in memory only; the refresh token and DPoP key are the only secrets
|
|
12
|
+
persisted.
|
|
13
|
+
- A brand-new DPoP proof is signed for every single attempt. There is no proof cache and no
|
|
14
|
+
option to add one.
|
|
15
|
+
- Every incoming value is validated: `state`, RFC 9207 `iss`, `nonce`, PKCE S256, ID token
|
|
16
|
+
signature, `iss`, `aud`, `azp`, `exp`/`iat`, and `cnf.jkt` binding.
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Node.js 20 or newer, or any browser with Web Crypto (ES256), `fetch` and IndexedDB.
|
|
21
|
+
- A publicly dereferenceable **Solid Client ID Document** served over HTTPS.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @gaballahtech/seba
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Publish a Client ID Document
|
|
30
|
+
|
|
31
|
+
Host a JSON-LD document and use its URL as `clientId`. The `client_id` member must equal the
|
|
32
|
+
document URL, and every callback you use must be listed verbatim.
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"@context": ["https://www.w3.org/ns/solid/oidc-context.jsonld"],
|
|
37
|
+
"client_id": "https://app.example/client-id.jsonld",
|
|
38
|
+
"redirect_uris": [
|
|
39
|
+
"https://app.example/auth/callback",
|
|
40
|
+
"http://127.0.0.1:3000/auth/callback"
|
|
41
|
+
],
|
|
42
|
+
"client_name": "My Solid App"
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
No `registration_endpoint` is ever called; `client_secret` is not used.
|
|
47
|
+
|
|
48
|
+
## Browser
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { Seba } from "@gaballahtech/seba";
|
|
52
|
+
|
|
53
|
+
const seba = new Seba({
|
|
54
|
+
clientId: "https://app.example/client-id.jsonld",
|
|
55
|
+
redirectUri: "https://app.example/auth/callback",
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Call `login()` on the page that handles the callback, as well as on the page that starts it:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// Runs on every load of the callback route.
|
|
63
|
+
const session = await seba.login({ webId: "https://alice.example/profile/card#me" });
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- Without an authorization response in the URL, `login()` stores the PKCE verifier in
|
|
67
|
+
`sessionStorage` and navigates to the provider. **That promise never settles**, because
|
|
68
|
+
the document is replaced.
|
|
69
|
+
- When the provider redirects back with `?code=…&state=…&iss=…`, the same call validates the
|
|
70
|
+
response, exchanges the code, stores the refresh token and DPoP key in IndexedDB, keeps the
|
|
71
|
+
access token in memory, removes the callback parameters from the address bar, and resolves
|
|
72
|
+
with the session.
|
|
73
|
+
|
|
74
|
+
On later page loads:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const session = (await seba.restoreSession()) ?? (await seba.login());
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`login()` with no arguments opens a small built-in dialog asking for a WebID or an identity
|
|
81
|
+
provider URL. Pass `{ webId }` or `{ issuer }` to skip it.
|
|
82
|
+
|
|
83
|
+
### React
|
|
84
|
+
|
|
85
|
+
See [`examples/react/App.tsx`](examples/react/App.tsx).
|
|
86
|
+
|
|
87
|
+
## Node.js
|
|
88
|
+
|
|
89
|
+
Node has no document, so `login()` starts a temporary HTTP server on the loopback interface
|
|
90
|
+
from `redirectUri` and waits for the provider to redirect back. The redirect URI must use
|
|
91
|
+
plain `http` on `127.0.0.1`, `::1` or `localhost`, and must be published in the Client ID
|
|
92
|
+
Document.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { exec } from "node:child_process";
|
|
96
|
+
import { Seba } from "@gaballahtech/seba/node";
|
|
97
|
+
|
|
98
|
+
const seba = new Seba({
|
|
99
|
+
clientId: "https://app.example/client-id.jsonld",
|
|
100
|
+
redirectUri: "http://127.0.0.1:3000/auth/callback",
|
|
101
|
+
openAuthorizationUrl(url) {
|
|
102
|
+
exec(`xdg-open "${url}"`); // or `open` on macOS, `start` on Windows
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const session = (await seba.restoreSession()) ?? (await seba.login({ issuer: "https://idp.example" }));
|
|
107
|
+
const response = await seba.fetch(session.webId);
|
|
108
|
+
console.log(await response.text());
|
|
109
|
+
|
|
110
|
+
await seba.logout();
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Without `openAuthorizationUrl`, `login()` throws `SebaNoUIError` instead of hanging. Node
|
|
114
|
+
storage is in memory, so a process restart requires a new login.
|
|
115
|
+
|
|
116
|
+
See [`examples/node/index.ts`](examples/node/index.ts).
|
|
117
|
+
|
|
118
|
+
## API
|
|
119
|
+
|
|
120
|
+
### `new Seba(config)`
|
|
121
|
+
|
|
122
|
+
| Option | Required | Description |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `clientId` | yes | HTTPS URL of your Client ID Document (loopback `http` allowed in development). |
|
|
125
|
+
| `redirectUri` | yes | Callback URL published verbatim in `redirect_uris`. Node.js: loopback `http` only. |
|
|
126
|
+
| `storage` | no | `"indexeddb"` (browser default) or `"memory"` (Node.js default). |
|
|
127
|
+
| `debug` | no | `true` emits non-sensitive diagnostics through `console.debug`. |
|
|
128
|
+
| `openAuthorizationUrl` | Node.js | Receives the authorization URL; without it interactive login is impossible. |
|
|
129
|
+
|
|
130
|
+
### `login(options?)`
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
login(options?: { webId?: string; issuer?: string }): Promise<Session>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Starts or completes an interactive login. Prefers `issuer`; otherwise resolves the issuer
|
|
137
|
+
from the WebID profile (`solid:oidcIssuer`); otherwise asks the user in the browser. The
|
|
138
|
+
resulting WebID must match `options.webId` when it was supplied.
|
|
139
|
+
|
|
140
|
+
### `restoreSession()`
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
restoreSession(): Promise<Session | null>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Loads the stored refresh token and DPoP key, refreshes the access token, and returns the
|
|
147
|
+
session. Returns `null` when nothing is stored. Throws `SebaSessionExpiredError` when the
|
|
148
|
+
provider rejected the grant (local state is cleared) and `SebaNetworkError` when the
|
|
149
|
+
provider is unreachable (local state is kept).
|
|
150
|
+
|
|
151
|
+
### `fetch(input, init?)`
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
A `fetch` with `Authorization: DPoP …` and a fresh DPoP proof. Same-origin redirects are
|
|
158
|
+
followed (up to five, one new proof per hop); cross-origin redirects are returned to the
|
|
159
|
+
caller instead of being followed with credentials. One retry is allowed for a
|
|
160
|
+
`use_dpop_nonce` challenge and one for an expired/rejected access token; a streamed body is
|
|
161
|
+
never resent. The access token is refreshed automatically when it is close to expiry.
|
|
162
|
+
|
|
163
|
+
### `logout()`
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
logout(): Promise<void>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Clears memory and storage first, then revokes the refresh token if the provider advertises a
|
|
170
|
+
revocation endpoint. Revocation failures never block logout.
|
|
171
|
+
|
|
172
|
+
### `Session`
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
interface Session {
|
|
176
|
+
webId: string;
|
|
177
|
+
isLoggedIn: true;
|
|
178
|
+
provider: string;
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
No tokens, no keys, no expiry bookkeeping.
|
|
183
|
+
|
|
184
|
+
## Errors
|
|
185
|
+
|
|
186
|
+
All errors extend `SebaError` and carry a stable `code`:
|
|
187
|
+
|
|
188
|
+
| Class | Meaning |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `SebaConfigError` | Invalid configuration or unsupported environment. |
|
|
191
|
+
| `SebaDiscoveryError` | Issuer, discovery document, WebID profile or Client ID Document could not be validated. |
|
|
192
|
+
| `SebaRedirectMismatchError` | The redirect URI is not published by the Client ID Document. |
|
|
193
|
+
| `SebaAuthError` | Authorization response, token response or ID token failed validation. |
|
|
194
|
+
| `SebaDPoPError` | DPoP key or proof could not be created/verified. |
|
|
195
|
+
| `SebaSessionExpiredError` | The stored grant is no longer valid (state cleared). |
|
|
196
|
+
| `SebaNetworkError` | Transport failure or 5xx response (state kept). |
|
|
197
|
+
| `SebaNoUIError` | Interactive login is impossible in the current runtime (Node.js without a hook). |
|
|
198
|
+
|
|
199
|
+
## Security defaults
|
|
200
|
+
|
|
201
|
+
- PKCE S256 only; `state` and `nonce` are 256-bit random values, single-use.
|
|
202
|
+
- RFC 9207 `iss` in the authorization response must equal the issuer the login was started
|
|
203
|
+
for.
|
|
204
|
+
- ID token signatures are verified against the discovered JWKS; `aud` must contain the
|
|
205
|
+
client ID and `"solid"` when the provider advertises `webid`; `cnf.jkt` must match the
|
|
206
|
+
session key (and is required for `webid`).
|
|
207
|
+
- The refresh token and DPoP key are the only persisted secrets; access tokens are held in
|
|
208
|
+
a private field.
|
|
209
|
+
- Non-extractable `CryptoKey` storage is used when the engine supports structured cloning;
|
|
210
|
+
otherwise an exported JWK fallback is used (documented in `SECURITY.md`).
|
|
211
|
+
- Refresh-token rotation is atomic, and concurrent refreshes are collapsed into one.
|
|
212
|
+
|
|
213
|
+
See [`SECURITY.md`](SECURITY.md) for the full threat model and limitations, and
|
|
214
|
+
[`ARCHITECTURE.md`](ARCHITECTURE.md) for module boundaries.
|
|
215
|
+
|
|
216
|
+
## Provider compatibility
|
|
217
|
+
|
|
218
|
+
This table is deliberately conservative: "Publicly tested" means the full login → fetch →
|
|
219
|
+
restore → logout cycle was executed against that provider. If it was not, it says so.
|
|
220
|
+
|
|
221
|
+
| Provider | Protocol support (spec level) | Locally tested | Publicly tested |
|
|
222
|
+
|---|---|---|---|
|
|
223
|
+
| Hand-written fake provider (`tests/integration`) | Solid-OIDC-shaped: array `aud` with `"solid"`, `azp`, `cnf.jkt` | Full cycle in CI (`npm test`) | — |
|
|
224
|
+
| Community Solid Server 7.3.0 | **Not conformant** with Solid-OIDC §8.1: ID token has a string `aud` without `"solid"`, no `cnf` | Discovery, Client ID Document, PKCE/state/nonce, consent, callback, DPoP token exchange all pass; login rejected at ID token validation | No |
|
|
225
|
+
| solidcommunity.net | Discovery and issuer handling verified; issuer is `https://solidcommunity.net/` | Discovery only | **No** — no account was available |
|
|
226
|
+
| solidweb.org | Discovery verified; issuer is `https://solidweb.org` (no trailing slash); no revocation endpoint advertised | Discovery only | **No** — no account was available |
|
|
227
|
+
| solidweb.me | Discovery verified; issuer is `https://solidweb.me/` | Discovery only | **No** — no account was available |
|
|
228
|
+
|
|
229
|
+
> **⚠️ Community Solid Server 7.3.0**: CSS issues `id_token.aud` as a string instead of `["solid", client_id]` and omits `cnf.jkt`, violating Solid-OIDC §8.1. Seba correctly rejects this token. Track the upstream issue at https://github.com/CommunitySolidServer/CommunitySolidServer/issues/2295. Do not weaken Seba's validation to work around this.
|
|
230
|
+
|
|
231
|
+
No public provider has completed the full cycle in this release. See
|
|
232
|
+
[`VALIDATION_REPORT.md`](VALIDATION_REPORT.md) for transcripts and blockers, and
|
|
233
|
+
[`CHANGELOG.md`](CHANGELOG.md) for what changed.
|
|
234
|
+
|
|
235
|
+
SEBA does not support dynamic client registration. The Client ID Document URL is the
|
|
236
|
+
`client_id`, and the provider must be able to dereference it.
|
|
237
|
+
|
|
238
|
+
## Running the examples
|
|
239
|
+
|
|
240
|
+
Node (after `npm run build`; requires a provider and a published loopback redirect URI):
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
SEBA_CLIENT_ID=http://localhost:8443/client-id.jsonld \
|
|
244
|
+
SEBA_REDIRECT_URI=http://localhost:3001/auth/callback \
|
|
245
|
+
SEBA_ISSUER=http://localhost:3000 \
|
|
246
|
+
WEBID=http://localhost:3000/alice/profile/card#me \
|
|
247
|
+
node examples/node/index.ts
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
React:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
VITE_SEBA_CLIENT_ID=https://app.example/client-id.jsonld npm run example:react:build
|
|
254
|
+
npx vite preview --config examples/react/vite.config.ts
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The React example expects its redirect URI to be `${window.location.origin}/auth/callback`
|
|
258
|
+
and published in the Client ID Document.
|
|
259
|
+
|
|
260
|
+
## Validation tooling
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
npm run validate:discovery # discovery against public providers (no login)
|
|
264
|
+
node scripts/validation/css-setup.mjs # create a local CSS account + pod
|
|
265
|
+
node scripts/validation/css-flow.mjs # full flow against local CSS (see report)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Development
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
npm install
|
|
272
|
+
npm run typecheck
|
|
273
|
+
npm test
|
|
274
|
+
npm run build
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
The test suite includes unit tests, DPoP replay tests, and an integration suite that runs the
|
|
278
|
+
full login → fetch → refresh → logout flow against a hand-written OpenID Provider over
|
|
279
|
+
loopback HTTP.
|
|
280
|
+
|
|
281
|
+
## License
|
|
282
|
+
|
|
283
|
+
MIT
|
package/RESEARCH.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# RESEARCH
|
|
2
|
+
|
|
3
|
+
Bounded research for SEBA. Ten-source budget; sources below were actually fetched and read
|
|
4
|
+
during this project (no blog posts, no secondary summaries).
|
|
5
|
+
|
|
6
|
+
## Sources inspected
|
|
7
|
+
|
|
8
|
+
1. **Solid-OIDC specification** (`solid.github.io/solid-oidc`, Solid-OIDC 1.0 draft) —
|
|
9
|
+
§5 client identification, §6 WebID issuer discovery, §7 authorization code flow + PKCE,
|
|
10
|
+
§8 callback/`iss` handling, §9 token validation and DPoP binding, §10 refresh,
|
|
11
|
+
§11 conformance discovery.
|
|
12
|
+
2. **RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP)** — §4 proof JWT
|
|
13
|
+
construction and validation, §4.2 claims, §4.3 `ath`, §5 token requests, §6 nonce,
|
|
14
|
+
§7 protected resource access, §8 server-side validation rules.
|
|
15
|
+
3. **RFC 7636 — Proof Key for Code Exchange** — §4.1 verifier generation, §4.2 S256
|
|
16
|
+
challenge, §4.3/4.4/4.5 exchange rules.
|
|
17
|
+
4. **OpenID Connect Discovery 1.0 (errata set 2)** — §3 metadata values, §4 discovery
|
|
18
|
+
request/response/validation, §7.1 TLS requirements.
|
|
19
|
+
5. **`jose` 6.2.15 type definitions and exports inspected locally** (`SignJWT`,
|
|
20
|
+
`jwtVerify`, `createRemoteJWKSet`, `generateKeyPair`, `exportJWK`, `importJWK`,
|
|
21
|
+
`calculateJwkThumbprint`, `base64url`, `errors.JOSEError`).
|
|
22
|
+
6. **`idb` 8.0.4 type definitions inspected locally** (`openDB`, `IDBPDatabase`, upgrade
|
|
23
|
+
callbacks, `IDBPDatabase` transaction helpers).
|
|
24
|
+
|
|
25
|
+
OpenID Connect Core 1.0 §3.1.3.7 (ID token validation steps) was not fetched separately;
|
|
26
|
+
its steps are reproduced here through the explicit requirements Solid-OIDC §9 places on
|
|
27
|
+
the same validations, and each step is implemented and covered by a test in
|
|
28
|
+
`tests/unit/oidc.test.ts`.
|
|
29
|
+
|
|
30
|
+
## Relevant protocol requirements
|
|
31
|
+
|
|
32
|
+
### Client identification (Solid-OIDC §5)
|
|
33
|
+
|
|
34
|
+
- `client_id` is the URL of a dereferenceable **Client ID Document**, served over HTTPS.
|
|
35
|
+
- The document is JSON-LD and carries the Solid-OIDC context
|
|
36
|
+
`https://www.w3.org/ns/solid/oidc-context.jsonld`.
|
|
37
|
+
- The document's `client_id` member must equal the URL it is served from.
|
|
38
|
+
- `redirect_uris` must list the exact redirect URI used in the authorization request.
|
|
39
|
+
- Dynamic Client Registration is not used, so the client never calls
|
|
40
|
+
`registration_endpoint` (SEBA ignores that metadata entirely).
|
|
41
|
+
|
|
42
|
+
### WebID discovery (Solid-OIDC §6)
|
|
43
|
+
|
|
44
|
+
- Given a WebID `https://host/profile/card#me`, the profile document is
|
|
45
|
+
`https://host/profile/card` — the fragment identifies the subject, it is not sent to the
|
|
46
|
+
server.
|
|
47
|
+
- The profile must state `<webID> solid:oidcIssuer <issuer>`.
|
|
48
|
+
- The issuer must be an `https` URL with a host, and no query or fragment. Loopback `http`
|
|
49
|
+
is accepted for local development only.
|
|
50
|
+
- JSON-LD (`application/ld+json`) and Turtle (`text/turtle`) are the interoperable
|
|
51
|
+
profile formats; a `Link` header with
|
|
52
|
+
`rel="http://www.w3.org/ns/solid/terms#oidcIssuer"` is a documented fallback.
|
|
53
|
+
|
|
54
|
+
### OpenID Provider discovery (OIDC Discovery §3/§4)
|
|
55
|
+
|
|
56
|
+
- `issuer` in the metadata document must be identical to the requested issuer; a mismatch
|
|
57
|
+
invalidates the whole document.
|
|
58
|
+
- `authorization_endpoint`, `token_endpoint` and `jwks_uri` are required and must be
|
|
59
|
+
absolute `https` URLs.
|
|
60
|
+
- `revocation_endpoint` and `dpop_signing_alg_values_supported` are optional.
|
|
61
|
+
- For issuers with a path component, RFC 8414 places the well-known segment between host
|
|
62
|
+
and path (`https://host/.well-known/openid-configuration/<path>`); OIDC Discovery
|
|
63
|
+
examples also show `https://host/<path>/.well-known/openid-configuration`. SEBA tries
|
|
64
|
+
the RFC 8414 form first and falls back to the appended form on 404 (documented
|
|
65
|
+
compatibility handling).
|
|
66
|
+
|
|
67
|
+
### Authorization Code Flow + PKCE (RFC 7636, OIDC Core)
|
|
68
|
+
|
|
69
|
+
- `code_verifier`: 43–128 characters from `[A-Za-z0-9-._~]`.
|
|
70
|
+
- `code_challenge` = `BASE64URL(SHA-256(ASCII(code_verifier)))`, `code_challenge_method=S256`.
|
|
71
|
+
- `state` must be cryptographically random and validated before the code is used.
|
|
72
|
+
- `nonce` must be cryptographically random and must match the ID token's `nonce` claim.
|
|
73
|
+
- The token request repeats `redirect_uri`, `client_id`, `code` and `code_verifier`.
|
|
74
|
+
|
|
75
|
+
### DPoP (RFC 9449)
|
|
76
|
+
|
|
77
|
+
- Proof header: `typ=dpop+jwt`, `alg=ES256`, `jwk` = **public** key only.
|
|
78
|
+
- Payload: `jti` (unique), `htm` (uppercase method), `htu` (target URI without query and
|
|
79
|
+
fragment, syntactically normalized), `iat` (seconds).
|
|
80
|
+
- `ath` = `BASE64URL(SHA-256(ASCII(access_token)))`, included only when an access token is
|
|
81
|
+
presented (protected-resource requests), never on token-endpoint requests.
|
|
82
|
+
- Proofs are single-use by design: the server tracks `jti`. A new proof is required for
|
|
83
|
+
every attempt, including retries and authorization-server nonce challenges.
|
|
84
|
+
- The authorization server binds the issued access/refresh token to the proof key via
|
|
85
|
+
`cnf.jkt` (JWK thumbprint). The client must keep using that key.
|
|
86
|
+
- `DPoP-Nonce` challenges (`use_dpop_nonce`) require a fresh proof carrying the new
|
|
87
|
+
`nonce`; a nonce may be cached, a signed proof may not.
|
|
88
|
+
|
|
89
|
+
### Refresh (Solid-OIDC §10, RFC 9449 §5)
|
|
90
|
+
|
|
91
|
+
- `grant_type=refresh_token` with a fresh DPoP proof bound to the same key.
|
|
92
|
+
- Refresh tokens rotate; the newest refresh token must be persisted atomically.
|
|
93
|
+
- A rotated/deleted refresh token that is rejected invalidates the local session.
|
|
94
|
+
|
|
95
|
+
## Existing implementation findings
|
|
96
|
+
|
|
97
|
+
No existing implementation was consulted, by design: `@inrupt/solid-client-authn-*` is
|
|
98
|
+
excluded by the task, and reading a reference client was not necessary once the normative
|
|
99
|
+
text above was available. The only "existing implementation" inspected was the installed
|
|
100
|
+
`jose`/`idb` type surface, to confirm the exact function signatures used here
|
|
101
|
+
(for example `generateKeyPair` returning `{ privateKey, publicKey }` and defaulting to
|
|
102
|
+
non-extractable keys, and `calculateJwkThumbprint` defaulting to SHA-256).
|
|
103
|
+
|
|
104
|
+
## Security findings that shaped the design
|
|
105
|
+
|
|
106
|
+
1. **Proof reuse is the primary DPoP failure mode.** Any cache keyed by `(htu, htm)`
|
|
107
|
+
silently disables replay protection. SEBA therefore has no proof cache, no TTL, and no
|
|
108
|
+
configuration knob for one; every attempt signs a brand-new JWT with a new `jti`.
|
|
109
|
+
2. **Refresh tokens are bearer-equivalent if the key leaks.** Both the refresh token and
|
|
110
|
+
the private key live in IndexedDB; an XSS on the application origin can use both. This
|
|
111
|
+
is inherent to browser-based public clients and is documented, not "solved".
|
|
112
|
+
3. **`redirect: "manual"` makes cross-origin redirects opaque in browsers.** Credentials
|
|
113
|
+
cannot be re-attached safely, so SEBA never follows cross-origin redirects with
|
|
114
|
+
`Authorization`/`DPoP` headers, and returns the redirect response to the caller.
|
|
115
|
+
4. **Content negotiation matters.** Solid providers serve WebID profiles as
|
|
116
|
+
`text/turtle`, `application/ld+json` or HTML with a `Link` header; a parser written for
|
|
117
|
+
one concrete example is fragile.
|
|
118
|
+
5. **Provider clock skew is real.** A 60-second tolerance is applied to `exp`/`nbf`/`iat`;
|
|
119
|
+
it is not configurable, so it cannot be widened into a replay window.
|
|
120
|
+
6. **RFC 9207 `iss` is mandatory in Solid-OIDC** and prevents mix-up attacks; SEBA rejects
|
|
121
|
+
callbacks without it.
|
|
122
|
+
|
|
123
|
+
## Identified technical limitations
|
|
124
|
+
|
|
125
|
+
- **Browsers**: `fetch(..., { redirect: "manual" })` yields an opaque response
|
|
126
|
+
(status `0`) for redirects, so redirected requests are surfaced to the caller instead of
|
|
127
|
+
being followed. The library never re-sends credentials to another origin.
|
|
128
|
+
- **IndexedDB** is not available in private-mode variants of some older browsers; SEBA
|
|
129
|
+
fails with `SebaConfigError` rather than falling back to `localStorage`.
|
|
130
|
+
- **Non-extractable keys**: whether a `CryptoKey` survives IndexedDB structured cloning
|
|
131
|
+
depends on the engine. SEBA probes this at login and, when cloning is impossible, stores
|
|
132
|
+
an exported JWK instead (documented downgrade, never silent).
|
|
133
|
+
- **Token introspection / revocation semantics** vary per provider; revocation is
|
|
134
|
+
best-effort and never blocks local logout.
|
|
135
|
+
- **Live provider interoperability was not tested**: there is no network access to any
|
|
136
|
+
Solid identity provider from the build environment and no test account. Compatibility
|
|
137
|
+
claims in `README.md` are limited accordingly.
|
|
138
|
+
|
|
139
|
+
## Unresolved questions (and the conservative decision taken)
|
|
140
|
+
|
|
141
|
+
1. **Does every provider emit `iss` on the authorization response (RFC 9207)?**
|
|
142
|
+
Solid-OIDC requires it. Decision: require it, fail closed with `SebaAuthError`, and
|
|
143
|
+
document the requirement. A silent fallback would re-open mix-up attacks.
|
|
144
|
+
2. **Should `aud` contain `"solid"`?** Solid-OIDC requires the ID token audience to
|
|
145
|
+
include both the client ID and `solid`. Decision: require `"solid"` **only when the
|
|
146
|
+
provider advertises the `webid` scope** in `scopes_supported`; this keeps SEBA strict for
|
|
147
|
+
Solid providers while remaining usable against plain OIDC providers during evaluation.
|
|
148
|
+
`azp` is required whenever `aud` has more than one value, and must equal `client_id`.
|
|
149
|
+
3. **Is `cnf.jkt` always present on ID tokens?** Not guaranteed by every deployment.
|
|
150
|
+
Decision: validate `cnf.jkt` whenever present, and require it when the provider
|
|
151
|
+
advertises `webid`. Access-token binding is enforced by the resource server regardless.
|
|
152
|
+
4. **Turtle breadth.** A full RDF parser is a large dependency. Decision: a documented
|
|
153
|
+
subset parser (`src/core/identity.ts`) plus JSON-LD and `Link`-header support; exact
|
|
154
|
+
supported syntax is listed in `README.md` and each construct is unit-tested. Unsupported
|
|
155
|
+
constructs raise `SebaDiscoveryError` instead of being mis-parsed.
|
|
156
|
+
5. **Issuer with a path.** Decision: try RFC 8414 ordering, then the appended form, and
|
|
157
|
+
never accept a discovery document whose `issuer` differs from the request.
|
|
158
|
+
|
|
159
|
+
## Explicit decisions
|
|
160
|
+
|
|
161
|
+
| Decision | Justification |
|
|
162
|
+
|---|---|
|
|
163
|
+
| Static Client ID Document only; DCR never implemented | Required by the product constraint; removes a whole class of client-registration attacks and provider-specific behaviour. |
|
|
164
|
+
| ES256 / P-256 DPoP keys only | Required by Solid-OIDC; avoids algorithm negotiation bugs. |
|
|
165
|
+
| No proof cache, no TTL, no `trustedZoneTtl` option | RFC 9449 replay protection is per-proof; any reuse optimisation would defeat it. |
|
|
166
|
+
| Access token held in a private field only | The spec forbids persisting access tokens; nothing else in the library exposes them. |
|
|
167
|
+
| Refresh token + key + metadata in IndexedDB, keyed by `client_id` | One origin can host several applications; keying by client ID avoids cross-app overwrite. |
|
|
168
|
+
| `sessionStorage` for `state`/`nonce`/`code_verifier` | Survives the redirect round-trip, dies with the tab, never persisted to disk like `localStorage`. |
|
|
169
|
+
| Node.js uses a real loopback HTTP server on `127.0.0.1`/`localhost` | Only honest way to receive the callback without browser APIs; bound to loopback only, closed in `finally`. |
|
|
170
|
+
| `openAuthorizationUrl` required for Node interactive login | The library must not pretend to open a browser; without the hook it throws `SebaNoUIError`. |
|
|
171
|
+
| ID token validated with `jose` + remote JWKS, `requiredClaims: exp/iat/iss/aud/sub` | Signature and claim validation before any identity value is trusted. |
|
|
172
|
+
| `htu` = `new URL(target)` with search and hash removed | Matches RFC 9449's "URI without query and fragment" plus RFC 3986 syntax normalization. |
|
|
173
|
+
| Errors are typed classes with stable codes | Applications branch on `error.code`; no string matching, no raw string throws. |
|
|
174
|
+
|
|
175
|
+
## Dependency decision
|
|
176
|
+
|
|
177
|
+
Runtime dependencies are exactly `jose` (JWS/JWK/JWKS primitives) and `idb` (IndexedDB).
|
|
178
|
+
No RDF library was added: the required WebID assertion is a single triple, and the subset
|
|
179
|
+
parser is ~200 lines, fully tested, with JSON-LD and `Link` fallbacks. No UI framework is
|
|
180
|
+
included; the browser provider selector is vanilla DOM in `src/browser/provider-modal.ts`.
|