@ghub/gopencsr-client 0.1.0 → 0.2.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,9 @@
1
+ /**
2
+ * RFC 8785 canonical JSON (JCS), as @gopencsr/contracts writes it: one value always has one
3
+ * serialisation, so a registry computes the same change hash GOpenCSR's signing step shows and
4
+ * signs. Object keys are sorted by UTF-16 code units, which is how JavaScript compares strings;
5
+ * strings and numbers are written as ECMAScript's JSON.stringify writes them, which is what RFC
6
+ * 8785 specifies.
7
+ */
8
+ export declare function canonicalJson(value: unknown): string;
9
+ //# sourceMappingURL=canonical.d.ts.map
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.canonicalJson = canonicalJson;
4
+ /**
5
+ * RFC 8785 canonical JSON (JCS), as @gopencsr/contracts writes it: one value always has one
6
+ * serialisation, so a registry computes the same change hash GOpenCSR's signing step shows and
7
+ * signs. Object keys are sorted by UTF-16 code units, which is how JavaScript compares strings;
8
+ * strings and numbers are written as ECMAScript's JSON.stringify writes them, which is what RFC
9
+ * 8785 specifies.
10
+ */
11
+ function canonicalJson(value) {
12
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
13
+ return JSON.stringify(value);
14
+ }
15
+ if (typeof value === "number") {
16
+ if (!Number.isFinite(value))
17
+ throw new Error("canonical JSON has no form for a non-finite number");
18
+ return JSON.stringify(value);
19
+ }
20
+ if (Array.isArray(value))
21
+ return `[${value.map(canonicalJson).join(",")}]`;
22
+ if (typeof value === "object") {
23
+ // A plain object only. A Date, a Buffer or a Decimal would be written through its own
24
+ // members here, while JSON.stringify and a jsonb column go through its toJSON: the log and
25
+ // the database would then hold two different bodies.
26
+ const prototype = Object.getPrototypeOf(value);
27
+ if (prototype !== Object.prototype && prototype !== null) {
28
+ const kind = value.constructor?.name;
29
+ throw new Error(`canonical JSON has no form for a ${kind ?? "non-plain object"}`);
30
+ }
31
+ const members = Object.entries(value)
32
+ .filter(([, member]) => member !== undefined)
33
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
34
+ return `{${members.map(([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`).join(",")}}`;
35
+ }
36
+ throw new Error(`canonical JSON has no form for a ${typeof value}`);
37
+ }
38
+ //# sourceMappingURL=canonical.js.map
@@ -0,0 +1,218 @@
1
+ /** GOpenCSR could not be asked: unreachable, too slow, or failing on its side. */
2
+ export declare class CsrUnavailable extends Error {
3
+ readonly name = "CsrUnavailable";
4
+ }
5
+ /** GOpenCSR answered, and refused: what it said, for the log and the audit record. */
6
+ export declare class CsrRefused extends Error {
7
+ readonly status: number;
8
+ readonly reason: string;
9
+ readonly name = "CsrRefused";
10
+ constructor(status: number, reason: string);
11
+ }
12
+ /** What GOpenCSR's tokens say when the sign-in was a passkey (GOPENCSR-15). */
13
+ export declare const ACR_PASSKEY = "urn:gopencsr:acr:passkey";
14
+ /** What a registry asks GOpenCSR to tell it: who, their address and name, and their passkeys. */
15
+ export declare const SCOPE = "openid email profile roles";
16
+ /** The registries GOpenCSR signs changes for; every change hash names its own. */
17
+ export type Product = "cdr" | "cnr" | "csr";
18
+ /** The person as GOpenCSR's ID token describes them, checked. */
19
+ export interface Identity {
20
+ /** GOpenCSR's subject, the person's account id in every registry. */
21
+ sub: string;
22
+ acr: string;
23
+ /** When the person last signed in at GOpenCSR, by `acr`. */
24
+ authTime: Date;
25
+ /** GOpenCSR's grant. */
26
+ sid: string | null;
27
+ email: string | null;
28
+ emailVerified: boolean;
29
+ name: string | null;
30
+ /** How many passkeys the account holds at GOpenCSR, or null when the token did not say. */
31
+ passkeys: number | null;
32
+ /**
33
+ * The person's standing with the registry's documents, which GOpenCSR states in every token for
34
+ * the registry to enforce (consent per product): agreed; asked, with the date the version they
35
+ * have not accepted takes effect; restricted, allowed to sign in, read, export and use their
36
+ * data-protection rights and nothing else; null when the token said nothing.
37
+ */
38
+ terms: {
39
+ state: string;
40
+ inForceFrom: Date | null;
41
+ } | null;
42
+ }
43
+ /** A token response, its ID token checked. */
44
+ export interface CsrTokens {
45
+ refreshToken: string;
46
+ accessExpiresAt: Date;
47
+ identity: Identity;
48
+ }
49
+ /** A change as GOpenCSR's signing step shows it (GOPENCSR-17). */
50
+ export interface ChangeDocument {
51
+ type: string;
52
+ object: string;
53
+ description: string;
54
+ changes: {
55
+ label: string;
56
+ before: string | null;
57
+ after: string | null;
58
+ }[];
59
+ detail?: Record<string, unknown>;
60
+ }
61
+ /** A signing request as GOpenCSR reads it back to the client that made it. */
62
+ export interface SigningState {
63
+ id: string;
64
+ status: "open" | "signed" | "declined" | "moved" | "expired";
65
+ account: string;
66
+ hash: string;
67
+ signaturesRequired: number;
68
+ signatures: {
69
+ passkey: string;
70
+ signedAt: string;
71
+ receipt: string;
72
+ }[];
73
+ }
74
+ /**
75
+ * An entry of the one log as a registry delivers it (GOPENCSR-44): the registry's own id and time
76
+ * for it, a kind with the registry's prefix (cnr.change.signed), and the public body, ids and
77
+ * hashes only.
78
+ */
79
+ export interface TlogEntry {
80
+ id: string;
81
+ kind: string;
82
+ /** As toISOString() writes it. */
83
+ time: string;
84
+ body: Record<string, unknown>;
85
+ }
86
+ /** An entry as the one log holds it: its leaf byte for byte and, once appended, its index. */
87
+ export interface TlogEntryState {
88
+ id: string;
89
+ kind: string;
90
+ time: string;
91
+ leaf: string;
92
+ index: number | null;
93
+ appendedAt: string | null;
94
+ }
95
+ /** One passkey's signature, as GOpenCSR's receipt states it, checked. */
96
+ export interface Receipt {
97
+ passkey: string;
98
+ hash: string;
99
+ }
100
+ /**
101
+ * The SHA-256 GOpenCSR's signing step commits to, in hex: the change's canonical form with the
102
+ * registry's product added, as GOpenCSR computes it.
103
+ */
104
+ export declare function changeHash(product: Product, change: ChangeDocument): string;
105
+ /** How a registry is GOpenCSR's client. */
106
+ export interface CsrClientOptions {
107
+ /** GOpenCSR's issuer, the origin people sign in at, such as https://csr.gplatform.org. */
108
+ issuer: string;
109
+ /**
110
+ * Where the registry's own calls to GOpenCSR go, when not the issuer: a direct link between
111
+ * the hosts. The browser always goes to the issuer.
112
+ */
113
+ endpoints?: string | undefined;
114
+ clientId: string;
115
+ /** Where GOpenCSR sends the browser back, one of the client's registered redirect URIs. */
116
+ redirectUri: string;
117
+ /** The registry this client is, which every change hash names. */
118
+ product: Product;
119
+ /**
120
+ * The client secret, asked for at each authenticated call: the registry keeps it where its
121
+ * secrets are (Vault KV) and reads it again as often as it likes, so a new one takes without a
122
+ * restart.
123
+ */
124
+ clientSecret: () => Promise<string>;
125
+ /** The scope asked for; SCOPE when left out. */
126
+ scope?: string;
127
+ /** The tests' own fetch and clock. */
128
+ fetch?: typeof fetch;
129
+ now?: () => number;
130
+ }
131
+ /**
132
+ * A registry as GOpenCSR's client (GOPENCDR-333, extracted from GOpenCDR's api for every
133
+ * registry): the authorisation code flow with PKCE for sign-in and step-up, refresh and revocation
134
+ * of the tokens a session holds, the signing step that a change asks one or two passkeys through,
135
+ * and the delivery of the registry's entries to the one log. Every token is checked against GOpenCSR's published keys (EdDSA), which are read again
136
+ * for a key id not seen yet. Sessions, accounts and what a person may do stay the registry's own.
137
+ */
138
+ export declare class CsrClient {
139
+ private readonly options;
140
+ private keys;
141
+ private readonly fetch;
142
+ private readonly now;
143
+ constructor(options: CsrClientOptions);
144
+ /** GOpenCSR's issuer, the origin people sign in at. */
145
+ get issuer(): string;
146
+ /** The authorisation request a browser takes to GOpenCSR. */
147
+ authorizeUrl(request: {
148
+ state: string;
149
+ nonce: string;
150
+ challenge: string;
151
+ /** For a step-up: a sign-in older than this many seconds is asked for again. */
152
+ maxAge?: number;
153
+ }): string;
154
+ /** The code GOpenCSR sent back, for tokens. The ID token must carry the nonce sent. */
155
+ exchange(code: string, verifier: string, nonce: string): Promise<CsrTokens>;
156
+ /**
157
+ * New tokens for a refresh token, which GOpenCSR replaces at each use; null when it refuses,
158
+ * because the person signed out there or the grant was revoked.
159
+ */
160
+ refresh(refreshToken: string): Promise<CsrTokens | null>;
161
+ /** Revokes a refresh token, and with it GOpenCSR's grant (RFC 7009). */
162
+ revoke(refreshToken: string): Promise<void>;
163
+ /**
164
+ * Asks GOpenCSR for the person's signature over a change (GOPENCSR-17), and checks that the
165
+ * hash it will show and sign is the one the registry computed.
166
+ */
167
+ requestSignature(request: {
168
+ account: string;
169
+ change: ChangeDocument;
170
+ returnUri: string;
171
+ signatures: 1 | 2;
172
+ }): Promise<{
173
+ id: string;
174
+ url: string;
175
+ hash: string;
176
+ expiresAt: string;
177
+ }>;
178
+ /** A signing request this client made, as GOpenCSR reads it back; null if it knows none. */
179
+ signingRequest(id: string): Promise<SigningState | null>;
180
+ /** Withdraws an open signing request whose change moved on: nothing can sign it any more. */
181
+ withdrawSignature(id: string): Promise<void>;
182
+ /**
183
+ * A signature's receipt, checked: GOpenCSR's key, its type, made for this client and account,
184
+ * over this request; null for any receipt that is not all of those.
185
+ */
186
+ receipt(token: string, expect: {
187
+ account: string;
188
+ request: string;
189
+ }): Promise<Receipt | null>;
190
+ private tokensOf;
191
+ /** The ID token's claims, checked (OpenID Connect Core 3.1.3.7), or null. */
192
+ private identityOf;
193
+ /**
194
+ * The claims of a compact JWS GOpenCSR signed, typed `typ`, or null for any that is not one.
195
+ * Times and audience are the caller's to check.
196
+ */
197
+ private verifyJws;
198
+ private publicKeys;
199
+ /**
200
+ * Delivers entries to the one log (GOPENCSR-44), at least once: an id delivered again with the
201
+ * same leaf is the same entry, and with another it is refused. Each comes back as the log holds
202
+ * it, its leaf checked to be the one this registry wrote (the canonical JSON of id, kind, time
203
+ * and body), with its index once the appender added it, which is soon but not at once.
204
+ */
205
+ deliverEntries(entries: TlogEntry[]): Promise<TlogEntryState[]>;
206
+ /** An entry of the one log as GOpenCSR holds it, by its id; null if it holds none. */
207
+ tlogEntry(id: string): Promise<TlogEntryState | null>;
208
+ private tokenCall;
209
+ /** client_secret_basic (RFC 6749 2.3.1): the id and the secret, each form-encoded. */
210
+ private authorization;
211
+ /**
212
+ * One call to GOpenCSR's api, authenticated as the registry's client unless `anonymous`. A
213
+ * refusal (4xx) throws CsrRefused; anything that kept an answer from coming (the network, a
214
+ * timeout, a 5xx) throws CsrUnavailable, which the caller does not take as a refusal.
215
+ */
216
+ private call;
217
+ }
218
+ //# sourceMappingURL=client.d.ts.map
package/dist/client.js ADDED
@@ -0,0 +1,373 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CsrClient = exports.SCOPE = exports.ACR_PASSKEY = exports.CsrRefused = exports.CsrUnavailable = void 0;
4
+ exports.changeHash = changeHash;
5
+ const node_crypto_1 = require("node:crypto");
6
+ const canonical_1 = require("./canonical");
7
+ /** GOpenCSR could not be asked: unreachable, too slow, or failing on its side. */
8
+ class CsrUnavailable extends Error {
9
+ name = "CsrUnavailable";
10
+ }
11
+ exports.CsrUnavailable = CsrUnavailable;
12
+ /** GOpenCSR answered, and refused: what it said, for the log and the audit record. */
13
+ class CsrRefused extends Error {
14
+ status;
15
+ reason;
16
+ name = "CsrRefused";
17
+ constructor(status, reason) {
18
+ super(`gopencsr refused: HTTP ${status} ${reason}`);
19
+ this.status = status;
20
+ this.reason = reason;
21
+ }
22
+ }
23
+ exports.CsrRefused = CsrRefused;
24
+ /** What GOpenCSR's tokens say when the sign-in was a passkey (GOPENCSR-15). */
25
+ exports.ACR_PASSKEY = "urn:gopencsr:acr:passkey";
26
+ /** What a registry asks GOpenCSR to tell it: who, their address and name, and their passkeys. */
27
+ exports.SCOPE = "openid email profile roles";
28
+ /** The JOSE type of a signing receipt, which nothing else of GOpenCSR's carries. */
29
+ const RECEIPT_TYPE = "gopencsr-signature+jwt";
30
+ /** Every call to GOpenCSR must answer within this. */
31
+ const TIMEOUT_MS = 10_000;
32
+ /** How long GOpenCSR's public keys are trusted before they are read again. */
33
+ const KEYS_FOR_MS = 5 * 60_000;
34
+ /** How far apart the two clocks may be. */
35
+ const LEEWAY_S = 60;
36
+ /**
37
+ * The SHA-256 GOpenCSR's signing step commits to, in hex: the change's canonical form with the
38
+ * registry's product added, as GOpenCSR computes it.
39
+ */
40
+ function changeHash(product, change) {
41
+ return (0, node_crypto_1.createHash)("sha256")
42
+ .update((0, canonical_1.canonicalJson)({ ...change, product }))
43
+ .digest("hex");
44
+ }
45
+ const isString = (value) => typeof value === "string";
46
+ /** The terms claim, as GOpenCSR writes it: { state, in_force_from? }, or null for anything else. */
47
+ function termsOf(claim) {
48
+ if (typeof claim !== "object" || claim === null)
49
+ return null;
50
+ const { state, in_force_from: inForceFrom } = claim;
51
+ if (!isString(state))
52
+ return null;
53
+ return {
54
+ state,
55
+ inForceFrom: typeof inForceFrom === "number" ? new Date(inForceFrom * 1000) : null,
56
+ };
57
+ }
58
+ /**
59
+ * A registry as GOpenCSR's client (GOPENCDR-333, extracted from GOpenCDR's api for every
60
+ * registry): the authorisation code flow with PKCE for sign-in and step-up, refresh and revocation
61
+ * of the tokens a session holds, the signing step that a change asks one or two passkeys through,
62
+ * and the delivery of the registry's entries to the one log. Every token is checked against GOpenCSR's published keys (EdDSA), which are read again
63
+ * for a key id not seen yet. Sessions, accounts and what a person may do stay the registry's own.
64
+ */
65
+ class CsrClient {
66
+ options;
67
+ keys = null;
68
+ fetch;
69
+ now;
70
+ constructor(options) {
71
+ this.options = options;
72
+ this.fetch = options.fetch ?? fetch;
73
+ this.now = options.now ?? Date.now;
74
+ }
75
+ /** GOpenCSR's issuer, the origin people sign in at. */
76
+ get issuer() {
77
+ return new URL(this.options.issuer).origin;
78
+ }
79
+ /** The authorisation request a browser takes to GOpenCSR. */
80
+ authorizeUrl(request) {
81
+ const url = new URL("/api/v1/oidc/authorize", this.issuer);
82
+ url.search = new URLSearchParams({
83
+ response_type: "code",
84
+ client_id: this.options.clientId,
85
+ redirect_uri: this.options.redirectUri,
86
+ scope: this.options.scope ?? exports.SCOPE,
87
+ state: request.state,
88
+ nonce: request.nonce,
89
+ code_challenge: request.challenge,
90
+ code_challenge_method: "S256",
91
+ acr_values: exports.ACR_PASSKEY,
92
+ ...(request.maxAge === undefined ? {} : { max_age: String(request.maxAge) }),
93
+ }).toString();
94
+ return url.toString();
95
+ }
96
+ /** The code GOpenCSR sent back, for tokens. The ID token must carry the nonce sent. */
97
+ async exchange(code, verifier, nonce) {
98
+ const body = await this.tokenCall({
99
+ grant_type: "authorization_code",
100
+ code,
101
+ redirect_uri: this.options.redirectUri,
102
+ code_verifier: verifier,
103
+ });
104
+ return this.tokensOf(body, nonce);
105
+ }
106
+ /**
107
+ * New tokens for a refresh token, which GOpenCSR replaces at each use; null when it refuses,
108
+ * because the person signed out there or the grant was revoked.
109
+ */
110
+ async refresh(refreshToken) {
111
+ try {
112
+ return await this.tokensOf(await this.tokenCall({ grant_type: "refresh_token", refresh_token: refreshToken }), null);
113
+ }
114
+ catch (error) {
115
+ if (error instanceof CsrRefused && error.status === 400)
116
+ return null;
117
+ throw error;
118
+ }
119
+ }
120
+ /** Revokes a refresh token, and with it GOpenCSR's grant (RFC 7009). */
121
+ async revoke(refreshToken) {
122
+ await this.call("POST", "oidc/revoke", {
123
+ form: { token: refreshToken, token_type_hint: "refresh_token" },
124
+ });
125
+ }
126
+ /**
127
+ * Asks GOpenCSR for the person's signature over a change (GOPENCSR-17), and checks that the
128
+ * hash it will show and sign is the one the registry computed.
129
+ */
130
+ async requestSignature(request) {
131
+ const created = (await this.call("POST", "signing-requests", { json: request }));
132
+ const hash = changeHash(this.options.product, request.change);
133
+ if (!isString(created.id) ||
134
+ !isString(created.url) ||
135
+ !isString(created.expiresAt) ||
136
+ created.hash !== hash) {
137
+ throw new Error("gopencsr: the signing request it made is not the change sent");
138
+ }
139
+ if (new URL(created.url).origin !== this.issuer) {
140
+ throw new Error("gopencsr: the signing page is not on its own origin");
141
+ }
142
+ return { id: created.id, url: created.url, hash, expiresAt: created.expiresAt };
143
+ }
144
+ /** A signing request this client made, as GOpenCSR reads it back; null if it knows none. */
145
+ async signingRequest(id) {
146
+ try {
147
+ return (await this.call("GET", `signing-requests/${encodeURIComponent(id)}`, {}));
148
+ }
149
+ catch (error) {
150
+ if (error instanceof CsrRefused && error.status === 404)
151
+ return null;
152
+ throw error;
153
+ }
154
+ }
155
+ /** Withdraws an open signing request whose change moved on: nothing can sign it any more. */
156
+ async withdrawSignature(id) {
157
+ await this.call("POST", `signing-requests/${encodeURIComponent(id)}/withdrawal`, {
158
+ json: { reason: "moved" },
159
+ });
160
+ }
161
+ /**
162
+ * A signature's receipt, checked: GOpenCSR's key, its type, made for this client and account,
163
+ * over this request; null for any receipt that is not all of those.
164
+ */
165
+ async receipt(token, expect) {
166
+ const claims = await this.verifyJws(token, RECEIPT_TYPE);
167
+ if (claims === null ||
168
+ claims.iss !== this.issuer ||
169
+ claims.aud !== this.options.clientId ||
170
+ claims.sub !== expect.account ||
171
+ claims.req !== expect.request ||
172
+ !isString(claims.hash) ||
173
+ !isString(claims.passkey)) {
174
+ return null;
175
+ }
176
+ return { passkey: claims.passkey, hash: claims.hash };
177
+ }
178
+ async tokensOf(body, nonce) {
179
+ const { refresh_token: refreshToken, id_token: idToken, expires_in: expiresIn } = body;
180
+ if (!isString(refreshToken) || !isString(idToken) || typeof expiresIn !== "number") {
181
+ throw new Error("gopencsr: the token response lacks a refresh token or an ID token");
182
+ }
183
+ const identity = await this.identityOf(idToken, nonce);
184
+ if (identity === null)
185
+ throw new Error("gopencsr: an ID token that does not verify");
186
+ return { refreshToken, accessExpiresAt: new Date(this.now() + expiresIn * 1000), identity };
187
+ }
188
+ /** The ID token's claims, checked (OpenID Connect Core 3.1.3.7), or null. */
189
+ async identityOf(idToken, nonce) {
190
+ const claims = await this.verifyJws(idToken, "JWT");
191
+ const now = Math.floor(this.now() / 1000);
192
+ if (claims === null ||
193
+ claims.iss !== this.issuer ||
194
+ claims.aud !== this.options.clientId ||
195
+ !isString(claims.sub) ||
196
+ !isString(claims.acr) ||
197
+ typeof claims.exp !== "number" ||
198
+ claims.exp + LEEWAY_S <= now ||
199
+ typeof claims.iat !== "number" ||
200
+ claims.iat - LEEWAY_S > now ||
201
+ typeof claims.auth_time !== "number" ||
202
+ (nonce !== null && claims.nonce !== nonce)) {
203
+ return null;
204
+ }
205
+ return {
206
+ sub: claims.sub,
207
+ acr: claims.acr,
208
+ authTime: new Date(claims.auth_time * 1000),
209
+ sid: isString(claims.sid) ? claims.sid : null,
210
+ email: isString(claims.email) ? claims.email : null,
211
+ emailVerified: claims.email_verified === true,
212
+ name: isString(claims.name) ? claims.name : null,
213
+ passkeys: typeof claims.passkeys === "number" && Number.isInteger(claims.passkeys)
214
+ ? claims.passkeys
215
+ : null,
216
+ terms: termsOf(claims.terms),
217
+ };
218
+ }
219
+ /**
220
+ * The claims of a compact JWS GOpenCSR signed, typed `typ`, or null for any that is not one.
221
+ * Times and audience are the caller's to check.
222
+ */
223
+ async verifyJws(token, typ) {
224
+ const parts = token.split(".");
225
+ if (parts.length !== 3)
226
+ return null;
227
+ const [head, body, signature] = parts;
228
+ let header;
229
+ let claims;
230
+ try {
231
+ header = JSON.parse(Buffer.from(head, "base64url").toString("utf8"));
232
+ claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ if (header.alg !== "EdDSA" || header.typ !== typ || !isString(header.kid))
238
+ return null;
239
+ if (typeof claims !== "object" || claims === null || Array.isArray(claims))
240
+ return null;
241
+ let key = (await this.publicKeys()).get(header.kid);
242
+ // A key id not seen yet may be a rotation since the last read: ask once more.
243
+ if (key === undefined) {
244
+ this.keys = null;
245
+ key = (await this.publicKeys()).get(header.kid);
246
+ }
247
+ if (key === undefined)
248
+ return null;
249
+ const ok = (0, node_crypto_1.verify)(null, Buffer.from(`${head}.${body}`, "ascii"), key, Buffer.from(signature, "base64url"));
250
+ return ok ? claims : null;
251
+ }
252
+ async publicKeys() {
253
+ if (this.keys !== null && this.now() - this.keys.at < KEYS_FOR_MS)
254
+ return this.keys.byKid;
255
+ const jwks = (await this.call("GET", "oidc/jwks", { anonymous: true }));
256
+ const byKid = new Map();
257
+ for (const jwk of jwks.keys ?? []) {
258
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !isString(jwk.x) || !isString(jwk.kid)) {
259
+ continue;
260
+ }
261
+ byKid.set(jwk.kid, (0, node_crypto_1.createPublicKey)({ key: { kty: "OKP", crv: "Ed25519", x: jwk.x }, format: "jwk" }));
262
+ }
263
+ this.keys = { at: this.now(), byKid };
264
+ return byKid;
265
+ }
266
+ /**
267
+ * Delivers entries to the one log (GOPENCSR-44), at least once: an id delivered again with the
268
+ * same leaf is the same entry, and with another it is refused. Each comes back as the log holds
269
+ * it, its leaf checked to be the one this registry wrote (the canonical JSON of id, kind, time
270
+ * and body), with its index once the appender added it, which is soon but not at once.
271
+ */
272
+ async deliverEntries(entries) {
273
+ const prefix = `${this.options.product}.`;
274
+ for (const entry of entries) {
275
+ if (!entry.kind.startsWith(prefix)) {
276
+ throw new Error(`gopencsr: ${entry.kind} is not a ${this.options.product} kind`);
277
+ }
278
+ }
279
+ const answer = (await this.call("POST", "tlog/entries", { json: { entries } }));
280
+ const held = Array.isArray(answer?.entries) ? answer.entries : [];
281
+ if (held.length !== entries.length) {
282
+ throw new Error("gopencsr: the log answered for other entries than those delivered");
283
+ }
284
+ entries.forEach((entry, at) => {
285
+ const leaf = (0, canonical_1.canonicalJson)({
286
+ id: entry.id,
287
+ kind: entry.kind,
288
+ time: entry.time,
289
+ body: entry.body,
290
+ });
291
+ if (held[at]?.id !== entry.id || held[at]?.leaf !== leaf) {
292
+ throw new Error(`gopencsr: the log holds entry ${entry.id} with another leaf`);
293
+ }
294
+ });
295
+ return held;
296
+ }
297
+ /** An entry of the one log as GOpenCSR holds it, by its id; null if it holds none. */
298
+ async tlogEntry(id) {
299
+ try {
300
+ return (await this.call("GET", `tlog/entries/${encodeURIComponent(id)}`, {
301
+ anonymous: true,
302
+ }));
303
+ }
304
+ catch (error) {
305
+ if (error instanceof CsrRefused && error.status === 404)
306
+ return null;
307
+ throw error;
308
+ }
309
+ }
310
+ async tokenCall(form) {
311
+ return (await this.call("POST", "oidc/token", { form }));
312
+ }
313
+ /** client_secret_basic (RFC 6749 2.3.1): the id and the secret, each form-encoded. */
314
+ async authorization() {
315
+ const form = (value) => encodeURIComponent(value).replace(/%20/g, "+");
316
+ const secret = await this.options.clientSecret();
317
+ const pair = `${form(this.options.clientId)}:${form(secret)}`;
318
+ return `Basic ${Buffer.from(pair, "utf8").toString("base64")}`;
319
+ }
320
+ /**
321
+ * One call to GOpenCSR's api, authenticated as the registry's client unless `anonymous`. A
322
+ * refusal (4xx) throws CsrRefused; anything that kept an answer from coming (the network, a
323
+ * timeout, a 5xx) throws CsrUnavailable, which the caller does not take as a refusal.
324
+ */
325
+ async call(method, path, request) {
326
+ const base = this.options.endpoints ?? this.issuer;
327
+ const url = new URL(`/api/v1/${path}`, base);
328
+ const headers = { accept: "application/json" };
329
+ if (request.anonymous !== true)
330
+ headers.authorization = await this.authorization();
331
+ let body;
332
+ if (request.form !== undefined) {
333
+ headers["content-type"] = "application/x-www-form-urlencoded";
334
+ body = new URLSearchParams(request.form).toString();
335
+ }
336
+ else if (request.json !== undefined) {
337
+ headers["content-type"] = "application/json";
338
+ body = JSON.stringify(request.json);
339
+ }
340
+ let res;
341
+ try {
342
+ res = await this.fetch(url, {
343
+ method,
344
+ headers,
345
+ ...(body === undefined ? {} : { body }),
346
+ redirect: "error",
347
+ signal: AbortSignal.timeout(TIMEOUT_MS),
348
+ });
349
+ }
350
+ catch (error) {
351
+ throw new CsrUnavailable(`gopencsr: ${method} ${url.pathname} failed`, { cause: error });
352
+ }
353
+ const text = await res.text();
354
+ if (res.status >= 500) {
355
+ throw new CsrUnavailable(`gopencsr: ${method} ${url.pathname} answered ${res.status}`);
356
+ }
357
+ let parsed = undefined;
358
+ try {
359
+ parsed = text === "" ? undefined : JSON.parse(text);
360
+ }
361
+ catch {
362
+ // Not JSON, perhaps a proxy's page: the status says enough.
363
+ }
364
+ if (!res.ok) {
365
+ const reason = parsed?.error ??
366
+ parsed?.code;
367
+ throw new CsrRefused(res.status, isString(reason) ? reason : "unknown");
368
+ }
369
+ return parsed;
370
+ }
371
+ }
372
+ exports.CsrClient = CsrClient;
373
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @ghub/gopencsr-client: what a registry needs to be GOpenCSR's client. GOpenCSR signs people in
3
+ * for GOpenCDR and GOpenCNR, and its signing step signs their changes; the code that talks to it
4
+ * is built and released here once, for every registry (GOpenCSR's shared-code rule).
5
+ */
6
+ export { canonicalJson } from "./canonical";
7
+ export * from "./client";
8
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.canonicalJson = void 0;
18
+ /**
19
+ * @ghub/gopencsr-client: what a registry needs to be GOpenCSR's client. GOpenCSR signs people in
20
+ * for GOpenCDR and GOpenCNR, and its signing step signs their changes; the code that talks to it
21
+ * is built and released here once, for every registry (GOpenCSR's shared-code rule).
22
+ */
23
+ var canonical_1 = require("./canonical");
24
+ Object.defineProperty(exports, "canonicalJson", { enumerable: true, get: function () { return canonical_1.canonicalJson; } });
25
+ __exportStar(require("./client"), exports);
26
+ //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghub/gopencsr-client",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "GOpenCSR's identity client for the registries it signs people in for: the OpenID Connect code flow with PKCE, refresh and revocation, ID tokens and signing receipts checked against GOpenCSR's EdDSA keys, and the signing step's requests and change hash. No framework, no dependencies.",
5
5
  "author": "Gelhaus Solutions (Enno Gelhaus) <egelhaus@ennogelhaus.de> (https://ennogelhaus.de)",
6
6
  "homepage": "https://ennogelhaus.de",