@structured-id/ui-core 4.8.0 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structured-id/ui-core",
3
- "version": "4.8.0",
3
+ "version": "4.10.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import type { MethodInfo, RpcOptions } from "@protobuf-ts/runtime-rpc";
3
+ import { OPERATION_KEY, operationKey } from "./operationKey";
4
+
5
+ /** The options a unary call reaches the transport with. */
6
+ function sent(
7
+ idempotency: MethodInfo["idempotency"],
8
+ options: RpcOptions = {},
9
+ ) {
10
+ const next = vi.fn((_m, _i, o: RpcOptions) => o);
11
+ const method = { idempotency } as unknown as MethodInfo;
12
+ operationKey.interceptUnary!(next as never, method, {}, options);
13
+ return next.mock.calls[0][2] as RpcOptions;
14
+ }
15
+
16
+ describe("operationKey", () => {
17
+ // A state-changing call is named by a key, so SID can answer its retry
18
+ // with the recorded result instead of applying it again.
19
+ it("adds a key to a call that changes state", () => {
20
+ expect(sent(undefined).meta?.[OPERATION_KEY]).toMatch(/^[0-9a-f-]{36}$/);
21
+ expect(sent("IDEMPOTENT").meta?.[OPERATION_KEY]).toMatch(/^[0-9a-f-]{36}$/);
22
+ });
23
+
24
+ it("adds no key to a read", () => {
25
+ expect(sent("NO_SIDE_EFFECTS").meta?.[OPERATION_KEY]).toBeUndefined();
26
+ });
27
+
28
+ // A caller retrying one command passes the same key every time.
29
+ it("keeps a key the caller passed", () => {
30
+ const options = { meta: { [OPERATION_KEY]: "mine", other: "x" } };
31
+ expect(sent(undefined, options).meta).toEqual(options.meta);
32
+ });
33
+
34
+ it("gives every call its own key", () => {
35
+ expect(sent(undefined).meta?.[OPERATION_KEY]).not.toBe(
36
+ sent(undefined).meta?.[OPERATION_KEY],
37
+ );
38
+ });
39
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The operation key of state-changing calls: SID answers a retried command
3
+ * with its recorded result only when the call names it by a key the caller
4
+ * created before sending it.
5
+ */
6
+ import type { RpcInterceptor, RpcOptions } from "@protobuf-ts/runtime-rpc";
7
+
8
+ /** The metadata entry carrying the key (the `Idempotency-Key` header). */
9
+ export const OPERATION_KEY = "idempotency-key";
10
+
11
+ /**
12
+ * `options` with an operation key: the caller's own when it passed one (a
13
+ * caller retrying one command passes the same key every time), a new one
14
+ * otherwise.
15
+ */
16
+ export function withOperationKey(options: RpcOptions): RpcOptions {
17
+ if (options.meta?.[OPERATION_KEY]) {
18
+ return options;
19
+ }
20
+ return {
21
+ ...options,
22
+ meta: { ...options.meta, [OPERATION_KEY]: crypto.randomUUID() },
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Adds an operation key to every unary call of a method not declared free of
28
+ * side effects; reads carry none.
29
+ */
30
+ export const operationKey: RpcInterceptor = {
31
+ interceptUnary(next, method, input, options) {
32
+ return next(
33
+ method,
34
+ input,
35
+ method.idempotency === "NO_SIDE_EFFECTS"
36
+ ? options
37
+ : withOperationKey(options),
38
+ );
39
+ },
40
+ };
@@ -10,6 +10,7 @@
10
10
  import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
11
11
  import type { RpcInterceptor, RpcTransport } from "@protobuf-ts/runtime-rpc";
12
12
  import { bffCsrfToken } from "./useBffAuth";
13
+ import { operationKey } from "./operationKey";
13
14
 
14
15
  /** Where the account API is reached. */
15
16
  export interface AccountApiConfig {
@@ -49,7 +50,7 @@ export function initAccountApi(config: AccountApiConfig): void {
49
50
  baseUrl: config.baseUrl,
50
51
  // The BFF is on this origin; its cookie must not travel anywhere else.
51
52
  fetchInit: { credentials: "same-origin" },
52
- interceptors: [csrf],
53
+ interceptors: [csrf, operationKey],
53
54
  });
54
55
  }
55
56
 
@@ -14,6 +14,7 @@ vi.mock("@protobuf-ts/grpcweb-transport", () => ({
14
14
  // Import AFTER mocks
15
15
  import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
16
16
  import { initGrpc, getTransport, closeTransport, useGrpc } from "./useGrpc";
17
+ import { operationKey } from "./operationKey";
17
18
  import type { GrpcConfig } from "../types/auth";
18
19
 
19
20
  // ---------------------------------------------------------------------------
@@ -28,7 +29,7 @@ describe("useGrpc", () => {
28
29
 
29
30
  describe("initGrpc", () => {
30
31
  // The BFF session cookie rides on every call, so credentials are always
31
- // included; nothing else configures the transport.
32
+ // included; state-changing calls carry an operation key.
32
33
  it("creates GrpcWebFetchTransport with baseUrl and credentials", () => {
33
34
  const config: GrpcConfig = { baseUrl: "https://api.sid.example.com" };
34
35
  initGrpc(config);
@@ -36,6 +37,7 @@ describe("useGrpc", () => {
36
37
  expect(GrpcWebFetchTransport).toHaveBeenCalledWith({
37
38
  baseUrl: "https://api.sid.example.com",
38
39
  fetchInit: { credentials: "include" },
40
+ interceptors: [operationKey],
39
41
  });
40
42
  });
41
43
  });
@@ -2,6 +2,7 @@ import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
2
2
  import type { RpcTransport } from "@protobuf-ts/runtime-rpc";
3
3
  import { ref, type Ref } from "vue";
4
4
  import type { GrpcConfig } from "../types/auth";
5
+ import { operationKey } from "./operationKey";
5
6
 
6
7
  /** Shared gRPC-web transport instance. */
7
8
  let _transport: RpcTransport | null = null;
@@ -23,6 +24,7 @@ export function initGrpc(config: GrpcConfig): void {
23
24
  // The BFF proxy exchanges the session cookie for a Bearer token
24
25
  // server-side.
25
26
  fetchInit: { credentials: "include" },
27
+ interceptors: [operationKey],
26
28
  });
27
29
  }
28
30
 
package/src/index.ts CHANGED
@@ -54,6 +54,10 @@ export {
54
54
  formatRelativeTime,
55
55
  truncate,
56
56
  maskEmail,
57
+ PasskeyError,
58
+ createPasskey,
59
+ getPasskey,
60
+ type PasskeyFailure,
57
61
  } from "./utils";
58
62
 
59
63
  export {
@@ -20,6 +20,13 @@ export {
20
20
  type EmailPolicy,
21
21
  } from "./email";
22
22
 
23
+ export {
24
+ PasskeyError,
25
+ createPasskey,
26
+ getPasskey,
27
+ type PasskeyFailure,
28
+ } from "./passkey";
29
+
23
30
  export {
24
31
  formatDate,
25
32
  formatRelativeTime,
@@ -0,0 +1,116 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { PasskeyError, createPasskey, getPasskey } from "./passkey";
3
+
4
+ const bytes = (value: unknown) =>
5
+ new TextEncoder().encode(JSON.stringify(value));
6
+ const json = (data: Uint8Array) =>
7
+ JSON.parse(new TextDecoder().decode(data)) as Record<string, unknown>;
8
+
9
+ /** A browser credential whose JSON form is `form`. */
10
+ class FakeCredential {
11
+ constructor(private readonly form: unknown) {}
12
+ toJSON() {
13
+ return this.form;
14
+ }
15
+ }
16
+
17
+ const parseCreation = vi.fn((options: unknown) => ({ parsed: options }));
18
+ const parseRequest = vi.fn((options: unknown) => ({ parsed: options }));
19
+ const create = vi.fn();
20
+ const get = vi.fn();
21
+
22
+ beforeEach(() => {
23
+ Object.assign(FakeCredential, {
24
+ parseCreationOptionsFromJSON: parseCreation,
25
+ parseRequestOptionsFromJSON: parseRequest,
26
+ });
27
+ vi.stubGlobal("PublicKeyCredential", FakeCredential);
28
+ vi.stubGlobal("navigator", { credentials: { create, get } });
29
+ });
30
+
31
+ afterEach(() => {
32
+ vi.unstubAllGlobals();
33
+ vi.clearAllMocks();
34
+ });
35
+
36
+ // The JSON the server reads is the browser's own form, clientExtensionResults
37
+ // included: SID's relying party refuses a response without that member
38
+ // (WebAuthn Level 3 §5.1.4), which hand-built responses left out.
39
+ const REGISTRATION = {
40
+ id: "Y3JlZA",
41
+ rawId: "Y3JlZA",
42
+ type: "public-key",
43
+ response: { clientDataJSON: "e30", attestationObject: "o2M" },
44
+ clientExtensionResults: {},
45
+ };
46
+
47
+ describe("passkey creation", () => {
48
+ // The server wraps the options in `publicKey` (CredentialCreationOptions);
49
+ // reading members off the top level parsed nothing.
50
+ it("hands the browser the options inside publicKey", async () => {
51
+ create.mockResolvedValue(new FakeCredential(REGISTRATION));
52
+ const publicKey = { challenge: "Y2g", rp: { id: "sid.example.com" } };
53
+ const response = await createPasskey(bytes({ publicKey }));
54
+ expect(parseCreation).toHaveBeenCalledWith(publicKey);
55
+ expect(create).toHaveBeenCalledWith({ publicKey: { parsed: publicKey } });
56
+ expect(json(response)).toEqual(REGISTRATION);
57
+ });
58
+
59
+ it("tells a cancellation and a known authenticator apart", async () => {
60
+ const options = bytes({ publicKey: { challenge: "Y2g" } });
61
+ create.mockRejectedValue(new DOMException("no", "NotAllowedError"));
62
+ await expect(createPasskey(options)).rejects.toMatchObject({
63
+ failure: "cancelled",
64
+ });
65
+ create.mockRejectedValue(new DOMException("held", "InvalidStateError"));
66
+ await expect(createPasskey(options)).rejects.toMatchObject({
67
+ failure: "already-registered",
68
+ });
69
+ create.mockRejectedValue(new Error("broken"));
70
+ await expect(createPasskey(options)).rejects.toMatchObject({
71
+ failure: "failed",
72
+ message: "broken",
73
+ });
74
+ });
75
+
76
+ it("refuses options without publicKey and a missing credential", async () => {
77
+ await expect(
78
+ createPasskey(bytes({ challenge: "Y2g" })),
79
+ ).rejects.toBeInstanceOf(PasskeyError);
80
+ create.mockResolvedValue(null);
81
+ await expect(
82
+ createPasskey(bytes({ publicKey: { challenge: "Y2g" } })),
83
+ ).rejects.toMatchObject({ failure: "failed" });
84
+ });
85
+ });
86
+
87
+ describe("passkey assertion", () => {
88
+ const ASSERTION = {
89
+ id: "Y3JlZA",
90
+ rawId: "Y3JlZA",
91
+ type: "public-key",
92
+ response: {
93
+ clientDataJSON: "e30",
94
+ authenticatorData: "YXV0aA",
95
+ signature: "c2ln",
96
+ userHandle: "dXNlcg",
97
+ },
98
+ clientExtensionResults: {},
99
+ };
100
+
101
+ it("answers the request options with the browser's JSON form", async () => {
102
+ get.mockResolvedValue(new FakeCredential(ASSERTION));
103
+ const publicKey = { challenge: "Y2g", rpId: "sid.example.com" };
104
+ const response = await getPasskey(bytes({ publicKey }));
105
+ expect(parseRequest).toHaveBeenCalledWith(publicKey);
106
+ expect(get).toHaveBeenCalledWith({ publicKey: { parsed: publicKey } });
107
+ expect(json(response)).toEqual(ASSERTION);
108
+ });
109
+
110
+ it("reports a cancelled sign-in as cancelled", async () => {
111
+ get.mockRejectedValue(new DOMException("no", "NotAllowedError"));
112
+ await expect(
113
+ getPasskey(bytes({ publicKey: { challenge: "Y2g" } })),
114
+ ).rejects.toMatchObject({ failure: "cancelled" });
115
+ });
116
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The browser side of SID's passkey ceremonies, whatever transport carries
3
+ * them: the options a start returns (`CredentialCreationOptions` /
4
+ * `CredentialRequestOptions` as JSON, `publicKey` inside) in, the
5
+ * `RegistrationResponseJSON` / `AuthenticationResponseJSON` a finish reads
6
+ * out (WebAuthn Level 3 §5.1.4), on the browser's own JSON forms.
7
+ */
8
+
9
+ /** How a ceremony ended without a credential. */
10
+ export type PasskeyFailure =
11
+ /** The user cancelled, or the authenticator timed out (`NotAllowedError`). */
12
+ | "cancelled"
13
+ /** The authenticator already holds a passkey of this account (`InvalidStateError`). */
14
+ | "already-registered"
15
+ /** Anything else, the browser's message kept in `cause`. */
16
+ | "failed";
17
+
18
+ /** A ceremony that produced no credential, and why. */
19
+ export class PasskeyError extends Error {
20
+ constructor(
21
+ readonly failure: PasskeyFailure,
22
+ message: string,
23
+ options?: { cause?: unknown },
24
+ ) {
25
+ super(message, options);
26
+ this.name = "PasskeyError";
27
+ }
28
+ }
29
+
30
+ /** The `publicKey` member of the options JSON the server sent. */
31
+ function publicKeyOf<T>(options: Uint8Array): T {
32
+ const parsed = JSON.parse(new TextDecoder().decode(options)) as {
33
+ publicKey?: T;
34
+ };
35
+ if (!parsed.publicKey) {
36
+ throw new PasskeyError("failed", "The server sent no passkey options");
37
+ }
38
+ return parsed.publicKey;
39
+ }
40
+
41
+ /** Why the browser refused, as the page tells it. */
42
+ function refusal(error: unknown): PasskeyError {
43
+ if (error instanceof DOMException && error.name === "NotAllowedError") {
44
+ return new PasskeyError(
45
+ "cancelled",
46
+ "The request was cancelled or timed out",
47
+ {
48
+ cause: error,
49
+ },
50
+ );
51
+ }
52
+ if (error instanceof DOMException && error.name === "InvalidStateError") {
53
+ return new PasskeyError(
54
+ "already-registered",
55
+ "This authenticator already holds a passkey for this account",
56
+ { cause: error },
57
+ );
58
+ }
59
+ return new PasskeyError(
60
+ "failed",
61
+ error instanceof Error ? error.message : "The passkey ceremony failed",
62
+ { cause: error },
63
+ );
64
+ }
65
+
66
+ /** `credential` as the JSON bytes a finish reads. */
67
+ function responseBytes(credential: Credential | null): Uint8Array {
68
+ if (!(credential instanceof PublicKeyCredential)) {
69
+ throw new PasskeyError("failed", "The browser returned no passkey");
70
+ }
71
+ return new TextEncoder().encode(JSON.stringify(credential.toJSON()));
72
+ }
73
+
74
+ /**
75
+ * Create a passkey from a registration start's `options`; resolves to the
76
+ * response a registration finish takes.
77
+ */
78
+ export async function createPasskey(options: Uint8Array): Promise<Uint8Array> {
79
+ const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(
80
+ publicKeyOf<PublicKeyCredentialCreationOptionsJSON>(options),
81
+ );
82
+ let credential: Credential | null;
83
+ try {
84
+ credential = await navigator.credentials.create({ publicKey });
85
+ } catch (error) {
86
+ throw refusal(error);
87
+ }
88
+ return responseBytes(credential);
89
+ }
90
+
91
+ /**
92
+ * Assert a passkey for a sign-in or step-up start's `options`; resolves to
93
+ * the response its finish takes. With no allowed credentials in `options`
94
+ * the browser offers the discoverable passkeys it holds for the RP.
95
+ */
96
+ export async function getPasskey(options: Uint8Array): Promise<Uint8Array> {
97
+ const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(
98
+ publicKeyOf<PublicKeyCredentialRequestOptionsJSON>(options),
99
+ );
100
+ let credential: Credential | null;
101
+ try {
102
+ credential = await navigator.credentials.get({ publicKey });
103
+ } catch (error) {
104
+ throw refusal(error);
105
+ }
106
+ return responseBytes(credential);
107
+ }