| 1 | // @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync. |
| 2 | import * as NodeCrypto from "node:crypto"; |
| 3 | |
| 4 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | import { signRelayJwt } from "@t3tools/shared/relayJwt"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Redacted from "effect/Redacted"; |
| 9 | |
| 10 | import * as RelayConfiguration from "../Config.ts"; |
| 11 | import * as RelayTokens from "./RelayTokens.ts"; |
| 12 | |
| 13 | const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { |
| 14 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 15 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 16 | }); |
| 17 | |
| 18 | const 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 | |
| 37 | const layer = RelayTokens.layer.pipe(Layer.provide(RelayConfiguration.layer(config))); |
| 38 | |
| 39 | describe("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 | }); |