@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.
@@ -0,0 +1,51 @@
1
+ import { Cause, Context, Effect, Exit } from "effect";
2
+ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3
+ import { ProviderUnavailable } from "./errors.js";
4
+ /** Preserve Effect causes while crossing the foreign library's Promise callback boundary. */
5
+ export class TransportFailure extends Error {
6
+ effectCause;
7
+ constructor(effectCause) {
8
+ super("Protocol transport failed");
9
+ this.effectCause = effectCause;
10
+ }
11
+ }
12
+ /**
13
+ * The only Effect -> Promise bridge: the third-party OAuth library requires Fetch.
14
+ * Capture the calling fiber's context per invocation, and propagate its AbortSignal.
15
+ * Buffer small protocol responses so no response resource outlives the adapter effect.
16
+ */
17
+ export const protocolTransport = Effect.fn("Auth.protocolTransport")(function* () {
18
+ const client = yield* HttpClient.HttpClient;
19
+ const context = yield* Effect.context();
20
+ return (url, init) => Effect.runPromiseExitWith(context)(Effect.gen(function* () {
21
+ const request = yield* Effect.try({
22
+ try: () => HttpClientRequest.fromWeb(new Request(url, init)),
23
+ catch: (cause) => new ProviderUnavailable({ operation: "request", cause }),
24
+ });
25
+ const response = yield* execute(client, request);
26
+ if (response.status >= 300 && response.status < 400)
27
+ return yield* new ProviderUnavailable({
28
+ operation: "redirect",
29
+ cause: { status: response.status },
30
+ });
31
+ const body = yield* response.arrayBuffer;
32
+ return new Response(response.status === 204 || response.status === 205 || response.status === 304
33
+ ? null
34
+ : body, {
35
+ status: response.status,
36
+ headers: response.headers,
37
+ });
38
+ }).pipe(Effect.scoped), { signal: init.signal ?? undefined }).then((exit) => {
39
+ if (Exit.isFailure(exit))
40
+ throw new TransportFailure(exit.cause);
41
+ return exit.value;
42
+ });
43
+ });
44
+ // Node transports do not follow redirects by default. Also pin FetchHttpClient's policy;
45
+ // forwarding credentials through a redirect is not an authentication fallback.
46
+ export const execute = (client, request) => Effect.contextWith((context) => HttpClient.withScope(client)
47
+ .execute(request)
48
+ .pipe(Effect.provideService(FetchHttpClient.RequestInit, {
49
+ ...Context.getOrUndefined(context, FetchHttpClient.RequestInit),
50
+ redirect: "error",
51
+ }), Effect.mapError((cause) => new ProviderUnavailable({ operation: "http.request", cause }))));
@@ -0,0 +1,59 @@
1
+ import { Effect } from "effect";
2
+ import { HttpClient } from "effect/unstable/http";
3
+ import { ConfigurationError, Forbidden, ProviderUnavailable, RateLimited, Unauthorized } from "./errors.ts";
4
+ export interface Principal {
5
+ readonly subject: string;
6
+ readonly scopes: readonly string[];
7
+ readonly actor: {
8
+ readonly kind: "client";
9
+ readonly clientId: string;
10
+ } | {
11
+ readonly kind: "key";
12
+ readonly keyId: string;
13
+ };
14
+ }
15
+ export interface Options {
16
+ readonly issuer: string;
17
+ readonly resource: string;
18
+ readonly requiredScopes?: readonly string[];
19
+ /** `false` rejects API keys without consulting the issuer; only OAuth access tokens are accepted. */
20
+ readonly apiKeys?: boolean;
21
+ }
22
+ export interface Verifier {
23
+ readonly verify: (authorization: string | null | undefined) => Effect.Effect<Principal, VerificationError>;
24
+ readonly verifyToken: (token: string) => Effect.Effect<Principal, VerificationError>;
25
+ }
26
+ export type VerificationError = Unauthorized | Forbidden | RateLimited | ProviderUnavailable;
27
+ /** Acquire once per resource; transport is selected by the application, not the SDK. */
28
+ export declare const make: (options: Options) => Effect.Effect<{
29
+ verifyToken: (token: string) => Effect.Effect<{
30
+ subject: string;
31
+ scopes: string[];
32
+ actor: {
33
+ kind: "client";
34
+ clientId: string;
35
+ };
36
+ } | {
37
+ subject: string;
38
+ scopes: readonly string[];
39
+ actor: {
40
+ kind: "key";
41
+ keyId: string;
42
+ };
43
+ }, Forbidden | ProviderUnavailable | RateLimited | Unauthorized, never>;
44
+ verify: (authorization: string | null | undefined) => Effect.Effect<{
45
+ subject: string;
46
+ scopes: string[];
47
+ actor: {
48
+ kind: "client";
49
+ clientId: string;
50
+ };
51
+ } | {
52
+ subject: string;
53
+ scopes: readonly string[];
54
+ actor: {
55
+ kind: "key";
56
+ keyId: string;
57
+ };
58
+ }, Forbidden | ProviderUnavailable | RateLimited | Unauthorized, never>;
59
+ }, ConfigurationError, HttpClient.HttpClient>;
package/dist/verify.js ADDED
@@ -0,0 +1,175 @@
1
+ import { Clock, Effect, Exit, Schema, Semaphore } from "effect";
2
+ import { createLocalJWKSet, decodeProtectedHeader, errors, jwtVerify } from "jose";
3
+ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
+ import { ConfigurationError, Forbidden, ProviderUnavailable, RateLimited, Unauthorized, } from "./errors.js";
5
+ import { execute } from "./transport.js";
6
+ const Jwks = Schema.Struct({
7
+ keys: Schema.Array(Schema.Struct({
8
+ kty: Schema.String,
9
+ crv: Schema.optionalKey(Schema.String),
10
+ x: Schema.optionalKey(Schema.String),
11
+ kid: Schema.optionalKey(Schema.String),
12
+ alg: Schema.optionalKey(Schema.String),
13
+ use: Schema.optionalKey(Schema.String),
14
+ key_ops: Schema.optionalKey(Schema.Array(Schema.String)),
15
+ })),
16
+ });
17
+ const Claims = Schema.Struct({
18
+ sub: Schema.NonEmptyString,
19
+ client_id: Schema.NonEmptyString,
20
+ scope: Schema.String,
21
+ });
22
+ const KeyResponse = Schema.Struct({
23
+ keyId: Schema.NonEmptyString,
24
+ ownerId: Schema.NonEmptyString,
25
+ resource: Schema.String,
26
+ scopes: Schema.Array(Schema.NonEmptyString),
27
+ expiresAt: Schema.NullOr(Schema.String),
28
+ });
29
+ const credentialErrors = [
30
+ errors.JWTExpired,
31
+ errors.JWTClaimValidationFailed,
32
+ errors.JWSSignatureVerificationFailed,
33
+ errors.JWSInvalid,
34
+ errors.JWTInvalid,
35
+ errors.JOSENotSupported,
36
+ errors.JOSEAlgNotAllowed,
37
+ errors.JWKSNoMatchingKey,
38
+ ];
39
+ const unauthorized = () => new Unauthorized({ message: "Authentication required" });
40
+ /** Acquire once per resource; transport is selected by the application, not the SDK. */
41
+ export const make = Effect.fn("Verifier.make")(function* (options) {
42
+ const client = yield* HttpClient.HttpClient;
43
+ const endpoints = yield* Effect.try({
44
+ try: () => ({
45
+ jwks: new URL(`${options.issuer}/jwks`).href,
46
+ apiKey: new URL("/api/issuer/verifyApiKey", options.issuer).href,
47
+ }),
48
+ catch: () => new ConfigurationError({ message: "Invalid issuer URL" }),
49
+ });
50
+ const refreshLock = yield* Semaphore.make(1);
51
+ let refreshAfter = 0;
52
+ let refreshFailure;
53
+ const makeCachedKeys = Effect.cachedWithTTL(Effect.gen(function* () {
54
+ const response = yield* execute(client, HttpClientRequest.get(endpoints.jwks));
55
+ if (response.status !== 200)
56
+ return yield* new ProviderUnavailable({
57
+ operation: "jwks.fetch",
58
+ cause: { status: response.status },
59
+ });
60
+ const document = yield* HttpClientResponse.schemaBodyJson(Jwks)(response).pipe(Effect.mapError((cause) => new ProviderUnavailable({ operation: "jwks.decode", cause })));
61
+ refreshAfter = (yield* Clock.currentTimeMillis) + 30_000;
62
+ refreshFailure = undefined;
63
+ return createLocalJWKSet({
64
+ keys: document.keys.map(({ key_ops, ...key }) => key_ops ? { ...key, key_ops: [...key_ops] } : key),
65
+ });
66
+ }).pipe(Effect.scoped), (exit) => (Exit.isSuccess(exit) ? "10 minutes" : 0));
67
+ let keys = yield* makeCachedKeys;
68
+ const resolveKey = (resolve, header) => Effect.tryPromise({
69
+ try: () => resolve(header),
70
+ catch: (cause) => cause instanceof errors.JWKSNoMatchingKey
71
+ ? unauthorized()
72
+ : new ProviderUnavailable({ operation: "jwks.resolve", cause }),
73
+ });
74
+ const signingKey = Effect.fn("Verifier.signingKey")(function* (header) {
75
+ const resolve = yield* keys;
76
+ return yield* resolveKey(resolve, header).pipe(Effect.catchTag("Unauthorized", () => refreshLock.withPermit(Effect.gen(function* () {
77
+ const current = yield* keys;
78
+ // Another request may have refreshed while this one waited for admission.
79
+ if (current !== resolve)
80
+ return yield* resolveKey(current, header);
81
+ const now = yield* Clock.currentTimeMillis;
82
+ // Cool down failed or interrupted refreshes too, without discarding still-valid cached keys.
83
+ if (now < refreshAfter)
84
+ return yield* refreshFailure ?? unauthorized();
85
+ refreshAfter = now + 30_000;
86
+ refreshFailure = new ProviderUnavailable({ operation: "jwks.refresh" });
87
+ const next = yield* makeCachedKeys;
88
+ const refreshed = yield* next.pipe(Effect.tapError((error) => Effect.sync(() => {
89
+ refreshFailure = error;
90
+ })));
91
+ keys = next;
92
+ return yield* resolveKey(refreshed, header);
93
+ }))));
94
+ });
95
+ const verifyJwt = Effect.fn("Verifier.jwt")(function* (token) {
96
+ const header = yield* Effect.try({
97
+ try: () => decodeProtectedHeader(token),
98
+ catch: unauthorized,
99
+ });
100
+ // Reject unsupported algorithms before doing provider I/O; JOSE still enforces its allowlist.
101
+ if (header.alg !== "EdDSA")
102
+ return yield* unauthorized();
103
+ const key = yield* signingKey(header);
104
+ const now = yield* Clock.currentTimeMillis;
105
+ const { payload } = yield* Effect.tryPromise({
106
+ try: () => jwtVerify(token, key, {
107
+ issuer: options.issuer,
108
+ audience: options.resource,
109
+ algorithms: ["EdDSA"],
110
+ typ: "at+jwt",
111
+ requiredClaims: ["sub", "client_id", "scope", "iat", "exp"],
112
+ currentDate: new Date(now),
113
+ }),
114
+ catch: (cause) => credentialErrors.some((kind) => cause instanceof kind)
115
+ ? unauthorized()
116
+ : new ProviderUnavailable({ operation: "jwt.verify", cause }),
117
+ });
118
+ if (payload.cnf !== undefined)
119
+ return yield* unauthorized();
120
+ const claims = yield* Schema.decodeUnknownEffect(Claims)(payload).pipe(Effect.mapError(unauthorized));
121
+ return {
122
+ subject: claims.sub,
123
+ scopes: claims.scope.split(" ").filter(Boolean),
124
+ actor: { kind: "client", clientId: claims.client_id },
125
+ };
126
+ });
127
+ const verifyKey = Effect.fn("Verifier.apiKey")(function* (key) {
128
+ const request = HttpClientRequest.post(endpoints.apiKey).pipe(HttpClientRequest.setHeader("authorization", `Bearer ${key}`), HttpClientRequest.bodyJsonUnsafe({ resource: options.resource }));
129
+ const response = yield* execute(client, request);
130
+ if (response.status === 401)
131
+ return yield* unauthorized();
132
+ if (response.status === 403)
133
+ return yield* new Forbidden({ message: "Insufficient scope" });
134
+ if (response.status === 429)
135
+ return yield* new RateLimited({ message: "Authentication rate exceeded" });
136
+ if (response.status !== 200)
137
+ return yield* new ProviderUnavailable({
138
+ operation: "api-key.verify",
139
+ cause: { status: response.status },
140
+ });
141
+ const body = yield* HttpClientResponse.schemaBodyJson(KeyResponse)(response).pipe(Effect.mapError((cause) => new ProviderUnavailable({ operation: "api-key.response", cause })));
142
+ const expiry = body.expiresAt === null ? null : Date.parse(body.expiresAt);
143
+ if (body.resource !== options.resource || (expiry !== null && !Number.isFinite(expiry)))
144
+ return yield* new ProviderUnavailable({ operation: "api-key.response" });
145
+ if (expiry !== null && expiry <= (yield* Clock.currentTimeMillis))
146
+ return yield* unauthorized();
147
+ return {
148
+ subject: body.ownerId,
149
+ scopes: body.scopes,
150
+ actor: { kind: "key", keyId: body.keyId },
151
+ };
152
+ }, Effect.scoped);
153
+ const verifyToken = Effect.fn("Verifier.verifyToken")(function* (token) {
154
+ const principal = yield* token.startsWith("ca_")
155
+ ? options.apiKeys === false
156
+ ? Effect.fail(unauthorized())
157
+ : verifyKey(token)
158
+ : verifyJwt(token);
159
+ if (options.requiredScopes?.some((scope) => !principal.scopes.includes(scope)))
160
+ return yield* new Forbidden({ message: "Insufficient scope" });
161
+ return principal;
162
+ }, Effect.timeoutOrElse({
163
+ duration: "5 seconds",
164
+ orElse: () => Effect.fail(new ProviderUnavailable({ operation: "verify.timeout" })),
165
+ }));
166
+ return {
167
+ verifyToken,
168
+ verify: Effect.fn("Verifier.verify")(function* (authorization) {
169
+ const token = /^Bearer ([^\s,]+)$/iu.exec(authorization ?? "")?.[1];
170
+ if (!token)
171
+ return yield* unauthorized();
172
+ return yield* verifyToken(token);
173
+ }),
174
+ };
175
+ });
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@gjermundgaraba/clankerauth-sdk",
3
+ "version": "0.4.0",
4
+ "description": "Effect-native Clanker Auth verification and browser sessions, with optional effect-actions integration",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/gjermundgaraba/clankerauth.git",
9
+ "directory": "packages/sdk"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ },
20
+ "./effect-actions": {
21
+ "types": "./dist/effect-actions.d.ts",
22
+ "import": "./dist/effect-actions.js"
23
+ },
24
+ "./browser-api": {
25
+ "types": "./dist/browser-api.d.ts",
26
+ "import": "./dist/browser-api.js"
27
+ }
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "dependencies": {
33
+ "effect": "4.0.0-rc.116",
34
+ "jose": "6.2.12",
35
+ "oauth4webapi": "3.8.8"
36
+ },
37
+ "devDependencies": {
38
+ "@gjermundgaraba/effect-actions": "0.1.0",
39
+ "@types/node": "26.6.2",
40
+ "typescript": "7.0.2",
41
+ "vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
42
+ "vite-plus": "0.3.3"
43
+ },
44
+ "peerDependencies": {
45
+ "@gjermundgaraba/effect-actions": "0.1.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@gjermundgaraba/effect-actions": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "engines": {
53
+ "node": ">=26"
54
+ },
55
+ "scripts": {
56
+ "test": "vp test && node scripts/package-smoke.mjs"
57
+ }
58
+ }