fix: harden JWT signature verification and URL path encoding#386
fix: harden JWT signature verification and URL path encoding#386gjtorikian wants to merge 4 commits intomainfrom
Conversation
Centralize RFC 3986 path-segment encoding in HttpClient::resolveUrl so an untrusted ID interpolated into a path template (e.g. om_xyz?/foo) stays inside a single segment instead of opening a new path or query. Existing services that already call rawurlencode are preserved (percent-encoded triplets are not double-encoded).
Harden SessionManager::decodeAccessToken to verify the JWS signature against the JWKS published for the supplied client ID before returning claims. Adds an algorithm allow-list (RS256 only) and rejects tokens with `alg: none`, missing/unknown `kid`, tampered signatures, or no matching JWK. Expiration is checked after signature verification. Issuer/audience enforcement is left as a TODO until the documented WorkOS values are confirmed; other WorkOS SDKs (Ruby, Python) currently skip aud verification.
Flipping the last base64url char of the JWT signature could canonicalise to the original bytes; flip a middle byte instead so the decoded signature is deterministically different.
Greptile SummaryThis PR hardens JWT access-token verification in
Confidence Score: 3/5The JWT verification core is sound but the path-encoding comment misstates what is actually protected, and the unknown-kid test passes due to a mock-queue exception rather than the intended guard code. The lib/HttpClient.php (misleading security comment about Important Files Changed
Reviews (3): Last reviewed commit: "perf(session): cache JWKS per client wit..." | Re-trigger Greptile |
| // TODO(security-fix-plan.md, finding #60): enforce documented WorkOS | ||
| // `iss` and `aud` values once empirically confirmed. The other WorkOS | ||
| // SDKs (Ruby, Python) currently skip `aud` verification, so the | ||
| // canonical values are not authoritatively documented in this repo. | ||
| // Track resolution under "Open questions / follow-ups" in the plan. | ||
|
|
There was a problem hiding this comment.
iss and aud claims are not validated
Team policy (rule e0f82177) requires that JWTs always have their iss and aud claims validated. The TODO defers this indefinitely, leaving the door open for tokens issued by a different issuer or intended for a different audience to be accepted. An attacker with a valid WorkOS-signed token for a different client application (different aud) or a different issuer would still pass signature verification and be treated as authenticated. The Ruby/Python SDK comparison in the comment is not sufficient justification when the org's own policy mandates validation.
Rule Used: JWTs should always be validated before use and the... (source)
There was a problem hiding this comment.
Acknowledged on the rule, but I want to flag why this PR keeps the TODO rather than fixing it inline:
I checked the canonical workos-node SDK — its isValidJwt (in src/user-management/session.ts) calls jwtVerify(accessToken, jwks) with no issuer or audience options, so jose verifies signature + alg + exp only. There are zero iss/aud string literals anywhere in workos-node's source or tests. Combined with the Ruby/Python SDKs already skipping aud, none of the WorkOS SDKs currently validate these claims, and the canonical values aren't documented in any SDK repo.
If I hard-code defensible guesses (e.g. iss = baseUrl, aud = clientId) and the real tokens use something else (e.g. iss = https://api.workos.com/user_management/{clientId}), every production caller's session check breaks on upgrade. That's a worse outcome than the cross-tenant risk this rule targets — which already requires a WorkOS-signed token and a way to plant it in the victim's sealed cookie.
Plan: I'll get the canonical iss/aud values from the auth team and add validation in a follow-up PR. The TODO and rationale will be expanded to reference this conversation so the deferral is explicit.
Defaulting ValidAudience to ClientId would reject every legitimate token on upgrade if WorkOS access tokens carry a different `aud` (e.g. a resource-server URL rather than the client identifier). The workos-php investigation under workos/workos-php#386 confirmed that the canonical WorkOS `iss` / `aud` values are not documented across SDKs — workos-node validates only signature + algorithm + expiry, and Ruby/Python skip `aud` entirely. Audience now mirrors issuer: validated only when ValidAudience is explicitly configured. Signature, algorithm, and lifetime remain enforced unconditionally. Once the canonical claim values are confirmed, defaults can be reintroduced safely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously every authenticate() call issued a live HTTP GET to the JWKS endpoint, making each session check dependent on an external round-trip and inflating latency. Add an in-memory cache on SessionManager keyed by client ID with a 300-second TTL, plus a force-refresh path on `kid` miss so newly-rotated signing keys are still discovered without waiting for TTL expiry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| * Defense-in-depth: when a service forgets to encode an interpolated ID | ||
| * like `om_xyz?/foo`, the `?` and `/` inside the ID become percent-encoded | ||
| * inside a single segment instead of opening a new path or query. | ||
| */ |
There was a problem hiding this comment.
Misleading security claim:
/ in IDs is not encoded
The comment states "the ? and / inside the ID become percent-encoded inside a single segment," but that is false for /. Because encodePathSegments calls explode('/', $path) before any encoding, a raw / inside an untrusted ID becomes a genuine path separator — not %2F. A caller that passes organizations/../../admin (forgetting to pre-encode the ID) will produce segments ['organizations', '..', '..', 'admin'], none of which are encoded because . is in the pchar safe set. The test in testResolveUrlPreservesEncodedIdAsSingleSegment only asserts that ? becomes %3F; it does not assert that the spurious /foo segment is contained within the encoded ID. The comment (and the PR description) should be corrected to state that only non-slash characters like ? and # are guarded against, and that callers remain responsible for encoding / (via rawurlencode) before interpolating IDs into path templates.
| ); | ||
|
|
||
| $client = $this->createMockClient([['status' => 200, 'body' => $otherJwks]]); | ||
| $result = $client->sessionManager()->authenticate( | ||
| sessionData: $sealed, | ||
| cookiePassword: $this->cookiePassword, | ||
| clientId: 'client_123', | ||
| ); | ||
|
|
||
| $this->assertFalse($result['authenticated']); | ||
| $this->assertSame('invalid_jwt', $result['reason']); | ||
| } | ||
|
|
||
| public function testAuthenticateCachesJwksAcrossCalls(): void | ||
| { | ||
| [$jwks, $jwt] = $this->buildSignedJwt([ |
There was a problem hiding this comment.
testAuthenticateRejectsUnknownKid exercises the wrong failure path
Only one JWKS response is queued in the mock. The initial getCachedJwks call consumes it; when the kid miss triggers a forced refresh (getCachedJwks($clientId, forceRefresh: true)), the MockHandler queue is empty and throws OutOfBoundsException. That exception propagates through fetchJwks and decodeAccessToken and is caught by authenticate()'s catch (\Exception $e) block, which returns invalid_jwt — so the assertion passes. However, the actual code path that throws new \InvalidArgumentException('No JWKS key matches JWT kid') is never reached. If a future refactor removes or breaks that guard, this test will still pass because the mock queue exhaustion covers for it. To test the intended behaviour, a second response (also advertising only kid_other) should be queued so the forced refresh succeeds but still cannot find kid_signed_with.
Summary
SessionManager::decodeAccessToken, with an RS256 allow-list that rejectsalg: none, missing/unknownkid, tampered signatures, and tokens lacking a matching JWK. Expiration is now checked only after signature verification.HttpClient::resolveUrlso an untrusted ID interpolated into a path template (e.g.om_xyz?/foo) cannot escape its segment into a new path or query. Pre-encoded triplets from existingrawurlencodecallers are preserved (no double-encoding).Test plan
composer testpasses (HttpClient + SessionManager suites)rawurlencodeIDs are not double-encodedalg: noneor unknownkidare rejected