Unified · Split
| @@ -21,6 +21,7 @@ import { · 20 unmodified lines | ||
| 21 | 21 | relayEnvironmentAuthLayer, |
| 22 | 22 | relayNotFoundRoute, |
| 23 | 23 | traceRelayHttpRequestWith, |
| 24 | + verifyRelayDpopTokenExchangeSubject, | |
| 24 | 25 | verifyRelayClientBearerToken, |
| 25 | 26 | withoutCapturedParentSpan, |
| 26 | 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 | 243 | describe("relay environment authentication", () => { |
| 192 | 244 | it.effect("preserves credential lookup persistence failures as internal errors", () => { |
| 193 | 245 | const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({ |
| @@ -586,12 +586,10 @@ export const tokenApi = HttpApiBuilder.group( · 585 unmodified lines | ||
| 586 | 586 | return yield* new HttpApiError.Unauthorized({}); |
| 587 | 587 | } |
| 588 | 588 | |
| 589 | − const verified = yield* verifyClerkBearerToken(config, args.payload.subject_token).pipe( | |
| 590 | − Effect.catch(() => relayAuthInvalidError("invalid_bearer")), | |
| 589 | + const userId = yield* verifyRelayDpopTokenExchangeSubject( | |
| 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 | 593 | const proofKeyThumbprint = yield* requireDpopProof().pipe( |
| 596 | 594 | Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs), |
| 597 | 595 | ); |
| @@ -603,7 +601,7 @@ export const tokenApi = HttpApiBuilder.group( · 5 unmodified lines | ||
| 603 | 601 | return { |
| 604 | 602 | access_token: yield* relayTokens |
| 605 | 603 | .issueDpopAccessToken({ |
| 606 | − userId: verified.sub, | |
| 604 | + userId, | |
| 607 | 605 | proofKeyThumbprint, |
| 608 | 606 | jti, |
| 609 | 607 | issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), |
| @@ -1062,6 +1060,26 @@ function hasExpectedClerkAudience(audience: unknown, expectedAudience: string): · 452 unmodified lines | ||
| 1062 | 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 | 1083 | function verifyClerkBearerToken( |
| 1066 | 1084 | config: RelayConfiguration.RelayConfiguration["Service"], |
| 1067 | 1085 | token: string, |