1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
2import * as NodeCrypto from "node:crypto";
3
4import { describe, expect, it } from "@effect/vitest";
5import { signRelayJwt } from "@t3tools/shared/relayJwt";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Redacted from "effect/Redacted";
9
10import * as RelayConfiguration from "../Config.ts";
11import * as RelayTokens from "./RelayTokens.ts";
12
13const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
14 privateKeyEncoding: { format: "pem", type: "pkcs8" },
15 publicKeyEncoding: { format: "pem", type: "spki" },
16});
17
18const config = RelayConfiguration.RelayConfiguration.of({
19 relayIssuer: "https://relay.example.test/",
20 apns: {
21 environment: "sandbox",
22 teamId: "team-id",
23 keyId: "key-id",
24 privateKey: Redacted.make("private-key"),
25 bundleId: "com.t3tools.t3code.dev",
26 },
27 apnsDeliveryJobSigningSecret: Redacted.make("job-secret"),
28 clerkSecretKey: Redacted.make("clerk-secret"),
29 clerkPublishableKey: "pk_test_test",
30 clerkJwtAudience: "t3-code-relay",
31 cloudMintPrivateKey: Redacted.make(keyPair.privateKey),
32 cloudMintPublicKey: keyPair.publicKey,
33 managedEndpointBaseDomain: undefined,
34 managedEndpointNamespace: undefined,
35});
36
37const layer = RelayTokens.layer.pipe(Layer.provide(RelayConfiguration.layer(config)));
38
39describe("RelayTokens", () => {
40 it.effect("issues a user-bound environment link challenge", () =>
41 Effect.gen(function* () {
42 const relayTokens = yield* RelayTokens.RelayTokens;
43 const token = yield* relayTokens.issueLinkChallenge({
44 userId: "user_123",
45 request: {
46 notificationsEnabled: true,
47 liveActivitiesEnabled: true,
48 managedTunnelsEnabled: true,
49 },
50 jti: "challenge-1",
51 issuedAtEpochSeconds: 100,
52 expiresAtEpochSeconds: 200,
53 });
54
55 expect(
56 yield* relayTokens.verifyLinkChallenge({
57 token,
58 userId: "user_123",
59 request: {
60 notificationsEnabled: true,
61 liveActivitiesEnabled: true,
62 managedTunnelsEnabled: true,
63 },
64 nowEpochSeconds: 150,
65 }),
66 ).toMatchObject({ sub: "user_123", jti: "challenge-1" });
67 expect(
68 yield* relayTokens.verifyLinkChallenge({
69 token,
70 userId: "attacker",
71 request: {
72 notificationsEnabled: true,
73 liveActivitiesEnabled: true,
74 managedTunnelsEnabled: true,
75 },
76 nowEpochSeconds: 150,
77 }),
78 ).toBeNull();
79 }).pipe(Effect.provide(layer)),
80 );
81
82 it.effect("issues and verifies DPoP access tokens bound to one proof-key thumbprint", () =>
83 Effect.gen(function* () {
84 const relayTokens = yield* RelayTokens.RelayTokens;
85 const token = yield* relayTokens.issueDpopAccessToken({
86 userId: "user_123",
87 proofKeyThumbprint: "proof-key-thumbprint",
88 jti: "access-token-1",
89 issuedAtEpochSeconds: 100,
90 expiresAtEpochSeconds: 1_900,
91 clientId: "t3-mobile",
92 scopes: ["environment:connect", "environment:status", "mobile:registration"],
93 });
94
95 expect(
96 yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 700 }),
97 ).toMatchObject({
98 sub: "user_123",
99 cnf: { jkt: "proof-key-thumbprint" },
100 client_id: "t3-mobile",
101 scope: ["environment:connect", "environment:status", "mobile:registration"],
102 });
103 expect(
104 yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 1_961 }),
105 ).toBeNull();
106 }).pipe(Effect.provide(layer)),
107 );
108
109 it.effect("issues tunnel-only DPoP access tokens to web public clients", () =>
110 Effect.gen(function* () {
111 const relayTokens = yield* RelayTokens.RelayTokens;
112 const token = yield* relayTokens.issueDpopAccessToken({
113 userId: "user_123",
114 proofKeyThumbprint: "web-proof-key-thumbprint",
115 jti: "web-access-token-1",
116 issuedAtEpochSeconds: 100,
117 expiresAtEpochSeconds: 200,
118 clientId: "t3-web",
119 scopes: ["environment:connect", "environment:status"],
120 });
121
122 expect(
123 yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 }),
124 ).toMatchObject({
125 client_id: "t3-web",
126 scope: ["environment:connect", "environment:status"],
127 cnf: { jkt: "web-proof-key-thumbprint" },
128 });
129 }).pipe(Effect.provide(layer)),
130 );
131
132 it.effect("treats requested scope as an order-independent set", () =>
133 Effect.gen(function* () {
134 const relayTokens = yield* RelayTokens.RelayTokens;
135 expect(
136 relayTokens.resolveDpopAccessTokenScopes({
137 clientId: "t3-mobile",
138 scope: "environment:status environment:connect environment:status",
139 }),
140 ).toEqual(["environment:status", "environment:connect"]);
141 }).pipe(Effect.provide(layer)),
142 );
143
144 it.effect("rejects signed DPoP tokens whose scope is outside the relay policy", () =>
145 Effect.gen(function* () {
146 const relayTokens = yield* RelayTokens.RelayTokens;
147 const token = yield* signRelayJwt({
148 privateKey: keyPair.privateKey,
149 typ: "t3-relay-dpop-access+jwt",
150 payload: {
151 iss: "https://relay.example.test",
152 aud: "https://relay.example.test",
153 sub: "user_123",
154 jti: "access-token-invalid-scope",
155 iat: 100,
156 exp: 200,
157 client_id: "t3-mobile",
158 scope: "environment:admin",
159 cnf: { jkt: "proof-key-thumbprint" },
160 },
161 });
162
163 expect(yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 })).toBeNull();
164 }).pipe(Effect.provide(layer)),
165 );
166
167 it.effect("rejects mobile registration scope on a web public client token", () =>
168 Effect.gen(function* () {
169 const relayTokens = yield* RelayTokens.RelayTokens;
170 const token = yield* signRelayJwt({
171 privateKey: keyPair.privateKey,
172 typ: "t3-relay-dpop-access+jwt",
173 payload: {
174 iss: "https://relay.example.test",
175 aud: "https://relay.example.test",
176 sub: "user_123",
177 jti: "web-token-invalid-mobile-scope",
178 iat: 100,
179 exp: 200,
180 client_id: "t3-web",
181 scope: "environment:connect mobile:registration",
182 cnf: { jkt: "proof-key-thumbprint" },
183 },
184 });
185
186 expect(yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 })).toBeNull();
187 }).pipe(Effect.provide(layer)),
188 );
189});