Unified · Split
| @@ -31,4 +31,17 @@ describe("resolveCloudPublicConfig", () => { · 30 unmodified lines | |||
| 31 | relayUrl: "https://relay.example.test", | 31 | relayUrl: "https://relay.example.test", |
| 32 | }); | 32 | }); |
| 33 | }); | 33 | }); |
| 34 | |||
| 35 | it("rejects an insecure relay URL", () => { | ||
| 36 | expect( | ||
| 37 | resolveCloudPublicConfig({ | ||
| 38 | clerk: { publishableKey: "pk_test_example", jwtTemplate: "t3-relay" }, | ||
| 39 | relay: { url: "http://relay.example.test" }, | ||
| 40 | }), | ||
| 41 | ).toEqual({ | ||
| 42 | clerkPublishableKey: "pk_test_example", | ||
| 43 | clerkJwtTemplate: "t3-relay", | ||
| 44 | relayUrl: null, | ||
| 45 | }); | ||
| 46 | }); | ||
| 34 | }); | 47 | }); |
| @@ -1,5 +1,6 @@ | |||
| 1 | import Constants from "expo-constants"; | 1 | import Constants from "expo-constants"; |
| 2 | import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; | 2 | import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; |
| 3 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; | ||
| 3 | 4 | ||
| 4 | type ExpoExtra = Readonly<Record<string, unknown>> | undefined; | 5 | type ExpoExtra = Readonly<Record<string, unknown>> | undefined; |
| 5 | 6 | ||
| @@ -22,7 +23,7 @@ export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig · 16 unmodified lines | |||
| 22 | return { | 23 | return { |
| 23 | clerkPublishableKey: trimNonEmpty(clerk?.publishableKey), | 24 | clerkPublishableKey: trimNonEmpty(clerk?.publishableKey), |
| 24 | clerkJwtTemplate: trimNonEmpty(clerk?.jwtTemplate), | 25 | clerkJwtTemplate: trimNonEmpty(clerk?.jwtTemplate), |
| 25 | relayUrl: trimNonEmpty(relay?.url)?.replace(/\/+$/u, "") ?? null, | 26 | relayUrl: normalizeSecureRelayUrl(trimNonEmpty(relay?.url) ?? ""), |
| 26 | } satisfies CloudPublicConfig; | 27 | } satisfies CloudPublicConfig; |
| 27 | } | 28 | } |
| 28 | 29 | ||
| @@ -37,6 +37,7 @@ import { · 36 unmodified lines | |||
| 37 | signRelayJwt, | 37 | signRelayJwt, |
| 38 | verifyRelayJwt, | 38 | verifyRelayJwt, |
| 39 | } from "@t3tools/shared/relayJwt"; | 39 | } from "@t3tools/shared/relayJwt"; |
| 40 | import { isSecureRelayUrl } from "@t3tools/shared/relayUrl"; | ||
| 40 | import * as DateTime from "effect/DateTime"; | 41 | import * as DateTime from "effect/DateTime"; |
| 41 | import * as Crypto from "effect/Crypto"; | 42 | import * as Crypto from "effect/Crypto"; |
| 42 | import * as Duration from "effect/Duration"; | 43 | import * as Duration from "effect/Duration"; |
| @@ -155,20 +156,6 @@ function validateCloudMintPublicKey( · 112 unmodified lines | |||
| 155 | ); | 156 | ); |
| 156 | } | 157 | } |
| 157 | 158 | ||
| 158 | function isSecureRelayUrl(value: string): boolean { | ||
| 159 | try { | ||
| 160 | const url = new URL(value); | ||
| 161 | return ( | ||
| 162 | url.protocol === "https:" && | ||
| 163 | url.username.length === 0 && | ||
| 164 | url.password.length === 0 && | ||
| 165 | url.hash.length === 0 | ||
| 166 | ); | ||
| 167 | } catch { | ||
| 168 | return false; | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | function validateRelayConfigPayload( | 159 | function validateRelayConfigPayload( |
| 173 | payload: RelayEnvironmentConfigRequest, | 160 | payload: RelayEnvironmentConfigRequest, |
| 174 | ): Effect.Effect<void, EnvironmentHttpBadRequestError> { | 161 | ): Effect.Effect<void, EnvironmentHttpBadRequestError> { |
| @@ -2127,6 +2127,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 2126 unmodified lines | |||
| 2127 | relayIssuer: "http://relay.example.test", | 2127 | relayIssuer: "http://relay.example.test", |
| 2128 | environmentCredential: "t3env_test_credential", | 2128 | environmentCredential: "t3env_test_credential", |
| 2129 | }); | 2129 | }); |
| 2130 | const nonOriginRelayUrl = yield* postRelayConfig({ | ||
| 2131 | relayUrl: "https://relay.example.test/path", | ||
| 2132 | cloudUserId: "user_123", | ||
| 2133 | environmentCredential: "t3env_test_credential", | ||
| 2134 | }); | ||
| 2130 | const emptyCredential = yield* postRelayConfig({ | 2135 | const emptyCredential = yield* postRelayConfig({ |
| 2131 | relayUrl: "https://relay.example.test", | 2136 | relayUrl: "https://relay.example.test", |
| 2132 | cloudUserId: "user_123", | 2137 | cloudUserId: "user_123", |
| @@ -2138,6 +2143,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 5 unmodified lines | |||
| 2138 | const insecureRelayIssuerBody = yield* responseJsonEffect<{ readonly message?: string }>( | 2143 | const insecureRelayIssuerBody = yield* responseJsonEffect<{ readonly message?: string }>( |
| 2139 | insecureRelayIssuer, | 2144 | insecureRelayIssuer, |
| 2140 | ); | 2145 | ); |
| 2146 | const nonOriginRelayUrlBody = yield* responseJsonEffect<{ readonly message?: string }>( | ||
| 2147 | nonOriginRelayUrl, | ||
| 2148 | ); | ||
| 2141 | const emptyCredentialBody = yield* responseJsonEffect<{ readonly message?: string }>( | 2149 | const emptyCredentialBody = yield* responseJsonEffect<{ readonly message?: string }>( |
| 2142 | emptyCredential, | 2150 | emptyCredential, |
| 2143 | ); | 2151 | ); |
| @@ -2149,6 +2157,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 5 unmodified lines | |||
| 2149 | insecureRelayIssuerBody.message, | 2157 | insecureRelayIssuerBody.message, |
| 2150 | "Relay issuer must be a secure absolute HTTPS URL.", | 2158 | "Relay issuer must be a secure absolute HTTPS URL.", |
| 2151 | ); | 2159 | ); |
| 2160 | assert.equal(nonOriginRelayUrl.status, 400); | ||
| 2161 | assert.equal(nonOriginRelayUrlBody.message, "Relay URL must be a secure absolute HTTPS URL."); | ||
| 2152 | assert.equal(emptyCredential.status, 400); | 2162 | assert.equal(emptyCredential.status, 400); |
| 2153 | assert.equal(emptyCredentialBody.message, "Relay environment credential is required."); | 2163 | assert.equal(emptyCredentialBody.message, "Relay environment credential is required."); |
| 2154 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 2164 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| @@ -22,4 +22,12 @@ describe("hasCloudPublicConfig", () => { · 21 unmodified lines | |||
| 22 | vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); | 22 | vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); |
| 23 | expect(hasCloudPublicConfig()).toBe(true); | 23 | expect(hasCloudPublicConfig()).toBe(true); |
| 24 | }); | 24 | }); |
| 25 | |||
| 26 | it("rejects an insecure relay URL", () => { | ||
| 27 | vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_test_example"); | ||
| 28 | vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); | ||
| 29 | vi.stubEnv("VITE_T3CODE_RELAY_URL", "http://relay.example.test"); | ||
| 30 | |||
| 31 | expect(hasCloudPublicConfig()).toBe(false); | ||
| 32 | }); | ||
| 25 | }); | 33 | }); |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; | 1 | import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; |
| 2 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; | ||
| 2 | 3 | ||
| 3 | export interface CloudPublicConfig { | 4 | export interface CloudPublicConfig { |
| 4 | readonly clerkPublishableKey: string | null; | 5 | readonly clerkPublishableKey: string | null; |
| @@ -16,11 +17,9 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { · 11 unmodified lines | |||
| 16 | import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined, | 17 | import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined, |
| 17 | ), | 18 | ), |
| 18 | clerkJwtTemplate: trimNonEmpty(import.meta.env.VITE_CLERK_JWT_TEMPLATE as string | undefined), | 19 | clerkJwtTemplate: trimNonEmpty(import.meta.env.VITE_CLERK_JWT_TEMPLATE as string | undefined), |
| 19 | relayUrl: | 20 | relayUrl: normalizeSecureRelayUrl( |
| 20 | trimNonEmpty(import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined)?.replace( | 21 | (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "", |
| 21 | /\/+$/u, | 22 | ), |
| 22 | "", | ||
| 23 | ) ?? null, | ||
| 24 | }; | 23 | }; |
| 25 | } | 24 | } |
| 26 | 25 | ||
| @@ -16,7 +16,10 @@ import { · 15 unmodified lines | |||
| 16 | } from "./managedRelay.ts"; | 16 | } from "./managedRelay.ts"; |
| 17 | import { remoteHttpClientLayer } from "./remote.ts"; | 17 | import { remoteHttpClientLayer } from "./remote.ts"; |
| 18 | 18 | ||
| 19 | function managedRelayTestLayer(fetchFn: typeof globalThis.fetch) { | 19 | function managedRelayTestLayer( |
| 20 | fetchFn: typeof globalThis.fetch, | ||
| 21 | relayUrl = "https://relay.example.test", | ||
| 22 | ) { | ||
| 20 | const httpClientLayer = remoteHttpClientLayer(fetchFn); | 23 | const httpClientLayer = remoteHttpClientLayer(fetchFn); |
| 21 | const signerLayer = Layer.succeed( | 24 | const signerLayer = Layer.succeed( |
| 22 | ManagedRelayDpopSigner, | 25 | ManagedRelayDpopSigner, |
| @@ -26,12 +29,33 @@ function managedRelayTestLayer(fetchFn: typeof globalThis.fetch) { · 3 unmodified lines | |||
| 26 | }), | 29 | }), |
| 27 | ); | 30 | ); |
| 28 | return managedRelayClientLayer({ | 31 | return managedRelayClientLayer({ |
| 29 | relayUrl: "https://relay.example.test", | 32 | relayUrl, |
| 30 | clientId: "t3-mobile", | 33 | clientId: "t3-mobile", |
| 31 | }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer)); | 34 | }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer)); |
| 32 | } | 35 | } |
| 33 | 36 | ||
| 34 | describe("ManagedRelayClient", () => { | 37 | describe("ManagedRelayClient", () => { |
| 38 | it.effect("rejects unsafe relay URLs before sending credentials", () => { | ||
| 39 | let requestCount = 0; | ||
| 40 | const fetchFn = (() => { | ||
| 41 | requestCount += 1; | ||
| 42 | return Promise.resolve(Response.json({})); | ||
| 43 | }) satisfies typeof globalThis.fetch; | ||
| 44 | |||
| 45 | return Effect.gen(function* () { | ||
| 46 | const relayClient = yield* ManagedRelayClient; | ||
| 47 | const error = yield* relayClient | ||
| 48 | .listEnvironments({ clerkToken: "clerk-token" }) | ||
| 49 | .pipe(Effect.flip); | ||
| 50 | |||
| 51 | expect(error).toMatchObject({ | ||
| 52 | _tag: "ManagedRelayClientError", | ||
| 53 | message: "Relay URL must be a secure absolute HTTPS origin.", | ||
| 54 | }); | ||
| 55 | expect(requestCount).toBe(0); | ||
| 56 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test"))); | ||
| 57 | }); | ||
| 58 | |||
| 35 | it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => { | 59 | it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => { |
| 36 | let tokenExchangeCount = 0; | 60 | let tokenExchangeCount = 0; |
| 37 | const fetchFn = ((input) => { | 61 | const fetchFn = ((input) => { |
| @@ -26,6 +26,7 @@ import { · 25 unmodified lines | |||
| 26 | RelayUnregisterDeviceEndpoint, | 26 | RelayUnregisterDeviceEndpoint, |
| 27 | } from "@t3tools/contracts/relay"; | 27 | } from "@t3tools/contracts/relay"; |
| 28 | import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; | 28 | import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; |
| 29 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; | ||
| 29 | import * as Clock from "effect/Clock"; | 30 | import * as Clock from "effect/Clock"; |
| 30 | import * as Context from "effect/Context"; | 31 | import * as Context from "effect/Context"; |
| 31 | import * as Data from "effect/Data"; | 32 | import * as Data from "effect/Data"; |
| @@ -183,14 +184,37 @@ function dpopHeaders(authorization: ManagedRelayAuthorization) { · 151 unmodified lines | |||
| 183 | }; | 184 | }; |
| 184 | } | 185 | } |
| 185 | 186 | ||
| 187 | function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { | ||
| 188 | const unavailable = () => | ||
| 189 | Effect.fail(relayClientError("Relay URL must be a secure absolute HTTPS origin.")); | ||
| 190 | return ManagedRelayClient.of({ | ||
| 191 | relayUrl, | ||
| 192 | listEnvironments: unavailable, | ||
| 193 | listDevices: unavailable, | ||
| 194 | createEnvironmentLinkChallenge: unavailable, | ||
| 195 | linkEnvironment: unavailable, | ||
| 196 | unlinkEnvironment: unavailable, | ||
| 197 | getEnvironmentStatus: unavailable, | ||
| 198 | connectEnvironment: unavailable, | ||
| 199 | registerDevice: unavailable, | ||
| 200 | unregisterDevice: unavailable, | ||
| 201 | registerLiveActivity: unavailable, | ||
| 202 | resetTokenCache: Effect.void, | ||
| 203 | }); | ||
| 204 | } | ||
| 205 | |||
| 186 | export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { | 206 | export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { |
| 187 | return Layer.effect( | 207 | return Layer.effect( |
| 188 | ManagedRelayClient, | 208 | ManagedRelayClient, |
| 189 | Effect.gen(function* () { | 209 | Effect.gen(function* () { |
| 210 | const relayUrl = normalizeSecureRelayUrl(options.relayUrl); | ||
| 211 | if (relayUrl === null) { | ||
| 212 | return disabledManagedRelayClient(options.relayUrl); | ||
| 213 | } | ||
| 190 | const signer = yield* ManagedRelayDpopSigner; | 214 | const signer = yield* ManagedRelayDpopSigner; |
| 191 | const client = yield* HttpApiClient.make(RelayApi, { baseUrl: options.relayUrl }); | 215 | const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); |
| 192 | const cachedTokens = yield* SynchronizedRef.make<ReadonlyArray<CachedRelayAccessToken>>([]); | 216 | const cachedTokens = yield* SynchronizedRef.make<ReadonlyArray<CachedRelayAccessToken>>([]); |
| 193 | const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: options.relayUrl }); | 217 | const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); |
| 194 | 218 | ||
| 195 | type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">; | 219 | type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">; |
| 196 | const dpopProofTargets = { | 220 | const dpopProofTargets = { |
| @@ -257,7 +281,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) · 60 unmodified lines | |||
| 257 | subject_token: input.clerkToken, | 281 | subject_token: input.clerkToken, |
| 258 | subject_token_type: RelayJwtSubjectTokenType, | 282 | subject_token_type: RelayJwtSubjectTokenType, |
| 259 | requested_token_type: RelayAccessTokenType, | 283 | requested_token_type: RelayAccessTokenType, |
| 260 | resource: options.relayUrl, | 284 | resource: relayUrl, |
| 261 | scope: encodeOAuthScope(input.scopes), | 285 | scope: encodeOAuthScope(input.scopes), |
| 262 | client_id: options.clientId, | 286 | client_id: options.clientId, |
| 263 | }, | 287 | }, |
| @@ -325,7 +349,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) · 61 unmodified lines | |||
| 325 | }); | 349 | }); |
| 326 | 350 | ||
| 327 | return ManagedRelayClient.of({ | 351 | return ManagedRelayClient.of({ |
| 328 | relayUrl: options.relayUrl, | 352 | relayUrl, |
| 329 | listEnvironments: (input) => | 353 | listEnvironments: (input) => |
| 330 | client.client.listEnvironments({ headers: bearerHeaders(input.clerkToken) }).pipe( | 354 | client.client.listEnvironments({ headers: bearerHeaders(input.clerkToken) }).pipe( |
| 331 | Effect.map((response) => response.environments), | 355 | Effect.map((response) => response.environments), |
| @@ -103,6 +103,10 @@ · 102 unmodified lines | |||
| 103 | "types": "./src/relayAuth.ts", | 103 | "types": "./src/relayAuth.ts", |
| 104 | "import": "./src/relayAuth.ts" | 104 | "import": "./src/relayAuth.ts" |
| 105 | }, | 105 | }, |
| 106 | "./relayUrl": { | ||
| 107 | "types": "./src/relayUrl.ts", | ||
| 108 | "import": "./src/relayUrl.ts" | ||
| 109 | }, | ||
| 106 | "./relayJwt": { | 110 | "./relayJwt": { |
| 107 | "types": "./src/relayJwt.ts", | 111 | "types": "./src/relayJwt.ts", |
| 108 | "import": "./src/relayJwt.ts" | 112 | "import": "./src/relayJwt.ts" |
| @@ -0,0 +1,26 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { isSecureRelayUrl, normalizeSecureRelayUrl } from "./relayUrl.ts"; | ||
| 4 | |||
| 5 | describe("normalizeSecureRelayUrl", () => { | ||
| 6 | it("normalizes secure relay origins", () => { | ||
| 7 | expect(normalizeSecureRelayUrl(" https://relay.example.test/// ")).toBe( | ||
| 8 | "https://relay.example.test", | ||
| 9 | ); | ||
| 10 | expect(normalizeSecureRelayUrl("https://relay.example.test:8443/")).toBe( | ||
| 11 | "https://relay.example.test:8443", | ||
| 12 | ); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it.each([ | ||
| 16 | "http://relay.example.test", | ||
| 17 | "https://user:password@relay.example.test", | ||
| 18 | "https://relay.example.test/path", | ||
| 19 | "https://relay.example.test?query=value", | ||
| 20 | "https://relay.example.test#fragment", | ||
| 21 | "not a url", | ||
| 22 | ])("rejects unsafe relay URL %s", (value) => { | ||
| 23 | expect(normalizeSecureRelayUrl(value)).toBeNull(); | ||
| 24 | expect(isSecureRelayUrl(value)).toBe(false); | ||
| 25 | }); | ||
| 26 | }); | ||
| @@ -0,0 +1,22 @@ | |||
| 1 | export function normalizeSecureRelayUrl(value: string): string | null { | ||
| 2 | try { | ||
| 3 | const url = new URL(value.trim()); | ||
| 4 | if ( | ||
| 5 | url.protocol !== "https:" || | ||
| 6 | url.username.length > 0 || | ||
| 7 | url.password.length > 0 || | ||
| 8 | url.search.length > 0 || | ||
| 9 | url.hash.length > 0 || | ||
| 10 | !/^\/+$/u.test(url.pathname) | ||
| 11 | ) { | ||
| 12 | return null; | ||
| 13 | } | ||
| 14 | return url.origin; | ||
| 15 | } catch { | ||
| 16 | return null; | ||
| 17 | } | ||
| 18 | } | ||
| 19 | |||
| 20 | export function isSecureRelayUrl(value: string): boolean { | ||
| 21 | return normalizeSecureRelayUrl(value) !== null; | ||
| 22 | } | ||