Skip to content
Source
Sign inSign up

RelayTokens.test.ts

184 lines6.4 KB

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