@yielded/jose 0.1.0-beta.19

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/src/Jwks.ts ADDED
@@ -0,0 +1,366 @@
1
+ import type { Signature } from "@yielded/crypto/Signature";
2
+ import {
3
+ Clock,
4
+ Context,
5
+ Deferred,
6
+ Effect,
7
+ Exit,
8
+ Layer,
9
+ Option,
10
+ Schema,
11
+ Scope,
12
+ Semaphore,
13
+ Stream,
14
+ SynchronizedRef,
15
+ } from "effect";
16
+ import { FetchHttpClient, HttpClient } from "effect/http";
17
+
18
+ import { AmbiguousKey, InvalidKey, JwksUnavailable, KeyNotFound, type KeyError } from "./Errors";
19
+ import { json } from "./internal/encoding";
20
+ import * as KeyInput from "./internal/keyInput";
21
+ import { AsymmetricAlgorithm, importPublic, type PublicKey, PublicJwk } from "./Jwk";
22
+
23
+ export const KeySet = Schema.Struct({
24
+ keys: Schema.Array(PublicJwk).check(Schema.isMaxLength(KeyInput.maxArrayLength)),
25
+ });
26
+
27
+ export type KeySet = typeof KeySet.Type;
28
+
29
+ export const Selection = Schema.Struct({
30
+ algorithm: AsymmetricAlgorithm,
31
+ kid: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(256))),
32
+ });
33
+
34
+ export type Selection = typeof Selection.Type;
35
+
36
+ export class Jwks extends Context.Service<
37
+ Jwks,
38
+ {
39
+ readonly resolve: (selection: Selection) => Effect.Effect<PublicKey, KeyError, Signature>;
40
+ }
41
+ >()("@yielded/jose/Jwks") {}
42
+
43
+ /** One immutable public key set; local selection never requires HttpClient. */
44
+ export const layerLocal = (input: unknown): Layer.Layer<Jwks, InvalidKey> =>
45
+ Layer.effect(
46
+ Jwks,
47
+ Effect.gen(function* () {
48
+ const set = yield* readSet(input);
49
+
50
+ return Jwks.of({
51
+ resolve: Effect.fnUntraced(function* (input) {
52
+ const selection = yield* readSelection(input);
53
+
54
+ return yield* importPublic(yield* select(set, selection), selection.algorithm);
55
+ }),
56
+ });
57
+ }),
58
+ );
59
+
60
+ export interface RemoteOptions {
61
+ /** Trusted application configuration, never a URL from a token. HTTPS only. */
62
+ readonly url: string;
63
+ readonly cacheTimeMs?: number;
64
+ readonly cooldownMs?: number;
65
+ readonly timeoutMs?: number;
66
+ readonly maxResponseBytes?: number;
67
+ readonly maxKeys?: number;
68
+ readonly maxWaiters?: number;
69
+ }
70
+
71
+ const readSet = (input: unknown) =>
72
+ Effect.try({
73
+ try: () => KeyInput.keySet(input),
74
+ catch: () => InvalidKey.make({}),
75
+ }).pipe(
76
+ Effect.flatMap(Effect.fromResult),
77
+ Effect.flatMap((captured) =>
78
+ Schema.decodeUnknownEffect(KeySet)(captured, { reportInput: false }).pipe(
79
+ Effect.mapError(() => InvalidKey.make({})),
80
+ ),
81
+ ),
82
+ Effect.map((set) => {
83
+ for (const key of set.keys) {
84
+ KeyInput.detach(key);
85
+ if (key.key_ops !== undefined) Object.freeze(key.key_ops);
86
+ Object.freeze(key);
87
+ }
88
+ Object.freeze(set.keys);
89
+
90
+ return Object.freeze(KeyInput.detach(set));
91
+ }),
92
+ );
93
+
94
+ const readSelection = (input: Selection) =>
95
+ Effect.try({
96
+ try: () => KeyInput.object(input),
97
+ catch: () => InvalidKey.make({}),
98
+ }).pipe(
99
+ Effect.flatMap((captured) =>
100
+ Schema.decodeUnknownEffect(Selection)(captured, { reportInput: false }).pipe(
101
+ Effect.mapError(() => InvalidKey.make({})),
102
+ ),
103
+ ),
104
+ Effect.map(KeyInput.detach),
105
+ );
106
+
107
+ const select = Effect.fnUntraced(function* (set: KeySet, selection: Selection) {
108
+ const candidates = set.keys.filter(
109
+ (key) =>
110
+ (selection.kid === undefined || key.kid === selection.kid) &&
111
+ (key.alg === undefined || key.alg === selection.algorithm) &&
112
+ (key.use === undefined || key.use === "sig") &&
113
+ (key.key_ops === undefined || key.key_ops.includes("verify")) &&
114
+ (selection.algorithm === "ES256"
115
+ ? key.kty === "EC"
116
+ : selection.algorithm === "EdDSA"
117
+ ? key.kty === "OKP"
118
+ : key.kty === "RSA"),
119
+ );
120
+
121
+ if (candidates.length === 0) return yield* KeyNotFound.make({});
122
+ if (candidates.length !== 1) return yield* AmbiguousKey.make({});
123
+
124
+ return candidates[0];
125
+ });
126
+
127
+ const bounded = (minimum: number, maximum: number) =>
128
+ Schema.Int.check(Schema.isBetween({ minimum, maximum }));
129
+
130
+ const RemoteConfiguration = Schema.Struct({
131
+ url: Schema.URLFromString.check(
132
+ Schema.makeFilter(
133
+ (url) =>
134
+ url.protocol === "https:" && url.username === "" && url.password === "" && url.hash === "",
135
+ ),
136
+ ),
137
+ cacheTimeMs: bounded(1, 86400000),
138
+ cooldownMs: bounded(0, 3600000),
139
+ timeoutMs: bounded(1, 60000),
140
+ maxResponseBytes: bounded(1, 1048576),
141
+ maxKeys: bounded(1, KeyInput.maxArrayLength),
142
+ maxWaiters: bounded(1, 1024),
143
+ });
144
+
145
+ interface Snapshot {
146
+ readonly keys: KeySet;
147
+ readonly expiresAt: number;
148
+ }
149
+
150
+ type RemoteState =
151
+ | { readonly _tag: "Closed" }
152
+ | {
153
+ readonly _tag: "Open";
154
+ readonly cache: Snapshot | undefined;
155
+ readonly pending: Deferred.Deferred<Snapshot, JwksUnavailable> | undefined;
156
+ readonly lastAttempt: number;
157
+ };
158
+
159
+ type Admission =
160
+ | { readonly _tag: "Rejected"; readonly reason: "closed" | "cooldown" }
161
+ | { readonly _tag: "Cached"; readonly snapshot: Snapshot }
162
+ | {
163
+ readonly _tag: "Join" | "Start";
164
+ readonly result: Deferred.Deferred<Snapshot, JwksUnavailable>;
165
+ };
166
+
167
+ /**
168
+ * One trusted HTTPS endpoint per Layer. The supplied client owns egress policy
169
+ * and must not follow redirects; FetchHttpClient is configured to reject them.
170
+ * Responses from another URL are rejected with every client. No stale-on-error
171
+ * fallback, global cache, background polling, or token-supplied key URLs.
172
+ */
173
+ export const layerRemote = (
174
+ input: RemoteOptions,
175
+ ): Layer.Layer<Jwks, JwksUnavailable, HttpClient.HttpClient> => {
176
+ const configuration = {
177
+ cacheTimeMs: 600000,
178
+ cooldownMs: 30000,
179
+ timeoutMs: 5000,
180
+ maxResponseBytes: 131072,
181
+ maxKeys: 64,
182
+ maxWaiters: 64,
183
+ ...input,
184
+ };
185
+
186
+ return Layer.effect(
187
+ Jwks,
188
+ Effect.gen(function* () {
189
+ const options = yield* Schema.decodeUnknownEffect(RemoteConfiguration)(configuration, {
190
+ reportInput: false,
191
+ }).pipe(Effect.mapError(() => JwksUnavailable.make({ reason: "configuration" })));
192
+
193
+ const client = HttpClient.withScope(yield* HttpClient.HttpClient);
194
+
195
+ const state = yield* SynchronizedRef.make<RemoteState>({
196
+ _tag: "Open",
197
+ cache: undefined,
198
+ pending: undefined,
199
+ lastAttempt: -Infinity,
200
+ });
201
+
202
+ const permits = yield* Semaphore.make(options.maxWaiters);
203
+
204
+ const scope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
205
+ Effect.gen(function* () {
206
+ const previous = yield* SynchronizedRef.getAndSet(state, { _tag: "Closed" });
207
+
208
+ // A scoped fiber can be interrupted before its first instruction, so
209
+ // its onExit handler alone cannot release pending lookup waiters.
210
+ if (previous._tag === "Open" && previous.pending !== undefined)
211
+ yield* Deferred.fail(previous.pending, JwksUnavailable.make({ reason: "closed" }));
212
+ yield* Scope.close(scope, Exit.void);
213
+ }),
214
+ );
215
+
216
+ const fetch = Effect.gen(function* () {
217
+ const response = yield* client.get(options.url.href, {
218
+ headers: { accept: "application/json" },
219
+ });
220
+
221
+ if (response.status !== 200 || response.url !== options.url.href)
222
+ return yield* JwksUnavailable.make({ reason: "response" });
223
+ const buffer = new Uint8Array(options.maxResponseBytes);
224
+
225
+ const length = yield* Stream.runFoldEffect(
226
+ response.stream,
227
+ () => 0,
228
+ (length, chunk) => {
229
+ if (chunk.byteLength > buffer.length - length)
230
+ return Effect.fail(JwksUnavailable.make({ reason: "response" }));
231
+ buffer.set(chunk, length);
232
+
233
+ return Effect.succeed(length + chunk.length);
234
+ },
235
+ );
236
+
237
+ const document = yield* json(buffer.subarray(0, length), "payload");
238
+ const keys = yield* readSet(document);
239
+
240
+ if (keys.keys.length > options.maxKeys)
241
+ return yield* JwksUnavailable.make({ reason: "response" });
242
+
243
+ return {
244
+ keys,
245
+ expiresAt: (yield* Clock.currentTimeMillis) + options.cacheTimeMs,
246
+ } satisfies Snapshot;
247
+ }).pipe(
248
+ Effect.scoped,
249
+ Effect.provideService(FetchHttpClient.RequestInit, {
250
+ redirect: "error",
251
+ credentials: "omit",
252
+ }),
253
+ Effect.catchTag("HttpClientError", () => JwksUnavailable.make({ reason: "transport" })),
254
+ Effect.catchTag(["JoseInvalidToken", "JoseInvalidKey"], () =>
255
+ JwksUnavailable.make({ reason: "response" }),
256
+ ),
257
+ Effect.timeoutOrElse({
258
+ duration: options.timeoutMs,
259
+ orElse: () => JwksUnavailable.make({ reason: "transport" }),
260
+ }),
261
+ );
262
+
263
+ const refresh = (missingFrom?: Snapshot) =>
264
+ Effect.uninterruptibleMask((restore) =>
265
+ Effect.gen(function* () {
266
+ const now = yield* restore(Clock.currentTimeMillis);
267
+ const result = yield* Deferred.make<Snapshot, JwksUnavailable>();
268
+
269
+ // Recheck cache and ownership together after the caller's snapshot.
270
+ // A missing key bypasses only the snapshot that failed selection.
271
+ const admission = yield* SynchronizedRef.modify(
272
+ state,
273
+ (current): readonly [Admission, RemoteState] => {
274
+ if (current._tag === "Closed")
275
+ return [{ _tag: "Rejected", reason: "closed" }, current];
276
+ if (
277
+ current.cache !== undefined &&
278
+ current.cache !== missingFrom &&
279
+ current.cache.expiresAt > now
280
+ )
281
+ return [{ _tag: "Cached", snapshot: current.cache }, current];
282
+ if (current.pending !== undefined)
283
+ return [{ _tag: "Join", result: current.pending }, current];
284
+ if (now - current.lastAttempt < options.cooldownMs)
285
+ return [{ _tag: "Rejected", reason: "cooldown" }, current];
286
+
287
+ return [
288
+ { _tag: "Start", result },
289
+ { ...current, pending: result, lastAttempt: now },
290
+ ];
291
+ },
292
+ );
293
+
294
+ if (admission._tag === "Rejected")
295
+ return yield* JwksUnavailable.make({ reason: admission.reason });
296
+ if (admission._tag === "Cached") return admission.snapshot;
297
+ if (admission._tag === "Join") return yield* restore(Deferred.await(admission.result));
298
+
299
+ // Admission through fork registration is uninterruptible. Network
300
+ // work and waiter suspension never run under the state lock.
301
+ yield* fetch.pipe(
302
+ Effect.onExit((exit) =>
303
+ SynchronizedRef.updateEffect(state, (current) =>
304
+ current._tag === "Closed"
305
+ ? Effect.succeed(current)
306
+ : Deferred.done(result, exit).pipe(
307
+ // Keep pending ownership until publication, serialized
308
+ // with shutdown so a closed owner cannot publish success.
309
+ Effect.as({
310
+ ...current,
311
+ cache: Exit.isSuccess(exit) ? exit.value : current.cache,
312
+ pending: undefined,
313
+ }),
314
+ ),
315
+ ),
316
+ ),
317
+ Effect.forkIn(scope, { uninterruptible: false }),
318
+ );
319
+
320
+ return yield* restore(Deferred.await(result));
321
+ }),
322
+ );
323
+
324
+ const resolve = Effect.fnUntraced(function* (input: Selection) {
325
+ if ((yield* SynchronizedRef.get(state))._tag === "Closed")
326
+ return yield* JwksUnavailable.make({ reason: "closed" });
327
+ const selection = yield* readSelection(input);
328
+ const now = yield* Clock.currentTimeMillis;
329
+ const snapshot = yield* SynchronizedRef.get(state);
330
+
331
+ if (snapshot._tag === "Closed") return yield* JwksUnavailable.make({ reason: "closed" });
332
+
333
+ const current =
334
+ snapshot.cache !== undefined && snapshot.cache.expiresAt > now
335
+ ? snapshot.cache
336
+ : yield* refresh();
337
+
338
+ const candidate = yield* select(current.keys, selection).pipe(
339
+ Effect.catchTag("JoseKeyNotFound", () =>
340
+ refresh(current).pipe(
341
+ Effect.mapError((error) =>
342
+ error.reason === "cooldown" ? KeyNotFound.make({}) : error,
343
+ ),
344
+ Effect.flatMap((snapshot) => select(snapshot.keys, selection)),
345
+ ),
346
+ ),
347
+ );
348
+
349
+ return yield* importPublic(candidate, selection.algorithm);
350
+ });
351
+
352
+ return Jwks.of({
353
+ resolve: (selection) =>
354
+ resolve(selection).pipe(
355
+ permits.withPermitsIfAvailable(1),
356
+ Effect.flatMap(
357
+ Option.match({
358
+ onNone: () => JwksUnavailable.make({ reason: "busy" }),
359
+ onSome: Effect.succeed,
360
+ }),
361
+ ),
362
+ ),
363
+ });
364
+ }),
365
+ );
366
+ };
package/src/Jws.ts ADDED
@@ -0,0 +1,206 @@
1
+ import { Hmac } from "@yielded/crypto/Hmac";
2
+ import { Signature } from "@yielded/crypto/Signature";
3
+ import { Effect, Redacted, Schema } from "effect";
4
+ import { Base64Url } from "effect/encoding";
5
+
6
+ import {
7
+ AlgorithmNotAllowed,
8
+ InvalidKey,
9
+ InvalidToken,
10
+ SignatureVerificationFailed,
11
+ } from "./Errors";
12
+ import { algorithms } from "./internal/algorithms";
13
+ import {
14
+ decode,
15
+ json,
16
+ JsonObject,
17
+ parse,
18
+ reveal,
19
+ split,
20
+ stringify,
21
+ utf8,
22
+ } from "./internal/encoding";
23
+ import * as KeyPolicy from "./internal/keyPolicy";
24
+ import {
25
+ Algorithm,
26
+ type SigningKey,
27
+ type VerificationKey,
28
+ type PublicKey,
29
+ type PrivateJwk,
30
+ type SecretJwk,
31
+ } from "./Jwk";
32
+ import { Jwks } from "./Jwks";
33
+
34
+ export const Header = Schema.Struct({
35
+ alg: Algorithm,
36
+ kid: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(256))),
37
+ typ: Schema.optionalKey(Schema.NonEmptyString),
38
+ cty: Schema.optionalKey(Schema.NonEmptyString),
39
+ });
40
+
41
+ export type Header = typeof Header.Type;
42
+
43
+ export const VerifyOptions = Schema.Struct({
44
+ algorithms: Schema.Array(Algorithm).check(Schema.isMinLength(1)),
45
+ });
46
+
47
+ export type VerifyOptions = typeof VerifyOptions.Type;
48
+
49
+ export interface Verified {
50
+ readonly protectedHeader: Header;
51
+ readonly payload: Redacted.Redacted<Uint8Array>;
52
+ }
53
+
54
+ export type Requirements = Hmac | Signature;
55
+
56
+ const readHeader = Effect.fnUntraced(function* (input: unknown) {
57
+ const raw = yield* parse(JsonObject, input, "header");
58
+
59
+ // No extension handlers are installed. These options change authenticated
60
+ // processing and may never be silently stripped by the structural schema.
61
+ if (raw.crit !== undefined || raw.b64 !== undefined)
62
+ return yield* InvalidToken.make({ reason: "header" });
63
+
64
+ return yield* parse(Header, raw, "header");
65
+ });
66
+
67
+ const parseCompact = Effect.fnUntraced(function* (
68
+ token: Redacted.Redacted<string>,
69
+ options: VerifyOptions,
70
+ ) {
71
+ const policy = yield* parse(VerifyOptions, options, "parameters");
72
+ const [protectedPart, payloadPart, signaturePart] = yield* split(token, 3);
73
+
74
+ if (protectedPart.length > 4096) return yield* InvalidToken.make({ reason: "header" });
75
+ const protectedHeader = yield* readHeader(yield* json(yield* decode(protectedPart), "header"));
76
+
77
+ if (!policy.algorithms.includes(protectedHeader.alg)) return yield* AlgorithmNotAllowed.make({});
78
+ const payload = yield* decode(payloadPart);
79
+ const signature = yield* decode(signaturePart);
80
+
81
+ return {
82
+ protectedHeader,
83
+ payload,
84
+ signature,
85
+ signingInput: utf8(`${protectedPart}.${payloadPart}`),
86
+ };
87
+ });
88
+
89
+ const verifyAsymmetric = Effect.fnUntraced(function* (
90
+ parsed: Effect.Success<ReturnType<typeof parseCompact>>,
91
+ key: PublicKey,
92
+ ) {
93
+ const algorithm = parsed.protectedHeader.alg;
94
+
95
+ if (algorithm === "HS256" || key.algorithm !== algorithm) return yield* InvalidKey.make({});
96
+ yield* KeyPolicy.check(key.jwk, algorithm, "verify");
97
+ const signatures = yield* Signature;
98
+
99
+ const valid = yield* signatures.verify({
100
+ algorithm: algorithms[algorithm],
101
+ publicKey: key.material,
102
+ data: parsed.signingInput,
103
+ signature: parsed.signature,
104
+ });
105
+
106
+ if (!valid) return yield* SignatureVerificationFailed.make({});
107
+
108
+ return {
109
+ protectedHeader: parsed.protectedHeader,
110
+ payload: Redacted.make(parsed.payload),
111
+ } satisfies Verified;
112
+ });
113
+
114
+ export const sign = Effect.fnUntraced(function* (
115
+ payload: Redacted.Redacted<Uint8Array>,
116
+ key: SigningKey,
117
+ input: Header,
118
+ ) {
119
+ const header = yield* readHeader(input);
120
+
121
+ if (key.algorithm !== header.alg) return yield* InvalidKey.make({});
122
+ yield* KeyPolicy.check(yield* reveal<PrivateJwk | SecretJwk>(key.jwk), header.alg, "sign");
123
+
124
+ const data = yield* parse(
125
+ Schema.Uint8Array.check(Schema.isMaxLength(45000)),
126
+ yield* reveal(payload),
127
+ "payload",
128
+ );
129
+
130
+ const protectedPart = Base64Url.encode(
131
+ yield* stringify(header).pipe(Effect.mapError(() => InvalidToken.make({ reason: "header" }))),
132
+ );
133
+
134
+ if (protectedPart.length > 4096) return yield* InvalidToken.make({ reason: "header" });
135
+ const signingInput = `${protectedPart}.${Base64Url.encode(data)}`;
136
+ let signature: Uint8Array;
137
+
138
+ if (key._tag === "SecretKey") {
139
+ if (header.alg !== "HS256") return yield* InvalidKey.make({});
140
+ const hmac = yield* Hmac;
141
+
142
+ signature = yield* hmac.sign({
143
+ algorithm: "SHA-256",
144
+ key: key.material,
145
+ data: utf8(signingInput),
146
+ });
147
+ } else {
148
+ if (header.alg === "HS256") return yield* InvalidKey.make({});
149
+ const signatures = yield* Signature;
150
+
151
+ signature = yield* signatures.sign({
152
+ algorithm: algorithms[header.alg],
153
+ privateKey: key.material,
154
+ data: utf8(signingInput),
155
+ });
156
+ }
157
+
158
+ return Redacted.make(`${signingInput}.${Base64Url.encode(signature)}`);
159
+ });
160
+
161
+ export const verify = Effect.fnUntraced(function* (
162
+ token: Redacted.Redacted<string>,
163
+ key: VerificationKey,
164
+ options: VerifyOptions,
165
+ ) {
166
+ const parsed = yield* parseCompact(token, options);
167
+
168
+ if (key._tag !== "SecretKey") return yield* verifyAsymmetric(parsed, key);
169
+ const algorithm = parsed.protectedHeader.alg;
170
+
171
+ if (key.algorithm !== algorithm) return yield* InvalidKey.make({});
172
+ yield* KeyPolicy.check(yield* reveal(key.jwk), algorithm, "verify");
173
+ if (algorithm !== "HS256") return yield* InvalidKey.make({});
174
+ const hmac = yield* Hmac;
175
+
176
+ const valid = yield* hmac.verify({
177
+ algorithm: "SHA-256",
178
+ key: key.material,
179
+ data: parsed.signingInput,
180
+ tag: parsed.signature,
181
+ });
182
+
183
+ if (!valid) return yield* SignatureVerificationFailed.make({});
184
+
185
+ return {
186
+ protectedHeader: parsed.protectedHeader,
187
+ payload: Redacted.make(parsed.payload),
188
+ } satisfies Verified;
189
+ });
190
+
191
+ export const verifyWithKeySet = Effect.fnUntraced(function* (
192
+ token: Redacted.Redacted<string>,
193
+ options: VerifyOptions,
194
+ ) {
195
+ const parsed = yield* parseCompact(token, options);
196
+
197
+ if (parsed.protectedHeader.alg === "HS256") return yield* InvalidKey.make({});
198
+ const keys = yield* Jwks;
199
+
200
+ const key = yield* keys.resolve({
201
+ algorithm: parsed.protectedHeader.alg,
202
+ ...(parsed.protectedHeader.kid === undefined ? {} : { kid: parsed.protectedHeader.kid }),
203
+ });
204
+
205
+ return yield* verifyAsymmetric(parsed, key);
206
+ });