@yielded/oauth 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/Oidc.ts ADDED
@@ -0,0 +1,256 @@
1
+ import * as Jwks from "@yielded/jose/Jwks";
2
+ import type * as Jws from "@yielded/jose/Jws";
3
+ import * as Jwt from "@yielded/jose/Jwt";
4
+ import {
5
+ Clock,
6
+ Context,
7
+ Crypto,
8
+ DateTime,
9
+ Effect,
10
+ Layer,
11
+ Redacted,
12
+ Schema,
13
+ type Scope,
14
+ } from "effect";
15
+ import { Base64Url } from "effect/encoding";
16
+ import { HttpClient, HttpClientRequest } from "effect/http";
17
+
18
+ import { ConfigurationError, Rejected, Unavailable } from "./Errors";
19
+ import * as Ownership from "./internal/ownership";
20
+ import * as Transport from "./internal/transport";
21
+ import * as V from "./internal/validation";
22
+ import { JsonObject, Metadata, type RequestOptions } from "./OAuth";
23
+
24
+ export interface VerifierOptions extends RequestOptions {
25
+ readonly metadata: Metadata;
26
+ readonly clientId: string;
27
+ }
28
+
29
+ export interface VerificationInput {
30
+ readonly verificationStartedAt: DateTime.Utc;
31
+ readonly nonce: Redacted.Redacted<string>;
32
+ readonly maxAgeSeconds?: number;
33
+ readonly accessToken?: Redacted.Redacted<string>;
34
+ readonly code?: Redacted.Redacted<string>;
35
+ /** Refresh continuation: same subject, optional nonce must match, auth_time cannot change. */
36
+ readonly previous?: { readonly subject: string; readonly authTime?: number };
37
+ }
38
+
39
+ export interface Verified {
40
+ readonly claims: Redacted.Redacted<JsonObject>;
41
+ readonly subject: string;
42
+ readonly authTime?: number;
43
+ readonly upstreamAuthenticatedAt?: DateTime.Utc;
44
+ }
45
+
46
+ type VerificationRequirements =
47
+ | Exclude<Effect.Services<ReturnType<typeof Jws.verifyWithKeySet>>, Jwks.Jwks>
48
+ | Crypto.Crypto;
49
+
50
+ export interface Verifier {
51
+ readonly verify: (
52
+ token: Redacted.Redacted<string>,
53
+ input: VerificationInput,
54
+ ) => Effect.Effect<Verified, Rejected | Unavailable, VerificationRequirements>;
55
+ }
56
+
57
+ const Configuration = Schema.Struct({
58
+ metadata: Metadata,
59
+ clientId: V.text(1024),
60
+ ...V.RequestOptions.fields,
61
+ });
62
+
63
+ const VerifyInput = Schema.Struct({
64
+ verificationStartedAt: Schema.DateTimeUtc,
65
+ nonce: V.secret(256),
66
+ maxAgeSeconds: Schema.optional(V.integer(0, 86400)),
67
+ accessToken: Schema.optional(V.secret(16384)),
68
+ code: Schema.optional(V.secret(16384)),
69
+ previous: Schema.optional(
70
+ Schema.Struct({ subject: V.text(1024), authTime: Schema.optional(V.NumericDate) }),
71
+ ),
72
+ });
73
+
74
+ const IdClaims = Schema.Struct({
75
+ iss: V.text(2048),
76
+ sub: V.text(1024),
77
+ aud: Schema.Union([
78
+ V.text(1024),
79
+ Schema.Array(V.text(1024)).check(Schema.isMinLength(1), Schema.isMaxLength(1)),
80
+ ]),
81
+ azp: Schema.optionalKey(V.text(1024)),
82
+ exp: V.NumericDate,
83
+ iat: V.NumericDate,
84
+ nbf: Schema.optionalKey(V.NumericDate),
85
+ auth_time: Schema.optionalKey(V.NumericDate),
86
+ nonce: Schema.optionalKey(V.text(256)),
87
+ // The RS256 half digest is 128 bits (22 base64url characters).
88
+ at_hash: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{22}$/u))),
89
+ c_hash: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{22}$/u))),
90
+ });
91
+
92
+ /** OIDC discovery uses the trusted issuer identifier, retaining its exact
93
+ * spelling for claim comparison even if its network URL normalizes. */
94
+ export const discover = Effect.fnUntraced(function* (
95
+ input: string,
96
+ inputOptions: RequestOptions,
97
+ ): Effect.fn.Return<Metadata, ConfigurationError | Unavailable, HttpClient.HttpClient> {
98
+ const issuer = yield* V.configuration(V.Issuer, input, "issuer");
99
+
100
+ const options = yield* V.configuration(V.RequestOptions, inputOptions);
101
+
102
+ const url = new URL(issuer);
103
+
104
+ url.pathname = `${url.pathname.replace(/\/$/u, "")}/.well-known/openid-configuration`;
105
+ const http = yield* Transport.capture;
106
+
107
+ const response = yield* Transport.request(
108
+ http,
109
+ HttpClientRequest.get(url.href).pipe(HttpClientRequest.acceptJson),
110
+ options,
111
+ );
112
+
113
+ if (response.status !== 200) return yield* Unavailable.make({});
114
+ const document = yield* Transport.json(response);
115
+ const metadata = yield* V.configuration(Metadata, yield* V.reveal(document.body), "metadata");
116
+
117
+ if (metadata.issuer !== issuer) return yield* ConfigurationError.make({ reason: "issuer" });
118
+
119
+ return V.freeze(metadata);
120
+ });
121
+
122
+ const claimFailure = () => Rejected.make({ reason: "claims" });
123
+
124
+ /** RS256-only signed ID tokens. Verification and the bounded JWKS cache belong
125
+ * to the caller's Scope. Owner closure cancels and joins active verification,
126
+ * returning Unavailable; caller interruption joins its operation without closing
127
+ * the verifier. No process cache or automatic exchange retry is created. */
128
+ export const makeVerifier = Effect.fnUntraced(function* (
129
+ input: VerifierOptions,
130
+ ): Effect.fn.Return<
131
+ Verifier,
132
+ ConfigurationError | Unavailable,
133
+ HttpClient.HttpClient | Scope.Scope
134
+ > {
135
+ const options = yield* V.configuration(Configuration, input, "metadata");
136
+
137
+ const metadata = V.freeze(options.metadata);
138
+
139
+ if (
140
+ metadata.jwks_uri === undefined ||
141
+ !metadata.code_challenge_methods_supported?.includes("S256") ||
142
+ !metadata.response_types_supported?.includes("code") ||
143
+ !metadata.id_token_signing_alg_values_supported?.includes("RS256")
144
+ )
145
+ return yield* ConfigurationError.make({ reason: "metadata" });
146
+ const http = yield* Transport.capture;
147
+
148
+ const context = yield* Layer.build(
149
+ Jwks.layerRemote({
150
+ url: metadata.jwks_uri,
151
+ timeoutMs: options.timeoutMs,
152
+ maxResponseBytes: options.maxResponseBytes,
153
+ }).pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, http))),
154
+ ).pipe(Effect.mapError(() => Unavailable.make({})));
155
+
156
+ const keys = Context.get(context, Jwks.Jwks);
157
+ const { use } = yield* Ownership.make;
158
+
159
+ const verify = Effect.fnUntraced(function* (
160
+ token: Redacted.Redacted<string>,
161
+ input: VerificationInput,
162
+ ): Effect.fn.Return<Verified, Rejected | Unavailable, VerificationRequirements> {
163
+ // Capture caller policy and secret values before key lookup or cryptography.
164
+ const policy = yield* V.decode(VerifyInput, input);
165
+ const start = DateTime.toEpochMillis(policy.verificationStartedAt);
166
+ const expectedNonce = yield* V.reveal(policy.nonce);
167
+
168
+ const accessToken =
169
+ policy.accessToken === undefined ? undefined : yield* V.reveal(policy.accessToken);
170
+
171
+ const code = policy.code === undefined ? undefined : yield* V.reveal(policy.code);
172
+
173
+ // Jwt verifies the signature before any registered/application claim checks.
174
+ // Unauthenticated malformed/key/signature failures must never burn a receipt.
175
+ const verified = yield* Jwt.verifyWithKeySet(JsonObject, token, {
176
+ algorithms: ["RS256"],
177
+ issuer: metadata.issuer,
178
+ audience: options.clientId,
179
+ requiredClaims: ["iss", "sub", "aud", "exp", "iat"],
180
+ clockTolerance: 0,
181
+ }).pipe(
182
+ Effect.provideService(Jwks.Jwks, keys),
183
+ Effect.mapError((error) =>
184
+ error._tag === "JoseClaimValidationFailed" ? claimFailure() : Unavailable.make({}),
185
+ ),
186
+ );
187
+
188
+ const claims = yield* V.decode(IdClaims, verified.claims).pipe(Effect.mapError(claimFailure));
189
+
190
+ const now = (yield* Clock.currentTimeMillis) / 1000;
191
+
192
+ if (
193
+ start < 0 ||
194
+ start > now * 1000 ||
195
+ (claims.azp !== undefined && claims.azp !== options.clientId) ||
196
+ claims.exp <= now ||
197
+ claims.iat > now ||
198
+ (claims.nbf !== undefined && claims.nbf > now) ||
199
+ (claims.auth_time !== undefined && claims.auth_time > now)
200
+ )
201
+ return yield* claimFailure();
202
+ if (policy.previous === undefined) {
203
+ if (
204
+ claims.nonce !== expectedNonce ||
205
+ (policy.maxAgeSeconds !== undefined &&
206
+ (claims.auth_time === undefined ||
207
+ claims.auth_time + policy.maxAgeSeconds < Math.floor(now)))
208
+ )
209
+ return yield* claimFailure();
210
+ } else if (
211
+ claims.sub !== policy.previous.subject ||
212
+ (claims.nonce !== undefined && claims.nonce !== expectedNonce) ||
213
+ (claims.auth_time !== undefined && claims.auth_time !== policy.previous.authTime)
214
+ )
215
+ return yield* claimFailure();
216
+ for (const [hash, secret] of [
217
+ [claims.at_hash, accessToken],
218
+ [claims.c_hash, code],
219
+ ] as const) {
220
+ if (hash === undefined) continue;
221
+ if (secret === undefined) return yield* claimFailure();
222
+ const crypto = yield* Crypto.Crypto;
223
+
224
+ const digest = yield* crypto
225
+ .digest("SHA-256", new TextEncoder().encode(secret))
226
+ .pipe(Effect.mapError(() => Unavailable.make({})));
227
+
228
+ if (digest.length !== 32) return yield* Unavailable.make({});
229
+ if (hash !== Base64Url.encode(digest.subarray(0, 16))) return yield* claimFailure();
230
+ }
231
+ // Re-read time after optional digest I/O so expired tokens cannot escape.
232
+ const finished = (yield* Clock.currentTimeMillis) / 1000;
233
+
234
+ if (
235
+ claims.exp <= finished ||
236
+ (policy.previous === undefined &&
237
+ policy.maxAgeSeconds !== undefined &&
238
+ claims.auth_time !== undefined &&
239
+ claims.auth_time + policy.maxAgeSeconds < Math.floor(finished))
240
+ )
241
+ return yield* claimFailure();
242
+
243
+ return {
244
+ claims: Redacted.make(V.freeze(verified.claims)),
245
+ subject: claims.sub,
246
+ ...(claims.auth_time === undefined
247
+ ? {}
248
+ : {
249
+ authTime: claims.auth_time,
250
+ upstreamAuthenticatedAt: DateTime.makeUnsafe(Math.min(start, claims.auth_time * 1000)),
251
+ }),
252
+ };
253
+ }, use);
254
+
255
+ return { verify };
256
+ });
package/src/Pkce.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { Crypto, Effect, Redacted } from "effect";
2
+ import { Base64Url } from "effect/encoding";
3
+
4
+ import { Unavailable } from "./Errors";
5
+ import * as V from "./internal/validation";
6
+
7
+ /** A fresh 256-bit state, nonce, or PKCE verifier; application policy owns storage. */
8
+ export const random = Effect.fnUntraced(
9
+ function* () {
10
+ const crypto = yield* Crypto.Crypto;
11
+ const bytes = yield* crypto.randomBytes(32);
12
+
13
+ if (bytes.length !== 32) return yield* Unavailable.make({});
14
+
15
+ return Redacted.make(Base64Url.encode(bytes));
16
+ },
17
+ Effect.mapError(() => Unavailable.make({})),
18
+ );
19
+
20
+ export const challenge = Effect.fnUntraced(
21
+ function* (input: Redacted.Redacted<string>) {
22
+ const verifier = yield* V.reveal(yield* V.decode(V.Verifier, input));
23
+ const crypto = yield* Crypto.Crypto;
24
+ const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier));
25
+
26
+ if (digest.length !== 32) return yield* Unavailable.make({});
27
+
28
+ return Base64Url.encode(digest);
29
+ },
30
+ Effect.mapError(() => Unavailable.make({})),
31
+ );
32
+
33
+ export const make = Effect.fnUntraced(function* () {
34
+ const verifier = yield* random();
35
+
36
+ return { verifier, challenge: yield* challenge(verifier) };
37
+ });
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * as Errors from "./Errors.ts";
2
+ export * as OAuth from "./OAuth.ts";
3
+ export * as Oidc from "./Oidc.ts";
4
+ export * as Pkce from "./Pkce.ts";
@@ -0,0 +1,30 @@
1
+ import { Effect, Fiber } from "effect";
2
+
3
+ import { Unavailable } from "../Errors";
4
+
5
+ /** Each operation belongs to the factory's Scope and is joined by its caller. */
6
+ export const make = Effect.gen(function* () {
7
+ const scope = yield* Effect.scope;
8
+
9
+ const available = Effect.suspend(() =>
10
+ scope.state._tag === "Closed" ? Effect.fail(Unavailable.make({})) : Effect.void,
11
+ );
12
+
13
+ // Inspect the exit only after checking the owner: closure must stay unavailable,
14
+ // even when an interrupted operation was about to return a definite rejection.
15
+ const use = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
16
+ Effect.acquireUseRelease(
17
+ available.pipe(Effect.andThen(Effect.forkIn(effect, scope, { uninterruptible: false }))),
18
+ (fiber) =>
19
+ Effect.gen(function* () {
20
+ const exit = yield* Fiber.await(fiber);
21
+
22
+ yield* available;
23
+
24
+ return yield* exit;
25
+ }),
26
+ Fiber.interrupt,
27
+ );
28
+
29
+ return { available, use };
30
+ });
@@ -0,0 +1,100 @@
1
+ import { Effect, Redacted, Schema, Stream, Tracer } from "effect";
2
+ import { FetchHttpClient, HttpClient, type HttpClientRequest } from "effect/http";
3
+
4
+ import { Unavailable } from "../Errors";
5
+ import * as V from "./validation";
6
+
7
+ export interface Options {
8
+ readonly timeoutMs: number;
9
+ readonly maxResponseBytes: number;
10
+ }
11
+
12
+ /** Caller supplies a nonretrying, nonredirecting HttpClient. Each body and socket
13
+ * belongs to this request's Scope; cancellation reaches the entire body read. */
14
+ export const request = Effect.fnUntraced(
15
+ function* (
16
+ client: HttpClient.HttpClient,
17
+ request: HttpClientRequest.HttpClientRequest,
18
+ options: Options,
19
+ ) {
20
+ const response = yield* HttpClient.withScope(client).execute(request);
21
+
22
+ if (response.url !== request.url || (response.status >= 300 && response.status < 400))
23
+ return yield* Unavailable.make({});
24
+ const announced = response.headers["content-length"];
25
+
26
+ if (
27
+ announced !== undefined &&
28
+ (!/^\d+$/u.test(announced) || Number(announced) > options.maxResponseBytes)
29
+ )
30
+ return yield* Unavailable.make({});
31
+ const buffer = new Uint8Array(options.maxResponseBytes);
32
+
33
+ const length = yield* Stream.runFoldEffect(
34
+ response.stream,
35
+ () => 0,
36
+ (length, chunk) => {
37
+ if (chunk.byteLength > buffer.length - length) return Effect.fail(Unavailable.make({}));
38
+ buffer.set(chunk, length);
39
+
40
+ return Effect.succeed(length + chunk.byteLength);
41
+ },
42
+ ).pipe(Effect.catchReason("HttpClientError", "EmptyBodyError", () => Effect.succeed(0)));
43
+
44
+ return {
45
+ status: response.status,
46
+ contentType: response.headers["content-type"] ?? null,
47
+ bytes: Redacted.make(buffer.subarray(0, length)),
48
+ };
49
+ },
50
+ (effect, _client, _request, options) =>
51
+ effect.pipe(
52
+ Effect.scoped,
53
+ Effect.provideService(FetchHttpClient.RequestInit, {
54
+ redirect: "error",
55
+ credentials: "omit",
56
+ cache: "no-store",
57
+ }),
58
+ Effect.provideService(Tracer.DisablePropagation, true),
59
+ Effect.mapError(() => Unavailable.make({})),
60
+ Effect.timeoutOrElse({ duration: options.timeoutMs, orElse: () => Unavailable.make({}) }),
61
+ ),
62
+ );
63
+
64
+ export const isJson = (contentType: string | null) =>
65
+ contentType?.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
66
+
67
+ export const json = Effect.fnUntraced(function* (
68
+ response: Effect.Success<ReturnType<typeof request>>,
69
+ ) {
70
+ if (!isJson(response.contentType)) return yield* Unavailable.make({});
71
+ const bytes = yield* V.reveal(response.bytes);
72
+
73
+ const text = yield* Effect.try({
74
+ try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes),
75
+ catch: () => Unavailable.make({}),
76
+ });
77
+
78
+ const body = yield* V.decode(Json, text);
79
+
80
+ return {
81
+ status: response.status,
82
+ contentType: response.contentType,
83
+ body: Redacted.make(V.freeze(body)),
84
+ };
85
+ });
86
+
87
+ const Json = Schema.fromJsonString(V.JsonObject);
88
+
89
+ /** Capture Fetch's optional reference at the installed-client boundary too. */
90
+ export const capture = Effect.gen(function* () {
91
+ const client = yield* HttpClient.HttpClient;
92
+ const fetch = yield* FetchHttpClient.Fetch;
93
+
94
+ return HttpClient.transformResponse(client, (effect) =>
95
+ effect.pipe(
96
+ Effect.provideService(FetchHttpClient.Fetch, fetch),
97
+ Effect.provideService(Tracer.DisablePropagation, true),
98
+ ),
99
+ );
100
+ });
@@ -0,0 +1,236 @@
1
+ import { Effect, Predicate, Redacted, Schema } from "effect";
2
+
3
+ import { ConfigurationError, Unavailable } from "../Errors";
4
+
5
+ export const text = (maximum: number) => Schema.NonEmptyString.check(Schema.isMaxLength(maximum));
6
+
7
+ export const integer = (minimum: number, maximum: number) =>
8
+ Schema.Int.check(Schema.isBetween({ minimum, maximum }));
9
+
10
+ export const secret = (maximum: number) =>
11
+ Schema.Redacted(text(maximum), { disallowJsonEncode: true });
12
+
13
+ /** At most 64 nested containers and 65,536 values, including the root object. */
14
+ export const JsonObject = Schema.Record(Schema.String, Schema.Json).check(
15
+ Schema.makeFilter((value) => {
16
+ const pending: Array<{ value: Schema.JsonObject | Schema.JsonArray; depth: number }> = [
17
+ { value, depth: 1 },
18
+ ];
19
+
20
+ let values = 1;
21
+
22
+ for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
23
+ const { value, depth } = entry;
24
+
25
+ if (depth > 64) return false;
26
+ const children = Object.values(value);
27
+
28
+ values += children.length;
29
+ if (values > 65536) return false;
30
+ for (const child of children)
31
+ if (child !== null && typeof child === "object")
32
+ pending.push({ value: child, depth: depth + 1 });
33
+ }
34
+
35
+ return true;
36
+ }),
37
+ );
38
+
39
+ const validUrl = (value: string, loopback: boolean) => {
40
+ try {
41
+ const url = new URL(value);
42
+
43
+ return (
44
+ (url.protocol === "https:" ||
45
+ (loopback &&
46
+ url.protocol === "http:" &&
47
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) &&
48
+ url.username === "" &&
49
+ url.password === "" &&
50
+ !value.includes("#") &&
51
+ // oxlint-disable-next-line no-control-regex -- Reject URL parser normalization of untrusted controls.
52
+ !/[\s\\\u0000-\u001f\u007f]/u.test(value)
53
+ );
54
+ } catch {
55
+ return false;
56
+ }
57
+ };
58
+
59
+ export const Endpoint = text(2048).check(Schema.makeFilter((value) => validUrl(value, false)));
60
+
61
+ export const Issuer = Endpoint.check(
62
+ Schema.makeFilter(
63
+ (value) => !value.includes("?") && !new URL(value).pathname.includes("/.well-known/"),
64
+ ),
65
+ );
66
+
67
+ export const Callback = text(2048).check(
68
+ Schema.makeFilter(
69
+ (value) => validUrl(value, true) && new URL(value).href === value && !value.includes("?"),
70
+ ),
71
+ );
72
+
73
+ // oxlint-disable-next-line no-control-regex -- RFC6749 scope-token permits these visible ASCII ranges.
74
+ export const ScopeName = Schema.String.check(Schema.isPattern(/^[\x21\x23-\x5b\x5d-\x7e]{1,256}$/));
75
+
76
+ export const Scopes = Schema.Array(ScopeName).check(
77
+ Schema.isMaxLength(64),
78
+ Schema.makeFilter((values) => new Set(values).size === values.length),
79
+ );
80
+
81
+ export const Resource = text(2048).check(
82
+ Schema.makeFilter((value) => {
83
+ try {
84
+ const url = new URL(value);
85
+
86
+ return (
87
+ // oxlint-disable-next-line no-control-regex -- Preserve exact resource identifiers.
88
+ url.username === "" && url.password === "" && !/[\s\\#\u0000-\u001f\u007f]/u.test(value)
89
+ );
90
+ } catch {
91
+ return false;
92
+ }
93
+ }),
94
+ );
95
+
96
+ export const Resources = Schema.Array(Resource).check(Schema.isMaxLength(64));
97
+
98
+ export const RequestOptions = Schema.Struct({
99
+ timeoutMs: Schema.Finite.check(Schema.isBetween({ minimum: 1, maximum: 60000 })),
100
+ maxResponseBytes: integer(1, 1048576).pipe(
101
+ Schema.withDecodingDefaultKey(Effect.succeed(1048576)),
102
+ ),
103
+ });
104
+
105
+ export const Verifier = Schema.Redacted(
106
+ Schema.String.check(Schema.isPattern(/^[A-Za-z0-9._~-]{43,128}$/)),
107
+ { disallowJsonEncode: true },
108
+ );
109
+
110
+ export const Challenge = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{43}$/));
111
+
112
+ export const NumericDate = Schema.Finite.check(
113
+ Schema.isBetween({ minimum: 0, maximum: 8640000000000 }),
114
+ );
115
+
116
+ export const reserved = new Set([
117
+ "client_id",
118
+ "client_secret",
119
+ "client_assertion",
120
+ "client_assertion_type",
121
+ "redirect_uri",
122
+ "response_type",
123
+ "response_mode",
124
+ "state",
125
+ "code",
126
+ "code_verifier",
127
+ "code_challenge",
128
+ "code_challenge_method",
129
+ "nonce",
130
+ "scope",
131
+ "iss",
132
+ "grant_type",
133
+ "refresh_token",
134
+ "token",
135
+ "token_type_hint",
136
+ "max_age",
137
+ "request",
138
+ "request_uri",
139
+ "authorization_details",
140
+ "id_token_hint",
141
+ "login_hint",
142
+ "login_hint_token",
143
+ "subject_token",
144
+ "subject_token_type",
145
+ "actor_token",
146
+ "actor_token_type",
147
+ "requested_token_type",
148
+ ]);
149
+
150
+ export const Parameters = Schema.Record(
151
+ Schema.String.check(Schema.isPattern(/^[A-Za-z][A-Za-z0-9._~-]{0,63}$/)),
152
+ Schema.String.check(Schema.isMaxLength(2048)),
153
+ ).check(
154
+ Schema.makeFilter(
155
+ (value) =>
156
+ Object.keys(value).length <= 16 &&
157
+ Object.keys(value).every((key) => !reserved.has(key.toLowerCase())),
158
+ ),
159
+ );
160
+
161
+ const forbiddenHeaders = new Set([
162
+ "authorization",
163
+ "proxy-authorization",
164
+ "cookie",
165
+ "host",
166
+ "content-length",
167
+ "content-type",
168
+ "transfer-encoding",
169
+ "connection",
170
+ "upgrade",
171
+ "trailer",
172
+ "te",
173
+ "traceparent",
174
+ "tracestate",
175
+ "baggage",
176
+ ]);
177
+
178
+ export const Headers = Schema.Record(
179
+ Schema.String.check(Schema.isPattern(/^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,64}$/)),
180
+ Schema.String.check(Schema.isMaxLength(2048)),
181
+ ).check(
182
+ Schema.makeFilter((value) => {
183
+ const names = Object.keys(value).map((name) => name.toLowerCase());
184
+
185
+ return (
186
+ names.length <= 16 &&
187
+ new Set(names).size === names.length &&
188
+ names.every((name) => !forbiddenHeaders.has(name)) &&
189
+ Object.values(value).every((value) => !/[\r\n]/u.test(value))
190
+ );
191
+ }),
192
+ );
193
+
194
+ export const ProtocolEndpoint = Endpoint.check(
195
+ Schema.makeFilter((value) =>
196
+ Array.from(new URL(value).searchParams.keys()).every((key) => !reserved.has(key.toLowerCase())),
197
+ ),
198
+ );
199
+
200
+ // Keep exceptions from hostile input access and erased Redacted values inside
201
+ // schema decoding; service execution must remain outside these catches.
202
+ export const configuration = <S extends Schema.Constraint>(
203
+ schema: S,
204
+ input: unknown,
205
+ reason: ConfigurationError["reason"] = "parameters",
206
+ ) =>
207
+ Effect.suspend(() => Schema.decodeUnknownEffect(schema)(input, { reportInput: false })).pipe(
208
+ Effect.mapError(() => ConfigurationError.make({ reason })),
209
+ Effect.catchDefect(() => ConfigurationError.make({ reason })),
210
+ );
211
+
212
+ export const decode = <S extends Schema.Constraint>(schema: S, input: unknown) =>
213
+ Effect.suspend(() => Schema.decodeUnknownEffect(schema)(input, { reportInput: false })).pipe(
214
+ Effect.mapError(() => Unavailable.make({})),
215
+ Effect.catchDefect(() => Unavailable.make({})),
216
+ );
217
+
218
+ export const reveal = <A>(value: Redacted.Redacted<A>) =>
219
+ Effect.try({ try: () => Redacted.value(value), catch: () => Unavailable.make({}) });
220
+
221
+ /** Freeze owned configuration snapshots or freshly parsed wire JSON, not caller-owned graphs. */
222
+ export const freeze = <A>(value: A): A => {
223
+ const pending: Array<unknown> = [value];
224
+ const seen = new WeakSet<object>();
225
+
226
+ while (pending.length > 0) {
227
+ const item = pending.pop();
228
+
229
+ if (!Predicate.isObjectOrArray(item) || Redacted.isRedacted(item) || seen.has(item)) continue;
230
+ seen.add(item);
231
+ for (const child of Object.values(item)) pending.push(child);
232
+ Object.freeze(item);
233
+ }
234
+
235
+ return value;
236
+ };