1/// <reference types="node" />
2
3import * as NodeCrypto from "node:crypto";
4
5import { vi } from "vite-plus/test";
6import { describe, expect, it } from "@effect/vitest";
7import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";
9import { verifyDpopProof } from "@t3tools/shared/dpop";
10
11import { createDpopProof, generateDpopProofKeyPair, loadOrCreateDpopProofKeyPair } from "./dpop";
12import * as Dpop from "./dpop";
13
14vi.mock("expo-crypto", () => ({
15 CryptoDigestAlgorithm: {
16 SHA1: "SHA-1",
17 SHA256: "SHA-256",
18 SHA384: "SHA-384",
19 SHA512: "SHA-512",
20 },
21 getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)),
22 getRandomBytesAsync: (byteCount: number) =>
23 Promise.resolve(new Uint8Array(NodeCrypto.randomBytes(byteCount))),
24 digest: (algorithm: string, data: unknown) => {
25 if (!(data instanceof Uint8Array)) {
26 return Promise.reject(new TypeError("expo-crypto digest data must be a typed array."));
27 }
28 return Promise.resolve(
29 new Uint8Array(NodeCrypto.createHash(algorithm).update(data).digest()).buffer,
30 );
31 },
32}));
33
34const secureStore = new Map<string, string>();
35vi.mock("expo-secure-store", () => ({
36 getItemAsync: (key: string) => Promise.resolve(secureStore.get(key) ?? null),
37 setItemAsync: (key: string, value: string) => {
38 secureStore.set(key, value);
39 return Promise.resolve();
40 },
41}));
42
43function proofIat(proof: string): number {
44 const payload = proof.split(".")[1];
45 if (!payload) {
46 throw new Error("Missing DPoP payload.");
47 }
48 const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
49 readonly iat: number;
50 };
51 return decoded.iat;
52}
53
54function proofHtu(proof: string): string {
55 const payload = proof.split(".")[1];
56 if (!payload) {
57 throw new Error("Missing DPoP payload.");
58 }
59 const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
60 readonly htu: string;
61 };
62 return decoded.htu;
63}
64
65describe("mobile DPoP", () => {
66 it.effect("passes typed-array digest input through the Expo Crypto adapter", () =>
67 Effect.gen(function* () {
68 const crypto = yield* Crypto.Crypto;
69 const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode("typed-array"));
70
71 expect(Buffer.from(digest).toString("hex")).toBe(
72 NodeCrypto.createHash("sha256").update("typed-array").digest("hex"),
73 );
74 }).pipe(Effect.provide(Dpop.layer)),
75 );
76
77 it.effect("persists and reuses the installation proof key", () =>
78 Effect.gen(function* () {
79 secureStore.clear();
80 const first = yield* loadOrCreateDpopProofKeyPair();
81 const second = yield* loadOrCreateDpopProofKeyPair();
82
83 expect(second.thumbprint).toBe(first.thumbprint);
84 expect(second.privateJwk).toEqual(first.privateJwk);
85 }).pipe(Effect.provide(Dpop.layer)),
86 );
87
88 it.effect("rejects malformed persisted proof keys", () =>
89 Effect.gen(function* () {
90 secureStore.set("t3code.cloud.dpop-proof-key", `{"kty":"EC","crv":"P-256","d":42}`);
91
92 const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip);
93
94 expect(error.message).toBe("Stored DPoP proof key is invalid.");
95 }).pipe(Effect.provide(Dpop.layer)),
96 );
97
98 it.effect("signs connect and bootstrap proofs with the same ephemeral proof key", () =>
99 Effect.gen(function* () {
100 const proofKey = yield* generateDpopProofKeyPair();
101 const connect = yield* createDpopProof({
102 method: "POST",
103 url: "https://relay.example.test/v1/environments/env-1/connect",
104 accessToken: "clerk-token",
105 proofKey,
106 });
107 const bootstrap = yield* createDpopProof({
108 method: "POST",
109 url: "https://desktop.example.test/oauth/token",
110 proofKey,
111 });
112
113 expect(connect.thumbprint).toBe(proofKey.thumbprint);
114 expect(bootstrap.thumbprint).toBe(proofKey.thumbprint);
115 expect(
116 verifyDpopProof({
117 proof: connect.proof,
118 method: "POST",
119 url: "https://relay.example.test/v1/environments/env-1/connect",
120 expectedThumbprint: proofKey.thumbprint,
121 expectedAccessToken: "clerk-token",
122 nowEpochSeconds: proofIat(connect.proof),
123 }),
124 ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint });
125 expect(
126 verifyDpopProof({
127 proof: bootstrap.proof,
128 method: "POST",
129 url: "https://desktop.example.test/oauth/token",
130 expectedThumbprint: proofKey.thumbprint,
131 nowEpochSeconds: proofIat(bootstrap.proof),
132 }),
133 ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint });
134 }).pipe(Effect.provide(Dpop.layer)),
135 );
136
137 it.effect("signs DPoP proofs with RFC 9449 htu normalization", () =>
138 Effect.gen(function* () {
139 const proofKey = yield* generateDpopProofKeyPair();
140 const proof = yield* createDpopProof({
141 method: "POST",
142 url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored",
143 accessToken: "clerk-token",
144 proofKey,
145 });
146
147 expect(proofHtu(proof.proof)).toBe(
148 "https://relay.example.test/v1/environments/env-1/connect",
149 );
150 expect(
151 verifyDpopProof({
152 proof: proof.proof,
153 method: "POST",
154 url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored",
155 expectedThumbprint: proofKey.thumbprint,
156 expectedAccessToken: "clerk-token",
157 nowEpochSeconds: proofIat(proof.proof),
158 }),
159 ).toMatchObject({ ok: true });
160 }).pipe(Effect.provide(Dpop.layer)),
161 );
162});