@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/Errors.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { Schema } from "effect";
2
+
3
+ /** Local protocol configuration is invalid; no remote response or credential is retained. */
4
+ export class ConfigurationError extends Schema.TaggedError<ConfigurationError>()(
5
+ "OAuthConfigurationError",
6
+ {
7
+ reason: Schema.Literals(["issuer", "metadata", "authentication", "parameters", "endpoint"]),
8
+ },
9
+ ) {}
10
+
11
+ /** A definite, authenticated claim rejection or a complete HTTP 400 invalid_grant. */
12
+ export class Rejected extends Schema.TaggedError<Rejected>()("OAuthRejected", {
13
+ reason: Schema.Literals(["invalid_grant", "claims"]),
14
+ }) {}
15
+
16
+ /** The outcome is unknown. This error never authorizes retrying a grant. */
17
+ export class Unavailable extends Schema.TaggedError<Unavailable>()("OAuthUnavailable", {}) {}
package/src/OAuth.ts ADDED
@@ -0,0 +1,463 @@
1
+ import { Effect, Redacted, Schema, type Scope } from "effect";
2
+ import { Base64 } from "effect/encoding";
3
+ import type { HttpClient } from "effect/http";
4
+ import { HttpClientRequest } from "effect/http";
5
+
6
+ import { ConfigurationError, Rejected, Unavailable } from "./Errors";
7
+ import * as Ownership from "./internal/ownership";
8
+ import * as Transport from "./internal/transport";
9
+ import * as V from "./internal/validation";
10
+
11
+ export const JsonObject = V.JsonObject;
12
+ export type JsonObject = typeof JsonObject.Type;
13
+
14
+ export const Metadata = Schema.Struct({
15
+ issuer: V.Issuer,
16
+ authorization_endpoint: V.ProtocolEndpoint,
17
+ token_endpoint: V.ProtocolEndpoint,
18
+ jwks_uri: Schema.optionalKey(V.Endpoint),
19
+ userinfo_endpoint: Schema.optionalKey(V.Endpoint),
20
+ revocation_endpoint: Schema.optionalKey(V.ProtocolEndpoint),
21
+ code_challenge_methods_supported: Schema.optionalKey(
22
+ Schema.Array(V.text(64)).check(Schema.isMaxLength(64)),
23
+ ),
24
+ response_types_supported: Schema.optionalKey(
25
+ Schema.Array(V.text(64)).check(Schema.isMaxLength(64)),
26
+ ),
27
+ grant_types_supported: Schema.optionalKey(
28
+ Schema.Array(V.text(128)).check(Schema.isMaxLength(64)),
29
+ ),
30
+ id_token_signing_alg_values_supported: Schema.optionalKey(
31
+ Schema.Array(V.text(64)).check(Schema.isMaxLength(64)),
32
+ ),
33
+ token_endpoint_auth_methods_supported: Schema.optionalKey(
34
+ Schema.Array(V.text(64)).check(Schema.isMaxLength(64)),
35
+ ),
36
+ revocation_endpoint_auth_methods_supported: Schema.optionalKey(
37
+ Schema.Array(V.text(64)).check(Schema.isMaxLength(64)),
38
+ ),
39
+ authorization_response_iss_parameter_supported: Schema.optionalKey(Schema.Boolean),
40
+ });
41
+
42
+ export type Metadata = typeof Metadata.Type;
43
+
44
+ export const Authentication = Schema.Union([
45
+ Schema.Struct({ method: Schema.Literal("client_secret_basic"), secret: V.secret(4096) }),
46
+ Schema.Struct({ method: Schema.Literal("client_secret_post"), secret: V.secret(4096) }),
47
+ Schema.Struct({ method: Schema.Literal("none"), publicClient: Schema.Literal(true) }),
48
+ ]);
49
+
50
+ export type Authentication = typeof Authentication.Type;
51
+
52
+ /** Uninterpreted private receipt. Inspect provider extensions before calling tokens. */
53
+ export const TokenReceipt = Schema.Struct({
54
+ status: V.integer(100, 599),
55
+ contentType: Schema.NullOr(Schema.String.check(Schema.isMaxLength(512))),
56
+ body: Schema.Redacted(JsonObject, { disallowJsonEncode: true }),
57
+ });
58
+
59
+ export type TokenReceipt = typeof TokenReceipt.Type;
60
+
61
+ export const TokenSet = Schema.Struct({
62
+ tokenType: Schema.Literal("bearer"),
63
+ accessToken: V.secret(16384),
64
+ refreshToken: Schema.optionalKey(V.secret(16384)),
65
+ idToken: Schema.optionalKey(V.secret(65536)),
66
+ expiresIn: Schema.optionalKey(Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))),
67
+ scope: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(16447))),
68
+ });
69
+
70
+ export type TokenSet = typeof TokenSet.Type;
71
+
72
+ export interface RequestOptions {
73
+ readonly timeoutMs: number;
74
+ readonly maxResponseBytes?: number;
75
+ }
76
+
77
+ export interface ClientOptions extends RequestOptions {
78
+ readonly metadata: Metadata;
79
+ readonly clientId: string;
80
+ readonly authentication: Authentication;
81
+ readonly profile?: { readonly url: string; readonly headers?: Readonly<Record<string, string>> };
82
+ readonly revocationAuthentication?: Authentication;
83
+ }
84
+
85
+ export interface Parameters {
86
+ readonly parameters?: Readonly<Record<string, string>>;
87
+ readonly resources?: ReadonlyArray<string>;
88
+ }
89
+
90
+ export interface AuthorizationInput extends Parameters {
91
+ readonly redirectUri: string;
92
+ readonly scopes: ReadonlyArray<string>;
93
+ readonly state: Redacted.Redacted<string>;
94
+ readonly codeChallenge: string;
95
+ readonly nonce?: Redacted.Redacted<string>;
96
+ readonly maxAgeSeconds?: number;
97
+ }
98
+
99
+ export interface CodeGrantInput extends Parameters {
100
+ readonly code: Redacted.Redacted<string>;
101
+ readonly redirectUri: string;
102
+ readonly pkceVerifier: Redacted.Redacted<string>;
103
+ }
104
+
105
+ export interface RefreshGrantInput extends Parameters {
106
+ readonly refreshToken: Redacted.Redacted<string>;
107
+ readonly scopes?: ReadonlyArray<string>;
108
+ }
109
+
110
+ export interface RevocationInput {
111
+ readonly token: Redacted.Redacted<string>;
112
+ readonly tokenTypeHint: "access_token" | "refresh_token";
113
+ }
114
+
115
+ export interface Client {
116
+ readonly metadata: Metadata;
117
+ readonly authorizationUrl: (
118
+ input: AuthorizationInput,
119
+ ) => Effect.Effect<Redacted.Redacted<string>, ConfigurationError | Unavailable>;
120
+ readonly codeGrant: (
121
+ input: CodeGrantInput,
122
+ ) => Effect.Effect<TokenReceipt, ConfigurationError | Unavailable>;
123
+ readonly refreshGrant: (
124
+ input: RefreshGrantInput,
125
+ ) => Effect.Effect<TokenReceipt, ConfigurationError | Unavailable>;
126
+ readonly fetchProfile: (
127
+ accessToken: Redacted.Redacted<string>,
128
+ ) => Effect.Effect<JsonObject, ConfigurationError | Unavailable>;
129
+ readonly revoke: (
130
+ input: RevocationInput,
131
+ ) => Effect.Effect<void, ConfigurationError | Unavailable>;
132
+ }
133
+
134
+ const ClientOptionsSchema = Schema.Struct({
135
+ metadata: Metadata,
136
+ clientId: V.text(1024),
137
+ authentication: Authentication,
138
+ ...V.RequestOptions.fields,
139
+ profile: Schema.optionalKey(
140
+ Schema.Struct({ url: V.Endpoint, headers: Schema.optionalKey(V.Headers) }),
141
+ ),
142
+ revocationAuthentication: Schema.optionalKey(Authentication),
143
+ });
144
+
145
+ const additional = {
146
+ parameters: Schema.optionalKey(V.Parameters),
147
+ resources: Schema.optionalKey(V.Resources),
148
+ };
149
+
150
+ const Authorization = Schema.Struct({
151
+ ...additional,
152
+ redirectUri: V.Callback,
153
+ scopes: V.Scopes,
154
+ state: V.secret(256),
155
+ codeChallenge: V.Challenge,
156
+ nonce: Schema.optionalKey(V.secret(256)),
157
+ maxAgeSeconds: Schema.optionalKey(V.integer(0, 86400)),
158
+ });
159
+
160
+ const CodeGrant = Schema.Struct({
161
+ ...additional,
162
+ code: V.secret(16384),
163
+ redirectUri: V.Callback,
164
+ pkceVerifier: V.Verifier,
165
+ });
166
+
167
+ const RefreshGrant = Schema.Struct({
168
+ ...additional,
169
+ refreshToken: V.secret(16384),
170
+ scopes: Schema.optionalKey(V.Scopes),
171
+ });
172
+
173
+ const Revocation = Schema.Struct({
174
+ token: V.secret(16384),
175
+ tokenTypeHint: Schema.Literals(["access_token", "refresh_token"]),
176
+ });
177
+
178
+ const ExpiresIn = Schema.Union([
179
+ Schema.Finite,
180
+ Schema.String.check(Schema.isPattern(/^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/u)).pipe(
181
+ Schema.decodeTo(Schema.FiniteFromString),
182
+ ),
183
+ ]).check(Schema.isGreaterThanOrEqualTo(0));
184
+
185
+ const RawToken = Schema.Struct({
186
+ access_token: V.text(16384),
187
+ token_type: Schema.String.check(Schema.isPattern(/^[Bb][Ee][Aa][Rr][Ee][Rr]$/)),
188
+ refresh_token: Schema.optionalKey(V.text(16384)),
189
+ id_token: Schema.optionalKey(V.text(65536)),
190
+ expires_in: Schema.optionalKey(ExpiresIn),
191
+ scope: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(16447))),
192
+ });
193
+
194
+ const Terminal = Schema.Struct({
195
+ error: V.text(128),
196
+ error_description: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(4096))),
197
+ error_uri: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(2048))),
198
+ });
199
+
200
+ const successFields = [
201
+ "access_token",
202
+ "refresh_token",
203
+ "id_token",
204
+ "token_type",
205
+ "scope",
206
+ "expires_in",
207
+ "refresh_expires_in",
208
+ "refresh_token_expires_in",
209
+ ];
210
+
211
+ /** Parse once provider-specific receipt checks have succeeded. expires_in accepts
212
+ * finite nonnegative numbers and whole decimal strings (for example "60.5").
213
+ * Whitespace, exponent/prefix syntax and nonfinite values are rejected. Raw
214
+ * receipt extensions/scopes are untouched; no request, retry or claim verification. */
215
+ export const tokens = Effect.fnUntraced(function* (
216
+ input: TokenReceipt,
217
+ ): Effect.fn.Return<TokenSet, Rejected | Unavailable> {
218
+ const receipt = yield* V.decode(TokenReceipt, input);
219
+
220
+ if (!Transport.isJson(receipt.contentType)) return yield* Unavailable.make({});
221
+ const raw = yield* V.reveal(receipt.body);
222
+
223
+ if (Object.hasOwn(raw, "error")) {
224
+ if (successFields.some((key) => Object.hasOwn(raw, key))) return yield* Unavailable.make({});
225
+ const terminal = yield* V.decode(Terminal, raw);
226
+
227
+ if (receipt.status === 400 && terminal.error === "invalid_grant")
228
+ return yield* Rejected.make({ reason: "invalid_grant" });
229
+
230
+ return yield* Unavailable.make({});
231
+ }
232
+ if (
233
+ receipt.status !== 200 ||
234
+ Object.hasOwn(raw, "error_description") ||
235
+ Object.hasOwn(raw, "error_uri")
236
+ )
237
+ return yield* Unavailable.make({});
238
+ const value = yield* V.decode(RawToken, raw);
239
+
240
+ return {
241
+ tokenType: "bearer",
242
+ accessToken: Redacted.make(value.access_token),
243
+ ...(value.refresh_token === undefined
244
+ ? {}
245
+ : { refreshToken: Redacted.make(value.refresh_token) }),
246
+ ...(value.id_token === undefined ? {} : { idToken: Redacted.make(value.id_token) }),
247
+ ...(value.expires_in === undefined ? {} : { expiresIn: value.expires_in }),
248
+ ...(value.scope === undefined ? {} : { scope: value.scope }),
249
+ };
250
+ });
251
+
252
+ const parameters = (input: Parameters) => {
253
+ const values = new URLSearchParams(input.parameters);
254
+
255
+ for (const resource of input.resources ?? []) values.append("resource", resource);
256
+
257
+ return values;
258
+ };
259
+
260
+ // RFC6749 Appendix B: encode client credentials before joining with the colon.
261
+ // Adapted from oauth4webapi 3.8.8 src/index.ts::formUrlEncode (MIT, Filip Skokan).
262
+ // Exact source commit and license: ../THIRD_PARTY_NOTICES.md.
263
+ const basicComponent = (value: string) =>
264
+ encodeURIComponent(value).replace(/[-_.!~*'()]|%20/gu, (part) =>
265
+ part === "%20" ? "+" : `%${part.charCodeAt(0).toString(16).toUpperCase()}`,
266
+ );
267
+
268
+ const authenticated = Effect.fnUntraced(function* (
269
+ url: string,
270
+ clientId: string,
271
+ authentication: Authentication,
272
+ body: URLSearchParams,
273
+ ) {
274
+ let request = HttpClientRequest.post(url).pipe(HttpClientRequest.acceptJson);
275
+
276
+ if (authentication.method === "client_secret_basic") {
277
+ const secret = yield* V.reveal(authentication.secret);
278
+
279
+ const credential = yield* Effect.try({
280
+ try: () => Base64.encode(`${basicComponent(clientId)}:${basicComponent(secret)}`),
281
+ catch: () => Unavailable.make({}),
282
+ });
283
+
284
+ request = HttpClientRequest.setHeader(request, "authorization", `Basic ${credential}`);
285
+ } else {
286
+ body.set("client_id", clientId);
287
+ if (authentication.method === "client_secret_post")
288
+ body.set("client_secret", yield* V.reveal(authentication.secret));
289
+ }
290
+ const encoded = body.toString();
291
+
292
+ if (new TextEncoder().encode(encoded).length > 131072)
293
+ return yield* ConfigurationError.make({ reason: "parameters" });
294
+
295
+ return HttpClientRequest.bodyText(
296
+ request,
297
+ encoded,
298
+ "application/x-www-form-urlencoded;charset=UTF-8",
299
+ );
300
+ });
301
+
302
+ /** One installed client, exact endpoints, private credentials and owning Scope.
303
+ * Supply an HttpClient without retry/redirect/cookie middleware. Fetch injection
304
+ * active at construction is captured. Owner closure cancels and joins active
305
+ * operations, then rejects their results as unavailable; caller interruption
306
+ * cancels and joins its operation without closing the client. */
307
+ export const make = Effect.fnUntraced(function* (
308
+ input: ClientOptions,
309
+ ): Effect.fn.Return<Client, ConfigurationError | Unavailable, HttpClient.HttpClient | Scope.Scope> {
310
+ const options = yield* V.configuration(ClientOptionsSchema, input, "metadata");
311
+
312
+ const authentication = yield* detachAuthentication(options.authentication);
313
+
314
+ const revocationAuthentication =
315
+ options.revocationAuthentication === undefined
316
+ ? authentication
317
+ : yield* detachAuthentication(options.revocationAuthentication);
318
+
319
+ const metadata = V.freeze(options.metadata);
320
+
321
+ const supported =
322
+ metadata.token_endpoint_auth_methods_supported ??
323
+ (metadata.jwks_uri === undefined ? undefined : ["client_secret_basic"]);
324
+
325
+ const revokeSupported =
326
+ metadata.revocation_endpoint_auth_methods_supported ??
327
+ (metadata.jwks_uri === undefined ? undefined : ["client_secret_basic"]);
328
+
329
+ if (
330
+ (supported !== undefined && !supported.includes(authentication.method)) ||
331
+ (options.revocationAuthentication !== undefined &&
332
+ metadata.revocation_endpoint !== undefined &&
333
+ revokeSupported !== undefined &&
334
+ !revokeSupported.includes(revocationAuthentication.method))
335
+ )
336
+ return yield* ConfigurationError.make({ reason: "authentication" });
337
+ const { available, use } = yield* Ownership.make;
338
+ const http = yield* Transport.capture;
339
+ const profile = options.profile === undefined ? undefined : V.freeze(options.profile);
340
+
341
+ yield* Effect.addFinalizer(() =>
342
+ Effect.sync(() => {
343
+ if (authentication.method !== "none") Redacted.wipeUnsafe(authentication.secret);
344
+ if (revocationAuthentication.method !== "none")
345
+ Redacted.wipeUnsafe(revocationAuthentication.secret);
346
+ }),
347
+ );
348
+
349
+ const send = Effect.fnUntraced(function* (body: URLSearchParams) {
350
+ yield* available;
351
+
352
+ const request = yield* authenticated(
353
+ new URL(metadata.token_endpoint).href,
354
+ options.clientId,
355
+ authentication,
356
+ body,
357
+ );
358
+
359
+ return yield* Transport.json(yield* Transport.request(http, request, options));
360
+ });
361
+
362
+ const authorizationUrl = Effect.fnUntraced(function* (input: AuthorizationInput) {
363
+ yield* available;
364
+ const value = yield* V.configuration(Authorization, input);
365
+ const url = new URL(metadata.authorization_endpoint);
366
+ const body = parameters(value);
367
+
368
+ body.set("client_id", options.clientId);
369
+ body.set("redirect_uri", value.redirectUri);
370
+ body.set("response_type", "code");
371
+ body.set("response_mode", "query");
372
+ body.set("scope", value.scopes.join(" "));
373
+ body.set("state", yield* V.reveal(value.state));
374
+ body.set("code_challenge", value.codeChallenge);
375
+ body.set("code_challenge_method", "S256");
376
+ if (value.nonce !== undefined) body.set("nonce", yield* V.reveal(value.nonce));
377
+ if (value.maxAgeSeconds !== undefined) body.set("max_age", String(value.maxAgeSeconds));
378
+ for (const [key, item] of body) url.searchParams.append(key, item);
379
+ if (url.href.length > 16384) return yield* ConfigurationError.make({ reason: "parameters" });
380
+
381
+ return Redacted.make(url.href);
382
+ }, use);
383
+
384
+ const codeGrant = Effect.fnUntraced(function* (input: CodeGrantInput) {
385
+ yield* available;
386
+ const value = yield* V.configuration(CodeGrant, input);
387
+ const body = parameters(value);
388
+
389
+ body.set("grant_type", "authorization_code");
390
+ body.set("code", yield* V.reveal(value.code));
391
+ body.set("code_verifier", yield* V.reveal(value.pkceVerifier));
392
+ body.set("redirect_uri", value.redirectUri);
393
+
394
+ return yield* send(body);
395
+ }, use);
396
+
397
+ const refreshGrant = Effect.fnUntraced(function* (input: RefreshGrantInput) {
398
+ yield* available;
399
+ const value = yield* V.configuration(RefreshGrant, input);
400
+ const body = parameters(value);
401
+
402
+ body.set("grant_type", "refresh_token");
403
+ body.set("refresh_token", yield* V.reveal(value.refreshToken));
404
+ if (value.scopes !== undefined) body.set("scope", value.scopes.join(" "));
405
+
406
+ return yield* send(body);
407
+ }, use);
408
+
409
+ const fetchProfile = Effect.fnUntraced(function* (input: Redacted.Redacted<string>) {
410
+ yield* available;
411
+ if (profile === undefined) return yield* ConfigurationError.make({ reason: "endpoint" });
412
+ const token = yield* V.reveal(yield* V.decode(Bearer, input));
413
+
414
+ const request = HttpClientRequest.get(new URL(profile.url).href).pipe(
415
+ HttpClientRequest.acceptJson,
416
+ HttpClientRequest.setHeaders(profile.headers ?? {}),
417
+ HttpClientRequest.setHeader("authorization", `Bearer ${token}`),
418
+ );
419
+
420
+ const response = yield* Transport.request(http, request, options);
421
+
422
+ if (response.status !== 200) return yield* Unavailable.make({});
423
+
424
+ return yield* V.reveal((yield* Transport.json(response)).body);
425
+ }, use);
426
+
427
+ const revoke = Effect.fnUntraced(function* (input: RevocationInput) {
428
+ yield* available;
429
+ if (metadata.revocation_endpoint === undefined)
430
+ return yield* ConfigurationError.make({ reason: "endpoint" });
431
+ if (revokeSupported !== undefined && !revokeSupported.includes(revocationAuthentication.method))
432
+ return yield* ConfigurationError.make({ reason: "authentication" });
433
+ const value = yield* V.configuration(Revocation, input);
434
+
435
+ const request = yield* authenticated(
436
+ new URL(metadata.revocation_endpoint).href,
437
+ options.clientId,
438
+ revocationAuthentication,
439
+ new URLSearchParams({
440
+ token: yield* V.reveal(value.token),
441
+ token_type_hint: value.tokenTypeHint,
442
+ }),
443
+ );
444
+
445
+ const response = yield* Transport.request(http, request, options);
446
+
447
+ if (response.status !== 200) return yield* Unavailable.make({});
448
+ }, use);
449
+
450
+ return { metadata, authorizationUrl, codeGrant, refreshGrant, fetchProfile, revoke };
451
+ });
452
+
453
+ const Bearer = Schema.Redacted(V.text(16384).check(Schema.isPattern(/^[A-Za-z0-9._~+/-]+=*$/u)), {
454
+ disallowJsonEncode: true,
455
+ });
456
+
457
+ const detachAuthentication = Effect.fnUntraced(function* (
458
+ authentication: Authentication,
459
+ ): Effect.fn.Return<Authentication, Unavailable> {
460
+ return authentication.method === "none"
461
+ ? { ...authentication }
462
+ : { ...authentication, secret: Redacted.make(yield* V.reveal(authentication.secret)) };
463
+ });