@gjermundgaraba/clankerauth-sdk 0.4.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 +21 -0
- package/README.md +178 -0
- package/dist/browser-actions.d.ts +5 -0
- package/dist/browser-actions.js +54 -0
- package/dist/browser-api.d.ts +23 -0
- package/dist/browser-api.js +24 -0
- package/dist/browser.d.ts +49 -0
- package/dist/browser.js +303 -0
- package/dist/coordination.d.ts +3 -0
- package/dist/coordination.js +18 -0
- package/dist/effect-actions.d.ts +3 -0
- package/dist/effect-actions.js +3 -0
- package/dist/errors.d.ts +58 -0
- package/dist/errors.js +49 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/resource.d.ts +83 -0
- package/dist/resource.js +64 -0
- package/dist/store.d.ts +16 -0
- package/dist/store.js +4 -0
- package/dist/transport.d.ts +16 -0
- package/dist/transport.js +51 -0
- package/dist/verify.d.ts +59 -0
- package/dist/verify.js +175 -0
- package/package.json +58 -0
package/dist/browser.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { Clock, Effect, Exit, Redacted, Result, Schema } from "effect";
|
|
3
|
+
import * as oauth from "oauth4webapi";
|
|
4
|
+
import { Cookies, HttpClient } from "effect/unstable/http";
|
|
5
|
+
import { ConfigurationError, Forbidden, InvalidRequest, ProviderUnavailable, Unauthorized, } from "./errors.js";
|
|
6
|
+
import { SessionStore } from "./store.js";
|
|
7
|
+
import { protocolTransport, TransportFailure } from "./transport.js";
|
|
8
|
+
import * as Coordination from "./coordination.js";
|
|
9
|
+
const Credentials = Schema.Struct({
|
|
10
|
+
accessToken: Schema.String,
|
|
11
|
+
refreshToken: Schema.optionalKey(Schema.String),
|
|
12
|
+
accessExpires: Schema.Finite,
|
|
13
|
+
subject: Schema.String,
|
|
14
|
+
refreshBlocked: Schema.Boolean,
|
|
15
|
+
});
|
|
16
|
+
const Login = Schema.Struct({
|
|
17
|
+
verifier: Schema.String,
|
|
18
|
+
state: Schema.String,
|
|
19
|
+
nonce: Schema.String,
|
|
20
|
+
returnTo: Schema.String,
|
|
21
|
+
});
|
|
22
|
+
export const transactionLifetime = 600;
|
|
23
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
24
|
+
const random = () => randomBytes(32).toString("base64url");
|
|
25
|
+
const unauthorized = (cause) => new Unauthorized({ message: "Sign in required", cause });
|
|
26
|
+
const pkceChallenge = Effect.fn("BrowserSession.pkceChallenge")((verifier) => Effect.promise(() => oauth.calculatePKCECodeChallenge(verifier)));
|
|
27
|
+
/** Domain/session capability. Cookies and HTTP response mapping live in BrowserActions. */
|
|
28
|
+
export const make = Effect.fn("BrowserSession.make")(function* (options) {
|
|
29
|
+
const store = yield* SessionStore;
|
|
30
|
+
const locked = yield* Coordination.make;
|
|
31
|
+
const parsed = yield* Effect.try({
|
|
32
|
+
try: () => {
|
|
33
|
+
const callback = new URL(options.callbackUrl);
|
|
34
|
+
if (!["http:", "https:"].includes(callback.protocol) ||
|
|
35
|
+
callback.username ||
|
|
36
|
+
callback.password ||
|
|
37
|
+
options.callbackUrl.includes("#"))
|
|
38
|
+
throw new Error("Callback must be an HTTP(S) URL without credentials or fragment");
|
|
39
|
+
return { callback, issuer: new URL(options.issuer) };
|
|
40
|
+
},
|
|
41
|
+
catch: () => new ConfigurationError({ message: "Invalid browser callback URL or issuer" }),
|
|
42
|
+
});
|
|
43
|
+
if (Redacted.value(options.secret).length < 32)
|
|
44
|
+
return yield* new ConfigurationError({ message: "Browser session secret needs 32 characters" });
|
|
45
|
+
if (["session", "login"].some((suffix) => Result.isFailure(Cookies.makeCookie(`${options.cookie.name}_${suffix}`, ""))))
|
|
46
|
+
return yield* new ConfigurationError({ message: "Invalid cookie name" });
|
|
47
|
+
const { callback: callbackUrl, issuer } = parsed;
|
|
48
|
+
const origin = callbackUrl.origin;
|
|
49
|
+
const lifetime = options.cookie.lifetime ?? 30 * 24 * 60 * 60;
|
|
50
|
+
if (!Number.isSafeInteger(lifetime) || lifetime <= 0)
|
|
51
|
+
return yield* new ConfigurationError({ message: "Invalid cookie lifetime" });
|
|
52
|
+
const redirectUri = options.callbackUrl;
|
|
53
|
+
const key = createHash("sha256").update(Redacted.value(options.secret)).digest();
|
|
54
|
+
const client = { client_id: options.clientId };
|
|
55
|
+
const clientAuth = oauth.ClientSecretBasic(Redacted.value(options.clientSecret));
|
|
56
|
+
const seal = (value) => {
|
|
57
|
+
const iv = randomBytes(12);
|
|
58
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
59
|
+
const bytes = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]);
|
|
60
|
+
return Buffer.concat([iv, cipher.getAuthTag(), bytes]).toString("base64url");
|
|
61
|
+
};
|
|
62
|
+
const open = Effect.fn("BrowserSession.open")(function* (payload) {
|
|
63
|
+
return yield* Effect.try({
|
|
64
|
+
try: () => {
|
|
65
|
+
const bytes = Buffer.from(payload, "base64url");
|
|
66
|
+
const cipher = createDecipheriv("aes-256-gcm", key, bytes.subarray(0, 12));
|
|
67
|
+
cipher.setAuthTag(bytes.subarray(12, 28));
|
|
68
|
+
return Buffer.concat([cipher.update(bytes.subarray(28)), cipher.final()]).toString("utf8");
|
|
69
|
+
},
|
|
70
|
+
catch: unauthorized,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
const decodeCredentials = (payload) => open(payload).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Credentials))), Effect.mapError(unauthorized));
|
|
74
|
+
const decodeLogin = (payload) => open(payload).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Login))), Effect.mapError(unauthorized));
|
|
75
|
+
const read = Effect.fn("BrowserSession.read")(function* (id) {
|
|
76
|
+
const row = yield* store.get(id);
|
|
77
|
+
if (!row)
|
|
78
|
+
return yield* unauthorized();
|
|
79
|
+
if (row.expires <= (yield* Clock.currentTimeMillis)) {
|
|
80
|
+
yield* store.delete(id);
|
|
81
|
+
return yield* unauthorized();
|
|
82
|
+
}
|
|
83
|
+
return row;
|
|
84
|
+
});
|
|
85
|
+
const write = (id, value, expires) => Effect.suspend(() => store.put(id, seal(value), expires));
|
|
86
|
+
// Library calls are the only Promise boundary. Transport and cancellation remain Effect-owned.
|
|
87
|
+
const transportClient = yield* HttpClient.HttpClient;
|
|
88
|
+
const protocol = (operation, call) => Effect.gen(function* () {
|
|
89
|
+
const fetch = yield* protocolTransport().pipe(Effect.provideService(HttpClient.HttpClient, transportClient));
|
|
90
|
+
return yield* Effect.tryPromise({
|
|
91
|
+
try: (signal) => call({
|
|
92
|
+
signal,
|
|
93
|
+
[oauth.customFetch]: fetch,
|
|
94
|
+
[oauth.allowInsecureRequests]: issuer.protocol === "http:" &&
|
|
95
|
+
["localhost", "127.0.0.1", "[::1]"].includes(issuer.hostname),
|
|
96
|
+
}),
|
|
97
|
+
catch: (cause) => {
|
|
98
|
+
if (cause instanceof TransportFailure)
|
|
99
|
+
return cause;
|
|
100
|
+
if (cause instanceof oauth.AuthorizationResponseError ||
|
|
101
|
+
(cause instanceof oauth.OperationProcessingError &&
|
|
102
|
+
(cause.code === oauth.JWT_TIMESTAMP_CHECK ||
|
|
103
|
+
cause.code === oauth.JWT_CLAIM_COMPARISON)) ||
|
|
104
|
+
(cause instanceof oauth.ResponseBodyError && cause.error === "invalid_grant"))
|
|
105
|
+
return unauthorized(cause);
|
|
106
|
+
return new ProviderUnavailable({ operation, cause });
|
|
107
|
+
},
|
|
108
|
+
}).pipe(Effect.catch((error) => error instanceof TransportFailure
|
|
109
|
+
? Effect.failCause(error.effectCause).pipe(Effect.mapError((cause) => cause instanceof ProviderUnavailable
|
|
110
|
+
? cause
|
|
111
|
+
: new ProviderUnavailable({ operation, cause })))
|
|
112
|
+
: Effect.fail(error)));
|
|
113
|
+
}).pipe(Effect.timeoutOrElse({
|
|
114
|
+
duration: "5 seconds",
|
|
115
|
+
orElse: () => Effect.fail(new ProviderUnavailable({ operation })),
|
|
116
|
+
}));
|
|
117
|
+
const provider = yield* Effect.cachedWithTTL(protocol("browser.discovery", async (opts) => {
|
|
118
|
+
const metadata = await oauth.processDiscoveryResponse(issuer, await oauth.discoveryRequest(issuer, opts));
|
|
119
|
+
if (!metadata.authorization_endpoint)
|
|
120
|
+
throw new Error("Missing authorization endpoint");
|
|
121
|
+
const endpoint = new URL(metadata.authorization_endpoint);
|
|
122
|
+
if (endpoint.protocol !== "https:" &&
|
|
123
|
+
!(endpoint.protocol === "http:" &&
|
|
124
|
+
["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname)))
|
|
125
|
+
throw new Error("Authorization endpoint must use HTTPS or loopback HTTP");
|
|
126
|
+
return { ...metadata, authorization_endpoint: endpoint.href };
|
|
127
|
+
}), (exit) => (Exit.isSuccess(exit) ? Infinity : 0));
|
|
128
|
+
const credentials = Effect.fn("BrowserSession.credentials")(function* (result, old) {
|
|
129
|
+
if (result.token_type !== "bearer" || !result.expires_in)
|
|
130
|
+
return yield* unauthorized();
|
|
131
|
+
const verified = yield* options.verifyToken(result.access_token);
|
|
132
|
+
if (old && old.subject !== verified.subject)
|
|
133
|
+
return yield* unauthorized();
|
|
134
|
+
const refreshToken = result.refresh_token ?? old?.refreshToken;
|
|
135
|
+
const next = {
|
|
136
|
+
accessToken: result.access_token,
|
|
137
|
+
accessExpires: (yield* Clock.currentTimeMillis) + result.expires_in * 1000,
|
|
138
|
+
subject: verified.subject,
|
|
139
|
+
refreshBlocked: false,
|
|
140
|
+
};
|
|
141
|
+
return refreshToken ? { ...next, refreshToken } : next;
|
|
142
|
+
});
|
|
143
|
+
const accessToken = Effect.fn("BrowserSession.accessToken")(function* (raw) {
|
|
144
|
+
if (!raw)
|
|
145
|
+
return yield* unauthorized();
|
|
146
|
+
const id = `session:${hash(raw)}`;
|
|
147
|
+
return yield* locked(id, Effect.gen(function* () {
|
|
148
|
+
const row = yield* read(id);
|
|
149
|
+
const saved = yield* decodeCredentials(row.payload);
|
|
150
|
+
// A persisted in-flight refresh always requires reauthentication, even if its access token is fresh.
|
|
151
|
+
if (saved.refreshBlocked) {
|
|
152
|
+
yield* store.delete(id);
|
|
153
|
+
return yield* unauthorized();
|
|
154
|
+
}
|
|
155
|
+
if (saved.accessExpires > (yield* Clock.currentTimeMillis) + 30_000)
|
|
156
|
+
return saved.accessToken;
|
|
157
|
+
if (!saved.refreshToken) {
|
|
158
|
+
yield* store.delete(id);
|
|
159
|
+
return yield* unauthorized();
|
|
160
|
+
}
|
|
161
|
+
const refreshToken = saved.refreshToken;
|
|
162
|
+
const as = yield* provider;
|
|
163
|
+
// Commit before the network call. Interruption/restart leaves a durable no-replay marker.
|
|
164
|
+
yield* write(id, { ...saved, refreshBlocked: true }, row.expires).pipe(Effect.uninterruptible);
|
|
165
|
+
const refresh = Effect.gen(function* () {
|
|
166
|
+
const result = yield* protocol("browser.refresh", async (opts) => {
|
|
167
|
+
const response = await oauth.refreshTokenGrantRequest(as, client, clientAuth, refreshToken, {
|
|
168
|
+
...opts,
|
|
169
|
+
additionalParameters: { resource: options.resource },
|
|
170
|
+
});
|
|
171
|
+
const result = await oauth.processRefreshTokenResponse(as, client, response);
|
|
172
|
+
if (result.id_token)
|
|
173
|
+
await oauth.validateApplicationLevelSignature(as, response, opts);
|
|
174
|
+
return result;
|
|
175
|
+
});
|
|
176
|
+
const next = yield* credentials(result, saved);
|
|
177
|
+
const identity = oauth.getValidatedIdTokenClaims(result);
|
|
178
|
+
if (identity && identity.sub !== next.subject)
|
|
179
|
+
return yield* unauthorized();
|
|
180
|
+
yield* write(id, next, row.expires).pipe(Effect.uninterruptible);
|
|
181
|
+
return next.accessToken;
|
|
182
|
+
});
|
|
183
|
+
// Typed failures invalidate locally. Interruption leaves the marker; defects are not auth failures.
|
|
184
|
+
return yield* refresh.pipe(Effect.tapError(() => store.delete(id)));
|
|
185
|
+
}));
|
|
186
|
+
});
|
|
187
|
+
const login = Effect.fn("BrowserSession.login")(function* (returnTo) {
|
|
188
|
+
const target = yield* Effect.try({
|
|
189
|
+
try: () => new URL(returnTo, origin),
|
|
190
|
+
catch: () => new InvalidRequest({ message: "Invalid return destination" }),
|
|
191
|
+
});
|
|
192
|
+
if (!returnTo.startsWith("/") ||
|
|
193
|
+
target.origin !== origin ||
|
|
194
|
+
target.pathname === callbackUrl.pathname)
|
|
195
|
+
return yield* new InvalidRequest({ message: "Invalid return destination" });
|
|
196
|
+
const as = yield* provider;
|
|
197
|
+
const raw = random();
|
|
198
|
+
const transaction = {
|
|
199
|
+
verifier: oauth.generateRandomCodeVerifier(),
|
|
200
|
+
state: oauth.generateRandomState(),
|
|
201
|
+
nonce: oauth.generateRandomNonce(),
|
|
202
|
+
returnTo: target.pathname + target.search + target.hash,
|
|
203
|
+
};
|
|
204
|
+
const challenge = yield* pkceChallenge(transaction.verifier);
|
|
205
|
+
const authorization = new URL(as.authorization_endpoint);
|
|
206
|
+
authorization.search = new URLSearchParams({
|
|
207
|
+
client_id: options.clientId,
|
|
208
|
+
redirect_uri: redirectUri,
|
|
209
|
+
response_type: "code",
|
|
210
|
+
scope: ["openid", "offline_access", ...options.scopes].join(" "),
|
|
211
|
+
resource: options.resource,
|
|
212
|
+
state: transaction.state,
|
|
213
|
+
nonce: transaction.nonce,
|
|
214
|
+
code_challenge: challenge,
|
|
215
|
+
code_challenge_method: "S256",
|
|
216
|
+
}).toString();
|
|
217
|
+
const now = yield* Clock.currentTimeMillis;
|
|
218
|
+
yield* store.sweep(now);
|
|
219
|
+
yield* write(`login:${hash(raw)}`, transaction, now + transactionLifetime * 1000);
|
|
220
|
+
return { url: authorization.href, transaction: raw };
|
|
221
|
+
});
|
|
222
|
+
const callback = Effect.fn("BrowserSession.callback")(function* (url, raw) {
|
|
223
|
+
if (url.searchParams.getAll("iss").length !== 1 ||
|
|
224
|
+
url.searchParams.get("iss") !== options.issuer ||
|
|
225
|
+
!raw)
|
|
226
|
+
return yield* unauthorized();
|
|
227
|
+
const id = `login:${hash(raw)}`;
|
|
228
|
+
return yield* locked(id, Effect.gen(function* () {
|
|
229
|
+
const row = yield* read(id);
|
|
230
|
+
// One-shot consume precedes every code exchange, including retries after interruption.
|
|
231
|
+
yield* store.delete(id).pipe(Effect.uninterruptible);
|
|
232
|
+
const transaction = yield* decodeLogin(row.payload);
|
|
233
|
+
const as = yield* provider;
|
|
234
|
+
const parameters = yield* Effect.try({
|
|
235
|
+
try: () => oauth.validateAuthResponse(as, client, url, transaction.state),
|
|
236
|
+
catch: unauthorized,
|
|
237
|
+
});
|
|
238
|
+
const result = yield* protocol("browser.callback", async (opts) => {
|
|
239
|
+
const response = await oauth.authorizationCodeGrantRequest(as, client, clientAuth, parameters, redirectUri, transaction.verifier, { ...opts, additionalParameters: { resource: options.resource } });
|
|
240
|
+
const result = await oauth.processAuthorizationCodeResponse(as, client, response, {
|
|
241
|
+
expectedNonce: transaction.nonce,
|
|
242
|
+
requireIdToken: true,
|
|
243
|
+
});
|
|
244
|
+
await oauth.validateApplicationLevelSignature(as, response, opts);
|
|
245
|
+
return result;
|
|
246
|
+
});
|
|
247
|
+
const saved = yield* credentials(result);
|
|
248
|
+
if (oauth.getValidatedIdTokenClaims(result)?.sub !== saved.subject)
|
|
249
|
+
return yield* unauthorized();
|
|
250
|
+
const session = random();
|
|
251
|
+
yield* write(`session:${hash(session)}`, saved, (yield* Clock.currentTimeMillis) + lifetime * 1000);
|
|
252
|
+
return { session, location: origin + transaction.returnTo };
|
|
253
|
+
}));
|
|
254
|
+
});
|
|
255
|
+
const session = Effect.fn("BrowserSession.session")(function* (raw) {
|
|
256
|
+
const token = yield* accessToken(raw);
|
|
257
|
+
const principal = yield* options.verifyToken(token);
|
|
258
|
+
return {
|
|
259
|
+
subject: principal.subject,
|
|
260
|
+
scopes: principal.scopes,
|
|
261
|
+
issuer: options.issuer,
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
const logout = Effect.fn("BrowserSession.logout")(function* (raw) {
|
|
265
|
+
if (!raw)
|
|
266
|
+
return;
|
|
267
|
+
const id = `session:${hash(raw)}`;
|
|
268
|
+
yield* locked(id, Effect.gen(function* () {
|
|
269
|
+
const row = yield* store.get(id);
|
|
270
|
+
yield* store.delete(id).pipe(Effect.uninterruptible);
|
|
271
|
+
if (!row)
|
|
272
|
+
return;
|
|
273
|
+
const saved = yield* decodeCredentials(row.payload);
|
|
274
|
+
if (!saved.refreshToken)
|
|
275
|
+
return;
|
|
276
|
+
const token = saved.refreshToken;
|
|
277
|
+
// Local deletion is authoritative. Provider revocation is deliberately best effort.
|
|
278
|
+
yield* Effect.gen(function* () {
|
|
279
|
+
const as = yield* provider;
|
|
280
|
+
yield* protocol("browser.revoke", async (opts) => oauth.processRevocationResponse(await oauth.revocationRequest(as, client, clientAuth, token, {
|
|
281
|
+
...opts,
|
|
282
|
+
additionalParameters: { token_type_hint: "refresh_token" },
|
|
283
|
+
})));
|
|
284
|
+
}).pipe(Effect.catch(() => Effect.logWarning("Browser session ended locally; provider revocation failed")));
|
|
285
|
+
}));
|
|
286
|
+
});
|
|
287
|
+
return {
|
|
288
|
+
login,
|
|
289
|
+
callback,
|
|
290
|
+
session,
|
|
291
|
+
logout,
|
|
292
|
+
accessToken,
|
|
293
|
+
origin,
|
|
294
|
+
// SAFETY: the validated HTTP(S) URL has an absolute pathname beginning with "/".
|
|
295
|
+
callbackPath: callbackUrl.pathname,
|
|
296
|
+
resource: options.resource,
|
|
297
|
+
issuer: options.issuer,
|
|
298
|
+
cookie: { name: options.cookie.name, lifetime, secure: callbackUrl.protocol === "https:" },
|
|
299
|
+
checkOrigin: (value) => value === origin
|
|
300
|
+
? Effect.void
|
|
301
|
+
: Effect.fail(new Forbidden({ message: "Cross-origin request rejected" })),
|
|
302
|
+
};
|
|
303
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Effect, Semaphore } from "effect";
|
|
2
|
+
/** Reference counts include waiters, so an interrupted waiter cannot split a live lock. */
|
|
3
|
+
export const make = Effect.sync(() => {
|
|
4
|
+
const entries = new Map();
|
|
5
|
+
return (id, action) => Effect.acquireUseRelease(Effect.sync(() => {
|
|
6
|
+
let entry = entries.get(id);
|
|
7
|
+
if (!entry) {
|
|
8
|
+
entry = { semaphore: Semaphore.makeUnsafe(1), users: 0 };
|
|
9
|
+
entries.set(id, entry);
|
|
10
|
+
}
|
|
11
|
+
entry.users++;
|
|
12
|
+
return entry;
|
|
13
|
+
}), (entry) => entry.semaphore.withPermit(action), (entry) => Effect.sync(() => {
|
|
14
|
+
entry.users--;
|
|
15
|
+
if (entry.users === 0)
|
|
16
|
+
entries.delete(id);
|
|
17
|
+
}));
|
|
18
|
+
});
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { type Headers, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
declare const Unauthorized_base: Schema.Class<Unauthorized, Schema.TaggedStruct<"Unauthorized", {
|
|
4
|
+
readonly message: Schema.String;
|
|
5
|
+
}>, import("effect/Cause").YieldableError>;
|
|
6
|
+
/** Schemas define public responses. Diagnostic causes are internal, non-enumerable fields. */
|
|
7
|
+
export declare class Unauthorized extends Unauthorized_base {
|
|
8
|
+
readonly cause?: unknown;
|
|
9
|
+
constructor(options: {
|
|
10
|
+
readonly message: string;
|
|
11
|
+
readonly cause?: unknown;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
declare const Forbidden_base: Schema.Class<Forbidden, Schema.TaggedStruct<"Forbidden", {
|
|
15
|
+
readonly message: Schema.String;
|
|
16
|
+
}>, import("effect/Cause").YieldableError>;
|
|
17
|
+
export declare class Forbidden extends Forbidden_base {
|
|
18
|
+
}
|
|
19
|
+
declare const RateLimited_base: Schema.Class<RateLimited, Schema.TaggedStruct<"RateLimited", {
|
|
20
|
+
readonly message: Schema.String;
|
|
21
|
+
}>, import("effect/Cause").YieldableError>;
|
|
22
|
+
export declare class RateLimited extends RateLimited_base {
|
|
23
|
+
}
|
|
24
|
+
declare const ProviderUnavailable_base: Schema.Class<ProviderUnavailable, Schema.TaggedStruct<"ProviderUnavailable", {
|
|
25
|
+
readonly operation: Schema.String;
|
|
26
|
+
}>, import("effect/Cause").YieldableError>;
|
|
27
|
+
export declare class ProviderUnavailable extends ProviderUnavailable_base {
|
|
28
|
+
readonly cause?: unknown;
|
|
29
|
+
constructor(options: {
|
|
30
|
+
readonly operation: string;
|
|
31
|
+
readonly cause?: unknown;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
declare const StoreError_base: Schema.Class<StoreError, Schema.TaggedStruct<"StoreError", {
|
|
35
|
+
readonly operation: Schema.String;
|
|
36
|
+
}>, import("effect/Cause").YieldableError>;
|
|
37
|
+
export declare class StoreError extends StoreError_base {
|
|
38
|
+
readonly cause?: unknown;
|
|
39
|
+
constructor(options: {
|
|
40
|
+
readonly operation: string;
|
|
41
|
+
readonly cause?: unknown;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
declare const InvalidRequest_base: Schema.Class<InvalidRequest, Schema.TaggedStruct<"InvalidRequest", {
|
|
45
|
+
readonly message: Schema.String;
|
|
46
|
+
}>, import("effect/Cause").YieldableError>;
|
|
47
|
+
export declare class InvalidRequest extends InvalidRequest_base {
|
|
48
|
+
}
|
|
49
|
+
declare const ConfigurationError_base: Schema.Class<ConfigurationError, Schema.TaggedStruct<"ConfigurationError", {
|
|
50
|
+
readonly message: Schema.String;
|
|
51
|
+
}>, import("effect/Cause").YieldableError>;
|
|
52
|
+
export declare class ConfigurationError extends ConfigurationError_base {
|
|
53
|
+
}
|
|
54
|
+
export declare const authenticationErrors: readonly [typeof Unauthorized, typeof Forbidden, typeof RateLimited, typeof ProviderUnavailable, typeof StoreError];
|
|
55
|
+
export type AuthenticationError = Unauthorized | Forbidden | RateLimited | ProviderUnavailable | StoreError;
|
|
56
|
+
/** Encode one of the declared schemas as JSON with its `httpApiStatus`. Undeclared errors are defects. */
|
|
57
|
+
export declare const encodeError: <const Schemas extends ReadonlyArray<Schema.Top>>(schemas: Schemas) => (error: Schemas[number]["Type"], headers?: Headers.Input) => Effect.Effect<HttpServerResponse.HttpServerResponse, never, { [K in keyof Schemas]: Schemas[K]["EncodingServices"]; }[number]>;
|
|
58
|
+
export {};
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Effect, Schema, SchemaAST } from "effect";
|
|
2
|
+
import { HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
/** Schemas define public responses. Diagnostic causes are internal, non-enumerable fields. */
|
|
4
|
+
export class Unauthorized extends Schema.TaggedError()("Unauthorized", { message: Schema.String }, { httpApiStatus: 401 }) {
|
|
5
|
+
constructor(options) {
|
|
6
|
+
super({ message: options.message });
|
|
7
|
+
Object.defineProperty(this, "cause", { value: options.cause });
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class Forbidden extends Schema.TaggedError()("Forbidden", { message: Schema.String }, { httpApiStatus: 403 }) {
|
|
11
|
+
}
|
|
12
|
+
export class RateLimited extends Schema.TaggedError()("RateLimited", { message: Schema.String }, { httpApiStatus: 429 }) {
|
|
13
|
+
}
|
|
14
|
+
export class ProviderUnavailable extends Schema.TaggedError()("ProviderUnavailable", { operation: Schema.String }, { httpApiStatus: 503 }) {
|
|
15
|
+
constructor(options) {
|
|
16
|
+
super({ operation: options.operation });
|
|
17
|
+
Object.defineProperty(this, "cause", { value: options.cause });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class StoreError extends Schema.TaggedError()("StoreError", { operation: Schema.String }, { httpApiStatus: 503 }) {
|
|
21
|
+
constructor(options) {
|
|
22
|
+
super({ operation: options.operation });
|
|
23
|
+
Object.defineProperty(this, "cause", { value: options.cause });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class InvalidRequest extends Schema.TaggedError()("InvalidRequest", { message: Schema.String }, { httpApiStatus: 400 }) {
|
|
27
|
+
}
|
|
28
|
+
export class ConfigurationError extends Schema.TaggedError()("ConfigurationError", { message: Schema.String }) {
|
|
29
|
+
}
|
|
30
|
+
export const authenticationErrors = [
|
|
31
|
+
Unauthorized,
|
|
32
|
+
Forbidden,
|
|
33
|
+
RateLimited,
|
|
34
|
+
ProviderUnavailable,
|
|
35
|
+
StoreError,
|
|
36
|
+
];
|
|
37
|
+
/** Encode one of the declared schemas as JSON with its `httpApiStatus`. Undeclared errors are defects. */
|
|
38
|
+
export const encodeError = (schemas) => {
|
|
39
|
+
const encode = HttpServerResponse.schemaJson(Schema.Union(schemas));
|
|
40
|
+
return (error, headers) => {
|
|
41
|
+
const schema = schemas.find((schema) => Schema.is(schema)(error));
|
|
42
|
+
if (schema === undefined)
|
|
43
|
+
return Effect.die(new Error("Undeclared error"));
|
|
44
|
+
return encode(error, {
|
|
45
|
+
status: SchemaAST.resolveAt("httpApiStatus")(schema.ast) ?? 500,
|
|
46
|
+
headers,
|
|
47
|
+
}).pipe(Effect.orDie);
|
|
48
|
+
};
|
|
49
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export * as Verifier from "./verify.ts";
|
|
2
|
+
export * as BrowserSession from "./browser.ts";
|
|
3
|
+
export { SessionStore, type Row } from "./store.ts";
|
|
4
|
+
export type { Principal } from "./verify.ts";
|
|
5
|
+
export { Unauthorized, Forbidden, RateLimited, ProviderUnavailable, StoreError, InvalidRequest, ConfigurationError, type AuthenticationError, } from "./errors.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export * as Verifier from "./verify.js";
|
|
2
|
+
export * as BrowserSession from "./browser.js";
|
|
3
|
+
export { SessionStore } from "./store.js";
|
|
4
|
+
export { Unauthorized, Forbidden, RateLimited, ProviderUnavailable, StoreError, InvalidRequest, ConfigurationError, } from "./errors.js";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
3
|
+
import { ConfigurationError, Forbidden, RateLimited, Unauthorized } from "./errors.ts";
|
|
4
|
+
import type { AuthenticationError } from "./errors.ts";
|
|
5
|
+
import * as Verifier from "./verify.ts";
|
|
6
|
+
import type { BrowserSession } from "./browser.ts";
|
|
7
|
+
declare const CurrentPrincipal_base: Context.ServiceClass<CurrentPrincipal, "@clankerauth/CurrentPrincipal", Verifier.Principal>;
|
|
8
|
+
export declare class CurrentPrincipal extends CurrentPrincipal_base {
|
|
9
|
+
}
|
|
10
|
+
export interface Options extends Verifier.Options {
|
|
11
|
+
readonly scopes: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
/** One resource: its verifier and discovery. Acquire once per protected audience. */
|
|
14
|
+
export declare const make: (options: Options) => Effect.Effect<{
|
|
15
|
+
verifier: {
|
|
16
|
+
verifyToken: (token: string) => Effect.Effect<{
|
|
17
|
+
subject: string;
|
|
18
|
+
scopes: string[];
|
|
19
|
+
actor: {
|
|
20
|
+
kind: "client";
|
|
21
|
+
clientId: string;
|
|
22
|
+
};
|
|
23
|
+
} | {
|
|
24
|
+
subject: string;
|
|
25
|
+
scopes: readonly string[];
|
|
26
|
+
actor: {
|
|
27
|
+
kind: "key";
|
|
28
|
+
keyId: string;
|
|
29
|
+
};
|
|
30
|
+
}, Forbidden | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
|
|
31
|
+
verify: (authorization: string | null | undefined) => Effect.Effect<{
|
|
32
|
+
subject: string;
|
|
33
|
+
scopes: string[];
|
|
34
|
+
actor: {
|
|
35
|
+
kind: "client";
|
|
36
|
+
clientId: string;
|
|
37
|
+
};
|
|
38
|
+
} | {
|
|
39
|
+
subject: string;
|
|
40
|
+
scopes: readonly string[];
|
|
41
|
+
actor: {
|
|
42
|
+
kind: "key";
|
|
43
|
+
keyId: string;
|
|
44
|
+
};
|
|
45
|
+
}, Forbidden | import("./errors.ts").ProviderUnavailable | RateLimited | Unauthorized, never>;
|
|
46
|
+
};
|
|
47
|
+
discovery: {
|
|
48
|
+
layer: import("effect/Layer").Layer<never, never, import("effect/unstable/http/HttpRouter").HttpRouter>;
|
|
49
|
+
metadataUrl: string;
|
|
50
|
+
challenge: (challenge?: Authentication.BearerChallengeOptions) => string;
|
|
51
|
+
};
|
|
52
|
+
challenge: (error?: "invalid_token" | "insufficient_scope") => {
|
|
53
|
+
"www-authenticate": string;
|
|
54
|
+
};
|
|
55
|
+
headers: (error: AuthenticationError) => {
|
|
56
|
+
"www-authenticate": string;
|
|
57
|
+
} | {
|
|
58
|
+
"retry-after": string;
|
|
59
|
+
} | {
|
|
60
|
+
"retry-after"?: undefined;
|
|
61
|
+
};
|
|
62
|
+
resource: string;
|
|
63
|
+
issuer: string;
|
|
64
|
+
}, ConfigurationError, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
65
|
+
export type Resource = Effect.Success<ReturnType<typeof make>>;
|
|
66
|
+
export interface MiddlewareOptions {
|
|
67
|
+
/** Authenticate requests without an Authorization header by session cookie. Never for MCP. */
|
|
68
|
+
readonly browser?: BrowserSession;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Bearer authentication providing `CurrentPrincipal`. With `browser`, a request
|
|
72
|
+
* without an Authorization header is authenticated by the session cookie instead,
|
|
73
|
+
* with Origin checked on unsafe methods.
|
|
74
|
+
*/
|
|
75
|
+
export declare const middleware: (resource: Resource, options?: MiddlewareOptions) => import("effect/unstable/http/HttpRouter").Middleware<{
|
|
76
|
+
provides: CurrentPrincipal;
|
|
77
|
+
handles: never;
|
|
78
|
+
error: never;
|
|
79
|
+
requires: never;
|
|
80
|
+
layerError: never;
|
|
81
|
+
layerRequires: never;
|
|
82
|
+
}>;
|
|
83
|
+
export {};
|
package/dist/resource.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
3
|
+
import { HttpServerRequest } from "effect/unstable/http";
|
|
4
|
+
import { authenticationErrors, ConfigurationError, encodeError, Forbidden, RateLimited, Unauthorized, } from "./errors.js";
|
|
5
|
+
import * as Verifier from "./verify.js";
|
|
6
|
+
const encode = encodeError(authenticationErrors);
|
|
7
|
+
export class CurrentPrincipal extends Context.Service()("@clankerauth/CurrentPrincipal") {
|
|
8
|
+
}
|
|
9
|
+
/** One resource: its verifier and discovery. Acquire once per protected audience. */
|
|
10
|
+
export const make = Effect.fn("Resource.make")(function* (options) {
|
|
11
|
+
const discovery = yield* Effect.try({
|
|
12
|
+
try: () => Authentication.protectedResource({
|
|
13
|
+
resource: options.resource,
|
|
14
|
+
authorizationServers: [options.issuer],
|
|
15
|
+
scopesSupported: options.scopes,
|
|
16
|
+
}),
|
|
17
|
+
catch: () => new ConfigurationError({ message: "Invalid resource metadata configuration" }),
|
|
18
|
+
});
|
|
19
|
+
const verifier = yield* Verifier.make(options);
|
|
20
|
+
const scope = options.requiredScopes?.length ? options.requiredScopes.join(" ") : undefined;
|
|
21
|
+
/** Bearer challenge for this resource; no error code when the request carried no credentials. */
|
|
22
|
+
const challenge = (error) => ({
|
|
23
|
+
"www-authenticate": discovery.challenge({ error, scope }),
|
|
24
|
+
});
|
|
25
|
+
const headers = (error) => {
|
|
26
|
+
if (error instanceof RateLimited)
|
|
27
|
+
return { "retry-after": "60" };
|
|
28
|
+
if (error instanceof Forbidden)
|
|
29
|
+
return challenge("insufficient_scope");
|
|
30
|
+
if (error instanceof Unauthorized)
|
|
31
|
+
return challenge("invalid_token");
|
|
32
|
+
return {};
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
verifier,
|
|
36
|
+
discovery,
|
|
37
|
+
challenge,
|
|
38
|
+
headers,
|
|
39
|
+
resource: options.resource,
|
|
40
|
+
issuer: options.issuer,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
/**
|
|
44
|
+
* Bearer authentication providing `CurrentPrincipal`. With `browser`, a request
|
|
45
|
+
* without an Authorization header is authenticated by the session cookie instead,
|
|
46
|
+
* with Origin checked on unsafe methods.
|
|
47
|
+
*/
|
|
48
|
+
export const middleware = (resource, options = {}) => Authentication.middleware(CurrentPrincipal, Effect.gen(function* () {
|
|
49
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
50
|
+
const { browser } = options;
|
|
51
|
+
if (browser === undefined || request.headers.authorization !== undefined)
|
|
52
|
+
return yield* resource.verifier.verify(request.headers.authorization);
|
|
53
|
+
const session = request.cookies[`${browser.cookie.name}_session`];
|
|
54
|
+
if (session !== undefined && !["GET", "HEAD", "OPTIONS"].includes(request.method))
|
|
55
|
+
yield* browser.checkOrigin(request.headers.origin);
|
|
56
|
+
return yield* resource.verifier.verifyToken(yield* browser.accessToken(session));
|
|
57
|
+
}).pipe(Effect.catch((error) => Effect.gen(function* () {
|
|
58
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
59
|
+
// RFC 6750 §3.1: a request that carried no credentials gets no error code.
|
|
60
|
+
const headers = error instanceof Unauthorized && request.headers.authorization === undefined
|
|
61
|
+
? resource.challenge()
|
|
62
|
+
: resource.headers(error);
|
|
63
|
+
return yield* Effect.flatMap(encode(error, headers), Effect.fail);
|
|
64
|
+
}))));
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
import type { StoreError } from "./errors.ts";
|
|
3
|
+
export interface Row {
|
|
4
|
+
readonly payload: string;
|
|
5
|
+
readonly expires: number;
|
|
6
|
+
}
|
|
7
|
+
declare const SessionStore_base: Context.ServiceClass<SessionStore, "@clankerauth/SessionStore", {
|
|
8
|
+
readonly get: (id: string) => Effect.Effect<Row | undefined, StoreError>;
|
|
9
|
+
readonly put: (id: string, payload: string, expires: number) => Effect.Effect<void, StoreError>;
|
|
10
|
+
readonly delete: (id: string) => Effect.Effect<void, StoreError>;
|
|
11
|
+
readonly sweep: (now: number) => Effect.Effect<void, StoreError>;
|
|
12
|
+
}>;
|
|
13
|
+
/** Sealed rows. One process owns a store; the session capability serializes work per id. */
|
|
14
|
+
export declare class SessionStore extends SessionStore_base {
|
|
15
|
+
}
|
|
16
|
+
export {};
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Cause, Effect } from "effect";
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http";
|
|
3
|
+
import type { HttpClientError } from "effect/unstable/http/HttpClientError";
|
|
4
|
+
import { ProviderUnavailable } from "./errors.ts";
|
|
5
|
+
/** Preserve Effect causes while crossing the foreign library's Promise callback boundary. */
|
|
6
|
+
export declare class TransportFailure extends Error {
|
|
7
|
+
readonly effectCause: Cause.Cause<ProviderUnavailable | HttpClientError>;
|
|
8
|
+
constructor(effectCause: Cause.Cause<ProviderUnavailable | HttpClientError>);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The only Effect -> Promise bridge: the third-party OAuth library requires Fetch.
|
|
12
|
+
* Capture the calling fiber's context per invocation, and propagate its AbortSignal.
|
|
13
|
+
* Buffer small protocol responses so no response resource outlives the adapter effect.
|
|
14
|
+
*/
|
|
15
|
+
export declare const protocolTransport: () => Effect.Effect<(url: string, init: RequestInit) => Promise<Response>, never, HttpClient.HttpClient>;
|
|
16
|
+
export declare const execute: (client: HttpClient.HttpClient, request: HttpClientRequest.HttpClientRequest) => Effect.Effect<import("effect/unstable/http/HttpClientResponse").HttpClientResponse, ProviderUnavailable, import("effect/Scope").Scope>;
|