fix: harden JWT signature verification and URL path encoding#386
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 adds proper JWS signature verification to
Confidence Score: 5/5Safe to merge; the JWS verification path is sound, the JWKS cache is correctly implemented, and the path-encoding change is well-tested with documented caveats. The core security additions — RS256-only algorithm enforcement, per-kid JWKS lookup with forced-refresh on miss, manual DER/PEM construction, and openssl_verify integration — are all implemented correctly. The DER OID encoding is byte-accurate. All current throw sites inside decodeAccessToken extend Exception, so the catch block is complete for today's code paths. The only open items are defensive hardening suggestions that do not represent present defects. No files require special attention; the three suggestions on lib/SessionManager.php are defensive hardening, not corrections to current broken behaviour. Important Files Changed
Reviews (5): Last reviewed commit: "chore(session,http): address Greptile re..." | 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>
- fetchJwks: raise RuntimeException on null response so the `array` return contract is honest (was a latent TypeError under malformed upstream JWKS responses). - decodeAccessToken: drop unused $baseUrl parameter; JWKS fetches go through $this->client and never read it. - testAuthenticateRejectsUnknownKid: queue a second JWKS response so the forced kid-miss refresh actually exercises the "No JWKS key matches JWT kid" guard instead of passing on a MockHandler queue-exhaustion exception. - encodePathSegments: correct the misleading comment — only non-slash unsafe characters are guarded; callers remain responsible for rawurlencode-ing IDs containing `/`. Strengthen the test to assert the exact `?`-encoded path and pin the documented `/`-pass-through caveat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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