@dwk/remotestorage 0.1.0-beta.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/LICENSE +15 -0
- package/README.md +131 -0
- package/dist/auth.d.ts +42 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +108 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +132 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +67 -0
- package/dist/config.js.map +1 -0
- package/dist/cors.d.ts +23 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/cors.js +56 -0
- package/dist/cors.js.map +1 -0
- package/dist/discovery.d.ts +31 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +35 -0
- package/dist/discovery.js.map +1 -0
- package/dist/encoding.d.ts +11 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +21 -0
- package/dist/encoding.js.map +1 -0
- package/dist/folder.d.ts +78 -0
- package/dist/folder.d.ts.map +1 -0
- package/dist/folder.js +0 -0
- package/dist/folder.js.map +1 -0
- package/dist/gc.d.ts +23 -0
- package/dist/gc.d.ts.map +1 -0
- package/dist/gc.js +34 -0
- package/dist/gc.js.map +1 -0
- package/dist/handler.d.ts +21 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +166 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +35 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +119 -0
- package/dist/jwt.js.map +1 -0
- package/dist/log.d.ts +29 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +27 -0
- package/dist/log.js.map +1 -0
- package/dist/scope.d.ts +54 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +83 -0
- package/dist/scope.js.map +1 -0
- package/dist/storage.d.ts +22 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +313 -0
- package/dist/storage.js.map +1 -0
- package/package.json +50 -0
- package/src/auth.ts +146 -0
- package/src/config.ts +197 -0
- package/src/cors.ts +68 -0
- package/src/discovery.ts +48 -0
- package/src/encoding.ts +22 -0
- package/src/folder.ts +0 -0
- package/src/gc.ts +50 -0
- package/src/handler.ts +225 -0
- package/src/index.ts +84 -0
- package/src/jwt.ts +155 -0
- package/src/log.ts +31 -0
- package/src/scope.ts +99 -0
- package/src/storage.ts +398 -0
package/dist/storage.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-account Durable Object: the single-threaded consistency authority for
|
|
3
|
+
* one remoteStorage vault.
|
|
4
|
+
*
|
|
5
|
+
* The stateless front door (`handler.ts`) authenticates the bearer token and
|
|
6
|
+
* enforces per-module scopes at the edge, then forwards already-authorized
|
|
7
|
+
* requests here, where Cloudflare guarantees one thread per account. Everything
|
|
8
|
+
* that must be strongly consistent — document GET/PUT/DELETE, conditional
|
|
9
|
+
* (`If-Match`/`If-None-Match`) writes, document↔folder conflict detection, the
|
|
10
|
+
* virtual folder listing and its aggregate ETag, and R2 copy-on-write through
|
|
11
|
+
* `@dwk/store` — happens here. This object reuses `@dwk/store` exactly as
|
|
12
|
+
* `@dwk/solid-pod` does (same library, same storage primitives); the only Solid
|
|
13
|
+
* facility it leans on beyond the blob tier is the generic `list(prefix)`
|
|
14
|
+
* projection. Consumers bind this class as a Durable Object namespace.
|
|
15
|
+
*/
|
|
16
|
+
import { DurableObject } from "cloudflare:workers";
|
|
17
|
+
import { createStore, d1OrphanSink, forwardOrphans, PreconditionFailedError, } from "@dwk/store";
|
|
18
|
+
import { INTERNAL_HEADERS } from "./config";
|
|
19
|
+
import { buildFolderModel, FOLDER_DESCRIPTION_TYPE, hashSignature, renderFolderDescription, } from "./folder";
|
|
20
|
+
import { isFolderPath } from "./scope";
|
|
21
|
+
function text(status, body, headers = {}) {
|
|
22
|
+
return new Response(body, {
|
|
23
|
+
status,
|
|
24
|
+
headers: { "content-type": "text/plain; charset=utf-8", ...headers },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export class RemoteStorageObject extends DurableObject {
|
|
28
|
+
#store = null;
|
|
29
|
+
/** Lazily build the store with the front-door's offload threshold. */
|
|
30
|
+
#getStore(config) {
|
|
31
|
+
if (this.#store === null) {
|
|
32
|
+
this.#store = createStore(this.ctx, this.env, {
|
|
33
|
+
...(config.maxInlineBytes !== undefined
|
|
34
|
+
? { maxInlineBytes: config.maxInlineBytes }
|
|
35
|
+
: {}),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return this.#store;
|
|
39
|
+
}
|
|
40
|
+
async fetch(request) {
|
|
41
|
+
const config = (() => {
|
|
42
|
+
const raw = request.headers.get(INTERNAL_HEADERS.config);
|
|
43
|
+
if (!raw)
|
|
44
|
+
return {};
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(raw);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
})();
|
|
52
|
+
const store = this.#getStore(config);
|
|
53
|
+
const url = new URL(request.url);
|
|
54
|
+
// Keep the path percent-encoded: decoding `%2F` would conflate it with a
|
|
55
|
+
// real separator and corrupt store keys.
|
|
56
|
+
const path = url.pathname;
|
|
57
|
+
const method = request.method.toUpperCase();
|
|
58
|
+
try {
|
|
59
|
+
switch (method) {
|
|
60
|
+
case "HEAD":
|
|
61
|
+
case "GET":
|
|
62
|
+
return isFolderPath(path)
|
|
63
|
+
? await this.#readFolder(store, path, request, method === "HEAD")
|
|
64
|
+
: await this.#readDocument(store, path, request, method === "HEAD");
|
|
65
|
+
case "PUT":
|
|
66
|
+
return await this.#putDocument(store, path, request);
|
|
67
|
+
case "DELETE":
|
|
68
|
+
return await this.#deleteDocument(store, path, request);
|
|
69
|
+
default:
|
|
70
|
+
return text(405, "Method Not Allowed", { allow: ALLOW });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error instanceof PreconditionFailedError) {
|
|
75
|
+
return text(412, "Precondition Failed");
|
|
76
|
+
}
|
|
77
|
+
if (error instanceof LengthRequiredError) {
|
|
78
|
+
return text(411, "Length Required");
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// -- document read ---------------------------------------------------------
|
|
84
|
+
async #readDocument(store, path, request, headOnly) {
|
|
85
|
+
const meta = store.head(path);
|
|
86
|
+
if (!meta)
|
|
87
|
+
return text(404, "Not Found");
|
|
88
|
+
const ifNoneMatch = request.headers.get("if-none-match");
|
|
89
|
+
if (ifNoneMatch && ifNoneMatchSatisfied(ifNoneMatch, meta.etag)) {
|
|
90
|
+
return new Response(null, {
|
|
91
|
+
status: 304,
|
|
92
|
+
headers: { etag: meta.etag, "cache-control": "no-cache" },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const blob = await store.readBlob(path);
|
|
96
|
+
// A non-blob pointer at a document path should not arise (writes always go
|
|
97
|
+
// through the blob tier), but guard rather than stream a null body.
|
|
98
|
+
if (!blob)
|
|
99
|
+
return text(404, "Not Found");
|
|
100
|
+
const headers = documentHeaders(meta.etag, blob.contentType);
|
|
101
|
+
headers.set("content-length", String(blob.size));
|
|
102
|
+
if (headOnly) {
|
|
103
|
+
await blob.stream.cancel();
|
|
104
|
+
return new Response(null, { status: 200, headers });
|
|
105
|
+
}
|
|
106
|
+
return new Response(blob.stream, { status: 200, headers });
|
|
107
|
+
}
|
|
108
|
+
// -- folder read -----------------------------------------------------------
|
|
109
|
+
async #readFolder(store, path, request, headOnly) {
|
|
110
|
+
// A folder is virtual: its listing and ETag derive from descendants. An
|
|
111
|
+
// empty/absent folder still answers 200 with a stable ETag (over the empty
|
|
112
|
+
// signature), matching remoteStorage's "folders always exist" model.
|
|
113
|
+
const model = buildFolderModel(path, store.list(path));
|
|
114
|
+
const etag = await hashSignature(model.signature);
|
|
115
|
+
const ifNoneMatch = request.headers.get("if-none-match");
|
|
116
|
+
if (ifNoneMatch && ifNoneMatchSatisfied(ifNoneMatch, etag)) {
|
|
117
|
+
return new Response(null, {
|
|
118
|
+
status: 304,
|
|
119
|
+
headers: { etag, "cache-control": "no-cache" },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const headers = new Headers({
|
|
123
|
+
etag,
|
|
124
|
+
"content-type": FOLDER_DESCRIPTION_TYPE,
|
|
125
|
+
"cache-control": "no-cache",
|
|
126
|
+
allow: ALLOW,
|
|
127
|
+
});
|
|
128
|
+
if (headOnly)
|
|
129
|
+
return new Response(null, { status: 200, headers });
|
|
130
|
+
const body = JSON.stringify(await renderFolderDescription(model));
|
|
131
|
+
headers.set("content-length", String(new TextEncoder().encode(body).length));
|
|
132
|
+
return new Response(body, { status: 200, headers });
|
|
133
|
+
}
|
|
134
|
+
// -- document write --------------------------------------------------------
|
|
135
|
+
async #putDocument(store, path, request) {
|
|
136
|
+
if (isFolderPath(path)) {
|
|
137
|
+
// A folder path (trailing slash) is not a document; remoteStorage has no
|
|
138
|
+
// verb to create a folder directly (draft §6).
|
|
139
|
+
return text(400, "Cannot PUT a folder", { allow: ALLOW });
|
|
140
|
+
}
|
|
141
|
+
// Document↔folder name collisions are forbidden (draft §6): a path that
|
|
142
|
+
// already has descendants is a folder, and any ancestor that is a document
|
|
143
|
+
// would shadow this one.
|
|
144
|
+
const conflict = this.#conflict(store, path);
|
|
145
|
+
if (conflict)
|
|
146
|
+
return text(409, conflict, { allow: ALLOW });
|
|
147
|
+
const existed = store.head(path) !== null;
|
|
148
|
+
await this.#writeBody(store, path, request, {
|
|
149
|
+
ifMatch: ifMatchOf(request),
|
|
150
|
+
ifNoneMatch: ifNoneMatchOf(request),
|
|
151
|
+
});
|
|
152
|
+
await this.#drainOrphans(store);
|
|
153
|
+
const meta = store.head(path);
|
|
154
|
+
const headers = new Headers({ allow: ALLOW });
|
|
155
|
+
if (meta)
|
|
156
|
+
headers.set("etag", meta.etag);
|
|
157
|
+
return new Response(null, { status: existed ? 200 : 201, headers });
|
|
158
|
+
}
|
|
159
|
+
// -- document delete -------------------------------------------------------
|
|
160
|
+
async #deleteDocument(store, path, request) {
|
|
161
|
+
if (isFolderPath(path)) {
|
|
162
|
+
return text(400, "Cannot DELETE a folder", { allow: ALLOW });
|
|
163
|
+
}
|
|
164
|
+
const meta = store.head(path);
|
|
165
|
+
if (!meta)
|
|
166
|
+
return text(404, "Not Found");
|
|
167
|
+
const oldEtag = meta.etag;
|
|
168
|
+
store.delete(path, { ifMatch: ifMatchOf(request) });
|
|
169
|
+
await this.#drainOrphans(store);
|
|
170
|
+
// The emptied parent folders simply vanish — they are virtual. The deleted
|
|
171
|
+
// document's ETag is returned for clients that track it (draft §6).
|
|
172
|
+
return new Response(null, {
|
|
173
|
+
status: 200,
|
|
174
|
+
headers: { etag: oldEtag, allow: ALLOW },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
// -- helpers ---------------------------------------------------------------
|
|
178
|
+
/**
|
|
179
|
+
* Detect a document↔folder name collision for a PUT to `path`. Returns a
|
|
180
|
+
* human-readable reason, or `null` when the write is unobstructed:
|
|
181
|
+
* - `path` already has descendants ⇒ it is a folder, not a document;
|
|
182
|
+
* - an ancestor segment is itself a stored document ⇒ it would shadow `path`.
|
|
183
|
+
*/
|
|
184
|
+
#conflict(store, path) {
|
|
185
|
+
if (store.list(`${path}/`).length > 0) {
|
|
186
|
+
return "A folder already exists at this path";
|
|
187
|
+
}
|
|
188
|
+
let slash = path.indexOf("/", 1);
|
|
189
|
+
while (slash !== -1) {
|
|
190
|
+
const ancestor = path.slice(0, slash);
|
|
191
|
+
if (ancestor.length > 0 && store.head(ancestor) !== null) {
|
|
192
|
+
return "A document already exists at an ancestor path";
|
|
193
|
+
}
|
|
194
|
+
slash = path.indexOf("/", slash + 1);
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Forward any blob keys the store outboxed (copy-on-write displacements and
|
|
200
|
+
* deletes) into the shared D1 GC table, so the out-of-band cron can reclaim
|
|
201
|
+
* them without waking this DO. No-op when `GC_DB` is not bound.
|
|
202
|
+
*/
|
|
203
|
+
async #drainOrphans(store) {
|
|
204
|
+
if (!this.env.GC_DB)
|
|
205
|
+
return;
|
|
206
|
+
await forwardOrphans(store, d1OrphanSink(this.env.GC_DB));
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Write a request body as a document blob without ever buffering an oversized
|
|
210
|
+
* body in the DO. A body known (by `Content-Length`) to exceed the offload
|
|
211
|
+
* ceiling is streamed straight to R2; a small or undeclared body is probed up
|
|
212
|
+
* to the ceiling (trusting nothing) and written from memory, while a probe
|
|
213
|
+
* that overflows an undeclared length is rejected `411` rather than buffered.
|
|
214
|
+
*/
|
|
215
|
+
async #writeBody(store, path, request, preconditions) {
|
|
216
|
+
const contentType = request.headers.get("content-type")?.split(";")[0]?.trim() ||
|
|
217
|
+
"application/octet-stream";
|
|
218
|
+
const declared = parseContentLength(request.headers.get("content-length"));
|
|
219
|
+
if (declared !== null && declared > store.maxInlineBytes) {
|
|
220
|
+
await store.putBlob(path, request.body ?? new Blob([]), {
|
|
221
|
+
contentType,
|
|
222
|
+
...preconditions,
|
|
223
|
+
});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const peeked = await readUpToLimit(request.body, store.maxInlineBytes);
|
|
227
|
+
if (peeked.kind === "overflow")
|
|
228
|
+
throw new LengthRequiredError();
|
|
229
|
+
await store.putBlob(path, peeked.bytes, { contentType, ...preconditions });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Module-level helpers
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
const ALLOW = "GET, HEAD, PUT, DELETE, OPTIONS";
|
|
236
|
+
/** Headers common to document responses. */
|
|
237
|
+
function documentHeaders(etag, contentType) {
|
|
238
|
+
return new Headers({
|
|
239
|
+
etag,
|
|
240
|
+
"content-type": contentType || "application/octet-stream",
|
|
241
|
+
"cache-control": "no-cache",
|
|
242
|
+
allow: ALLOW,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Whether a stored `etag` satisfies an `If-None-Match` header (RFC 7232 §3.2).
|
|
247
|
+
* `*` matches any current representation; otherwise the comma-separated entity
|
|
248
|
+
* tags are compared with the weak comparison function (the `W/` prefix ignored).
|
|
249
|
+
*/
|
|
250
|
+
function ifNoneMatchSatisfied(header, etag) {
|
|
251
|
+
if (header.trim() === "*")
|
|
252
|
+
return true;
|
|
253
|
+
const opaque = (tag) => tag.trim().replace(/^W\//, "");
|
|
254
|
+
const target = opaque(etag);
|
|
255
|
+
return header.split(",").some((tag) => {
|
|
256
|
+
const candidate = opaque(tag);
|
|
257
|
+
return candidate.length > 0 && candidate === target;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
function ifMatchOf(request) {
|
|
261
|
+
return request.headers.get("if-match") ?? undefined;
|
|
262
|
+
}
|
|
263
|
+
function ifNoneMatchOf(request) {
|
|
264
|
+
return request.headers.get("if-none-match") ?? undefined;
|
|
265
|
+
}
|
|
266
|
+
/** Concatenate read chunks into one `Uint8Array` of `total` bytes. */
|
|
267
|
+
function concatChunks(chunks, total) {
|
|
268
|
+
const out = new Uint8Array(total);
|
|
269
|
+
let offset = 0;
|
|
270
|
+
for (const chunk of chunks) {
|
|
271
|
+
out.set(chunk, offset);
|
|
272
|
+
offset += chunk.byteLength;
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Parse a `Content-Length` header into a non-negative integer, or `null` when
|
|
278
|
+
* absent or malformed (treated as "length unknown").
|
|
279
|
+
*/
|
|
280
|
+
function parseContentLength(header) {
|
|
281
|
+
if (header === null || !/^\d+$/.test(header))
|
|
282
|
+
return null;
|
|
283
|
+
const value = Number(header);
|
|
284
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Read at most `limit` bytes from `body` to classify an undeclared-length body
|
|
288
|
+
* without buffering an oversized one. Returns the buffered bytes when the body
|
|
289
|
+
* ends within `limit`; signals `overflow` (cancelling the body) the moment it
|
|
290
|
+
* exceeds `limit`. A body of exactly `limit` bytes still fits.
|
|
291
|
+
*/
|
|
292
|
+
async function readUpToLimit(body, limit) {
|
|
293
|
+
if (!body)
|
|
294
|
+
return { kind: "buffered", bytes: new Uint8Array(0) };
|
|
295
|
+
const reader = body.getReader();
|
|
296
|
+
const chunks = [];
|
|
297
|
+
let total = 0;
|
|
298
|
+
for (;;) {
|
|
299
|
+
const { done, value } = await reader.read();
|
|
300
|
+
if (done)
|
|
301
|
+
return { kind: "buffered", bytes: concatChunks(chunks, total) };
|
|
302
|
+
chunks.push(value);
|
|
303
|
+
total += value.byteLength;
|
|
304
|
+
if (total > limit) {
|
|
305
|
+
await reader.cancel();
|
|
306
|
+
return { kind: "overflow" };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Thrown by `#writeBody` when an unsized body is too large to buffer or stream (→ 411). */
|
|
311
|
+
class LengthRequiredError extends Error {
|
|
312
|
+
}
|
|
313
|
+
//# sourceMappingURL=storage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage.js","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dwk/remotestorage",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "remoteStorage (draft-dejong-remotestorage) personal data vault: OAuth-bearer GET/PUT/DELETE documents and folder listings over @dwk/store. Ships the per-account Durable Object.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"remotestorage",
|
|
7
|
+
"personal-data",
|
|
8
|
+
"data-vault",
|
|
9
|
+
"oauth2",
|
|
10
|
+
"cloudflare-workers",
|
|
11
|
+
"durable-objects"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"license": "ISC",
|
|
15
|
+
"author": "David W. Keith <me@dwk.io>",
|
|
16
|
+
"homepage": "https://github.com/davidwkeith/workers/tree/main/packages/remotestorage#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/davidwkeith/workers.git",
|
|
20
|
+
"directory": "packages/remotestorage"
|
|
21
|
+
},
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src",
|
|
34
|
+
"!src/**/*.test.ts",
|
|
35
|
+
"!src/test-harness.ts"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@dwk/log": "0.1.0-beta.0",
|
|
42
|
+
"@dwk/store": "0.1.0-beta.0",
|
|
43
|
+
"@dwk/webfinger": "0.1.0-beta.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.build.json",
|
|
47
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
48
|
+
"clean": "rm -rf dist"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge authentication for the vault: resolve an OAuth 2.0 bearer token into the
|
|
3
|
+
* scopes it grants, before any request reaches the per-account Durable Object.
|
|
4
|
+
*
|
|
5
|
+
* remoteStorage uses **plain bearer tokens** (no DPoP). A request with no
|
|
6
|
+
* `Authorization` header is `anonymous` — only `/public/` document reads will
|
|
7
|
+
* then succeed. A request that *presents* a token must verify fully or it is
|
|
8
|
+
* `rejected`: the built-in verifier checks the issuer JWKS signature and the
|
|
9
|
+
* `iss` / `aud` / `exp` / `nbf` claims, then reads the OAuth `scope` claim. A
|
|
10
|
+
* deployer-supplied {@link RemoteStorageConfig.authenticate} hook fully replaces
|
|
11
|
+
* the built-in path (e.g. to introspect opaque tokens via RFC 7662).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ResolvedConfig } from "./config";
|
|
15
|
+
import { decodeJwt, verifyJwtSignature } from "./jwt";
|
|
16
|
+
import { parseScopes, type RemoteStorageScope } from "./scope";
|
|
17
|
+
|
|
18
|
+
/** The verified facts a token yields: its scopes and (optionally) its subject. */
|
|
19
|
+
export interface RemoteStorageAuth {
|
|
20
|
+
/** The remoteStorage scopes the token grants. */
|
|
21
|
+
readonly scopes: readonly RemoteStorageScope[];
|
|
22
|
+
/** The token subject (`sub`), used only for sanitized logging. */
|
|
23
|
+
readonly subject?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A stable reason a token failed verification (for `WWW-Authenticate`). */
|
|
27
|
+
export type AuthFailureReason =
|
|
28
|
+
| "no_jwks"
|
|
29
|
+
| "token_malformed"
|
|
30
|
+
| "signature_invalid"
|
|
31
|
+
| "issuer_mismatch"
|
|
32
|
+
| "audience_mismatch"
|
|
33
|
+
| "token_expired"
|
|
34
|
+
| "token_not_yet_valid";
|
|
35
|
+
|
|
36
|
+
/** Outcome of {@link authenticate}: scopes, an explicit failure, or "no creds". */
|
|
37
|
+
export type AuthResult =
|
|
38
|
+
| { readonly kind: "authenticated"; readonly auth: RemoteStorageAuth }
|
|
39
|
+
| { readonly kind: "anonymous" }
|
|
40
|
+
| { readonly kind: "rejected"; readonly reason: AuthFailureReason };
|
|
41
|
+
|
|
42
|
+
/** In-memory JWKS cache shared across requests within an isolate. */
|
|
43
|
+
interface CachedJwks {
|
|
44
|
+
keys: readonly JsonWebKey[];
|
|
45
|
+
fetchedAt: number;
|
|
46
|
+
}
|
|
47
|
+
const JWKS_TTL_MS = 5 * 60 * 1000;
|
|
48
|
+
const jwksCache = new Map<string, CachedJwks>();
|
|
49
|
+
|
|
50
|
+
/** Resolve the issuer verification keys: static config first, then cached fetch. */
|
|
51
|
+
async function resolveJwks(
|
|
52
|
+
config: ResolvedConfig,
|
|
53
|
+
): Promise<readonly JsonWebKey[] | null> {
|
|
54
|
+
if (config.jwks && config.jwks.length > 0) return config.jwks;
|
|
55
|
+
if (!config.jwksUri) return null;
|
|
56
|
+
|
|
57
|
+
const now = config.now();
|
|
58
|
+
const cached = jwksCache.get(config.jwksUri);
|
|
59
|
+
if (cached && now - cached.fetchedAt < JWKS_TTL_MS) return cached.keys;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const response = await config.fetch(config.jwksUri);
|
|
63
|
+
if (!response.ok) return cached?.keys ?? null;
|
|
64
|
+
const body = (await response.json()) as { keys?: JsonWebKey[] } | null;
|
|
65
|
+
// Only cache a well-formed JWKS; a null/empty/garbled body (e.g. literal
|
|
66
|
+
// JSON `null`) is ignored rather than throwing — and caching it would poison
|
|
67
|
+
// verification for the whole TTL and discard the last good keys.
|
|
68
|
+
if (!body || !Array.isArray(body.keys)) return cached?.keys ?? null;
|
|
69
|
+
jwksCache.set(config.jwksUri, { keys: body.keys, fetchedAt: now });
|
|
70
|
+
return body.keys;
|
|
71
|
+
} catch {
|
|
72
|
+
return cached?.keys ?? null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Extract the `Bearer <token>` value, if present. */
|
|
77
|
+
export function bearerToken(request: Request): string | null {
|
|
78
|
+
const header = request.headers.get("authorization");
|
|
79
|
+
if (!header) return null;
|
|
80
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
81
|
+
return match ? (match[1] as string) : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether the token's `aud` claim intersects the accepted audience set. */
|
|
85
|
+
function audienceMatches(aud: unknown, accepted: readonly string[]): boolean {
|
|
86
|
+
if (accepted.length === 0) return true; // audience check disabled
|
|
87
|
+
const values = Array.isArray(aud)
|
|
88
|
+
? aud.filter((a): a is string => typeof a === "string")
|
|
89
|
+
: typeof aud === "string"
|
|
90
|
+
? [aud]
|
|
91
|
+
: [];
|
|
92
|
+
return values.some((a) => accepted.includes(a));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Authenticate a request at the edge. Returns `anonymous` when no credentials
|
|
97
|
+
* are presented, `authenticated` with the granted scopes on success, and
|
|
98
|
+
* `rejected` with a stable reason on any failure of a *presented* token.
|
|
99
|
+
*/
|
|
100
|
+
export async function authenticate(
|
|
101
|
+
request: Request,
|
|
102
|
+
config: ResolvedConfig,
|
|
103
|
+
): Promise<AuthResult> {
|
|
104
|
+
// A deployer-supplied hook fully replaces the built-in verifier.
|
|
105
|
+
if (config.authenticate) {
|
|
106
|
+
const auth = await config.authenticate(request);
|
|
107
|
+
return auth ? { kind: "authenticated", auth } : { kind: "anonymous" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const token = bearerToken(request);
|
|
111
|
+
if (!token) return { kind: "anonymous" };
|
|
112
|
+
|
|
113
|
+
const decoded = decodeJwt(token);
|
|
114
|
+
if (!decoded) return { kind: "rejected", reason: "token_malformed" };
|
|
115
|
+
|
|
116
|
+
const jwks = await resolveJwks(config);
|
|
117
|
+
if (!jwks || jwks.length === 0)
|
|
118
|
+
return { kind: "rejected", reason: "no_jwks" };
|
|
119
|
+
|
|
120
|
+
if (!(await verifyJwtSignature(decoded, jwks))) {
|
|
121
|
+
return { kind: "rejected", reason: "signature_invalid" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { iss, aud, exp, nbf, sub, scope } = decoded.payload;
|
|
125
|
+
if (config.issuer !== undefined && iss !== config.issuer) {
|
|
126
|
+
return { kind: "rejected", reason: "issuer_mismatch" };
|
|
127
|
+
}
|
|
128
|
+
if (!audienceMatches(aud, config.audience)) {
|
|
129
|
+
return { kind: "rejected", reason: "audience_mismatch" };
|
|
130
|
+
}
|
|
131
|
+
const now = Math.floor(config.now() / 1000);
|
|
132
|
+
if (typeof exp !== "number" || now >= exp) {
|
|
133
|
+
return { kind: "rejected", reason: "token_expired" };
|
|
134
|
+
}
|
|
135
|
+
if (nbf !== undefined && (typeof nbf !== "number" || now < nbf)) {
|
|
136
|
+
return { kind: "rejected", reason: "token_not_yet_valid" };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
kind: "authenticated",
|
|
141
|
+
auth: {
|
|
142
|
+
scopes: parseScopes(typeof scope === "string" ? scope : undefined),
|
|
143
|
+
...(typeof sub === "string" ? { subject: sub } : {}),
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|