Skip to content

feat: Add optional issuers check to session authentication - #433

Merged
gjtorikian merged 4 commits into
mainfrom
devin/1788639524-optional-issuer
Sep 10, 2026
Merged

gjtorikian merged 4 commits into
mainfrom
devin/1788639524-optional-issuer

Conversation

@m0tzy

@m0tzy m0tzy commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Lets apps opt in to validating the iss claim of session access tokens. Part of the cross-SDK rollout started in workos/authkit-react-router#83 (see also workos/workos-node#1694, workos/workos-python#725, workos/workos-ruby#552, workos/workos-php#440). Default behavior is unchanged: with no issuers passed, iss is not checked, exactly as before.

WorkOS's constructor lives in generated WorkOS.kt (oagen), so the option is a per-call parameter on the hand-maintained session helpers rather than client config:

fun Session.loadSealedSession(sessionData: String?, cookiePassword: String,
    issuers: List<String>? = null): SessionCookie
fun Session.authenticateWithSessionCookie(sessionData: String?, cookiePassword: String,
    issuers: List<String>? = null): AuthenticateSessionResult

class SessionCookie(userManagement, sessionData, cookiePassword,
    objectMapper = defaultObjectMapper(), issuers: List<String>?) {
  // pre-existing shape, kept as a secondary constructor -> issuers = null
  @JvmOverloads constructor(userManagement, sessionData, cookiePassword,
    objectMapper = defaultObjectMapper())
}

Jwks.isValidJwt keeps its existing Nimbus DefaultJWTProcessor (signature + default exp/nbf checks) and, when issuers != null, additionally requires claims.issuer to be non-null and contained in the list. An explicit empty list fails closed (rejects every token). Mismatches surface as the existing AuthenticateSessionFailureReason.INVALID_JWT. refresh() does not verify a JWT and is unchanged. SessionCookie snapshots the list (issuers?.toList()) so caller mutation can't change the policy later.

workos.session.authenticateWithSessionCookie(
  cookie, password,
  issuers = listOf("https://api.workos.com/user_management/$clientId", "https://auth.example.com")
)

Binary compatibility: the old SessionCookie JVM constructors — (UM, String, String), (UM, String, String, ObjectMapper) and the synthetic $default one — are unchanged (verified with javap); loadSealedSession/authenticateWithSessionCookie gain @JvmOverloads so their two-arg descriptors also remain.

Tests: ./script/ci (ktlint, full test suite, Dokka) passes. New SessionTest cases cover unset, matching, mismatched, absent iss, list, and empty list.

Link to Devin session: https://app.devin.ai/sessions/0ee38e859a9849658a7cdb2d215d89a6
Open in Devin Desktop: https://app.devin.ai/desktop/session/0ee38e859a9849658a7cdb2d215d89a6?variant=devin
Requested by: @m0tzy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@m0tzy
m0tzy requested review from a team as code owners September 5, 2026 20:19
@m0tzy
m0tzy requested a review from gjtorikian September 5, 2026 20:19
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

Original prompt from madison.packer

can we patch this SDK so that the issuer can be either by default (if not passed) or passed a specific issuer?

const issuer = opts.issuer ?? https://${getConfig('apiHostname')}

workos/authkit-react-router#83

@greptile-apps

greptile-apps Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge with no outstanding correctness, security, or repository-rule issues.

Summary

  • Propagates an optional issuer list through the session helper APIs and snapshots it in SessionCookie.
  • Rejects missing or unlisted issuer claims as INVALID_JWT.
  • Preserves existing JVM constructor and method overloads.
  • Adds tests for unset, matching, mismatched, absent, multiple, and empty issuer configurations.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Sealed session cookie] --> B[Unseal cookie]
  B --> C[Validate JWT signature and time claims]
  C --> D{Issuer list configured?}
  D -->|No| E[Authentication succeeds]
  D -->|Yes| F{iss exists and exactly matches list?}
  F -->|Yes| E
  F -->|No| G[INVALID_JWT]
Loading

Reviews (4) · Last reviewed commit: "Document issuer matching and where it is..."

Comment thread src/main/kotlin/com/workos/session/Session.kt

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Devin Review

Comment thread src/main/kotlin/com/workos/session/Session.kt Outdated
m0tzy and others added 2 commits September 5, 2026 20:22
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@gjtorikian

Copy link
Copy Markdown
Contributor

Doc follow-ups from review — all LOW/INFO, not blockers.

1. Expand the issuers KDoc to flag exact-match + environment variance
loadSealedSession / SessionCookie — the iss in issuers check is exact, case-sensitive string equality (correct per RFC 7519, matches jose.jwtVerify in the node SDK). But the WorkOS iss value is environment-dependent: https://api.workos.com (legacy), https://api.workos.com/user_management/<clientId> (envs from mid-2025+), a custom auth domain, or a flag-gated path variant (per the node PR body in workos/workos-node#1694). A caller who passes the wrong variant — or misses a trailing slash — gets a silent INVALID_JWT with no hint that the issuer string mismatched. Suggested KDoc addition:

 * The match is exact (case-sensitive). The `iss` value WorkOS mints varies
 * by environment — `https://api.workos.com`,
 * `https://api.workos.com/user_management/<clientId>`, or a custom auth
 * domain — so pass the precise value(s) your tokens actually carry.

2. Mirror @param issuers onto authenticateWithSessionCookie
loadSealedSession gained an @param issuers block, but the convenience authenticateWithSessionCookie (which gained the same parameter) kept its one-line KDoc with no @param issuers. Fold the same note in.

3. Note on refresh() that the issuer policy is not applied there
refresh() exchanges the refresh token and reseals without JWT/iss validation (correct — the refreshed token is server-issued, not from an untrusted cookie). A one-line KDoc stating the issuer policy applies on authenticate(), not refresh(), would set caller expectations.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

All three addressed in 34de9a0:

  1. SessionCookie class KDoc and the @param issuers on loadSealedSession now state the match is exact/case-sensitive and list the environment-dependent iss shapes (https://api.workos.com, https://api.workos.com/user_management/<clientId>, custom auth domain).
  2. authenticateWithSessionCookie now carries the same @param issuers block.
  3. SessionCookie.refresh() KDoc (and the class KDoc) note that the issuer policy is enforced on authenticate() only, not refresh().

Doc-only change; ktlintCheck and Dokka pass locally.

@gjtorikian gjtorikian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Verified independently:

  • Issuer logic is sound: opt-in (null = unchanged), fail-closed on empty list, exact case-sensitive match per RFC 7519 (matches jose.jwtVerify in workos/node#1694).
  • Binary compat preserved: javap confirms all 3 base SessionCookie constructors retained, additions are additive.
  • Tests: 18 pass, 0 failures — covers null/match/mismatch/absent/multi/empty issuer cases.
  • Doc follow-ups from review all addressed in 34de9a0 (exact-match semantics, environment variance, refresh() non-enforcement, @param issuers mirrored onto authenticateWithSessionCookie).
  • All 7 CI checks green.

@gjtorikian
gjtorikian merged commit 253da76 into main Sep 10, 2026
7 checks passed
@gjtorikian
gjtorikian deleted the devin/1788639524-optional-issuer branch September 10, 2026 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants