@yielded/jose 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/LICENSE +21 -0
- package/README.md +30 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/dist/Errors.d.mts +33 -0
- package/dist/Errors.mjs +49 -0
- package/dist/Errors.mjs.map +1 -0
- package/dist/Jwe.d.mts +35 -0
- package/dist/Jwe.mjs +66 -0
- package/dist/Jwe.mjs.map +1 -0
- package/dist/Jwk.d.mts +273 -0
- package/dist/Jwk.mjs +195 -0
- package/dist/Jwk.mjs.map +1 -0
- package/dist/Jwks.d.mts +71 -0
- package/dist/Jwks.mjs +169 -0
- package/dist/Jwks.mjs.map +1 -0
- package/dist/Jws.d.mts +53 -0
- package/dist/Jws.mjs +108 -0
- package/dist/Jws.mjs.map +1 -0
- package/dist/Jwt.d.mts +69 -0
- package/dist/Jwt.mjs +145 -0
- package/dist/Jwt.mjs.map +1 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +7 -0
- package/dist/internal/algorithms.mjs +11 -0
- package/dist/internal/algorithms.mjs.map +1 -0
- package/dist/internal/encoding.mjs +39 -0
- package/dist/internal/encoding.mjs.map +1 -0
- package/dist/internal/keyInput.mjs +44 -0
- package/dist/internal/keyInput.mjs.map +1 -0
- package/dist/internal/keyPolicy.mjs +11 -0
- package/dist/internal/keyPolicy.mjs.map +1 -0
- package/package.json +1 -0
- package/src/Errors.ts +55 -0
- package/src/Jwe.ts +117 -0
- package/src/Jwk.ts +269 -0
- package/src/Jwks.ts +366 -0
- package/src/Jws.ts +206 -0
- package/src/Jwt.ts +187 -0
- package/src/index.ts +6 -0
- package/src/internal/algorithms.ts +10 -0
- package/src/internal/encoding.ts +72 -0
- package/src/internal/keyInput.ts +75 -0
- package/src/internal/keyPolicy.ts +18 -0
package/src/Jwt.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { Signature } from "@yielded/crypto/Signature";
|
|
2
|
+
import { Clock, Effect, Redacted, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
import { ClaimValidationFailed, type VerifyError } from "./Errors";
|
|
5
|
+
import {
|
|
6
|
+
decode,
|
|
7
|
+
json,
|
|
8
|
+
JsonObject,
|
|
9
|
+
parse,
|
|
10
|
+
reveal,
|
|
11
|
+
split,
|
|
12
|
+
stringify,
|
|
13
|
+
utf8,
|
|
14
|
+
} from "./internal/encoding";
|
|
15
|
+
import type { SigningKey, VerificationKey } from "./Jwk";
|
|
16
|
+
import type { Jwks } from "./Jwks";
|
|
17
|
+
import { type Header, type Requirements, VerifyOptions as JwsVerifyOptions } from "./Jws";
|
|
18
|
+
import * as Jws from "./Jws";
|
|
19
|
+
|
|
20
|
+
export const RegisteredClaims = Schema.Struct({
|
|
21
|
+
iss: Schema.optionalKey(Schema.String),
|
|
22
|
+
sub: Schema.optionalKey(Schema.String),
|
|
23
|
+
aud: Schema.optionalKey(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
|
|
24
|
+
exp: Schema.optionalKey(Schema.Finite),
|
|
25
|
+
nbf: Schema.optionalKey(Schema.Finite),
|
|
26
|
+
iat: Schema.optionalKey(Schema.Finite),
|
|
27
|
+
jti: Schema.optionalKey(Schema.String),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export type RegisteredClaims = typeof RegisteredClaims.Type;
|
|
31
|
+
|
|
32
|
+
const expected = Schema.Union([
|
|
33
|
+
Schema.String,
|
|
34
|
+
Schema.Array(Schema.String).check(Schema.isMinLength(1)),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const seconds = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0));
|
|
38
|
+
|
|
39
|
+
export const VerifyOptions = Schema.Struct({
|
|
40
|
+
...JwsVerifyOptions.fields,
|
|
41
|
+
issuer: Schema.optionalKey(expected),
|
|
42
|
+
audience: Schema.optionalKey(expected),
|
|
43
|
+
subject: Schema.optionalKey(Schema.String),
|
|
44
|
+
typ: Schema.optionalKey(Schema.NonEmptyString),
|
|
45
|
+
clockTolerance: Schema.optionalKey(seconds),
|
|
46
|
+
maxTokenAge: Schema.optionalKey(seconds),
|
|
47
|
+
requiredClaims: Schema.optionalKey(
|
|
48
|
+
Schema.Array(Schema.Literals(["iss", "sub", "aud", "exp", "nbf", "iat", "jti"])),
|
|
49
|
+
),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export type VerifyOptions = typeof VerifyOptions.Type;
|
|
53
|
+
|
|
54
|
+
export interface Verified<A> {
|
|
55
|
+
readonly claims: A;
|
|
56
|
+
readonly registeredClaims: RegisteredClaims;
|
|
57
|
+
readonly protectedHeader: Header;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type JwtError = VerifyError | ClaimValidationFailed | Schema.SchemaError;
|
|
61
|
+
|
|
62
|
+
const checkClaims = Effect.fnUntraced(function* (
|
|
63
|
+
claims: Readonly<Record<string, Schema.Json>>,
|
|
64
|
+
header: Header,
|
|
65
|
+
options: VerifyOptions,
|
|
66
|
+
) {
|
|
67
|
+
const registered = yield* Schema.decodeUnknownEffect(RegisteredClaims)(claims, {
|
|
68
|
+
reportInput: false,
|
|
69
|
+
}).pipe(
|
|
70
|
+
Effect.mapError(() => ClaimValidationFailed.make({ claim: "registered", reason: "invalid" })),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
for (const claim of options.requiredClaims ?? []) {
|
|
74
|
+
if (registered[claim] === undefined)
|
|
75
|
+
return yield* ClaimValidationFailed.make({ claim, reason: "missing" });
|
|
76
|
+
}
|
|
77
|
+
if (options.issuer !== undefined) {
|
|
78
|
+
if (registered.iss === undefined)
|
|
79
|
+
return yield* ClaimValidationFailed.make({ claim: "iss", reason: "missing" });
|
|
80
|
+
const expected = typeof options.issuer === "string" ? [options.issuer] : options.issuer;
|
|
81
|
+
|
|
82
|
+
if (!expected.includes(registered.iss))
|
|
83
|
+
return yield* ClaimValidationFailed.make({ claim: "iss", reason: "mismatch" });
|
|
84
|
+
}
|
|
85
|
+
if (options.subject !== undefined) {
|
|
86
|
+
if (registered.sub === undefined)
|
|
87
|
+
return yield* ClaimValidationFailed.make({ claim: "sub", reason: "missing" });
|
|
88
|
+
if (options.subject !== registered.sub)
|
|
89
|
+
return yield* ClaimValidationFailed.make({ claim: "sub", reason: "mismatch" });
|
|
90
|
+
}
|
|
91
|
+
if (options.audience !== undefined) {
|
|
92
|
+
if (registered.aud === undefined)
|
|
93
|
+
return yield* ClaimValidationFailed.make({ claim: "aud", reason: "missing" });
|
|
94
|
+
const expected = typeof options.audience === "string" ? [options.audience] : options.audience;
|
|
95
|
+
const actual = typeof registered.aud === "string" ? [registered.aud] : registered.aud;
|
|
96
|
+
|
|
97
|
+
if (!expected.some((audience) => actual.includes(audience)))
|
|
98
|
+
return yield* ClaimValidationFailed.make({ claim: "aud", reason: "mismatch" });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const normalizedType = (value: string) => {
|
|
102
|
+
const type = value.toLowerCase();
|
|
103
|
+
|
|
104
|
+
return type.includes("/") ? type : `application/${type}`;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (
|
|
108
|
+
options.typ !== undefined &&
|
|
109
|
+
(header.typ === undefined || normalizedType(header.typ) !== normalizedType(options.typ))
|
|
110
|
+
)
|
|
111
|
+
return yield* ClaimValidationFailed.make({ claim: "typ", reason: "mismatch" });
|
|
112
|
+
const now = Math.floor((yield* Clock.currentTimeMillis) / 1000);
|
|
113
|
+
const tolerance = options.clockTolerance ?? 0;
|
|
114
|
+
|
|
115
|
+
if (registered.exp !== undefined && registered.exp <= now - tolerance)
|
|
116
|
+
return yield* ClaimValidationFailed.make({ claim: "exp", reason: "expired" });
|
|
117
|
+
if (registered.nbf !== undefined && registered.nbf > now + tolerance)
|
|
118
|
+
return yield* ClaimValidationFailed.make({ claim: "nbf", reason: "notYetValid" });
|
|
119
|
+
if (options.maxTokenAge !== undefined) {
|
|
120
|
+
if (registered.iat === undefined)
|
|
121
|
+
return yield* ClaimValidationFailed.make({ claim: "iat", reason: "missing" });
|
|
122
|
+
if (registered.iat > now + tolerance)
|
|
123
|
+
return yield* ClaimValidationFailed.make({ claim: "iat", reason: "future" });
|
|
124
|
+
if (now - registered.iat - tolerance > options.maxTokenAge)
|
|
125
|
+
return yield* ClaimValidationFailed.make({ claim: "iat", reason: "tooOld" });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return registered;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const decodeClaims = Effect.fnUntraced(function* <S extends Schema.Constraint>(
|
|
132
|
+
schema: S,
|
|
133
|
+
verified: Jws.Verified,
|
|
134
|
+
options: VerifyOptions,
|
|
135
|
+
) {
|
|
136
|
+
const wire = yield* json(yield* reveal(verified.payload), "payload");
|
|
137
|
+
const registeredClaims = yield* checkClaims(wire, verified.protectedHeader, options);
|
|
138
|
+
const claims = yield* Schema.decodeUnknownEffect(schema)(wire, { reportInput: false });
|
|
139
|
+
|
|
140
|
+
return { claims, registeredClaims, protectedHeader: verified.protectedHeader } satisfies Verified<
|
|
141
|
+
S["Type"]
|
|
142
|
+
>;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export const sign = Effect.fnUntraced(function* <S extends Schema.Constraint>(
|
|
146
|
+
schema: S,
|
|
147
|
+
claims: S["Type"],
|
|
148
|
+
key: SigningKey,
|
|
149
|
+
header: Header,
|
|
150
|
+
): Effect.fn.Return<Redacted.Redacted<string>, JwtError, Requirements | S["EncodingServices"]> {
|
|
151
|
+
const encoded = yield* Schema.encodeEffect(schema)(claims, { reportInput: false });
|
|
152
|
+
const wire = yield* Schema.decodeUnknownEffect(JsonObject)(encoded, { reportInput: false });
|
|
153
|
+
|
|
154
|
+
yield* Schema.decodeUnknownEffect(RegisteredClaims)(wire, { reportInput: false });
|
|
155
|
+
|
|
156
|
+
return yield* Jws.sign(Redacted.make(utf8(yield* stringify(wire))), key, header);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
export const verify = Effect.fnUntraced(function* <S extends Schema.Constraint>(
|
|
160
|
+
schema: S,
|
|
161
|
+
token: Redacted.Redacted<string>,
|
|
162
|
+
key: VerificationKey,
|
|
163
|
+
options: VerifyOptions,
|
|
164
|
+
): Effect.fn.Return<Verified<S["Type"]>, JwtError, Requirements | S["DecodingServices"]> {
|
|
165
|
+
// Capture all policy before asynchronous cryptography or key resolution.
|
|
166
|
+
const policy = yield* parse(VerifyOptions, options, "parameters");
|
|
167
|
+
|
|
168
|
+
return yield* decodeClaims(schema, yield* Jws.verify(token, key, policy), policy);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
export const verifyWithKeySet = Effect.fnUntraced(function* <S extends Schema.Constraint>(
|
|
172
|
+
schema: S,
|
|
173
|
+
token: Redacted.Redacted<string>,
|
|
174
|
+
options: VerifyOptions,
|
|
175
|
+
): Effect.fn.Return<Verified<S["Type"]>, JwtError, Signature | Jwks | S["DecodingServices"]> {
|
|
176
|
+
const policy = yield* parse(VerifyOptions, options, "parameters");
|
|
177
|
+
|
|
178
|
+
return yield* decodeClaims(schema, yield* Jws.verifyWithKeySet(token, policy), policy);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** Parsing only. This result does not establish authenticity or validate claims. */
|
|
182
|
+
export const decodeUnverified = Effect.fnUntraced(function* (token: Redacted.Redacted<string>) {
|
|
183
|
+
const [, payload] = yield* split(token, 3);
|
|
184
|
+
const claims = yield* json(yield* decode(payload), "payload");
|
|
185
|
+
|
|
186
|
+
return { _tag: "UnverifiedJwt", claims: Redacted.make(claims) } as const;
|
|
187
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Algorithm } from "@yielded/crypto/Signature";
|
|
2
|
+
|
|
3
|
+
import type { AsymmetricAlgorithm } from "../Jwk";
|
|
4
|
+
|
|
5
|
+
export const algorithms = {
|
|
6
|
+
ES256: "ECDSA-P256-SHA256",
|
|
7
|
+
RS256: "RSASSA-PKCS1-v1_5-SHA256",
|
|
8
|
+
PS256: "RSA-PSS-SHA256",
|
|
9
|
+
EdDSA: "Ed25519",
|
|
10
|
+
} satisfies Record<AsymmetricAlgorithm, Algorithm>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Effect, Redacted, Result, Schema } from "effect";
|
|
2
|
+
import { Base64Url } from "effect/encoding";
|
|
3
|
+
|
|
4
|
+
import { InvalidToken } from "../Errors";
|
|
5
|
+
|
|
6
|
+
export const JsonObject = Schema.Record(Schema.String, Schema.Json);
|
|
7
|
+
export const utf8 = (value: string) => new TextEncoder().encode(value);
|
|
8
|
+
|
|
9
|
+
const Segment = Schema.String.check(
|
|
10
|
+
Schema.isPattern(/^[A-Za-z0-9_-]*$/),
|
|
11
|
+
Schema.isMaxLength(65536),
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const Token = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(65536));
|
|
15
|
+
|
|
16
|
+
export const parse = <S extends Schema.Constraint>(
|
|
17
|
+
schema: S,
|
|
18
|
+
input: unknown,
|
|
19
|
+
reason: InvalidToken["reason"],
|
|
20
|
+
) =>
|
|
21
|
+
Effect.suspend(() => Schema.decodeUnknownEffect(schema)(input, { reportInput: false })).pipe(
|
|
22
|
+
Effect.mapError(() => InvalidToken.make({ reason })),
|
|
23
|
+
// Arbitrary nested input can still invoke foreign getters during decoding.
|
|
24
|
+
Effect.catchDefect(() => InvalidToken.make({ reason })),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export const reveal = <A>(value: Redacted.Redacted<A>) =>
|
|
28
|
+
Effect.try({
|
|
29
|
+
try: () => Redacted.value(value),
|
|
30
|
+
catch: () => InvalidToken.make({ reason: "parameters" }),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const decode = Effect.fnUntraced(function* (
|
|
34
|
+
value: string,
|
|
35
|
+
reason: InvalidToken["reason"] = "serialization",
|
|
36
|
+
) {
|
|
37
|
+
yield* parse(Segment, value, reason);
|
|
38
|
+
|
|
39
|
+
const bytes = yield* Result.match(Base64Url.decode(value), {
|
|
40
|
+
onFailure: () => Effect.fail(InvalidToken.make({ reason })),
|
|
41
|
+
onSuccess: Effect.succeed,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Canonical, unpadded base64url also rejects non-zero unused bits.
|
|
45
|
+
if (Base64Url.encode(bytes) !== value) return yield* InvalidToken.make({ reason });
|
|
46
|
+
|
|
47
|
+
return bytes;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const json = Effect.fnUntraced(function* (
|
|
51
|
+
bytes: Uint8Array,
|
|
52
|
+
reason: InvalidToken["reason"],
|
|
53
|
+
) {
|
|
54
|
+
const value = yield* Effect.try({
|
|
55
|
+
try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
56
|
+
catch: () => InvalidToken.make({ reason }),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return yield* parse(Schema.fromJsonString(JsonObject), value, reason);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const stringify = (value: Readonly<Record<string, Schema.Json>>) =>
|
|
63
|
+
Schema.encodeEffect(Schema.fromJsonString(JsonObject))(value, { reportInput: false });
|
|
64
|
+
|
|
65
|
+
export const split = Effect.fnUntraced(function* (input: Redacted.Redacted<string>, count: number) {
|
|
66
|
+
const value = yield* parse(Token, yield* reveal(input), "serialization");
|
|
67
|
+
const parts = value.split(".");
|
|
68
|
+
|
|
69
|
+
if (parts.length !== count) return yield* InvalidToken.make({ reason: "serialization" });
|
|
70
|
+
|
|
71
|
+
return parts;
|
|
72
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Predicate, Result, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
import { InvalidKey } from "../Errors";
|
|
4
|
+
|
|
5
|
+
export const maxArrayLength = 256;
|
|
6
|
+
|
|
7
|
+
const ArrayLength = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: maxArrayLength }));
|
|
8
|
+
|
|
9
|
+
/** Copy only own fields, evaluating accessors once before Schema validation. */
|
|
10
|
+
export const object = (input: unknown): unknown => {
|
|
11
|
+
if (!Predicate.isObject(input)) return input;
|
|
12
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
13
|
+
const copy: Record<string, unknown> = {};
|
|
14
|
+
|
|
15
|
+
Object.setPrototypeOf(copy, null);
|
|
16
|
+
for (const name of Object.getOwnPropertyNames(descriptors)) {
|
|
17
|
+
const descriptor = descriptors[name];
|
|
18
|
+
|
|
19
|
+
copy[name] = Object.hasOwn(descriptor, "value")
|
|
20
|
+
? descriptor.value
|
|
21
|
+
: descriptor.get?.call(input);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return copy;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const array = (input: ReadonlyArray<unknown>) =>
|
|
28
|
+
Result.gen(function* () {
|
|
29
|
+
const length = yield* Schema.decodeUnknownResult(ArrayLength)(input.length).pipe(
|
|
30
|
+
Result.mapError(() => InvalidKey.make({})),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// Capture bounded own slots before any accessor can mutate the array.
|
|
34
|
+
const descriptors = Array.from({ length }, (_, index) =>
|
|
35
|
+
Object.getOwnPropertyDescriptor(input, index),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return descriptors.map((descriptor) =>
|
|
39
|
+
descriptor === undefined
|
|
40
|
+
? undefined
|
|
41
|
+
: Object.hasOwn(descriptor, "value")
|
|
42
|
+
? descriptor.value
|
|
43
|
+
: descriptor.get?.call(input),
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const jwk = (input: unknown) =>
|
|
48
|
+
Result.gen(function* () {
|
|
49
|
+
const copy = object(input);
|
|
50
|
+
|
|
51
|
+
if (Predicate.isObject(copy) && Array.isArray(copy.key_ops))
|
|
52
|
+
copy.key_ops = yield* array(copy.key_ops);
|
|
53
|
+
|
|
54
|
+
return copy;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const keySet = (input: unknown) =>
|
|
58
|
+
Result.gen(function* () {
|
|
59
|
+
const copy = object(input);
|
|
60
|
+
|
|
61
|
+
if (Predicate.isObject(copy) && Array.isArray(copy.keys)) {
|
|
62
|
+
const keys = yield* array(copy.keys);
|
|
63
|
+
|
|
64
|
+
copy.keys = yield* Result.all(keys.map(jwk));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return copy;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** Struct decoding creates ordinary objects; detach owned output as well. */
|
|
71
|
+
export const detach = <A>(value: A): A => {
|
|
72
|
+
if (Predicate.isObject(value)) Object.setPrototypeOf(value, null);
|
|
73
|
+
|
|
74
|
+
return value;
|
|
75
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
import { InvalidKey } from "../Errors";
|
|
4
|
+
import type { PrivateJwk, PublicJwk, SecretJwk } from "../Jwk";
|
|
5
|
+
|
|
6
|
+
export const check = (
|
|
7
|
+
jwk: PublicJwk | PrivateJwk | SecretJwk,
|
|
8
|
+
algorithm: string,
|
|
9
|
+
operation: "sign" | "verify" | "encrypt" | "decrypt",
|
|
10
|
+
) => {
|
|
11
|
+
const use = operation === "sign" || operation === "verify" ? "sig" : "enc";
|
|
12
|
+
|
|
13
|
+
return (jwk.alg !== undefined && jwk.alg !== algorithm) ||
|
|
14
|
+
(jwk.use !== undefined && jwk.use !== use) ||
|
|
15
|
+
(jwk.key_ops !== undefined && !jwk.key_ops.includes(operation))
|
|
16
|
+
? Effect.fail(InvalidKey.make({}))
|
|
17
|
+
: Effect.void;
|
|
18
|
+
};
|