Unified · Split
| @@ -21,6 +21,7 @@ import { · 20 unmodified lines | |||
| 21 | relayEnvironmentAuthLayer, | 21 | relayEnvironmentAuthLayer, |
| 22 | relayNotFoundRoute, | 22 | relayNotFoundRoute, |
| 23 | traceRelayHttpRequestWith, | 23 | traceRelayHttpRequestWith, |
| 24 | verifyRelayDpopTokenExchangeSubject, | ||
| 24 | verifyRelayClientBearerToken, | 25 | verifyRelayClientBearerToken, |
| 25 | withoutCapturedParentSpan, | 26 | withoutCapturedParentSpan, |
| 26 | } from "./Api.ts"; | 27 | } from "./Api.ts"; |
| @@ -188,6 +189,57 @@ describe("relay client authentication", () => { · 161 unmodified lines | |||
| 188 | ); | 189 | ); |
| 189 | }); | 190 | }); |
| 190 | 191 | ||
| 192 | describe("relay DPoP token exchange authentication", () => { | ||
| 193 | it.effect("records safe Clerk verification diagnostics before returning invalid bearer", () => { | ||
| 194 | const verificationCause = Object.assign(new Error("private token verification detail"), { | ||
| 195 | reason: "session_invalid", | ||
| 196 | }); | ||
| 197 | |||
| 198 | return Effect.gen(function* () { | ||
| 199 | const spans: Array<Tracer.NativeSpan> = []; | ||
| 200 | const tracer = Tracer.make({ | ||
| 201 | span: (options) => { | ||
| 202 | const span = new Tracer.NativeSpan(options); | ||
| 203 | spans.push(span); | ||
| 204 | return span; | ||
| 205 | }, | ||
| 206 | }); | ||
| 207 | vi.mocked(verifyToken).mockRejectedValue(verificationCause); | ||
| 208 | |||
| 209 | const error = yield* Effect.flip( | ||
| 210 | verifyRelayDpopTokenExchangeSubject(relaySettings, "private-subject-token").pipe( | ||
| 211 | Effect.provideService(Tracer.Tracer, tracer), | ||
| 212 | ), | ||
| 213 | ); | ||
| 214 | |||
| 215 | expect(Predicate.isTagged(error, "RelayAuthInvalidError")).toBe(true); | ||
| 216 | if (Predicate.isTagged(error, "RelayAuthInvalidError")) { | ||
| 217 | expect(error.reason).toBe("invalid_bearer"); | ||
| 218 | } | ||
| 219 | const exchangeSpan = spans.find( | ||
| 220 | (span) => span.name === "relay.auth.dpop_token_exchange_subject", | ||
| 221 | ); | ||
| 222 | expect(exchangeSpan?.attributes.get("relay.auth.clerk_verification_failure")).toBe( | ||
| 223 | "session_invalid", | ||
| 224 | ); | ||
| 225 | expect(exchangeSpan?.attributes.get("relay.auth.clerk_verification_stage")).toBe( | ||
| 226 | "session-token-verification", | ||
| 227 | ); | ||
| 228 | for (const attribute of exchangeSpan?.attributes.values() ?? []) { | ||
| 229 | expect(String(attribute)).not.toContain("private-subject-token"); | ||
| 230 | expect(String(attribute)).not.toContain("private token verification detail"); | ||
| 231 | } | ||
| 232 | }).pipe( | ||
| 233 | Effect.ensuring( | ||
| 234 | Effect.sync(() => { | ||
| 235 | vi.mocked(verifyToken).mockReset(); | ||
| 236 | vi.mocked(createClerkClient).mockReset(); | ||
| 237 | }), | ||
| 238 | ), | ||
| 239 | ); | ||
| 240 | }); | ||
| 241 | }); | ||
| 242 | |||
| 191 | describe("relay environment authentication", () => { | 243 | describe("relay environment authentication", () => { |
| 192 | it.effect("preserves credential lookup persistence failures as internal errors", () => { | 244 | it.effect("preserves credential lookup persistence failures as internal errors", () => { |
| 193 | const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({ | 245 | const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({ |
| @@ -586,12 +586,10 @@ export const tokenApi = HttpApiBuilder.group( · 585 unmodified lines | |||
| 586 | return yield* new HttpApiError.Unauthorized({}); | 586 | return yield* new HttpApiError.Unauthorized({}); |
| 587 | } | 587 | } |
| 588 | 588 | ||
| 589 | const verified = yield* verifyClerkBearerToken(config, args.payload.subject_token).pipe( | 589 | const userId = yield* verifyRelayDpopTokenExchangeSubject( |
| 590 | Effect.catch(() => relayAuthInvalidError("invalid_bearer")), | 590 | config, |
| 591 | args.payload.subject_token, | ||
| 591 | ); | 592 | ); |
| 592 | if (!verified.sub || !hasExpectedClerkAudience(verified.aud, config.clerkJwtAudience)) { | ||
| 593 | return yield* relayAuthInvalidError("invalid_bearer"); | ||
| 594 | } | ||
| 595 | const proofKeyThumbprint = yield* requireDpopProof().pipe( | 593 | const proofKeyThumbprint = yield* requireDpopProof().pipe( |
| 596 | Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs), | 594 | Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs), |
| 597 | ); | 595 | ); |
| @@ -603,7 +601,7 @@ export const tokenApi = HttpApiBuilder.group( · 5 unmodified lines | |||
| 603 | return { | 601 | return { |
| 604 | access_token: yield* relayTokens | 602 | access_token: yield* relayTokens |
| 605 | .issueDpopAccessToken({ | 603 | .issueDpopAccessToken({ |
| 606 | userId: verified.sub, | 604 | userId, |
| 607 | proofKeyThumbprint, | 605 | proofKeyThumbprint, |
| 608 | jti, | 606 | jti, |
| 609 | issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | 607 | issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), |
| @@ -1062,6 +1060,26 @@ function hasExpectedClerkAudience(audience: unknown, expectedAudience: string): · 452 unmodified lines | |||
| 1062 | audience.some((entry) => typeof entry === "string" && entry === expectedAudience); | 1060 | audience.some((entry) => typeof entry === "string" && entry === expectedAudience); |
| 1063 | } | 1061 | } |
| 1064 | 1062 | ||
| 1063 | export const verifyRelayDpopTokenExchangeSubject = Effect.fn( | ||
| 1064 | "relay.auth.dpop_token_exchange_subject", | ||
| 1065 | )(function* (config: RelayConfiguration.RelayConfiguration["Service"], token: string) { | ||
| 1066 | const verified = yield* verifyClerkBearerToken(config, token).pipe( | ||
| 1067 | Effect.tapErrorTag("ClerkTokenVerificationError", (error) => | ||
| 1068 | Effect.annotateCurrentSpan({ | ||
| 1069 | "relay.auth.clerk_verification_failure": error.reason, | ||
| 1070 | "relay.auth.clerk_verification_stage": error.stage, | ||
| 1071 | }), | ||
| 1072 | ), | ||
| 1073 | Effect.catchTags({ | ||
| 1074 | ClerkTokenVerificationError: () => relayAuthInvalidError("invalid_bearer"), | ||
| 1075 | }), | ||
| 1076 | ); | ||
| 1077 | if (!verified.sub || !hasExpectedClerkAudience(verified.aud, config.clerkJwtAudience)) { | ||
| 1078 | return yield* relayAuthInvalidError("invalid_bearer"); | ||
| 1079 | } | ||
| 1080 | return verified.sub; | ||
| 1081 | }); | ||
| 1082 | |||
| 1065 | function verifyClerkBearerToken( | 1083 | function verifyClerkBearerToken( |
| 1066 | config: RelayConfiguration.RelayConfiguration["Service"], | 1084 | config: RelayConfiguration.RelayConfiguration["Service"], |
| 1067 | token: string, | 1085 | token: string, |