@house-rules/auth-jwt 0.0.0-stage → 0.1.1

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/AGENTS.md ADDED
@@ -0,0 +1,64 @@
1
+ # @house-rules/auth-jwt: notes for agents
2
+
3
+ ## What it does
4
+
5
+ It fills the `Authenticator` slot from `@house-rules/application`. `verify(token, { audience? })` checks a JWT's signature against a JSON Web Key Set, its `iss`, a required `exp` and its `nbf`, a non-empty string `sub`, and its `aud` when an audience is set. A good token becomes a `Principal`: `issuer`, `subject`, `audience`, `scopes` and every claim. A bad one fails with `TokenRejected`, whose `reason` is `invalid`, `expired`, `wrong_audience`, `wrong_issuer` or `unavailable` (the key set could not be fetched). It uses `jose` 6.2.12. Identity also uses jose for its passwords issuer; auth-jwt remains the pure token checker. It owns no tables, no migrations and no identity: who the subject is in the app is another package's job. Version 0.1.0 is ready for its first npm release, not yet published. It peers on exact `@house-rules/application` 0.2.0, `@house-rules/domain` 0.2.0 and Effect 4.0.3.
6
+
7
+ ## How an app mounts it
8
+
9
+ 1. [ ] For identity providers, choose `Identity.passwords(...)` or `Identity.oidc(...)` in Identity's options. Identity builds the matching auth-jwt verifier once. Direct uses, such as a separate MCP resource verifier, still provide one layer in the composition root:
10
+
11
+ ```ts
12
+ AuthJwt.layer({ issuer: "https://auth.example.com", audience: "https://app.example/mcp" })
13
+ // or, for Clerk:
14
+ AuthJwt.clerk({ publishableKey, authorizedParties: ["https://app.example.com"] })
15
+ ```
16
+
17
+ 2. [ ] Handle `AuthJwtConfigInvalid`, an unavailable-kind acquisition error. The layer fails with it when the issuer is not an absolute http(s) URL, the JWKS URL (explicit, or derived from the issuer) is not https, `clockTolerance` is not a finite, non-negative number, the Clerk key is not a `pk_test_` or `pk_live_` key, or the Clerk `authorizedParties` list is empty or contains blank values.
18
+ 3. [ ] Fill a way's Who with `Identity.who({ token, audience?, challenge? })` from identity, or a custom `WhoSocket`. It reads the token with `tokenFrom`, verifies it, then resolves the Actor. Rejected tokens become 401 `Refused`; unavailable verification becomes 503. One token source: `tokenFrom(incoming, "bearer")` or `tokenFrom(incoming, { cookie: "__session" })`. Several places, tried in order, first token wins: `tokenFrom(incoming, ["bearer", { cookie: "__session" }])`. List the bearer header first, so a script's header beats a browser's cookie.
19
+ 4. [ ] In tests, sign tokens with `AuthJwtTesting.keys` and pass its `jwks` to the layer, so no test touches the network. `keys.sign(claims, header?)` accepts protected-header fields for locally signed kind fixtures.
20
+
21
+ No `migrations.json` line.
22
+
23
+ | Option | Default | Effect |
24
+ | --- | --- | --- |
25
+ | `issuer` | required | the exact `iss` every token carries |
26
+ | `audience` | none | the `aud` a token must name; a `verify` call's `audience` wins over it |
27
+ | `jwksUrl` | `<issuer>/.well-known/jwks.json` | where the public keys are fetched and cached; https only |
28
+ | `jwks` | none | a fixed public key set used instead of `jwksUrl` |
29
+ | `clockTolerance` | `0`, Clerk `5` | seconds of skew allowed on `exp` and `nbf` |
30
+ | `authorizedParties` | none; required by Clerk | a present `azp` must be a string exactly matching a listed value; absent `azp` passes |
31
+ | `rejectPending` | `false` | reject `sts = pending` when true |
32
+
33
+ `AuthJwt.clerk({ publishableKey, authorizedParties, audience?, clockTolerance?, jwks?, rejectPending? })` derives the issuer from the key: the base64 after the prefix is the Frontend API host plus `$`, and the issuer is `https://<host>`. JWKS comes from `<issuer>/.well-known/jwks.json` unless `jwks` is supplied. The authorized-party list must contain at least one nonblank string. `AuthJwt.clerkIssuer(key)` returns that issuer as an `Option`.
34
+
35
+ ### Providers needing the protected header
36
+
37
+ `AuthJwt.verifier(options)` is an `Effect<AuthJwtVerifier, AuthJwtConfigInvalid>`. Acquire it once. Its `verify(token, { audience? })` returns an Effect of `{ header, principal }`, failing with `TokenRejected`. The header is jose's verified `protectedHeader`, never unverified decoded input. `AuthJwt.layer` wraps this verifier and drops the header; its Principal-only contract is unchanged.
38
+
39
+ `AuthJwt.clerkOptions(ClerkOptions)` is an Effect of `AuthJwtOptions`. It validates the publishable key, nonempty authorized-party list and clock tolerance, and derives issuer, JWKS URL and the 5-second default. Identity's Clerk provider must reuse this function instead of copying the defaults.
40
+
41
+ Apply pure `AuthJwt.clerkKind(header, principal.claims)` only after verification. It returns `oauth_access` for `typ` exactly `at+jwt` or `application/at+jwt`, then `machine` for `sub` starting `mch_`. A session requires `typ` absent or exactly `JWT`, `sub` starting `user_`, a nonempty string `sid` and no `act` claim. Anything else is `unknown`, which the caller must refuse. The classifier neither accepts impersonation nor chooses what a way admits. Any explicit impersonation override belongs to identity.
42
+
43
+ The Clerk rules follow the accepted identity proposal's C1, C3, C4, C10 and C28 facts. Before production use, verify a real session token's header and claims against the predicate. Local fixtures are not proof of a production instance's token shape.
44
+
45
+ ## Local issuer: dev and demos only
46
+
47
+ `AuthJwtLocal` signs tokens in process so a demo can sign in with no identity provider. Never mount it in production: whoever can call `issue` can sign in as anyone.
48
+
49
+ - `AuthJwtLocal.make({ issuer?, audience?, ttlSeconds? })` is an `Effect` of `{ issuer, audience?, jwks, issue, layer }`. It makes a fresh ES256 key pair in memory per call, so tokens die with the process and one issuer's tokens fail another's layer.
50
+ - `issue({ sub, name?, ...claims })` is an `Effect<string>`. It sets `iss`, `iat`, `exp` (now plus `ttlSeconds`) and `aud` after the given claims, so a caller cannot extend a token. Time comes from `Clock`.
51
+ - `layer` is a `Layer<Authenticator>` on the same verify code as `AuthJwt.layer`, with the local key set injected.
52
+ - `AuthJwtLocal.layer(options)` provides both `Authenticator` and the `AuthJwtLocalIssuer` service, whose `issue` a test or demo sign-in binding can yield.
53
+ - Defaults: issuer `https://dev-issuer.invalid`, no audience, `ttlSeconds` 3600. A bad issuer or a `ttlSeconds` that is not a positive whole number fails with `AuthJwtConfigInvalid`.
54
+ - `make` and `layer` refuse outside development/test themselves with unavailable `AuthJwtConfigInvalid`, option `NODE_ENV`. Effect Config reads NODE_ENV; after trimming and lowercasing, only development or test passes. Missing and blank values refuse. A host NODE_ENV, when present, must pass too.
55
+ - The public `requireDevelopmentSigning(refused)` helper implements this guard and raises the caller's typed `refused()` error. Identity reuses it for devLocal and ephemeral passwords signing.
56
+
57
+ ## Fixed rules
58
+
59
+ - No token passes without a matching signature, issuer, non-empty string subject and unexpired time. A token with no `exp` is refused. There is no option to skip a check.
60
+ - The audience is checked only when one is set. An MCP route sets it to its resource URL, so a token for another resource is refused.
61
+ - `scope` (space-separated) wins over `scp` (an array or a string). Missing both gives no scopes. Scopes are parsed but do not narrow access yet. Token-scope narrowing is a later addition. Composition plugs `Party.may()` once to resolve the person's roles. Ways narrow access only through feature exposure; MCP exposes read features and uses the same roles as HTTP. `defineWay({ features })` exposes features, never module operations.
62
+ - `verify` reads the time from Effect's `Clock`, so `TestClock` drives expiry in tests.
63
+ - No email, no user lookup and no session issuing here.
64
+ - Module user-facing errors keep their messages; unavailable and plumbing errors use fixed public text, with diagnostic details kept in separate fields or causes.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jakub Szwajka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,64 @@
1
- # Temporary Holding Version
1
+ # @house-rules/auth-jwt
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Fills the `Authenticator` slot from `@house-rules/application` by verifying JWTs against a JSON Web Key Set, with a Clerk preset. Plain JWT works with any OpenID-style issuer that publishes its keys.
4
+
5
+ ```ts
6
+ import { AuthJwt } from "@house-rules/auth-jwt";
7
+
8
+ const authenticator = AuthJwt.layer({ issuer: "https://auth.example.com" });
9
+ const clerk = AuthJwt.clerk({
10
+ publishableKey: "pk_live_...",
11
+ authorizedParties: ["https://app.example.com"],
12
+ });
13
+ ```
14
+
15
+ The layer fills the lower `Authenticator` slot, not a way's Who socket. Pair it with `Identity.who({ token, audience?, challenge? })` from identity, or a custom `WhoSocket` that resolves an Actor. Application never imports this plug. The way exposes features through `defineWay({ features })`; May owns feature access separately.
16
+
17
+ `AuthJwtConfigInvalid` carries unavailable kind for acquisition failures. `verify(token, { audience? })` returns a `Principal` or fails with `TokenRejected`. Keys come from `<issuer>/.well-known/jwks.json` by default, or from `jwksUrl` (https only), or from a fixed `jwks` set. `AuthJwtTesting.keys` gives a local key pair and a signer for tests.
18
+
19
+ ## Verified headers and Clerk tokens
20
+
21
+ `AuthJwt.verifier(options)` returns an Effect of a verifier. Its `verify(token, { audience? })` returns `{ header, principal }` only after signature and claim checks pass. `header` is jose's verified protected header. `AuthJwt.layer(options)` uses the same checks and returns only the Principal, as before.
22
+
23
+ Generic JWT options accept an optional `authorizedParties` list. A present `azp` must be a string exactly matching a listed value; an absent `azp` passes. Without the list, generic verification keeps its existing behaviour. `rejectPending: true` rejects `sts = pending`.
24
+
25
+ Breaking for the next release: the Clerk preset now requires a nonempty `authorizedParties` list (Clerk's `azp` check) and defaults `clockTolerance` to 5 seconds (was 0). Existing `AuthJwt.clerk({ publishableKey })` calls must add `authorizedParties`. Generic JWT verification still defaults to 0. `AuthJwt.clerkOptions(options)` returns an Effect of `AuthJwtOptions` with the issuer and JWKS URL derived from the publishable key. Use it with `AuthJwt.verifier` to keep those defaults in a provider that needs the verified header:
26
+
27
+ ```ts
28
+ const options = yield* AuthJwt.clerkOptions({ publishableKey, authorizedParties });
29
+ const verifier = yield* AuthJwt.verifier(options);
30
+ const { header, principal } = yield* verifier.verify(token);
31
+ const kind = AuthJwt.clerkKind(header, principal.claims);
32
+ ```
33
+
34
+ `clerkKind` is pure and must receive verified data. In order, it returns:
35
+
36
+ - `oauth_access` for protected-header `typ` equal to `at+jwt` or `application/at+jwt`.
37
+ - `machine` for a subject starting with `mch_`.
38
+ - `session` for `typ` absent or `JWT`, a subject starting with `user_`, a nonempty string `sid` and no `act` claim.
39
+ - `unknown` otherwise.
40
+
41
+ Classification does not admit a credential or resolve an Actor. The caller must refuse kinds it does not accept, including `unknown`. Impersonation policy belongs to identity, not this classifier. Before production use, check that a real Clerk session token satisfies this predicate; locally signed fixtures do not prove the instance's token shape.
42
+
43
+ ## Local issuer
44
+
45
+ For dev and demos only, never production, `AuthJwtLocal` is a local issuer: an in-memory key pair whose tokens the normal verify path accepts.
46
+
47
+ ```ts
48
+ import { AuthJwtLocal, AuthJwtLocalIssuer } from "@house-rules/auth-jwt";
49
+
50
+ const local = yield* AuthJwtLocal.make({ audience: "http://localhost:3000/api", ttlSeconds: 3600 });
51
+ const token = yield* local.issue({ sub: "ada", name: "Ada" }); // Effect<string>
52
+ // local.layer: Layer<Authenticator>; local.jwks: the public key set
53
+
54
+ // or one layer with both the Authenticator and the AuthJwtLocalIssuer service
55
+ const dev = AuthJwtLocal.layer({ audience: "http://localhost:3000/api" });
56
+ ```
57
+
58
+ Whoever can call `issue` can sign in as anyone. `AuthJwtLocal.make` and `.layer` refuse outside development/test themselves with unavailable `AuthJwtConfigInvalid`. They read NODE_ENV through Effect Config, trim and lowercase it, and accept only `development` or `test`. A host NODE_ENV, when present, must also pass. Missing and blank values refuse.
59
+
60
+ The public `requireDevelopmentSigning(refused)` helper applies that same guard and returns the caller's typed error from `refused()`. Identity reuses it for devLocal and ephemeral passwords signing.
61
+
62
+ `issue` drops the reserved claims `iss`, `aud`, `exp`, `iat`, `nbf` and `jti` from the claims you pass. The issuer sets `iss`, `iat` and `exp`, and sets `aud` only when you configured an audience; otherwise the token has no `aud`.
63
+
64
+ Version 0.1.0 is prepared for its first public release, not yet live on npm. Pin `@house-rules/application` 0.2.0, `@house-rules/domain` 0.2.0 and Effect 4.0.3 exactly. It depends on `jose` 6.2.12. See `AGENTS.md` for options and fixed rules.
@@ -0,0 +1,8 @@
1
+ export { AuthJwt, type AuthJwtVerifier, type VerifiedToken } from "./jwt/auth-jwt.ts";
2
+ export type { ClerkKind } from "./jwt/clerk-kind.ts";
3
+ export { requireDevelopmentSigning } from "./local-issuer/development-signing.ts";
4
+ export { AuthJwtConfigInvalid } from "./jwt/config-invalid.ts";
5
+ export type { AuthJwtOptions, ClerkOptions } from "./jwt/options.ts";
6
+ export { AuthJwtTesting, type TestKeys } from "./test-keys/test-keys.ts";
7
+ export { AuthJwtLocal, type AuthJwtLocalKit, type AuthJwtLocalOptions, } from "./local-issuer/auth-jwt-local.ts";
8
+ export { AuthJwtLocalIssuer, type LocalClaims, type LocalIssuerService, } from "./local-issuer/local-issuer.ts";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { AuthJwt } from "./jwt/auth-jwt.js";
2
+ export { requireDevelopmentSigning } from "./local-issuer/development-signing.js";
3
+ export { AuthJwtConfigInvalid } from "./jwt/config-invalid.js";
4
+ export { AuthJwtTesting } from "./test-keys/test-keys.js";
5
+ export { AuthJwtLocal, } from "./local-issuer/auth-jwt-local.js";
6
+ export { AuthJwtLocalIssuer, } from "./local-issuer/local-issuer.js";
@@ -0,0 +1,56 @@
1
+ import { Authenticator, type Principal, TokenRejected } from "@house-rules/application";
2
+ import { Effect, Layer } from "effect";
3
+ import { type JWTHeaderParameters, type JWTVerifyGetKey } from "jose";
4
+ import { clerkIssuer } from "./clerk-issuer.ts";
5
+ import { clerkKind } from "./clerk-kind.ts";
6
+ import { AuthJwtConfigInvalid } from "./config-invalid.ts";
7
+ import type { AuthJwtOptions, ClerkOptions } from "./options.ts";
8
+ export declare const issuerUrl: (value: string) => Effect.Effect<never, AuthJwtConfigInvalid, never> | Effect.Effect<URL, never, never>;
9
+ export type VerifiedToken = Readonly<{
10
+ header: JWTHeaderParameters;
11
+ principal: Principal;
12
+ }>;
13
+ export type AuthJwtVerifier = Readonly<{
14
+ verify: (token: string, options?: Readonly<{
15
+ audience?: string;
16
+ }>) => Effect.Effect<VerifiedToken, TokenRejected>;
17
+ }>;
18
+ export declare const authenticator: (options: AuthJwtOptions, keys: JWTVerifyGetKey, clockTolerance: number) => Authenticator["Service"];
19
+ declare const layer: (options: AuthJwtOptions) => Layer.Layer<Authenticator, AuthJwtConfigInvalid>;
20
+ declare const clerk: (options: ClerkOptions) => Layer.Layer<Authenticator, AuthJwtConfigInvalid>;
21
+ export declare const AuthJwt: {
22
+ readonly layer: typeof layer;
23
+ readonly verifier: (options: Readonly<{
24
+ issuer: string;
25
+ audience?: string;
26
+ jwksUrl?: string;
27
+ clockTolerance?: number;
28
+ jwks?: import("jose").JSONWebKeySet;
29
+ authorizedParties?: ReadonlyArray<string>;
30
+ rejectPending?: boolean;
31
+ }>) => Effect.Effect<Readonly<{
32
+ verify: (token: string, options?: Readonly<{
33
+ audience?: string;
34
+ }>) => Effect.Effect<VerifiedToken, TokenRejected>;
35
+ }>, AuthJwtConfigInvalid, never>;
36
+ readonly clerk: typeof clerk;
37
+ readonly clerkOptions: (options: Readonly<{
38
+ publishableKey: string;
39
+ authorizedParties: ReadonlyArray<string>;
40
+ rejectPending?: boolean;
41
+ audience?: string;
42
+ clockTolerance?: number;
43
+ jwks?: import("jose").JSONWebKeySet;
44
+ }>) => Effect.Effect<Readonly<{
45
+ issuer: string;
46
+ audience?: string;
47
+ jwksUrl?: string;
48
+ clockTolerance?: number;
49
+ jwks?: import("jose").JSONWebKeySet;
50
+ authorizedParties?: ReadonlyArray<string>;
51
+ rejectPending?: boolean;
52
+ }>, AuthJwtConfigInvalid, never>;
53
+ readonly clerkIssuer: typeof clerkIssuer;
54
+ readonly clerkKind: typeof clerkKind;
55
+ };
56
+ export {};
@@ -0,0 +1,101 @@
1
+ import { Authenticator, TokenRejected } from "@house-rules/application";
2
+ import { DateTime, Effect, Layer, Predicate } from "effect";
3
+ import { createLocalJWKSet, createRemoteJWKSet, jwtVerify, } from "jose";
4
+ import { clerkIssuer } from "./clerk-issuer.js";
5
+ import { clerkKind } from "./clerk-kind.js";
6
+ import { AuthJwtConfigInvalid } from "./config-invalid.js";
7
+ import { principalOf } from "./principal-of.js";
8
+ import { rejectionReason } from "./rejection.js";
9
+ const absoluteUrl = (value, option, protocols) => {
10
+ const url = URL.canParse(value) ? new URL(value) : undefined;
11
+ return url !== undefined && protocols.includes(url.protocol)
12
+ ? Effect.succeed(url)
13
+ : Effect.fail(new AuthJwtConfigInvalid({
14
+ option,
15
+ message: `${option} must be an absolute ${protocols.includes("http:") ? "http(s)" : "https"} URL.`,
16
+ }));
17
+ };
18
+ export const issuerUrl = (value) => absoluteUrl(value, "issuer", ["https:", "http:"]);
19
+ const jwksUrlOf = (value) => absoluteUrl(value, "jwksUrl", ["https:"]);
20
+ const toleranceOf = (value) => value === undefined || (Number.isFinite(value) && value >= 0)
21
+ ? Effect.succeed(value ?? 0)
22
+ : Effect.fail(new AuthJwtConfigInvalid({
23
+ option: "clockTolerance",
24
+ message: "clockTolerance must be a finite, non-negative number of seconds.",
25
+ }));
26
+ const keysOf = Effect.fnUntraced(function* (options) {
27
+ if (options.jwks !== undefined)
28
+ return createLocalJWKSet(options.jwks);
29
+ // Keys fetched over plain http could be swapped on the way, so the key set is https only.
30
+ const issuer = options.issuer.replace(/\/+$/u, "");
31
+ const url = yield* jwksUrlOf(options.jwksUrl ?? `${issuer}/.well-known/jwks.json`);
32
+ return createRemoteJWKSet(url);
33
+ });
34
+ const signedVerifier = (options, keys, clockTolerance) => ({
35
+ verify: (token, verifyOptions) => Effect.gen(function* () {
36
+ const now = yield* DateTime.now;
37
+ const audience = verifyOptions?.audience ?? options.audience;
38
+ const { payload, protectedHeader } = yield* Effect.tryPromise({
39
+ try: () => jwtVerify(token, keys, {
40
+ issuer: options.issuer,
41
+ requiredClaims: ["sub", "exp"],
42
+ currentDate: DateTime.toDate(now),
43
+ clockTolerance,
44
+ ...(audience === undefined ? {} : { audience }),
45
+ }),
46
+ catch: (error) => new TokenRejected({ reason: rejectionReason(error) }),
47
+ });
48
+ const azp = payload["azp"];
49
+ if (options.authorizedParties !== undefined &&
50
+ Object.hasOwn(payload, "azp") &&
51
+ (!Predicate.isString(azp) || !options.authorizedParties.includes(azp))) {
52
+ return yield* new TokenRejected({ reason: "invalid" });
53
+ }
54
+ if (options.rejectPending === true && payload["sts"] === "pending") {
55
+ return yield* new TokenRejected({ reason: "invalid" });
56
+ }
57
+ const principal = yield* Effect.fromOption(principalOf(payload)).pipe(Effect.mapError(() => new TokenRejected({ reason: "invalid" })));
58
+ return { header: protectedHeader, principal };
59
+ }),
60
+ });
61
+ export const authenticator = (options, keys, clockTolerance) => {
62
+ const checked = signedVerifier(options, keys, clockTolerance);
63
+ return {
64
+ verify: (token, verifyOptions) => Effect.map(checked.verify(token, verifyOptions), ({ principal }) => principal),
65
+ };
66
+ };
67
+ const verifier = Effect.fnUntraced(function* (options) {
68
+ yield* issuerUrl(options.issuer);
69
+ const clockTolerance = yield* toleranceOf(options.clockTolerance);
70
+ const keys = yield* keysOf(options);
71
+ return signedVerifier(options, keys, clockTolerance);
72
+ });
73
+ const layer = (options) => Layer.effect(Authenticator, Effect.map(verifier(options), (checked) => ({
74
+ verify: (token, verifyOptions) => Effect.map(checked.verify(token, verifyOptions), ({ principal }) => principal),
75
+ })));
76
+ const clerkOptions = Effect.fnUntraced(function* (options) {
77
+ const issuer = yield* Effect.fromOption(clerkIssuer(options.publishableKey)).pipe(Effect.mapError(() => new AuthJwtConfigInvalid({
78
+ option: "publishableKey",
79
+ message: "publishableKey is not a Clerk pk_test_ or pk_live_ key.",
80
+ })));
81
+ if (!Array.isArray(options.authorizedParties) ||
82
+ options.authorizedParties.length === 0 ||
83
+ !options.authorizedParties.every((party) => Predicate.isString(party) && party.trim() !== "")) {
84
+ return yield* new AuthJwtConfigInvalid({
85
+ option: "authorizedParties",
86
+ message: "authorizedParties must be a nonempty list of nonempty strings.",
87
+ });
88
+ }
89
+ const clockTolerance = yield* toleranceOf(options.clockTolerance ?? 5);
90
+ return {
91
+ issuer,
92
+ jwksUrl: `${issuer}/.well-known/jwks.json`,
93
+ clockTolerance,
94
+ authorizedParties: [...options.authorizedParties],
95
+ ...(options.audience === undefined ? {} : { audience: options.audience }),
96
+ ...(options.jwks === undefined ? {} : { jwks: options.jwks }),
97
+ ...(options.rejectPending === undefined ? {} : { rejectPending: options.rejectPending }),
98
+ };
99
+ });
100
+ const clerk = (options) => Layer.unwrap(Effect.map(clerkOptions(options), layer));
101
+ export const AuthJwt = { layer, verifier, clerk, clerkOptions, clerkIssuer, clerkKind };
@@ -0,0 +1,2 @@
1
+ import { Option } from "effect";
2
+ export declare const clerkIssuer: (publishableKey: string) => Option.Option<string>;
@@ -0,0 +1,13 @@
1
+ import { Option, Result } from "effect";
2
+ import { Base64 } from "effect/encoding";
3
+ const keyPattern = /^pk_(?:test|live)_([A-Za-z0-9+/]+={0,2})$/u;
4
+ const hostPattern = /^([a-z0-9.-]+\.[a-z]{2,})\$$/iu;
5
+ const padded = (value) => value.padEnd(Math.ceil(value.length / 4) * 4, "=");
6
+ export const clerkIssuer = (publishableKey) => {
7
+ const encoded = keyPattern.exec(publishableKey)?.[1];
8
+ if (encoded === undefined)
9
+ return Option.none();
10
+ const decoded = Result.getOrUndefined(Base64.decodeString(padded(encoded)));
11
+ const host = decoded === undefined ? undefined : hostPattern.exec(decoded)?.[1];
12
+ return host === undefined ? Option.none() : Option.some(`https://${host.toLowerCase()}`);
13
+ };
@@ -0,0 +1,2 @@
1
+ export type ClerkKind = "session" | "oauth_access" | "machine" | "unknown";
2
+ export declare const clerkKind: (header: Readonly<Record<string, unknown>>, claims: Readonly<Record<string, unknown>>) => ClerkKind;
@@ -0,0 +1,17 @@
1
+ import { Predicate } from "effect";
2
+ export const clerkKind = (header, claims) => {
3
+ if (header["typ"] === "at+jwt" || header["typ"] === "application/at+jwt") {
4
+ return "oauth_access";
5
+ }
6
+ const subject = claims["sub"];
7
+ if (Predicate.isString(subject) && subject.startsWith("mch_"))
8
+ return "machine";
9
+ return (header["typ"] === undefined || header["typ"] === "JWT") &&
10
+ Predicate.isString(subject) &&
11
+ subject.startsWith("user_") &&
12
+ Predicate.isString(claims["sid"]) &&
13
+ claims["sid"] !== "" &&
14
+ !Object.hasOwn(claims, "act")
15
+ ? "session"
16
+ : "unknown";
17
+ };
@@ -0,0 +1,9 @@
1
+ import { Schema } from "effect";
2
+ declare const AuthJwtConfigInvalid_base: Schema.Class<AuthJwtConfigInvalid, Schema.TaggedStruct<"AuthJwtConfigInvalid", {
3
+ readonly kind: Schema.tag<"unavailable">;
4
+ readonly option: Schema.String;
5
+ readonly message: Schema.String;
6
+ }>, import("effect/Cause").YieldableError>;
7
+ export declare class AuthJwtConfigInvalid extends AuthJwtConfigInvalid_base {
8
+ }
9
+ export {};
@@ -0,0 +1,8 @@
1
+ import { errorKind } from "@house-rules/domain/contract";
2
+ import { Schema } from "effect";
3
+ export class AuthJwtConfigInvalid extends Schema.TaggedError()("AuthJwtConfigInvalid", {
4
+ kind: errorKind("unavailable"),
5
+ option: Schema.String,
6
+ message: Schema.String,
7
+ }) {
8
+ }
@@ -0,0 +1,18 @@
1
+ import type { JSONWebKeySet } from "jose";
2
+ export type AuthJwtOptions = Readonly<{
3
+ issuer: string;
4
+ audience?: string;
5
+ jwksUrl?: string;
6
+ clockTolerance?: number;
7
+ jwks?: JSONWebKeySet;
8
+ authorizedParties?: ReadonlyArray<string>;
9
+ rejectPending?: boolean;
10
+ }>;
11
+ export type ClerkOptions = Readonly<{
12
+ publishableKey: string;
13
+ authorizedParties: ReadonlyArray<string>;
14
+ rejectPending?: boolean;
15
+ audience?: string;
16
+ clockTolerance?: number;
17
+ jwks?: JSONWebKeySet;
18
+ }>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import type { Principal } from "@house-rules/application";
2
+ import { Option } from "effect";
3
+ import type { JWTPayload } from "jose";
4
+ export declare const principalOf: (payload: JWTPayload) => Option.Option<Principal>;
@@ -0,0 +1,19 @@
1
+ import { Option, Predicate } from "effect";
2
+ const strings = (value) => Array.isArray(value) ? value.filter(Predicate.isString) : [];
3
+ const words = (value) => value.split(/\s+/u).filter((word) => word !== "");
4
+ const scopesOf = (payload) => {
5
+ if (Predicate.isString(payload["scope"]))
6
+ return words(payload["scope"]);
7
+ const scp = payload["scp"];
8
+ return Predicate.isString(scp) ? words(scp) : strings(scp);
9
+ };
10
+ const audienceOf = (aud) => aud === undefined ? [] : Predicate.isString(aud) ? [aud] : strings(aud);
11
+ export const principalOf = (payload) => Predicate.isString(payload.sub) && payload.sub !== ""
12
+ ? Option.some({
13
+ issuer: payload.iss ?? "",
14
+ subject: payload.sub,
15
+ audience: audienceOf(payload.aud),
16
+ scopes: scopesOf(payload),
17
+ claims: { ...payload },
18
+ })
19
+ : Option.none();
@@ -0,0 +1,2 @@
1
+ import type { TokenRejectedReason } from "@house-rules/application";
2
+ export declare const rejectionReason: (error: unknown) => TokenRejectedReason;
@@ -0,0 +1,21 @@
1
+ import { errors } from "jose";
2
+ export const rejectionReason = (error) => {
3
+ if (error instanceof errors.JWTExpired)
4
+ return "expired";
5
+ if (error instanceof errors.JWTClaimValidationFailed) {
6
+ if (error.claim === "iss")
7
+ return "wrong_issuer";
8
+ if (error.claim === "aud")
9
+ return "wrong_audience";
10
+ return "invalid";
11
+ }
12
+ if (error instanceof errors.JWKSTimeout || error instanceof errors.JWKSInvalid) {
13
+ return "unavailable";
14
+ }
15
+ if (error instanceof errors.JOSEError) {
16
+ // jose fails a JWKS fetch with a non-200 status or a body that is not JSON as a generic error.
17
+ return error.code === "ERR_JOSE_GENERIC" ? "unavailable" : "invalid";
18
+ }
19
+ // Anything that is not a jose error comes from fetching the key set, such as a network failure.
20
+ return "unavailable";
21
+ };
@@ -0,0 +1,22 @@
1
+ import { Authenticator } from "@house-rules/application";
2
+ import { Effect, Layer } from "effect";
3
+ import { AuthJwtConfigInvalid } from "../jwt/config-invalid.ts";
4
+ import { AuthJwtLocalIssuer, type LocalIssuerService } from "./local-issuer.ts";
5
+ export type AuthJwtLocalOptions = Readonly<{
6
+ issuer?: string;
7
+ audience?: string;
8
+ ttlSeconds?: number;
9
+ }>;
10
+ export type AuthJwtLocalKit = LocalIssuerService & Readonly<{
11
+ layer: Layer.Layer<Authenticator>;
12
+ }>;
13
+ declare const layer: (options?: AuthJwtLocalOptions) => Layer.Layer<Authenticator | AuthJwtLocalIssuer, AuthJwtConfigInvalid>;
14
+ export declare const AuthJwtLocal: {
15
+ readonly make: (options?: Readonly<{
16
+ issuer?: string;
17
+ audience?: string;
18
+ ttlSeconds?: number;
19
+ }> | undefined) => Effect.Effect<AuthJwtLocalKit, AuthJwtConfigInvalid, never>;
20
+ readonly layer: typeof layer;
21
+ };
22
+ export {};
@@ -0,0 +1,52 @@
1
+ import { Authenticator } from "@house-rules/application";
2
+ import { Clock, Effect, Layer } from "effect";
3
+ import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose";
4
+ import { authenticator, issuerUrl } from "../jwt/auth-jwt.js";
5
+ import { AuthJwtConfigInvalid } from "../jwt/config-invalid.js";
6
+ import { AuthJwtLocalIssuer } from "./local-issuer.js";
7
+ import { requireDevelopmentSigning } from "./development-signing.js";
8
+ const algorithm = "ES256";
9
+ const kid = "house-rules-local";
10
+ const reservedClaims = new Set(["iss", "aud", "exp", "iat", "nbf", "jti"]);
11
+ const ttlOf = (value) => value === undefined || (Number.isInteger(value) && value > 0)
12
+ ? Effect.succeed(value ?? 3600)
13
+ : Effect.fail(new AuthJwtConfigInvalid({
14
+ option: "ttlSeconds",
15
+ message: "ttlSeconds must be a positive whole number of seconds.",
16
+ }));
17
+ const make = Effect.fnUntraced(function* (options = {}) {
18
+ yield* requireDevelopmentSigning(() => new AuthJwtConfigInvalid({
19
+ option: "NODE_ENV",
20
+ message: "Development signing requires NODE_ENV development or test.",
21
+ }));
22
+ const issuer = options.issuer ?? "https://dev-issuer.invalid";
23
+ yield* issuerUrl(issuer);
24
+ const ttl = yield* ttlOf(options.ttlSeconds);
25
+ // A fresh key pair per issuer, kept in memory: its tokens die with the process.
26
+ const pair = yield* Effect.promise(() => generateKeyPair(algorithm));
27
+ const publicKey = yield* Effect.promise(() => exportJWK(pair.publicKey));
28
+ const jwks = { keys: [{ ...publicKey, kid, alg: algorithm, use: "sig" }] };
29
+ const verifier = authenticator({ issuer, jwks, ...(options.audience === undefined ? {} : { audience: options.audience }) }, createLocalJWKSet(jwks), 0);
30
+ const issue = (claims) => Effect.gen(function* () {
31
+ const now = Math.floor((yield* Clock.currentTimeMillis) / 1000);
32
+ // Reserved claims are the issuer's alone: a caller cannot mint a token that never expires or names another resource.
33
+ const payload = {
34
+ ...Object.fromEntries(Object.entries(claims).filter(([name]) => !reservedClaims.has(name))),
35
+ iss: issuer,
36
+ iat: now,
37
+ exp: now + ttl,
38
+ ...(options.audience === undefined ? {} : { aud: options.audience }),
39
+ };
40
+ return yield* Effect.promise(() => new SignJWT(payload).setProtectedHeader({ alg: algorithm, kid }).sign(pair.privateKey));
41
+ });
42
+ const kit = {
43
+ issuer,
44
+ jwks,
45
+ issue,
46
+ layer: Layer.succeed(Authenticator, verifier),
47
+ ...(options.audience === undefined ? {} : { audience: options.audience }),
48
+ };
49
+ return kit;
50
+ });
51
+ const layer = (options = {}) => Layer.unwrap(Effect.map(make(options), (kit) => Layer.merge(kit.layer, Layer.succeed(AuthJwtLocalIssuer, kit))));
52
+ export const AuthJwtLocal = { make, layer };
@@ -0,0 +1,2 @@
1
+ import { Effect } from "effect";
2
+ export declare const requireDevelopmentSigning: <E>(refused: () => E) => Effect.Effect<void, E>;
@@ -0,0 +1,10 @@
1
+ import { Config, Effect } from "effect";
2
+ const allowed = (value) => ["development", "test"].includes(value.trim().toLowerCase());
3
+ export const requireDevelopmentSigning = (refused) => Effect.gen(function* () {
4
+ const environment = yield* Config.String("NODE_ENV").pipe(Config.withDefault(""), Effect.mapError(refused));
5
+ const host = globalThis.process?.env
6
+ ?.NODE_ENV;
7
+ // A custom ConfigProvider cannot conceal a non-development host.
8
+ if (!allowed(environment) || (host !== undefined && !allowed(host)))
9
+ return yield* Effect.fail(refused());
10
+ });
@@ -0,0 +1,22 @@
1
+ import { Context, type Effect } from "effect";
2
+ import type { JSONWebKeySet } from "jose";
3
+ export type LocalClaims = Readonly<{
4
+ sub: string;
5
+ name?: string;
6
+ [claim: string]: unknown;
7
+ }>;
8
+ export type LocalIssuerService = Readonly<{
9
+ issuer: string;
10
+ audience?: string;
11
+ jwks: JSONWebKeySet;
12
+ issue: (claims: LocalClaims) => Effect.Effect<string>;
13
+ }>;
14
+ declare const AuthJwtLocalIssuer_base: Context.ServiceClass<AuthJwtLocalIssuer, "@house-rules/auth-jwt/AuthJwtLocalIssuer", Readonly<{
15
+ issuer: string;
16
+ audience?: string;
17
+ jwks: JSONWebKeySet;
18
+ issue: (claims: LocalClaims) => Effect.Effect<string>;
19
+ }>>;
20
+ export declare class AuthJwtLocalIssuer extends AuthJwtLocalIssuer_base {
21
+ }
22
+ export {};
@@ -0,0 +1,3 @@
1
+ import { Context } from "effect";
2
+ export class AuthJwtLocalIssuer extends Context.Service()("@house-rules/auth-jwt/AuthJwtLocalIssuer") {
3
+ }
@@ -0,0 +1,12 @@
1
+ import { Effect } from "effect";
2
+ import { type JSONWebKeySet, type JWTHeaderParameters } from "jose";
3
+ export type TestKeys = Readonly<{
4
+ jwks: JSONWebKeySet;
5
+ sign: (payload: Readonly<Record<string, unknown>>, header?: Readonly<Partial<JWTHeaderParameters>>) => Effect.Effect<string>;
6
+ }>;
7
+ export declare const AuthJwtTesting: {
8
+ readonly keys: Effect.Effect<Readonly<{
9
+ jwks: JSONWebKeySet;
10
+ sign: (payload: Readonly<Record<string, unknown>>, header?: Readonly<Partial<JWTHeaderParameters>>) => Effect.Effect<string>;
11
+ }>, never, never>;
12
+ };
@@ -0,0 +1,19 @@
1
+ import { Clock, Effect } from "effect";
2
+ import { exportJWK, generateKeyPair, SignJWT, } from "jose";
3
+ const algorithm = "ES256";
4
+ const kid = "house-rules-test";
5
+ const keys = Effect.gen(function* () {
6
+ const pair = yield* Effect.promise(() => generateKeyPair(algorithm, { extractable: true }));
7
+ const publicKey = yield* Effect.promise(() => exportJWK(pair.publicKey));
8
+ return {
9
+ jwks: { keys: [{ ...publicKey, kid, alg: algorithm, use: "sig" }] },
10
+ sign: (payload, header) => Effect.gen(function* () {
11
+ const now = Math.floor((yield* Clock.currentTimeMillis) / 1000);
12
+ const claims = { iat: now, exp: now + 600, ...payload };
13
+ return yield* Effect.promise(() => new SignJWT(claims)
14
+ .setProtectedHeader({ alg: algorithm, kid, ...header })
15
+ .sign(pair.privateKey));
16
+ }),
17
+ };
18
+ });
19
+ export const AuthJwtTesting = { keys };
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@house-rules/auth-jwt",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Fills the House Rules Authenticator slot by verifying JWTs against a JWKS, with a Clerk preset.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "dist",
9
+ "AGENTS.md",
10
+ "README.md"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "peerDependencies": {
22
+ "@house-rules/application": "0.3.0",
23
+ "@house-rules/domain": "0.2.1",
24
+ "effect": "4.0.3"
25
+ },
26
+ "devDependencies": {
27
+ "@effect/vitest": "4.0.3",
28
+ "@house-rules/application": "0.3.0",
29
+ "@house-rules/domain": "0.2.1",
30
+ "effect": "4.0.3",
31
+ "typescript": "7.0.2",
32
+ "vitest": "5.0.1"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/JakubSzwajka/house-rules.git",
37
+ "directory": "packages/auth-jwt"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/JakubSzwajka/house-rules/issues"
41
+ },
42
+ "homepage": "https://github.com/JakubSzwajka/house-rules",
43
+ "dependencies": {
44
+ "jose": "6.2.12"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc --project tsconfig.build.json",
48
+ "typecheck": "tsc --project tsconfig.json",
49
+ "test": "varlock run --path ../../ -- vitest run"
50
+ }
6
51
  }