@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 +1 -1
- package/src/composables/operationKey.test.ts +39 -0
- package/src/composables/operationKey.ts +40 -0
- package/src/composables/useAccountTransport.ts +2 -1
- package/src/composables/useGrpc.test.ts +3 -1
- package/src/composables/useGrpc.ts +2 -0
- package/src/index.ts +4 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/passkey.test.ts +116 -0
- package/src/utils/passkey.ts +107 -0
package/package.json
CHANGED
|
@@ -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;
|
|
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
package/src/utils/index.ts
CHANGED
|
@@ -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
|
+
}
|