fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168) - #255

Merged
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle
Jul 15, 2026
Merged

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168)#255
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 22, 2026

Copy link
Copy Markdown
Member

#168 was filed as a flagged "verify" finding: does the backend session really die after 5 minutes with no refresh? I traced the full path across both services. The worst case is not real — the 300s token is only a one-shot redirect handoff.

What actually happens

  1. Backend OAuth callback mints a short-lived HMAC token (exp = now + 300) and redirects to the frontend with ?auth_token=….
  2. The frontend session BFF (frontend/src/app/api/auth/session/route.ts) verifies it and re-mints a 30-day token (SESSION_MAX_AGE = 2592000) in a byte-identical format, set as the httpOnly/Securesapling_session cookie (scoped via COOKIE_DOMAIN).
  3. auth_guard._decode_session reads that cookie, verifies the HMAC with the shared SESSION_SECRET, and accepts it for the full 30 days.

So sessions persist 30 days, not 5 minutes. It holds as long as SESSION_SECRET matches across services and COOKIE_DOMAIN reaches the backend subdomain (both documented).

Changes

  • backend/routes/auth.py — named the magic 300 as _REDIRECT_TOKEN_TTL_SECONDS (env-overridable via SAPLING_AUTH_REDIRECT_TOKEN_TTL) and corrected the comment to say it's the redirect-handoff TTL, not the session TTL.
  • docs/decisions/0018-session-token-lifecycle.md — documents the full lifecycle, the verification outcome, the operational preconditions, and follow-ups (sliding refresh is a frontend-BFF concern, out of scope).
  • backend/tests/test_auth_session_contract.py — locks the cross-service contract: a frontend-style 30-day token is accepted by the backend decoder; expired, tampered, and wrong-secret tokens are rejected; the redirect TTL stays short.

Verification

  • ruff check . clean; gated suite green (+6 new). The acceptance criterion ("confirmed behavior documented; sessions persist for a configured TTL") is met.

Closes#168.

Summary by CodeRabbit

  • New Features

    • Added configurable expiration for one-time authentication handoff tokens, with safe limits and warnings for invalid settings.
    • Preserved support for long-lived sessions and legacy authentication flows.
  • Bug Fixes

    • Improved handling of expired, tampered, or incorrectly signed session tokens.
  • Documentation

    • Documented session-token lifecycle, authentication flow, configuration requirements, and operational considerations.
  • Tests

    • Added coverage for session compatibility, token validation, expiration, and configuration behavior.

@coderabbitai

coderabbitaiBot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The OAuth callback now uses a configurable, bounded redirect-token TTL. Contract tests validate long-lived session cookies and token rejection behavior, while a decision record documents the frontend/backend session lifecycle and operational requirements.

Changes

Session token lifecycle

Layer / File(s)Summary
Configurable redirect token TTL
backend/routes/auth.py
The OAuth handoff token TTL is read from SAPLING_AUTH_REDIRECT_TOKEN_TTL, defaults to 300 seconds, clamps to 30–600 seconds, warns on malformed values, and is applied during Google callback redirects.
Session contract validation
backend/tests/test_auth_session_contract.py
Tests cover 30-day session cookies, legacy query-token acceptance, expiry and signature rejection, shared-secret validation, and redirect TTL parsing and clamping.
Session lifecycle decision record
docs/decisions/0018-session-token-lifecycle.md
Documents the short redirect token, 30-day frontend session cookie, cookie forwarding path, shared-secret and rewrite requirements, verified outcome, and follow-ups.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: verifying and documenting the session-token lifecycle and locking the cross-service contract.
Description check✅ PassedThe description covers the purpose, key changes, linked issue, and testing, which is mostly aligned with the repository template.
Linked Issues check✅ PassedThe PR addresses #168 by verifying the 300s token is only a redirect handoff, documenting the lifecycle, and adding contract tests.
Out of Scope Changes check✅ PassedThe added TTL config, ADR documentation, and auth contract tests are all directly related to the verified session-token lifecycle.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/session-token-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitUpdated (UTC)
❌ Deployment failed
View logs
frontend28674bfJun 22 2026, 04:32 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 24, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging350f1a7Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:50 AM

_REDIRECT_TOKEN_TTL_SECONDS read SAPLING_AUTH_REDIRECT_TOKEN_TTL with no
upper bound, so an operator (or a bad env) could set it to hours/days.
That token is one-shot and travels in the OAuth-callback URL, so a long
TTL widens the window in which an intercepted URL can be replayed to mint
a session — it is not the session itself (ADR 0018). Clamp to [30, 600]s
via a small pure helper and add a test that asserts an extreme override
is clamped, so the "stays short" invariant holds at runtime, not just for
the default (the previous test only checked the unset-env default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_REDIRECT_TOKEN_TTL_SECONDS parsed SAPLING_AUTH_REDIRECT_TOKEN_TTL with a
bare int() at module scope. routes/auth.py is imported at router-mount time,
so `SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc` — or, realistically, declaring the
var in Railway/Wrangler with no value — raised ValueError at import and
stopped the app from booting.
Parse it in _parse_redirect_ttl() with try/except ValueError, falling back to
the 300s default and logging a warning. _clamp_redirect_ttl's docstring said
it defended against "a misconfigured override" but only clamped range, never
parseability; the parse guard makes that claim true.
Add coverage for _parse_redirect_ttl: malformed overrides ("abc", "", None)
fall back to 300s with a warning instead of raising at import; well-formed
ones are still parsed and clamped to [30, 600].
Correct two claims the tests did not support:
- _mint's docstring said it signs "exactly like the backend mint AND the
frontend signSession". It does not: Python's json.dumps emits
{"user_id": "x", "exp": 1} (spaces), JS JSON.stringify emits
{"user_id":"x","exp":1} (none), so the payloads differ byte-wise. It is
harmless — the verifier HMACs the received payload_b64 opaquely and never
re-serializes — but _mint mirrors only the backend mint, and is itself a
third Python re-implementation, so the suite proves a Python-minted token
is accepted, not a real frontend-minted one. Say so rather than claiming to
"lock the cross-service contract".
- test_redirect_auth_token_query_param_is_accepted read as an endorsement of
a constraint the code does not enforce. _decode_session reads ?auth_token=
before the cookie with no ttl/purpose check, so a 30-day session token in
the query string is accepted identically — and tokens in URLs leak via
access logs, Referer, and history. No client sends it: the redirect token
goes to the frontend, which POSTs it to the BFF in a JSON body. Rename to
test_legacy_unused_auth_token_query_param_is_still_accepted and document it
as characterization, so removing the channel is a deliberate, visible edit.
0018 documented the wrong mechanism. It claimed COOKIE_DOMAIN covers both
subdomains "so the browser sends sapling_session to the backend on
cross-origin API calls (credentials: 'include')", and that a host-only cookie
would not reach the backend. Neither is true:
- lib/api.ts sets API_URL = '', so all ~135 fetchJSON call sites are
same-origin, and next.config.ts rewrites /api/:path* to BACKEND_URL
server-side. The cookie reaches the backend because that server-side hop
forwards the Cookie header. A host-only cookie would work fine.
- No browser-side cross-origin authed call exists. The four
NEXT_PUBLIC_API_URL fetches all omit credentials entirely, and middleware.ts
runs server-side with a hand-set Cookie header.
This mattered because an ADR is authoritative: as written it would teach a dev
to point an authed fetch at NEXT_PUBLIC_API_URL, reintroducing the 2026-06-30
onboarding-loop bug — which is live at page.tsx:619 (#339), now cited as a
cautionary example. It also contradicted frontend/.env.example, which tells
you to leave NEXT_PUBLIC_API_URL empty in production.
Replace the precondition with the genuinely load-bearing one the ADR never
mentioned: BACKEND_URL must be set at *build* time for the CF Worker
(next.config.ts bakes it into the rewrite), or /api/* falls back to localhost
and 500s. Demote COOKIE_DOMAIN to what it actually governs — the cookie's
domain attribute — noting it is set (wrangler.toml:19), so nothing is broken.
Also drop the inaccurate "byte-identical format" claim (the two mints are
interoperable, not identical) and record two follow-ups: removing the unused
?auth_token= channel, and a shared JSON fixture consumed by both test suites
as the real cross-service lock.
The example cited frontend/src/app/page.tsx:619 as a live bug tracked in
#339. It is neither: the onboarding call was fixed weeks ago and now routes
through submitOnboardingProfile() -> fetchJSON, and the file moved to
(public)/page.tsx under the route-group refactor. #339 was filed against a
419-commit-stale branch and has been closed as invalid.
Describe the 2026-06-30 bug in the past tense and point at the comment in
(public)/page.tsx that records the real fix, which is a stronger cautionary
example because it actually happened.
@AndresL230

Copy link
Copy Markdown
Collaborator

@Jose-Gael-Cruz-Lopez I pushed 4 commits to this branch as part of a review sweep. Fast-forward only — nothing of yours was rewritten. Happy to back any of it out.

First, the headline: your central claim is correct and I verified it independently. Sessions last 30 days, not 5 minutes. The 300s token is a one-shot redirect handoff; the BFF (route.ts:99-106) re-mints a 30-day token into an httpOnly/Secure/SameSite=Lax cookie that auth_guard._decode_session accepts. Closing #168 as "not a bug" is right. The code and tests are sound — I did not rewrite them. I also confirmed the tests are real rather than testing conftest's stub, by mutation: disabling the expiry check at auth_guard.py:47 fails test_expired_token_is_rejected; disabling signature verification at :34 fails both tamper tests. conftest.py:88 captures _real_decode_sessionbefore the monkeypatch, so it's the genuine decoder.

The changes are confined to the ADR prose, one test name, and one small guard.

09b95bd — the ADR aside, this was the only real code bug

auth.py:93-95 crashed the app at import:

_REDIRECT_TOKEN_TTL_SECONDS=_clamp_redirect_ttl(int(os.getenv("SAPLING_AUTH_REDIRECT_TOKEN_TTL", "300")))

Reproduced: SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc and = (empty) both raise ValueError at module import → the router fails to import → the app won't boot. Empty-string is realistic — declaring the var in Railway/Wrangler without a value does it. Fails closed, so not a security hole, but a nasty deploy-time footgun in the one code path this PR adds. _clamp_redirect_ttl's docstring already promised to defend against "a misconfigured override" but only clamped range, not parseability — now it lives up to it via _parse_redirect_ttl → warn + fall back to 300.

187f419 + 350f1a7 — the ADR documented the wrong mechanism (the one that mattered)

0018:47-51 said COOKIE_DOMAIN covers both subdomains so the browser sends sapling_session cross-origin with credentials: 'include', and that a host-only cookie wouldn't reach the backend. That's not the topology: api.ts:12 sets API_URL = '' and next.config.ts:35 rewrites /api/:path*${BACKEND_URL}/api/:path*server-side, so all 135 fetchJSON sites are same-origin and the Next rewrite forwards the Cookie header. A host-only cookie would work fine.

The genuinely load-bearing precondition — BACKEND_URL must be set at build time — wasn't mentioned at all. Concretely: it sits in wrangler's [vars] (worker runtime vars) while next.config.ts reads it at build time, so [vars] alone doesn't feed the rewrite → falls back to localhost → /api/* 500s. That's now the documented precondition, with COOKIE_DOMAIN demoted to what it actually governs (the domain attribute; still set at wrangler.toml:19 via sanitizeCookieDomain#190, so nothing is broken).

This mattered because the ADR contradicted frontend/.env.example:5-8, which documents the opposite. An ADR is the authoritative reference — as written it would teach the next person to point an authed fetch at NEXT_PUBLIC_API_URL and reintroduce the 2026-06-30 onboarding-loop bug.

350f1a7 is me fixing my own mistake. My first pass cited that bug as "live right now at page.tsx:619, tracked in #339." It is neither. That finding came from an agent reading this branch, which is 419 commits behind main — where the raw fetch genuinely still exists. On main it was fixed weeks ago: the file moved to (public)/page.tsx and routes through submitOnboardingProfile()fetchJSON, with a comment recording the fix. I filed #339 off that and have since closed it as invalid. The ADR now describes the bug in past tense and points at that comment — a better cautionary example, since it actually happened.

1c1adf5 — renamed a test that blessed an unused credential channel

_decode_session (auth_guard.py:19) reads request.query_params.get("auth_token")before the cookie. I traced every occurrence: auth.py:452 puts it in a redirect URL to the frontend; callback/page.tsx:55 reads it and POSTs it to the BFF in a JSON body. No client ever sends ?auth_token= to the backend.

test_redirect_auth_token_query_param_is_accepted + its comment ("arrives as ?auth_token while fresh") asserted a constraint the code doesn't enforce — a 30-day session token in ?auth_token= is accepted identically, and URL-borne tokens leak via access logs, Referer, and history. Renamed to test_legacy_unused_auth_token_query_param_is_still_accepted and redocumented as characterization, not endorsement. Channel left in place — removing it is a follow-up, and the renamed test makes that removal a visible edit rather than a silent contract break.

Also corrected the "byte-identical format" claim: proved by running both serializers that Python emits {"user_id": "user_alice", …} (spaces) and JS JSON.stringify emits none → different base64. Harmless (the verifier HMACs the received payload_b64 opaquely and never re-serializes), but _mint's docstring claiming it signs "exactly like the backend mint AND the frontend signSession" was false — it mirrors the backend exactly (auth.py:440 uses default json.dumps), not signSession.

One nuance that sharpens your case

"The browser never makes a cross-origin authed API call" is true, but for a sharper reason than I first wrote. NEXT_PUBLIC_API_URLis consumed in four places, and wrangler sets it to https://api.saplinglearn.com. Checking each: SignInModal's uses are top-level OAuth navigations (not fetches); middleware.ts runs server-side with a hand-set Cookie header; and the remaining fetches omit credentials entirely. So no cross-origin authed browser call exists — and COOKIE_DOMAIN genuinely isn't load-bearing. Had any of them passed credentials: 'include', COOKIE_DOMAINwould have been the mechanism and this ADR rewrite would be wrong.

Follow-ups (not done here)

  1. Remove the ?auth_token= channel — no client uses it; it accepts a 30-day token with no ttl/purpose check.
  2. A shared JSON fixture as the real cross-service lock. "Lock the cross-service contract" overstates what's here: _mint is a third Python re-implementation, so the test proves a Python-minted token is accepted, not a real frontend one. There's no frontend/src/lib/sessionToken.test.ts, and route.ts:29-68 hand-rolls verifyAuthToken as a duplicate of sessionToken.ts::verifySession — so there are two frontend copies that can drift. I softened the framing; a checked-in fixture consumed by both suites would close it properly.
  3. Assert the build-time BACKEND_URL precondition in CI — it's the one genuinely load-bearing config, it fails as a confusing 500 rather than a clear error, and nothing guards it.

Verification: ruff check . clean, 674 passed, auth contract 18 passed, and the import guard confirmed (TTL='abc'/'' → warn + 300; '86400' → 600; '60' → 60).

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. To restate what I said in the comment: the code and tests here were already sound, and the central finding is correct — sessions last 30 days, not 5 minutes, and #168 is properly closed as "not a bug." I verified the tests are real by mutation rather than reading (breaking the expiry check and the signature check each fail the right test). The changes above are the ADR mechanism fix, one import-time crash guard, and a test rename — nothing that touches the substance of your work. CI green on 350f1a7.

@AndresL230
AndresL230 merged commit 193a70f into mainJul 15, 2026
4 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/decisions/0018-session-token-lifecycle.md`:
- Line 7: Update the line beginning with “#168” in the session token lifecycle
document so it no longer starts with a bare hash; prefix the reference with
descriptive text or escape the hash while preserving the issue reference and
sentence meaning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5066a8b6-9219-4344-8b9f-e81226c0ea6d

📥 Commits

Reviewing files that changed from the base of the PR and between 32409a8 and 350f1a7.

📒 Files selected for processing (3)
  • backend/routes/auth.py
  • backend/tests/test_auth_session_contract.py
  • docs/decisions/0018-session-token-lifecycle.md


## The claim

#168 raised the concern that the backend session has a hard 5-minute lifetime

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the hash or add a prefix to prevent markdown linting errors.

Starting a line with #168 causes markdown linters to interpret it as a malformed ATX heading (triggering the MD018 rule). Consider escaping the hash or prefixing it with a word to ensure it renders correctly as text.

🛠️ Proposed fix
- `#168` raised the concern that the backend session has a hard 5-minute lifetime+ Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#168 raised the concern that the backend session has a hard 5-minute lifetime
Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 7-7: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0018-session-token-lifecycle.md` at line 7, Update the line
beginning with “#168” in the session token lifecycle document so it no longer
starts with a bare hash; prefix the reference with descriptive text or escape
the hash while preserving the issue reference and sentence meaning.

Source: Linters/SAST tools

@AndresL230
AndresL230 deleted the fix/session-token-lifecycle branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backend session token has a hard 5-minute lifetime with no refresh path (verify)

2 participants

@Jose-Gael-Cruz-Lopez@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168) - #255

Merged
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle
Jul 15, 2026
Merged

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168)#255
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 22, 2026

Copy link
Copy Markdown
Member

#168 was filed as a flagged "verify" finding: does the backend session really die after 5 minutes with no refresh? I traced the full path across both services. The worst case is not real — the 300s token is only a one-shot redirect handoff.

What actually happens

  1. Backend OAuth callback mints a short-lived HMAC token (exp = now + 300) and redirects to the frontend with ?auth_token=….
  2. The frontend session BFF (frontend/src/app/api/auth/session/route.ts) verifies it and re-mints a 30-day token (SESSION_MAX_AGE = 2592000) in a byte-identical format, set as the httpOnly/Securesapling_session cookie (scoped via COOKIE_DOMAIN).
  3. auth_guard._decode_session reads that cookie, verifies the HMAC with the shared SESSION_SECRET, and accepts it for the full 30 days.

So sessions persist 30 days, not 5 minutes. It holds as long as SESSION_SECRET matches across services and COOKIE_DOMAIN reaches the backend subdomain (both documented).

Changes

  • backend/routes/auth.py — named the magic 300 as _REDIRECT_TOKEN_TTL_SECONDS (env-overridable via SAPLING_AUTH_REDIRECT_TOKEN_TTL) and corrected the comment to say it's the redirect-handoff TTL, not the session TTL.
  • docs/decisions/0018-session-token-lifecycle.md — documents the full lifecycle, the verification outcome, the operational preconditions, and follow-ups (sliding refresh is a frontend-BFF concern, out of scope).
  • backend/tests/test_auth_session_contract.py — locks the cross-service contract: a frontend-style 30-day token is accepted by the backend decoder; expired, tampered, and wrong-secret tokens are rejected; the redirect TTL stays short.

Verification

  • ruff check . clean; gated suite green (+6 new). The acceptance criterion ("confirmed behavior documented; sessions persist for a configured TTL") is met.

Closes#168.

Summary by CodeRabbit

  • New Features

    • Added configurable expiration for one-time authentication handoff tokens, with safe limits and warnings for invalid settings.
    • Preserved support for long-lived sessions and legacy authentication flows.
  • Bug Fixes

    • Improved handling of expired, tampered, or incorrectly signed session tokens.
  • Documentation

    • Documented session-token lifecycle, authentication flow, configuration requirements, and operational considerations.
  • Tests

    • Added coverage for session compatibility, token validation, expiration, and configuration behavior.

@coderabbitai

coderabbitaiBot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The OAuth callback now uses a configurable, bounded redirect-token TTL. Contract tests validate long-lived session cookies and token rejection behavior, while a decision record documents the frontend/backend session lifecycle and operational requirements.

Changes

Session token lifecycle

Layer / File(s)Summary
Configurable redirect token TTL
backend/routes/auth.py
The OAuth handoff token TTL is read from SAPLING_AUTH_REDIRECT_TOKEN_TTL, defaults to 300 seconds, clamps to 30–600 seconds, warns on malformed values, and is applied during Google callback redirects.
Session contract validation
backend/tests/test_auth_session_contract.py
Tests cover 30-day session cookies, legacy query-token acceptance, expiry and signature rejection, shared-secret validation, and redirect TTL parsing and clamping.
Session lifecycle decision record
docs/decisions/0018-session-token-lifecycle.md
Documents the short redirect token, 30-day frontend session cookie, cookie forwarding path, shared-secret and rewrite requirements, verified outcome, and follow-ups.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: verifying and documenting the session-token lifecycle and locking the cross-service contract.
Description check✅ PassedThe description covers the purpose, key changes, linked issue, and testing, which is mostly aligned with the repository template.
Linked Issues check✅ PassedThe PR addresses #168 by verifying the 300s token is only a redirect handoff, documenting the lifecycle, and adding contract tests.
Out of Scope Changes check✅ PassedThe added TTL config, ADR documentation, and auth contract tests are all directly related to the verified session-token lifecycle.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/session-token-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitUpdated (UTC)
❌ Deployment failed
View logs
frontend28674bfJun 22 2026, 04:32 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 24, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging350f1a7Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:50 AM

_REDIRECT_TOKEN_TTL_SECONDS read SAPLING_AUTH_REDIRECT_TOKEN_TTL with no
upper bound, so an operator (or a bad env) could set it to hours/days.
That token is one-shot and travels in the OAuth-callback URL, so a long
TTL widens the window in which an intercepted URL can be replayed to mint
a session — it is not the session itself (ADR 0018). Clamp to [30, 600]s
via a small pure helper and add a test that asserts an extreme override
is clamped, so the "stays short" invariant holds at runtime, not just for
the default (the previous test only checked the unset-env default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_REDIRECT_TOKEN_TTL_SECONDS parsed SAPLING_AUTH_REDIRECT_TOKEN_TTL with a
bare int() at module scope. routes/auth.py is imported at router-mount time,
so `SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc` — or, realistically, declaring the
var in Railway/Wrangler with no value — raised ValueError at import and
stopped the app from booting.
Parse it in _parse_redirect_ttl() with try/except ValueError, falling back to
the 300s default and logging a warning. _clamp_redirect_ttl's docstring said
it defended against "a misconfigured override" but only clamped range, never
parseability; the parse guard makes that claim true.
Add coverage for _parse_redirect_ttl: malformed overrides ("abc", "", None)
fall back to 300s with a warning instead of raising at import; well-formed
ones are still parsed and clamped to [30, 600].
Correct two claims the tests did not support:
- _mint's docstring said it signs "exactly like the backend mint AND the
frontend signSession". It does not: Python's json.dumps emits
{"user_id": "x", "exp": 1} (spaces), JS JSON.stringify emits
{"user_id":"x","exp":1} (none), so the payloads differ byte-wise. It is
harmless — the verifier HMACs the received payload_b64 opaquely and never
re-serializes — but _mint mirrors only the backend mint, and is itself a
third Python re-implementation, so the suite proves a Python-minted token
is accepted, not a real frontend-minted one. Say so rather than claiming to
"lock the cross-service contract".
- test_redirect_auth_token_query_param_is_accepted read as an endorsement of
a constraint the code does not enforce. _decode_session reads ?auth_token=
before the cookie with no ttl/purpose check, so a 30-day session token in
the query string is accepted identically — and tokens in URLs leak via
access logs, Referer, and history. No client sends it: the redirect token
goes to the frontend, which POSTs it to the BFF in a JSON body. Rename to
test_legacy_unused_auth_token_query_param_is_still_accepted and document it
as characterization, so removing the channel is a deliberate, visible edit.
0018 documented the wrong mechanism. It claimed COOKIE_DOMAIN covers both
subdomains "so the browser sends sapling_session to the backend on
cross-origin API calls (credentials: 'include')", and that a host-only cookie
would not reach the backend. Neither is true:
- lib/api.ts sets API_URL = '', so all ~135 fetchJSON call sites are
same-origin, and next.config.ts rewrites /api/:path* to BACKEND_URL
server-side. The cookie reaches the backend because that server-side hop
forwards the Cookie header. A host-only cookie would work fine.
- No browser-side cross-origin authed call exists. The four
NEXT_PUBLIC_API_URL fetches all omit credentials entirely, and middleware.ts
runs server-side with a hand-set Cookie header.
This mattered because an ADR is authoritative: as written it would teach a dev
to point an authed fetch at NEXT_PUBLIC_API_URL, reintroducing the 2026-06-30
onboarding-loop bug — which is live at page.tsx:619 (#339), now cited as a
cautionary example. It also contradicted frontend/.env.example, which tells
you to leave NEXT_PUBLIC_API_URL empty in production.
Replace the precondition with the genuinely load-bearing one the ADR never
mentioned: BACKEND_URL must be set at *build* time for the CF Worker
(next.config.ts bakes it into the rewrite), or /api/* falls back to localhost
and 500s. Demote COOKIE_DOMAIN to what it actually governs — the cookie's
domain attribute — noting it is set (wrangler.toml:19), so nothing is broken.
Also drop the inaccurate "byte-identical format" claim (the two mints are
interoperable, not identical) and record two follow-ups: removing the unused
?auth_token= channel, and a shared JSON fixture consumed by both test suites
as the real cross-service lock.
The example cited frontend/src/app/page.tsx:619 as a live bug tracked in
#339. It is neither: the onboarding call was fixed weeks ago and now routes
through submitOnboardingProfile() -> fetchJSON, and the file moved to
(public)/page.tsx under the route-group refactor. #339 was filed against a
419-commit-stale branch and has been closed as invalid.
Describe the 2026-06-30 bug in the past tense and point at the comment in
(public)/page.tsx that records the real fix, which is a stronger cautionary
example because it actually happened.
@AndresL230

Copy link
Copy Markdown
Collaborator

@Jose-Gael-Cruz-Lopez I pushed 4 commits to this branch as part of a review sweep. Fast-forward only — nothing of yours was rewritten. Happy to back any of it out.

First, the headline: your central claim is correct and I verified it independently. Sessions last 30 days, not 5 minutes. The 300s token is a one-shot redirect handoff; the BFF (route.ts:99-106) re-mints a 30-day token into an httpOnly/Secure/SameSite=Lax cookie that auth_guard._decode_session accepts. Closing #168 as "not a bug" is right. The code and tests are sound — I did not rewrite them. I also confirmed the tests are real rather than testing conftest's stub, by mutation: disabling the expiry check at auth_guard.py:47 fails test_expired_token_is_rejected; disabling signature verification at :34 fails both tamper tests. conftest.py:88 captures _real_decode_sessionbefore the monkeypatch, so it's the genuine decoder.

The changes are confined to the ADR prose, one test name, and one small guard.

09b95bd — the ADR aside, this was the only real code bug

auth.py:93-95 crashed the app at import:

_REDIRECT_TOKEN_TTL_SECONDS=_clamp_redirect_ttl(int(os.getenv("SAPLING_AUTH_REDIRECT_TOKEN_TTL", "300")))

Reproduced: SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc and = (empty) both raise ValueError at module import → the router fails to import → the app won't boot. Empty-string is realistic — declaring the var in Railway/Wrangler without a value does it. Fails closed, so not a security hole, but a nasty deploy-time footgun in the one code path this PR adds. _clamp_redirect_ttl's docstring already promised to defend against "a misconfigured override" but only clamped range, not parseability — now it lives up to it via _parse_redirect_ttl → warn + fall back to 300.

187f419 + 350f1a7 — the ADR documented the wrong mechanism (the one that mattered)

0018:47-51 said COOKIE_DOMAIN covers both subdomains so the browser sends sapling_session cross-origin with credentials: 'include', and that a host-only cookie wouldn't reach the backend. That's not the topology: api.ts:12 sets API_URL = '' and next.config.ts:35 rewrites /api/:path*${BACKEND_URL}/api/:path*server-side, so all 135 fetchJSON sites are same-origin and the Next rewrite forwards the Cookie header. A host-only cookie would work fine.

The genuinely load-bearing precondition — BACKEND_URL must be set at build time — wasn't mentioned at all. Concretely: it sits in wrangler's [vars] (worker runtime vars) while next.config.ts reads it at build time, so [vars] alone doesn't feed the rewrite → falls back to localhost → /api/* 500s. That's now the documented precondition, with COOKIE_DOMAIN demoted to what it actually governs (the domain attribute; still set at wrangler.toml:19 via sanitizeCookieDomain#190, so nothing is broken).

This mattered because the ADR contradicted frontend/.env.example:5-8, which documents the opposite. An ADR is the authoritative reference — as written it would teach the next person to point an authed fetch at NEXT_PUBLIC_API_URL and reintroduce the 2026-06-30 onboarding-loop bug.

350f1a7 is me fixing my own mistake. My first pass cited that bug as "live right now at page.tsx:619, tracked in #339." It is neither. That finding came from an agent reading this branch, which is 419 commits behind main — where the raw fetch genuinely still exists. On main it was fixed weeks ago: the file moved to (public)/page.tsx and routes through submitOnboardingProfile()fetchJSON, with a comment recording the fix. I filed #339 off that and have since closed it as invalid. The ADR now describes the bug in past tense and points at that comment — a better cautionary example, since it actually happened.

1c1adf5 — renamed a test that blessed an unused credential channel

_decode_session (auth_guard.py:19) reads request.query_params.get("auth_token")before the cookie. I traced every occurrence: auth.py:452 puts it in a redirect URL to the frontend; callback/page.tsx:55 reads it and POSTs it to the BFF in a JSON body. No client ever sends ?auth_token= to the backend.

test_redirect_auth_token_query_param_is_accepted + its comment ("arrives as ?auth_token while fresh") asserted a constraint the code doesn't enforce — a 30-day session token in ?auth_token= is accepted identically, and URL-borne tokens leak via access logs, Referer, and history. Renamed to test_legacy_unused_auth_token_query_param_is_still_accepted and redocumented as characterization, not endorsement. Channel left in place — removing it is a follow-up, and the renamed test makes that removal a visible edit rather than a silent contract break.

Also corrected the "byte-identical format" claim: proved by running both serializers that Python emits {"user_id": "user_alice", …} (spaces) and JS JSON.stringify emits none → different base64. Harmless (the verifier HMACs the received payload_b64 opaquely and never re-serializes), but _mint's docstring claiming it signs "exactly like the backend mint AND the frontend signSession" was false — it mirrors the backend exactly (auth.py:440 uses default json.dumps), not signSession.

One nuance that sharpens your case

"The browser never makes a cross-origin authed API call" is true, but for a sharper reason than I first wrote. NEXT_PUBLIC_API_URLis consumed in four places, and wrangler sets it to https://api.saplinglearn.com. Checking each: SignInModal's uses are top-level OAuth navigations (not fetches); middleware.ts runs server-side with a hand-set Cookie header; and the remaining fetches omit credentials entirely. So no cross-origin authed browser call exists — and COOKIE_DOMAIN genuinely isn't load-bearing. Had any of them passed credentials: 'include', COOKIE_DOMAINwould have been the mechanism and this ADR rewrite would be wrong.

Follow-ups (not done here)

  1. Remove the ?auth_token= channel — no client uses it; it accepts a 30-day token with no ttl/purpose check.
  2. A shared JSON fixture as the real cross-service lock. "Lock the cross-service contract" overstates what's here: _mint is a third Python re-implementation, so the test proves a Python-minted token is accepted, not a real frontend one. There's no frontend/src/lib/sessionToken.test.ts, and route.ts:29-68 hand-rolls verifyAuthToken as a duplicate of sessionToken.ts::verifySession — so there are two frontend copies that can drift. I softened the framing; a checked-in fixture consumed by both suites would close it properly.
  3. Assert the build-time BACKEND_URL precondition in CI — it's the one genuinely load-bearing config, it fails as a confusing 500 rather than a clear error, and nothing guards it.

Verification: ruff check . clean, 674 passed, auth contract 18 passed, and the import guard confirmed (TTL='abc'/'' → warn + 300; '86400' → 600; '60' → 60).

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. To restate what I said in the comment: the code and tests here were already sound, and the central finding is correct — sessions last 30 days, not 5 minutes, and #168 is properly closed as "not a bug." I verified the tests are real by mutation rather than reading (breaking the expiry check and the signature check each fail the right test). The changes above are the ADR mechanism fix, one import-time crash guard, and a test rename — nothing that touches the substance of your work. CI green on 350f1a7.

@AndresL230
AndresL230 merged commit 193a70f into mainJul 15, 2026
4 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/decisions/0018-session-token-lifecycle.md`:
- Line 7: Update the line beginning with “#168” in the session token lifecycle
document so it no longer starts with a bare hash; prefix the reference with
descriptive text or escape the hash while preserving the issue reference and
sentence meaning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5066a8b6-9219-4344-8b9f-e81226c0ea6d

📥 Commits

Reviewing files that changed from the base of the PR and between 32409a8 and 350f1a7.

📒 Files selected for processing (3)
  • backend/routes/auth.py
  • backend/tests/test_auth_session_contract.py
  • docs/decisions/0018-session-token-lifecycle.md


## The claim

#168 raised the concern that the backend session has a hard 5-minute lifetime

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the hash or add a prefix to prevent markdown linting errors.

Starting a line with #168 causes markdown linters to interpret it as a malformed ATX heading (triggering the MD018 rule). Consider escaping the hash or prefixing it with a word to ensure it renders correctly as text.

🛠️ Proposed fix
- `#168` raised the concern that the backend session has a hard 5-minute lifetime+ Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#168 raised the concern that the backend session has a hard 5-minute lifetime
Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 7-7: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0018-session-token-lifecycle.md` at line 7, Update the line
beginning with “#168” in the session token lifecycle document so it no longer
starts with a bare hash; prefix the reference with descriptive text or escape
the hash while preserving the issue reference and sentence meaning.

Source: Linters/SAST tools

@AndresL230
AndresL230 deleted the fix/session-token-lifecycle branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backend session token has a hard 5-minute lifetime with no refresh path (verify)

2 participants

@Jose-Gael-Cruz-Lopez@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168) - #255

Merged
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle
Jul 15, 2026
Merged

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168)#255
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 22, 2026

Copy link
Copy Markdown
Member

#168 was filed as a flagged "verify" finding: does the backend session really die after 5 minutes with no refresh? I traced the full path across both services. The worst case is not real — the 300s token is only a one-shot redirect handoff.

What actually happens

  1. Backend OAuth callback mints a short-lived HMAC token (exp = now + 300) and redirects to the frontend with ?auth_token=….
  2. The frontend session BFF (frontend/src/app/api/auth/session/route.ts) verifies it and re-mints a 30-day token (SESSION_MAX_AGE = 2592000) in a byte-identical format, set as the httpOnly/Securesapling_session cookie (scoped via COOKIE_DOMAIN).
  3. auth_guard._decode_session reads that cookie, verifies the HMAC with the shared SESSION_SECRET, and accepts it for the full 30 days.

So sessions persist 30 days, not 5 minutes. It holds as long as SESSION_SECRET matches across services and COOKIE_DOMAIN reaches the backend subdomain (both documented).

Changes

  • backend/routes/auth.py — named the magic 300 as _REDIRECT_TOKEN_TTL_SECONDS (env-overridable via SAPLING_AUTH_REDIRECT_TOKEN_TTL) and corrected the comment to say it's the redirect-handoff TTL, not the session TTL.
  • docs/decisions/0018-session-token-lifecycle.md — documents the full lifecycle, the verification outcome, the operational preconditions, and follow-ups (sliding refresh is a frontend-BFF concern, out of scope).
  • backend/tests/test_auth_session_contract.py — locks the cross-service contract: a frontend-style 30-day token is accepted by the backend decoder; expired, tampered, and wrong-secret tokens are rejected; the redirect TTL stays short.

Verification

  • ruff check . clean; gated suite green (+6 new). The acceptance criterion ("confirmed behavior documented; sessions persist for a configured TTL") is met.

Closes#168.

Summary by CodeRabbit

  • New Features

    • Added configurable expiration for one-time authentication handoff tokens, with safe limits and warnings for invalid settings.
    • Preserved support for long-lived sessions and legacy authentication flows.
  • Bug Fixes

    • Improved handling of expired, tampered, or incorrectly signed session tokens.
  • Documentation

    • Documented session-token lifecycle, authentication flow, configuration requirements, and operational considerations.
  • Tests

    • Added coverage for session compatibility, token validation, expiration, and configuration behavior.

@coderabbitai

coderabbitaiBot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The OAuth callback now uses a configurable, bounded redirect-token TTL. Contract tests validate long-lived session cookies and token rejection behavior, while a decision record documents the frontend/backend session lifecycle and operational requirements.

Changes

Session token lifecycle

Layer / File(s)Summary
Configurable redirect token TTL
backend/routes/auth.py
The OAuth handoff token TTL is read from SAPLING_AUTH_REDIRECT_TOKEN_TTL, defaults to 300 seconds, clamps to 30–600 seconds, warns on malformed values, and is applied during Google callback redirects.
Session contract validation
backend/tests/test_auth_session_contract.py
Tests cover 30-day session cookies, legacy query-token acceptance, expiry and signature rejection, shared-secret validation, and redirect TTL parsing and clamping.
Session lifecycle decision record
docs/decisions/0018-session-token-lifecycle.md
Documents the short redirect token, 30-day frontend session cookie, cookie forwarding path, shared-secret and rewrite requirements, verified outcome, and follow-ups.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: verifying and documenting the session-token lifecycle and locking the cross-service contract.
Description check✅ PassedThe description covers the purpose, key changes, linked issue, and testing, which is mostly aligned with the repository template.
Linked Issues check✅ PassedThe PR addresses #168 by verifying the 300s token is only a redirect handoff, documenting the lifecycle, and adding contract tests.
Out of Scope Changes check✅ PassedThe added TTL config, ADR documentation, and auth contract tests are all directly related to the verified session-token lifecycle.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/session-token-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitUpdated (UTC)
❌ Deployment failed
View logs
frontend28674bfJun 22 2026, 04:32 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 24, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging350f1a7Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:50 AM

_REDIRECT_TOKEN_TTL_SECONDS read SAPLING_AUTH_REDIRECT_TOKEN_TTL with no
upper bound, so an operator (or a bad env) could set it to hours/days.
That token is one-shot and travels in the OAuth-callback URL, so a long
TTL widens the window in which an intercepted URL can be replayed to mint
a session — it is not the session itself (ADR 0018). Clamp to [30, 600]s
via a small pure helper and add a test that asserts an extreme override
is clamped, so the "stays short" invariant holds at runtime, not just for
the default (the previous test only checked the unset-env default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_REDIRECT_TOKEN_TTL_SECONDS parsed SAPLING_AUTH_REDIRECT_TOKEN_TTL with a
bare int() at module scope. routes/auth.py is imported at router-mount time,
so `SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc` — or, realistically, declaring the
var in Railway/Wrangler with no value — raised ValueError at import and
stopped the app from booting.
Parse it in _parse_redirect_ttl() with try/except ValueError, falling back to
the 300s default and logging a warning. _clamp_redirect_ttl's docstring said
it defended against "a misconfigured override" but only clamped range, never
parseability; the parse guard makes that claim true.
Add coverage for _parse_redirect_ttl: malformed overrides ("abc", "", None)
fall back to 300s with a warning instead of raising at import; well-formed
ones are still parsed and clamped to [30, 600].
Correct two claims the tests did not support:
- _mint's docstring said it signs "exactly like the backend mint AND the
frontend signSession". It does not: Python's json.dumps emits
{"user_id": "x", "exp": 1} (spaces), JS JSON.stringify emits
{"user_id":"x","exp":1} (none), so the payloads differ byte-wise. It is
harmless — the verifier HMACs the received payload_b64 opaquely and never
re-serializes — but _mint mirrors only the backend mint, and is itself a
third Python re-implementation, so the suite proves a Python-minted token
is accepted, not a real frontend-minted one. Say so rather than claiming to
"lock the cross-service contract".
- test_redirect_auth_token_query_param_is_accepted read as an endorsement of
a constraint the code does not enforce. _decode_session reads ?auth_token=
before the cookie with no ttl/purpose check, so a 30-day session token in
the query string is accepted identically — and tokens in URLs leak via
access logs, Referer, and history. No client sends it: the redirect token
goes to the frontend, which POSTs it to the BFF in a JSON body. Rename to
test_legacy_unused_auth_token_query_param_is_still_accepted and document it
as characterization, so removing the channel is a deliberate, visible edit.
0018 documented the wrong mechanism. It claimed COOKIE_DOMAIN covers both
subdomains "so the browser sends sapling_session to the backend on
cross-origin API calls (credentials: 'include')", and that a host-only cookie
would not reach the backend. Neither is true:
- lib/api.ts sets API_URL = '', so all ~135 fetchJSON call sites are
same-origin, and next.config.ts rewrites /api/:path* to BACKEND_URL
server-side. The cookie reaches the backend because that server-side hop
forwards the Cookie header. A host-only cookie would work fine.
- No browser-side cross-origin authed call exists. The four
NEXT_PUBLIC_API_URL fetches all omit credentials entirely, and middleware.ts
runs server-side with a hand-set Cookie header.
This mattered because an ADR is authoritative: as written it would teach a dev
to point an authed fetch at NEXT_PUBLIC_API_URL, reintroducing the 2026-06-30
onboarding-loop bug — which is live at page.tsx:619 (#339), now cited as a
cautionary example. It also contradicted frontend/.env.example, which tells
you to leave NEXT_PUBLIC_API_URL empty in production.
Replace the precondition with the genuinely load-bearing one the ADR never
mentioned: BACKEND_URL must be set at *build* time for the CF Worker
(next.config.ts bakes it into the rewrite), or /api/* falls back to localhost
and 500s. Demote COOKIE_DOMAIN to what it actually governs — the cookie's
domain attribute — noting it is set (wrangler.toml:19), so nothing is broken.
Also drop the inaccurate "byte-identical format" claim (the two mints are
interoperable, not identical) and record two follow-ups: removing the unused
?auth_token= channel, and a shared JSON fixture consumed by both test suites
as the real cross-service lock.
The example cited frontend/src/app/page.tsx:619 as a live bug tracked in
#339. It is neither: the onboarding call was fixed weeks ago and now routes
through submitOnboardingProfile() -> fetchJSON, and the file moved to
(public)/page.tsx under the route-group refactor. #339 was filed against a
419-commit-stale branch and has been closed as invalid.
Describe the 2026-06-30 bug in the past tense and point at the comment in
(public)/page.tsx that records the real fix, which is a stronger cautionary
example because it actually happened.
@AndresL230

Copy link
Copy Markdown
Collaborator

@Jose-Gael-Cruz-Lopez I pushed 4 commits to this branch as part of a review sweep. Fast-forward only — nothing of yours was rewritten. Happy to back any of it out.

First, the headline: your central claim is correct and I verified it independently. Sessions last 30 days, not 5 minutes. The 300s token is a one-shot redirect handoff; the BFF (route.ts:99-106) re-mints a 30-day token into an httpOnly/Secure/SameSite=Lax cookie that auth_guard._decode_session accepts. Closing #168 as "not a bug" is right. The code and tests are sound — I did not rewrite them. I also confirmed the tests are real rather than testing conftest's stub, by mutation: disabling the expiry check at auth_guard.py:47 fails test_expired_token_is_rejected; disabling signature verification at :34 fails both tamper tests. conftest.py:88 captures _real_decode_sessionbefore the monkeypatch, so it's the genuine decoder.

The changes are confined to the ADR prose, one test name, and one small guard.

09b95bd — the ADR aside, this was the only real code bug

auth.py:93-95 crashed the app at import:

_REDIRECT_TOKEN_TTL_SECONDS=_clamp_redirect_ttl(int(os.getenv("SAPLING_AUTH_REDIRECT_TOKEN_TTL", "300")))

Reproduced: SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc and = (empty) both raise ValueError at module import → the router fails to import → the app won't boot. Empty-string is realistic — declaring the var in Railway/Wrangler without a value does it. Fails closed, so not a security hole, but a nasty deploy-time footgun in the one code path this PR adds. _clamp_redirect_ttl's docstring already promised to defend against "a misconfigured override" but only clamped range, not parseability — now it lives up to it via _parse_redirect_ttl → warn + fall back to 300.

187f419 + 350f1a7 — the ADR documented the wrong mechanism (the one that mattered)

0018:47-51 said COOKIE_DOMAIN covers both subdomains so the browser sends sapling_session cross-origin with credentials: 'include', and that a host-only cookie wouldn't reach the backend. That's not the topology: api.ts:12 sets API_URL = '' and next.config.ts:35 rewrites /api/:path*${BACKEND_URL}/api/:path*server-side, so all 135 fetchJSON sites are same-origin and the Next rewrite forwards the Cookie header. A host-only cookie would work fine.

The genuinely load-bearing precondition — BACKEND_URL must be set at build time — wasn't mentioned at all. Concretely: it sits in wrangler's [vars] (worker runtime vars) while next.config.ts reads it at build time, so [vars] alone doesn't feed the rewrite → falls back to localhost → /api/* 500s. That's now the documented precondition, with COOKIE_DOMAIN demoted to what it actually governs (the domain attribute; still set at wrangler.toml:19 via sanitizeCookieDomain#190, so nothing is broken).

This mattered because the ADR contradicted frontend/.env.example:5-8, which documents the opposite. An ADR is the authoritative reference — as written it would teach the next person to point an authed fetch at NEXT_PUBLIC_API_URL and reintroduce the 2026-06-30 onboarding-loop bug.

350f1a7 is me fixing my own mistake. My first pass cited that bug as "live right now at page.tsx:619, tracked in #339." It is neither. That finding came from an agent reading this branch, which is 419 commits behind main — where the raw fetch genuinely still exists. On main it was fixed weeks ago: the file moved to (public)/page.tsx and routes through submitOnboardingProfile()fetchJSON, with a comment recording the fix. I filed #339 off that and have since closed it as invalid. The ADR now describes the bug in past tense and points at that comment — a better cautionary example, since it actually happened.

1c1adf5 — renamed a test that blessed an unused credential channel

_decode_session (auth_guard.py:19) reads request.query_params.get("auth_token")before the cookie. I traced every occurrence: auth.py:452 puts it in a redirect URL to the frontend; callback/page.tsx:55 reads it and POSTs it to the BFF in a JSON body. No client ever sends ?auth_token= to the backend.

test_redirect_auth_token_query_param_is_accepted + its comment ("arrives as ?auth_token while fresh") asserted a constraint the code doesn't enforce — a 30-day session token in ?auth_token= is accepted identically, and URL-borne tokens leak via access logs, Referer, and history. Renamed to test_legacy_unused_auth_token_query_param_is_still_accepted and redocumented as characterization, not endorsement. Channel left in place — removing it is a follow-up, and the renamed test makes that removal a visible edit rather than a silent contract break.

Also corrected the "byte-identical format" claim: proved by running both serializers that Python emits {"user_id": "user_alice", …} (spaces) and JS JSON.stringify emits none → different base64. Harmless (the verifier HMACs the received payload_b64 opaquely and never re-serializes), but _mint's docstring claiming it signs "exactly like the backend mint AND the frontend signSession" was false — it mirrors the backend exactly (auth.py:440 uses default json.dumps), not signSession.

One nuance that sharpens your case

"The browser never makes a cross-origin authed API call" is true, but for a sharper reason than I first wrote. NEXT_PUBLIC_API_URLis consumed in four places, and wrangler sets it to https://api.saplinglearn.com. Checking each: SignInModal's uses are top-level OAuth navigations (not fetches); middleware.ts runs server-side with a hand-set Cookie header; and the remaining fetches omit credentials entirely. So no cross-origin authed browser call exists — and COOKIE_DOMAIN genuinely isn't load-bearing. Had any of them passed credentials: 'include', COOKIE_DOMAINwould have been the mechanism and this ADR rewrite would be wrong.

Follow-ups (not done here)

  1. Remove the ?auth_token= channel — no client uses it; it accepts a 30-day token with no ttl/purpose check.
  2. A shared JSON fixture as the real cross-service lock. "Lock the cross-service contract" overstates what's here: _mint is a third Python re-implementation, so the test proves a Python-minted token is accepted, not a real frontend one. There's no frontend/src/lib/sessionToken.test.ts, and route.ts:29-68 hand-rolls verifyAuthToken as a duplicate of sessionToken.ts::verifySession — so there are two frontend copies that can drift. I softened the framing; a checked-in fixture consumed by both suites would close it properly.
  3. Assert the build-time BACKEND_URL precondition in CI — it's the one genuinely load-bearing config, it fails as a confusing 500 rather than a clear error, and nothing guards it.

Verification: ruff check . clean, 674 passed, auth contract 18 passed, and the import guard confirmed (TTL='abc'/'' → warn + 300; '86400' → 600; '60' → 60).

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. To restate what I said in the comment: the code and tests here were already sound, and the central finding is correct — sessions last 30 days, not 5 minutes, and #168 is properly closed as "not a bug." I verified the tests are real by mutation rather than reading (breaking the expiry check and the signature check each fail the right test). The changes above are the ADR mechanism fix, one import-time crash guard, and a test rename — nothing that touches the substance of your work. CI green on 350f1a7.

@AndresL230
AndresL230 merged commit 193a70f into mainJul 15, 2026
4 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/decisions/0018-session-token-lifecycle.md`:
- Line 7: Update the line beginning with “#168” in the session token lifecycle
document so it no longer starts with a bare hash; prefix the reference with
descriptive text or escape the hash while preserving the issue reference and
sentence meaning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5066a8b6-9219-4344-8b9f-e81226c0ea6d

📥 Commits

Reviewing files that changed from the base of the PR and between 32409a8 and 350f1a7.

📒 Files selected for processing (3)
  • backend/routes/auth.py
  • backend/tests/test_auth_session_contract.py
  • docs/decisions/0018-session-token-lifecycle.md


## The claim

#168 raised the concern that the backend session has a hard 5-minute lifetime

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the hash or add a prefix to prevent markdown linting errors.

Starting a line with #168 causes markdown linters to interpret it as a malformed ATX heading (triggering the MD018 rule). Consider escaping the hash or prefixing it with a word to ensure it renders correctly as text.

🛠️ Proposed fix
- `#168` raised the concern that the backend session has a hard 5-minute lifetime+ Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#168 raised the concern that the backend session has a hard 5-minute lifetime
Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 7-7: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0018-session-token-lifecycle.md` at line 7, Update the line
beginning with “#168” in the session token lifecycle document so it no longer
starts with a bare hash; prefix the reference with descriptive text or escape
the hash while preserving the issue reference and sentence meaning.

Source: Linters/SAST tools

@AndresL230
AndresL230 deleted the fix/session-token-lifecycle branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backend session token has a hard 5-minute lifetime with no refresh path (verify)

2 participants

@Jose-Gael-Cruz-Lopez@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168) - #255

Merged
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle
Jul 15, 2026
Merged

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168)#255
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 22, 2026

Copy link
Copy Markdown
Member

#168 was filed as a flagged "verify" finding: does the backend session really die after 5 minutes with no refresh? I traced the full path across both services. The worst case is not real — the 300s token is only a one-shot redirect handoff.

What actually happens

  1. Backend OAuth callback mints a short-lived HMAC token (exp = now + 300) and redirects to the frontend with ?auth_token=….
  2. The frontend session BFF (frontend/src/app/api/auth/session/route.ts) verifies it and re-mints a 30-day token (SESSION_MAX_AGE = 2592000) in a byte-identical format, set as the httpOnly/Securesapling_session cookie (scoped via COOKIE_DOMAIN).
  3. auth_guard._decode_session reads that cookie, verifies the HMAC with the shared SESSION_SECRET, and accepts it for the full 30 days.

So sessions persist 30 days, not 5 minutes. It holds as long as SESSION_SECRET matches across services and COOKIE_DOMAIN reaches the backend subdomain (both documented).

Changes

  • backend/routes/auth.py — named the magic 300 as _REDIRECT_TOKEN_TTL_SECONDS (env-overridable via SAPLING_AUTH_REDIRECT_TOKEN_TTL) and corrected the comment to say it's the redirect-handoff TTL, not the session TTL.
  • docs/decisions/0018-session-token-lifecycle.md — documents the full lifecycle, the verification outcome, the operational preconditions, and follow-ups (sliding refresh is a frontend-BFF concern, out of scope).
  • backend/tests/test_auth_session_contract.py — locks the cross-service contract: a frontend-style 30-day token is accepted by the backend decoder; expired, tampered, and wrong-secret tokens are rejected; the redirect TTL stays short.

Verification

  • ruff check . clean; gated suite green (+6 new). The acceptance criterion ("confirmed behavior documented; sessions persist for a configured TTL") is met.

Closes#168.

Summary by CodeRabbit

  • New Features

    • Added configurable expiration for one-time authentication handoff tokens, with safe limits and warnings for invalid settings.
    • Preserved support for long-lived sessions and legacy authentication flows.
  • Bug Fixes

    • Improved handling of expired, tampered, or incorrectly signed session tokens.
  • Documentation

    • Documented session-token lifecycle, authentication flow, configuration requirements, and operational considerations.
  • Tests

    • Added coverage for session compatibility, token validation, expiration, and configuration behavior.

@coderabbitai

coderabbitaiBot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The OAuth callback now uses a configurable, bounded redirect-token TTL. Contract tests validate long-lived session cookies and token rejection behavior, while a decision record documents the frontend/backend session lifecycle and operational requirements.

Changes

Session token lifecycle

Layer / File(s)Summary
Configurable redirect token TTL
backend/routes/auth.py
The OAuth handoff token TTL is read from SAPLING_AUTH_REDIRECT_TOKEN_TTL, defaults to 300 seconds, clamps to 30–600 seconds, warns on malformed values, and is applied during Google callback redirects.
Session contract validation
backend/tests/test_auth_session_contract.py
Tests cover 30-day session cookies, legacy query-token acceptance, expiry and signature rejection, shared-secret validation, and redirect TTL parsing and clamping.
Session lifecycle decision record
docs/decisions/0018-session-token-lifecycle.md
Documents the short redirect token, 30-day frontend session cookie, cookie forwarding path, shared-secret and rewrite requirements, verified outcome, and follow-ups.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: verifying and documenting the session-token lifecycle and locking the cross-service contract.
Description check✅ PassedThe description covers the purpose, key changes, linked issue, and testing, which is mostly aligned with the repository template.
Linked Issues check✅ PassedThe PR addresses #168 by verifying the 300s token is only a redirect handoff, documenting the lifecycle, and adding contract tests.
Out of Scope Changes check✅ PassedThe added TTL config, ADR documentation, and auth contract tests are all directly related to the verified session-token lifecycle.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/session-token-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitUpdated (UTC)
❌ Deployment failed
View logs
frontend28674bfJun 22 2026, 04:32 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 24, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging350f1a7Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:50 AM

_REDIRECT_TOKEN_TTL_SECONDS read SAPLING_AUTH_REDIRECT_TOKEN_TTL with no
upper bound, so an operator (or a bad env) could set it to hours/days.
That token is one-shot and travels in the OAuth-callback URL, so a long
TTL widens the window in which an intercepted URL can be replayed to mint
a session — it is not the session itself (ADR 0018). Clamp to [30, 600]s
via a small pure helper and add a test that asserts an extreme override
is clamped, so the "stays short" invariant holds at runtime, not just for
the default (the previous test only checked the unset-env default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_REDIRECT_TOKEN_TTL_SECONDS parsed SAPLING_AUTH_REDIRECT_TOKEN_TTL with a
bare int() at module scope. routes/auth.py is imported at router-mount time,
so `SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc` — or, realistically, declaring the
var in Railway/Wrangler with no value — raised ValueError at import and
stopped the app from booting.
Parse it in _parse_redirect_ttl() with try/except ValueError, falling back to
the 300s default and logging a warning. _clamp_redirect_ttl's docstring said
it defended against "a misconfigured override" but only clamped range, never
parseability; the parse guard makes that claim true.
Add coverage for _parse_redirect_ttl: malformed overrides ("abc", "", None)
fall back to 300s with a warning instead of raising at import; well-formed
ones are still parsed and clamped to [30, 600].
Correct two claims the tests did not support:
- _mint's docstring said it signs "exactly like the backend mint AND the
frontend signSession". It does not: Python's json.dumps emits
{"user_id": "x", "exp": 1} (spaces), JS JSON.stringify emits
{"user_id":"x","exp":1} (none), so the payloads differ byte-wise. It is
harmless — the verifier HMACs the received payload_b64 opaquely and never
re-serializes — but _mint mirrors only the backend mint, and is itself a
third Python re-implementation, so the suite proves a Python-minted token
is accepted, not a real frontend-minted one. Say so rather than claiming to
"lock the cross-service contract".
- test_redirect_auth_token_query_param_is_accepted read as an endorsement of
a constraint the code does not enforce. _decode_session reads ?auth_token=
before the cookie with no ttl/purpose check, so a 30-day session token in
the query string is accepted identically — and tokens in URLs leak via
access logs, Referer, and history. No client sends it: the redirect token
goes to the frontend, which POSTs it to the BFF in a JSON body. Rename to
test_legacy_unused_auth_token_query_param_is_still_accepted and document it
as characterization, so removing the channel is a deliberate, visible edit.
0018 documented the wrong mechanism. It claimed COOKIE_DOMAIN covers both
subdomains "so the browser sends sapling_session to the backend on
cross-origin API calls (credentials: 'include')", and that a host-only cookie
would not reach the backend. Neither is true:
- lib/api.ts sets API_URL = '', so all ~135 fetchJSON call sites are
same-origin, and next.config.ts rewrites /api/:path* to BACKEND_URL
server-side. The cookie reaches the backend because that server-side hop
forwards the Cookie header. A host-only cookie would work fine.
- No browser-side cross-origin authed call exists. The four
NEXT_PUBLIC_API_URL fetches all omit credentials entirely, and middleware.ts
runs server-side with a hand-set Cookie header.
This mattered because an ADR is authoritative: as written it would teach a dev
to point an authed fetch at NEXT_PUBLIC_API_URL, reintroducing the 2026-06-30
onboarding-loop bug — which is live at page.tsx:619 (#339), now cited as a
cautionary example. It also contradicted frontend/.env.example, which tells
you to leave NEXT_PUBLIC_API_URL empty in production.
Replace the precondition with the genuinely load-bearing one the ADR never
mentioned: BACKEND_URL must be set at *build* time for the CF Worker
(next.config.ts bakes it into the rewrite), or /api/* falls back to localhost
and 500s. Demote COOKIE_DOMAIN to what it actually governs — the cookie's
domain attribute — noting it is set (wrangler.toml:19), so nothing is broken.
Also drop the inaccurate "byte-identical format" claim (the two mints are
interoperable, not identical) and record two follow-ups: removing the unused
?auth_token= channel, and a shared JSON fixture consumed by both test suites
as the real cross-service lock.
The example cited frontend/src/app/page.tsx:619 as a live bug tracked in
#339. It is neither: the onboarding call was fixed weeks ago and now routes
through submitOnboardingProfile() -> fetchJSON, and the file moved to
(public)/page.tsx under the route-group refactor. #339 was filed against a
419-commit-stale branch and has been closed as invalid.
Describe the 2026-06-30 bug in the past tense and point at the comment in
(public)/page.tsx that records the real fix, which is a stronger cautionary
example because it actually happened.
@AndresL230

Copy link
Copy Markdown
Collaborator

@Jose-Gael-Cruz-Lopez I pushed 4 commits to this branch as part of a review sweep. Fast-forward only — nothing of yours was rewritten. Happy to back any of it out.

First, the headline: your central claim is correct and I verified it independently. Sessions last 30 days, not 5 minutes. The 300s token is a one-shot redirect handoff; the BFF (route.ts:99-106) re-mints a 30-day token into an httpOnly/Secure/SameSite=Lax cookie that auth_guard._decode_session accepts. Closing #168 as "not a bug" is right. The code and tests are sound — I did not rewrite them. I also confirmed the tests are real rather than testing conftest's stub, by mutation: disabling the expiry check at auth_guard.py:47 fails test_expired_token_is_rejected; disabling signature verification at :34 fails both tamper tests. conftest.py:88 captures _real_decode_sessionbefore the monkeypatch, so it's the genuine decoder.

The changes are confined to the ADR prose, one test name, and one small guard.

09b95bd — the ADR aside, this was the only real code bug

auth.py:93-95 crashed the app at import:

_REDIRECT_TOKEN_TTL_SECONDS=_clamp_redirect_ttl(int(os.getenv("SAPLING_AUTH_REDIRECT_TOKEN_TTL", "300")))

Reproduced: SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc and = (empty) both raise ValueError at module import → the router fails to import → the app won't boot. Empty-string is realistic — declaring the var in Railway/Wrangler without a value does it. Fails closed, so not a security hole, but a nasty deploy-time footgun in the one code path this PR adds. _clamp_redirect_ttl's docstring already promised to defend against "a misconfigured override" but only clamped range, not parseability — now it lives up to it via _parse_redirect_ttl → warn + fall back to 300.

187f419 + 350f1a7 — the ADR documented the wrong mechanism (the one that mattered)

0018:47-51 said COOKIE_DOMAIN covers both subdomains so the browser sends sapling_session cross-origin with credentials: 'include', and that a host-only cookie wouldn't reach the backend. That's not the topology: api.ts:12 sets API_URL = '' and next.config.ts:35 rewrites /api/:path*${BACKEND_URL}/api/:path*server-side, so all 135 fetchJSON sites are same-origin and the Next rewrite forwards the Cookie header. A host-only cookie would work fine.

The genuinely load-bearing precondition — BACKEND_URL must be set at build time — wasn't mentioned at all. Concretely: it sits in wrangler's [vars] (worker runtime vars) while next.config.ts reads it at build time, so [vars] alone doesn't feed the rewrite → falls back to localhost → /api/* 500s. That's now the documented precondition, with COOKIE_DOMAIN demoted to what it actually governs (the domain attribute; still set at wrangler.toml:19 via sanitizeCookieDomain#190, so nothing is broken).

This mattered because the ADR contradicted frontend/.env.example:5-8, which documents the opposite. An ADR is the authoritative reference — as written it would teach the next person to point an authed fetch at NEXT_PUBLIC_API_URL and reintroduce the 2026-06-30 onboarding-loop bug.

350f1a7 is me fixing my own mistake. My first pass cited that bug as "live right now at page.tsx:619, tracked in #339." It is neither. That finding came from an agent reading this branch, which is 419 commits behind main — where the raw fetch genuinely still exists. On main it was fixed weeks ago: the file moved to (public)/page.tsx and routes through submitOnboardingProfile()fetchJSON, with a comment recording the fix. I filed #339 off that and have since closed it as invalid. The ADR now describes the bug in past tense and points at that comment — a better cautionary example, since it actually happened.

1c1adf5 — renamed a test that blessed an unused credential channel

_decode_session (auth_guard.py:19) reads request.query_params.get("auth_token")before the cookie. I traced every occurrence: auth.py:452 puts it in a redirect URL to the frontend; callback/page.tsx:55 reads it and POSTs it to the BFF in a JSON body. No client ever sends ?auth_token= to the backend.

test_redirect_auth_token_query_param_is_accepted + its comment ("arrives as ?auth_token while fresh") asserted a constraint the code doesn't enforce — a 30-day session token in ?auth_token= is accepted identically, and URL-borne tokens leak via access logs, Referer, and history. Renamed to test_legacy_unused_auth_token_query_param_is_still_accepted and redocumented as characterization, not endorsement. Channel left in place — removing it is a follow-up, and the renamed test makes that removal a visible edit rather than a silent contract break.

Also corrected the "byte-identical format" claim: proved by running both serializers that Python emits {"user_id": "user_alice", …} (spaces) and JS JSON.stringify emits none → different base64. Harmless (the verifier HMACs the received payload_b64 opaquely and never re-serializes), but _mint's docstring claiming it signs "exactly like the backend mint AND the frontend signSession" was false — it mirrors the backend exactly (auth.py:440 uses default json.dumps), not signSession.

One nuance that sharpens your case

"The browser never makes a cross-origin authed API call" is true, but for a sharper reason than I first wrote. NEXT_PUBLIC_API_URLis consumed in four places, and wrangler sets it to https://api.saplinglearn.com. Checking each: SignInModal's uses are top-level OAuth navigations (not fetches); middleware.ts runs server-side with a hand-set Cookie header; and the remaining fetches omit credentials entirely. So no cross-origin authed browser call exists — and COOKIE_DOMAIN genuinely isn't load-bearing. Had any of them passed credentials: 'include', COOKIE_DOMAINwould have been the mechanism and this ADR rewrite would be wrong.

Follow-ups (not done here)

  1. Remove the ?auth_token= channel — no client uses it; it accepts a 30-day token with no ttl/purpose check.
  2. A shared JSON fixture as the real cross-service lock. "Lock the cross-service contract" overstates what's here: _mint is a third Python re-implementation, so the test proves a Python-minted token is accepted, not a real frontend one. There's no frontend/src/lib/sessionToken.test.ts, and route.ts:29-68 hand-rolls verifyAuthToken as a duplicate of sessionToken.ts::verifySession — so there are two frontend copies that can drift. I softened the framing; a checked-in fixture consumed by both suites would close it properly.
  3. Assert the build-time BACKEND_URL precondition in CI — it's the one genuinely load-bearing config, it fails as a confusing 500 rather than a clear error, and nothing guards it.

Verification: ruff check . clean, 674 passed, auth contract 18 passed, and the import guard confirmed (TTL='abc'/'' → warn + 300; '86400' → 600; '60' → 60).

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. To restate what I said in the comment: the code and tests here were already sound, and the central finding is correct — sessions last 30 days, not 5 minutes, and #168 is properly closed as "not a bug." I verified the tests are real by mutation rather than reading (breaking the expiry check and the signature check each fail the right test). The changes above are the ADR mechanism fix, one import-time crash guard, and a test rename — nothing that touches the substance of your work. CI green on 350f1a7.

@AndresL230
AndresL230 merged commit 193a70f into mainJul 15, 2026
4 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/decisions/0018-session-token-lifecycle.md`:
- Line 7: Update the line beginning with “#168” in the session token lifecycle
document so it no longer starts with a bare hash; prefix the reference with
descriptive text or escape the hash while preserving the issue reference and
sentence meaning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5066a8b6-9219-4344-8b9f-e81226c0ea6d

📥 Commits

Reviewing files that changed from the base of the PR and between 32409a8 and 350f1a7.

📒 Files selected for processing (3)
  • backend/routes/auth.py
  • backend/tests/test_auth_session_contract.py
  • docs/decisions/0018-session-token-lifecycle.md


## The claim

#168 raised the concern that the backend session has a hard 5-minute lifetime

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the hash or add a prefix to prevent markdown linting errors.

Starting a line with #168 causes markdown linters to interpret it as a malformed ATX heading (triggering the MD018 rule). Consider escaping the hash or prefixing it with a word to ensure it renders correctly as text.

🛠️ Proposed fix
- `#168` raised the concern that the backend session has a hard 5-minute lifetime+ Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#168 raised the concern that the backend session has a hard 5-minute lifetime
Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 7-7: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0018-session-token-lifecycle.md` at line 7, Update the line
beginning with “#168” in the session token lifecycle document so it no longer
starts with a bare hash; prefix the reference with descriptive text or escape
the hash while preserving the issue reference and sentence meaning.

Source: Linters/SAST tools

@AndresL230
AndresL230 deleted the fix/session-token-lifecycle branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backend session token has a hard 5-minute lifetime with no refresh path (verify)

2 participants

@Jose-Gael-Cruz-Lopez@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168) - #255

Merged
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle
Jul 15, 2026
Merged

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168)#255
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 22, 2026

Copy link
Copy Markdown
Member

#168 was filed as a flagged "verify" finding: does the backend session really die after 5 minutes with no refresh? I traced the full path across both services. The worst case is not real — the 300s token is only a one-shot redirect handoff.

What actually happens

  1. Backend OAuth callback mints a short-lived HMAC token (exp = now + 300) and redirects to the frontend with ?auth_token=….
  2. The frontend session BFF (frontend/src/app/api/auth/session/route.ts) verifies it and re-mints a 30-day token (SESSION_MAX_AGE = 2592000) in a byte-identical format, set as the httpOnly/Securesapling_session cookie (scoped via COOKIE_DOMAIN).
  3. auth_guard._decode_session reads that cookie, verifies the HMAC with the shared SESSION_SECRET, and accepts it for the full 30 days.

So sessions persist 30 days, not 5 minutes. It holds as long as SESSION_SECRET matches across services and COOKIE_DOMAIN reaches the backend subdomain (both documented).

Changes

  • backend/routes/auth.py — named the magic 300 as _REDIRECT_TOKEN_TTL_SECONDS (env-overridable via SAPLING_AUTH_REDIRECT_TOKEN_TTL) and corrected the comment to say it's the redirect-handoff TTL, not the session TTL.
  • docs/decisions/0018-session-token-lifecycle.md — documents the full lifecycle, the verification outcome, the operational preconditions, and follow-ups (sliding refresh is a frontend-BFF concern, out of scope).
  • backend/tests/test_auth_session_contract.py — locks the cross-service contract: a frontend-style 30-day token is accepted by the backend decoder; expired, tampered, and wrong-secret tokens are rejected; the redirect TTL stays short.

Verification

  • ruff check . clean; gated suite green (+6 new). The acceptance criterion ("confirmed behavior documented; sessions persist for a configured TTL") is met.

Closes#168.

Summary by CodeRabbit

  • New Features

    • Added configurable expiration for one-time authentication handoff tokens, with safe limits and warnings for invalid settings.
    • Preserved support for long-lived sessions and legacy authentication flows.
  • Bug Fixes

    • Improved handling of expired, tampered, or incorrectly signed session tokens.
  • Documentation

    • Documented session-token lifecycle, authentication flow, configuration requirements, and operational considerations.
  • Tests

    • Added coverage for session compatibility, token validation, expiration, and configuration behavior.

@coderabbitai

coderabbitaiBot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The OAuth callback now uses a configurable, bounded redirect-token TTL. Contract tests validate long-lived session cookies and token rejection behavior, while a decision record documents the frontend/backend session lifecycle and operational requirements.

Changes

Session token lifecycle

Layer / File(s)Summary
Configurable redirect token TTL
backend/routes/auth.py
The OAuth handoff token TTL is read from SAPLING_AUTH_REDIRECT_TOKEN_TTL, defaults to 300 seconds, clamps to 30–600 seconds, warns on malformed values, and is applied during Google callback redirects.
Session contract validation
backend/tests/test_auth_session_contract.py
Tests cover 30-day session cookies, legacy query-token acceptance, expiry and signature rejection, shared-secret validation, and redirect TTL parsing and clamping.
Session lifecycle decision record
docs/decisions/0018-session-token-lifecycle.md
Documents the short redirect token, 30-day frontend session cookie, cookie forwarding path, shared-secret and rewrite requirements, verified outcome, and follow-ups.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: verifying and documenting the session-token lifecycle and locking the cross-service contract.
Description check✅ PassedThe description covers the purpose, key changes, linked issue, and testing, which is mostly aligned with the repository template.
Linked Issues check✅ PassedThe PR addresses #168 by verifying the 300s token is only a redirect handoff, documenting the lifecycle, and adding contract tests.
Out of Scope Changes check✅ PassedThe added TTL config, ADR documentation, and auth contract tests are all directly related to the verified session-token lifecycle.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/session-token-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitUpdated (UTC)
❌ Deployment failed
View logs
frontend28674bfJun 22 2026, 04:32 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 24, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging350f1a7Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:50 AM

_REDIRECT_TOKEN_TTL_SECONDS read SAPLING_AUTH_REDIRECT_TOKEN_TTL with no
upper bound, so an operator (or a bad env) could set it to hours/days.
That token is one-shot and travels in the OAuth-callback URL, so a long
TTL widens the window in which an intercepted URL can be replayed to mint
a session — it is not the session itself (ADR 0018). Clamp to [30, 600]s
via a small pure helper and add a test that asserts an extreme override
is clamped, so the "stays short" invariant holds at runtime, not just for
the default (the previous test only checked the unset-env default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_REDIRECT_TOKEN_TTL_SECONDS parsed SAPLING_AUTH_REDIRECT_TOKEN_TTL with a
bare int() at module scope. routes/auth.py is imported at router-mount time,
so `SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc` — or, realistically, declaring the
var in Railway/Wrangler with no value — raised ValueError at import and
stopped the app from booting.
Parse it in _parse_redirect_ttl() with try/except ValueError, falling back to
the 300s default and logging a warning. _clamp_redirect_ttl's docstring said
it defended against "a misconfigured override" but only clamped range, never
parseability; the parse guard makes that claim true.
Add coverage for _parse_redirect_ttl: malformed overrides ("abc", "", None)
fall back to 300s with a warning instead of raising at import; well-formed
ones are still parsed and clamped to [30, 600].
Correct two claims the tests did not support:
- _mint's docstring said it signs "exactly like the backend mint AND the
frontend signSession". It does not: Python's json.dumps emits
{"user_id": "x", "exp": 1} (spaces), JS JSON.stringify emits
{"user_id":"x","exp":1} (none), so the payloads differ byte-wise. It is
harmless — the verifier HMACs the received payload_b64 opaquely and never
re-serializes — but _mint mirrors only the backend mint, and is itself a
third Python re-implementation, so the suite proves a Python-minted token
is accepted, not a real frontend-minted one. Say so rather than claiming to
"lock the cross-service contract".
- test_redirect_auth_token_query_param_is_accepted read as an endorsement of
a constraint the code does not enforce. _decode_session reads ?auth_token=
before the cookie with no ttl/purpose check, so a 30-day session token in
the query string is accepted identically — and tokens in URLs leak via
access logs, Referer, and history. No client sends it: the redirect token
goes to the frontend, which POSTs it to the BFF in a JSON body. Rename to
test_legacy_unused_auth_token_query_param_is_still_accepted and document it
as characterization, so removing the channel is a deliberate, visible edit.
0018 documented the wrong mechanism. It claimed COOKIE_DOMAIN covers both
subdomains "so the browser sends sapling_session to the backend on
cross-origin API calls (credentials: 'include')", and that a host-only cookie
would not reach the backend. Neither is true:
- lib/api.ts sets API_URL = '', so all ~135 fetchJSON call sites are
same-origin, and next.config.ts rewrites /api/:path* to BACKEND_URL
server-side. The cookie reaches the backend because that server-side hop
forwards the Cookie header. A host-only cookie would work fine.
- No browser-side cross-origin authed call exists. The four
NEXT_PUBLIC_API_URL fetches all omit credentials entirely, and middleware.ts
runs server-side with a hand-set Cookie header.
This mattered because an ADR is authoritative: as written it would teach a dev
to point an authed fetch at NEXT_PUBLIC_API_URL, reintroducing the 2026-06-30
onboarding-loop bug — which is live at page.tsx:619 (#339), now cited as a
cautionary example. It also contradicted frontend/.env.example, which tells
you to leave NEXT_PUBLIC_API_URL empty in production.
Replace the precondition with the genuinely load-bearing one the ADR never
mentioned: BACKEND_URL must be set at *build* time for the CF Worker
(next.config.ts bakes it into the rewrite), or /api/* falls back to localhost
and 500s. Demote COOKIE_DOMAIN to what it actually governs — the cookie's
domain attribute — noting it is set (wrangler.toml:19), so nothing is broken.
Also drop the inaccurate "byte-identical format" claim (the two mints are
interoperable, not identical) and record two follow-ups: removing the unused
?auth_token= channel, and a shared JSON fixture consumed by both test suites
as the real cross-service lock.
The example cited frontend/src/app/page.tsx:619 as a live bug tracked in
#339. It is neither: the onboarding call was fixed weeks ago and now routes
through submitOnboardingProfile() -> fetchJSON, and the file moved to
(public)/page.tsx under the route-group refactor. #339 was filed against a
419-commit-stale branch and has been closed as invalid.
Describe the 2026-06-30 bug in the past tense and point at the comment in
(public)/page.tsx that records the real fix, which is a stronger cautionary
example because it actually happened.
@AndresL230

Copy link
Copy Markdown
Collaborator

@Jose-Gael-Cruz-Lopez I pushed 4 commits to this branch as part of a review sweep. Fast-forward only — nothing of yours was rewritten. Happy to back any of it out.

First, the headline: your central claim is correct and I verified it independently. Sessions last 30 days, not 5 minutes. The 300s token is a one-shot redirect handoff; the BFF (route.ts:99-106) re-mints a 30-day token into an httpOnly/Secure/SameSite=Lax cookie that auth_guard._decode_session accepts. Closing #168 as "not a bug" is right. The code and tests are sound — I did not rewrite them. I also confirmed the tests are real rather than testing conftest's stub, by mutation: disabling the expiry check at auth_guard.py:47 fails test_expired_token_is_rejected; disabling signature verification at :34 fails both tamper tests. conftest.py:88 captures _real_decode_sessionbefore the monkeypatch, so it's the genuine decoder.

The changes are confined to the ADR prose, one test name, and one small guard.

09b95bd — the ADR aside, this was the only real code bug

auth.py:93-95 crashed the app at import:

_REDIRECT_TOKEN_TTL_SECONDS=_clamp_redirect_ttl(int(os.getenv("SAPLING_AUTH_REDIRECT_TOKEN_TTL", "300")))

Reproduced: SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc and = (empty) both raise ValueError at module import → the router fails to import → the app won't boot. Empty-string is realistic — declaring the var in Railway/Wrangler without a value does it. Fails closed, so not a security hole, but a nasty deploy-time footgun in the one code path this PR adds. _clamp_redirect_ttl's docstring already promised to defend against "a misconfigured override" but only clamped range, not parseability — now it lives up to it via _parse_redirect_ttl → warn + fall back to 300.

187f419 + 350f1a7 — the ADR documented the wrong mechanism (the one that mattered)

0018:47-51 said COOKIE_DOMAIN covers both subdomains so the browser sends sapling_session cross-origin with credentials: 'include', and that a host-only cookie wouldn't reach the backend. That's not the topology: api.ts:12 sets API_URL = '' and next.config.ts:35 rewrites /api/:path*${BACKEND_URL}/api/:path*server-side, so all 135 fetchJSON sites are same-origin and the Next rewrite forwards the Cookie header. A host-only cookie would work fine.

The genuinely load-bearing precondition — BACKEND_URL must be set at build time — wasn't mentioned at all. Concretely: it sits in wrangler's [vars] (worker runtime vars) while next.config.ts reads it at build time, so [vars] alone doesn't feed the rewrite → falls back to localhost → /api/* 500s. That's now the documented precondition, with COOKIE_DOMAIN demoted to what it actually governs (the domain attribute; still set at wrangler.toml:19 via sanitizeCookieDomain#190, so nothing is broken).

This mattered because the ADR contradicted frontend/.env.example:5-8, which documents the opposite. An ADR is the authoritative reference — as written it would teach the next person to point an authed fetch at NEXT_PUBLIC_API_URL and reintroduce the 2026-06-30 onboarding-loop bug.

350f1a7 is me fixing my own mistake. My first pass cited that bug as "live right now at page.tsx:619, tracked in #339." It is neither. That finding came from an agent reading this branch, which is 419 commits behind main — where the raw fetch genuinely still exists. On main it was fixed weeks ago: the file moved to (public)/page.tsx and routes through submitOnboardingProfile()fetchJSON, with a comment recording the fix. I filed #339 off that and have since closed it as invalid. The ADR now describes the bug in past tense and points at that comment — a better cautionary example, since it actually happened.

1c1adf5 — renamed a test that blessed an unused credential channel

_decode_session (auth_guard.py:19) reads request.query_params.get("auth_token")before the cookie. I traced every occurrence: auth.py:452 puts it in a redirect URL to the frontend; callback/page.tsx:55 reads it and POSTs it to the BFF in a JSON body. No client ever sends ?auth_token= to the backend.

test_redirect_auth_token_query_param_is_accepted + its comment ("arrives as ?auth_token while fresh") asserted a constraint the code doesn't enforce — a 30-day session token in ?auth_token= is accepted identically, and URL-borne tokens leak via access logs, Referer, and history. Renamed to test_legacy_unused_auth_token_query_param_is_still_accepted and redocumented as characterization, not endorsement. Channel left in place — removing it is a follow-up, and the renamed test makes that removal a visible edit rather than a silent contract break.

Also corrected the "byte-identical format" claim: proved by running both serializers that Python emits {"user_id": "user_alice", …} (spaces) and JS JSON.stringify emits none → different base64. Harmless (the verifier HMACs the received payload_b64 opaquely and never re-serializes), but _mint's docstring claiming it signs "exactly like the backend mint AND the frontend signSession" was false — it mirrors the backend exactly (auth.py:440 uses default json.dumps), not signSession.

One nuance that sharpens your case

"The browser never makes a cross-origin authed API call" is true, but for a sharper reason than I first wrote. NEXT_PUBLIC_API_URLis consumed in four places, and wrangler sets it to https://api.saplinglearn.com. Checking each: SignInModal's uses are top-level OAuth navigations (not fetches); middleware.ts runs server-side with a hand-set Cookie header; and the remaining fetches omit credentials entirely. So no cross-origin authed browser call exists — and COOKIE_DOMAIN genuinely isn't load-bearing. Had any of them passed credentials: 'include', COOKIE_DOMAINwould have been the mechanism and this ADR rewrite would be wrong.

Follow-ups (not done here)

  1. Remove the ?auth_token= channel — no client uses it; it accepts a 30-day token with no ttl/purpose check.
  2. A shared JSON fixture as the real cross-service lock. "Lock the cross-service contract" overstates what's here: _mint is a third Python re-implementation, so the test proves a Python-minted token is accepted, not a real frontend one. There's no frontend/src/lib/sessionToken.test.ts, and route.ts:29-68 hand-rolls verifyAuthToken as a duplicate of sessionToken.ts::verifySession — so there are two frontend copies that can drift. I softened the framing; a checked-in fixture consumed by both suites would close it properly.
  3. Assert the build-time BACKEND_URL precondition in CI — it's the one genuinely load-bearing config, it fails as a confusing 500 rather than a clear error, and nothing guards it.

Verification: ruff check . clean, 674 passed, auth contract 18 passed, and the import guard confirmed (TTL='abc'/'' → warn + 300; '86400' → 600; '60' → 60).

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. To restate what I said in the comment: the code and tests here were already sound, and the central finding is correct — sessions last 30 days, not 5 minutes, and #168 is properly closed as "not a bug." I verified the tests are real by mutation rather than reading (breaking the expiry check and the signature check each fail the right test). The changes above are the ADR mechanism fix, one import-time crash guard, and a test rename — nothing that touches the substance of your work. CI green on 350f1a7.

@AndresL230
AndresL230 merged commit 193a70f into mainJul 15, 2026
4 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/decisions/0018-session-token-lifecycle.md`:
- Line 7: Update the line beginning with “#168” in the session token lifecycle
document so it no longer starts with a bare hash; prefix the reference with
descriptive text or escape the hash while preserving the issue reference and
sentence meaning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5066a8b6-9219-4344-8b9f-e81226c0ea6d

📥 Commits

Reviewing files that changed from the base of the PR and between 32409a8 and 350f1a7.

📒 Files selected for processing (3)
  • backend/routes/auth.py
  • backend/tests/test_auth_session_contract.py
  • docs/decisions/0018-session-token-lifecycle.md


## The claim

#168 raised the concern that the backend session has a hard 5-minute lifetime

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the hash or add a prefix to prevent markdown linting errors.

Starting a line with #168 causes markdown linters to interpret it as a malformed ATX heading (triggering the MD018 rule). Consider escaping the hash or prefixing it with a word to ensure it renders correctly as text.

🛠️ Proposed fix
- `#168` raised the concern that the backend session has a hard 5-minute lifetime+ Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#168 raised the concern that the backend session has a hard 5-minute lifetime
Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 7-7: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0018-session-token-lifecycle.md` at line 7, Update the line
beginning with “#168” in the session token lifecycle document so it no longer
starts with a bare hash; prefix the reference with descriptive text or escape
the hash while preserving the issue reference and sentence meaning.

Source: Linters/SAST tools

@AndresL230
AndresL230 deleted the fix/session-token-lifecycle branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backend session token has a hard 5-minute lifetime with no refresh path (verify)

2 participants

@Jose-Gael-Cruz-Lopez@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168) - #255

Merged
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle
Jul 15, 2026
Merged

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168)#255
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 22, 2026

Copy link
Copy Markdown
Member

#168 was filed as a flagged "verify" finding: does the backend session really die after 5 minutes with no refresh? I traced the full path across both services. The worst case is not real — the 300s token is only a one-shot redirect handoff.

What actually happens

  1. Backend OAuth callback mints a short-lived HMAC token (exp = now + 300) and redirects to the frontend with ?auth_token=….
  2. The frontend session BFF (frontend/src/app/api/auth/session/route.ts) verifies it and re-mints a 30-day token (SESSION_MAX_AGE = 2592000) in a byte-identical format, set as the httpOnly/Securesapling_session cookie (scoped via COOKIE_DOMAIN).
  3. auth_guard._decode_session reads that cookie, verifies the HMAC with the shared SESSION_SECRET, and accepts it for the full 30 days.

So sessions persist 30 days, not 5 minutes. It holds as long as SESSION_SECRET matches across services and COOKIE_DOMAIN reaches the backend subdomain (both documented).

Changes

  • backend/routes/auth.py — named the magic 300 as _REDIRECT_TOKEN_TTL_SECONDS (env-overridable via SAPLING_AUTH_REDIRECT_TOKEN_TTL) and corrected the comment to say it's the redirect-handoff TTL, not the session TTL.
  • docs/decisions/0018-session-token-lifecycle.md — documents the full lifecycle, the verification outcome, the operational preconditions, and follow-ups (sliding refresh is a frontend-BFF concern, out of scope).
  • backend/tests/test_auth_session_contract.py — locks the cross-service contract: a frontend-style 30-day token is accepted by the backend decoder; expired, tampered, and wrong-secret tokens are rejected; the redirect TTL stays short.

Verification

  • ruff check . clean; gated suite green (+6 new). The acceptance criterion ("confirmed behavior documented; sessions persist for a configured TTL") is met.

Closes#168.

Summary by CodeRabbit

  • New Features

    • Added configurable expiration for one-time authentication handoff tokens, with safe limits and warnings for invalid settings.
    • Preserved support for long-lived sessions and legacy authentication flows.
  • Bug Fixes

    • Improved handling of expired, tampered, or incorrectly signed session tokens.
  • Documentation

    • Documented session-token lifecycle, authentication flow, configuration requirements, and operational considerations.
  • Tests

    • Added coverage for session compatibility, token validation, expiration, and configuration behavior.

@coderabbitai

coderabbitaiBot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The OAuth callback now uses a configurable, bounded redirect-token TTL. Contract tests validate long-lived session cookies and token rejection behavior, while a decision record documents the frontend/backend session lifecycle and operational requirements.

Changes

Session token lifecycle

Layer / File(s)Summary
Configurable redirect token TTL
backend/routes/auth.py
The OAuth handoff token TTL is read from SAPLING_AUTH_REDIRECT_TOKEN_TTL, defaults to 300 seconds, clamps to 30–600 seconds, warns on malformed values, and is applied during Google callback redirects.
Session contract validation
backend/tests/test_auth_session_contract.py
Tests cover 30-day session cookies, legacy query-token acceptance, expiry and signature rejection, shared-secret validation, and redirect TTL parsing and clamping.
Session lifecycle decision record
docs/decisions/0018-session-token-lifecycle.md
Documents the short redirect token, 30-day frontend session cookie, cookie forwarding path, shared-secret and rewrite requirements, verified outcome, and follow-ups.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: verifying and documenting the session-token lifecycle and locking the cross-service contract.
Description check✅ PassedThe description covers the purpose, key changes, linked issue, and testing, which is mostly aligned with the repository template.
Linked Issues check✅ PassedThe PR addresses #168 by verifying the 300s token is only a redirect handoff, documenting the lifecycle, and adding contract tests.
Out of Scope Changes check✅ PassedThe added TTL config, ADR documentation, and auth contract tests are all directly related to the verified session-token lifecycle.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/session-token-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitUpdated (UTC)
❌ Deployment failed
View logs
frontend28674bfJun 22 2026, 04:32 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 24, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging350f1a7Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:50 AM

_REDIRECT_TOKEN_TTL_SECONDS read SAPLING_AUTH_REDIRECT_TOKEN_TTL with no
upper bound, so an operator (or a bad env) could set it to hours/days.
That token is one-shot and travels in the OAuth-callback URL, so a long
TTL widens the window in which an intercepted URL can be replayed to mint
a session — it is not the session itself (ADR 0018). Clamp to [30, 600]s
via a small pure helper and add a test that asserts an extreme override
is clamped, so the "stays short" invariant holds at runtime, not just for
the default (the previous test only checked the unset-env default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_REDIRECT_TOKEN_TTL_SECONDS parsed SAPLING_AUTH_REDIRECT_TOKEN_TTL with a
bare int() at module scope. routes/auth.py is imported at router-mount time,
so `SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc` — or, realistically, declaring the
var in Railway/Wrangler with no value — raised ValueError at import and
stopped the app from booting.
Parse it in _parse_redirect_ttl() with try/except ValueError, falling back to
the 300s default and logging a warning. _clamp_redirect_ttl's docstring said
it defended against "a misconfigured override" but only clamped range, never
parseability; the parse guard makes that claim true.
Add coverage for _parse_redirect_ttl: malformed overrides ("abc", "", None)
fall back to 300s with a warning instead of raising at import; well-formed
ones are still parsed and clamped to [30, 600].
Correct two claims the tests did not support:
- _mint's docstring said it signs "exactly like the backend mint AND the
frontend signSession". It does not: Python's json.dumps emits
{"user_id": "x", "exp": 1} (spaces), JS JSON.stringify emits
{"user_id":"x","exp":1} (none), so the payloads differ byte-wise. It is
harmless — the verifier HMACs the received payload_b64 opaquely and never
re-serializes — but _mint mirrors only the backend mint, and is itself a
third Python re-implementation, so the suite proves a Python-minted token
is accepted, not a real frontend-minted one. Say so rather than claiming to
"lock the cross-service contract".
- test_redirect_auth_token_query_param_is_accepted read as an endorsement of
a constraint the code does not enforce. _decode_session reads ?auth_token=
before the cookie with no ttl/purpose check, so a 30-day session token in
the query string is accepted identically — and tokens in URLs leak via
access logs, Referer, and history. No client sends it: the redirect token
goes to the frontend, which POSTs it to the BFF in a JSON body. Rename to
test_legacy_unused_auth_token_query_param_is_still_accepted and document it
as characterization, so removing the channel is a deliberate, visible edit.
0018 documented the wrong mechanism. It claimed COOKIE_DOMAIN covers both
subdomains "so the browser sends sapling_session to the backend on
cross-origin API calls (credentials: 'include')", and that a host-only cookie
would not reach the backend. Neither is true:
- lib/api.ts sets API_URL = '', so all ~135 fetchJSON call sites are
same-origin, and next.config.ts rewrites /api/:path* to BACKEND_URL
server-side. The cookie reaches the backend because that server-side hop
forwards the Cookie header. A host-only cookie would work fine.
- No browser-side cross-origin authed call exists. The four
NEXT_PUBLIC_API_URL fetches all omit credentials entirely, and middleware.ts
runs server-side with a hand-set Cookie header.
This mattered because an ADR is authoritative: as written it would teach a dev
to point an authed fetch at NEXT_PUBLIC_API_URL, reintroducing the 2026-06-30
onboarding-loop bug — which is live at page.tsx:619 (#339), now cited as a
cautionary example. It also contradicted frontend/.env.example, which tells
you to leave NEXT_PUBLIC_API_URL empty in production.
Replace the precondition with the genuinely load-bearing one the ADR never
mentioned: BACKEND_URL must be set at *build* time for the CF Worker
(next.config.ts bakes it into the rewrite), or /api/* falls back to localhost
and 500s. Demote COOKIE_DOMAIN to what it actually governs — the cookie's
domain attribute — noting it is set (wrangler.toml:19), so nothing is broken.
Also drop the inaccurate "byte-identical format" claim (the two mints are
interoperable, not identical) and record two follow-ups: removing the unused
?auth_token= channel, and a shared JSON fixture consumed by both test suites
as the real cross-service lock.
The example cited frontend/src/app/page.tsx:619 as a live bug tracked in
#339. It is neither: the onboarding call was fixed weeks ago and now routes
through submitOnboardingProfile() -> fetchJSON, and the file moved to
(public)/page.tsx under the route-group refactor. #339 was filed against a
419-commit-stale branch and has been closed as invalid.
Describe the 2026-06-30 bug in the past tense and point at the comment in
(public)/page.tsx that records the real fix, which is a stronger cautionary
example because it actually happened.
@AndresL230

Copy link
Copy Markdown
Collaborator

@Jose-Gael-Cruz-Lopez I pushed 4 commits to this branch as part of a review sweep. Fast-forward only — nothing of yours was rewritten. Happy to back any of it out.

First, the headline: your central claim is correct and I verified it independently. Sessions last 30 days, not 5 minutes. The 300s token is a one-shot redirect handoff; the BFF (route.ts:99-106) re-mints a 30-day token into an httpOnly/Secure/SameSite=Lax cookie that auth_guard._decode_session accepts. Closing #168 as "not a bug" is right. The code and tests are sound — I did not rewrite them. I also confirmed the tests are real rather than testing conftest's stub, by mutation: disabling the expiry check at auth_guard.py:47 fails test_expired_token_is_rejected; disabling signature verification at :34 fails both tamper tests. conftest.py:88 captures _real_decode_sessionbefore the monkeypatch, so it's the genuine decoder.

The changes are confined to the ADR prose, one test name, and one small guard.

09b95bd — the ADR aside, this was the only real code bug

auth.py:93-95 crashed the app at import:

_REDIRECT_TOKEN_TTL_SECONDS=_clamp_redirect_ttl(int(os.getenv("SAPLING_AUTH_REDIRECT_TOKEN_TTL", "300")))

Reproduced: SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc and = (empty) both raise ValueError at module import → the router fails to import → the app won't boot. Empty-string is realistic — declaring the var in Railway/Wrangler without a value does it. Fails closed, so not a security hole, but a nasty deploy-time footgun in the one code path this PR adds. _clamp_redirect_ttl's docstring already promised to defend against "a misconfigured override" but only clamped range, not parseability — now it lives up to it via _parse_redirect_ttl → warn + fall back to 300.

187f419 + 350f1a7 — the ADR documented the wrong mechanism (the one that mattered)

0018:47-51 said COOKIE_DOMAIN covers both subdomains so the browser sends sapling_session cross-origin with credentials: 'include', and that a host-only cookie wouldn't reach the backend. That's not the topology: api.ts:12 sets API_URL = '' and next.config.ts:35 rewrites /api/:path*${BACKEND_URL}/api/:path*server-side, so all 135 fetchJSON sites are same-origin and the Next rewrite forwards the Cookie header. A host-only cookie would work fine.

The genuinely load-bearing precondition — BACKEND_URL must be set at build time — wasn't mentioned at all. Concretely: it sits in wrangler's [vars] (worker runtime vars) while next.config.ts reads it at build time, so [vars] alone doesn't feed the rewrite → falls back to localhost → /api/* 500s. That's now the documented precondition, with COOKIE_DOMAIN demoted to what it actually governs (the domain attribute; still set at wrangler.toml:19 via sanitizeCookieDomain#190, so nothing is broken).

This mattered because the ADR contradicted frontend/.env.example:5-8, which documents the opposite. An ADR is the authoritative reference — as written it would teach the next person to point an authed fetch at NEXT_PUBLIC_API_URL and reintroduce the 2026-06-30 onboarding-loop bug.

350f1a7 is me fixing my own mistake. My first pass cited that bug as "live right now at page.tsx:619, tracked in #339." It is neither. That finding came from an agent reading this branch, which is 419 commits behind main — where the raw fetch genuinely still exists. On main it was fixed weeks ago: the file moved to (public)/page.tsx and routes through submitOnboardingProfile()fetchJSON, with a comment recording the fix. I filed #339 off that and have since closed it as invalid. The ADR now describes the bug in past tense and points at that comment — a better cautionary example, since it actually happened.

1c1adf5 — renamed a test that blessed an unused credential channel

_decode_session (auth_guard.py:19) reads request.query_params.get("auth_token")before the cookie. I traced every occurrence: auth.py:452 puts it in a redirect URL to the frontend; callback/page.tsx:55 reads it and POSTs it to the BFF in a JSON body. No client ever sends ?auth_token= to the backend.

test_redirect_auth_token_query_param_is_accepted + its comment ("arrives as ?auth_token while fresh") asserted a constraint the code doesn't enforce — a 30-day session token in ?auth_token= is accepted identically, and URL-borne tokens leak via access logs, Referer, and history. Renamed to test_legacy_unused_auth_token_query_param_is_still_accepted and redocumented as characterization, not endorsement. Channel left in place — removing it is a follow-up, and the renamed test makes that removal a visible edit rather than a silent contract break.

Also corrected the "byte-identical format" claim: proved by running both serializers that Python emits {"user_id": "user_alice", …} (spaces) and JS JSON.stringify emits none → different base64. Harmless (the verifier HMACs the received payload_b64 opaquely and never re-serializes), but _mint's docstring claiming it signs "exactly like the backend mint AND the frontend signSession" was false — it mirrors the backend exactly (auth.py:440 uses default json.dumps), not signSession.

One nuance that sharpens your case

"The browser never makes a cross-origin authed API call" is true, but for a sharper reason than I first wrote. NEXT_PUBLIC_API_URLis consumed in four places, and wrangler sets it to https://api.saplinglearn.com. Checking each: SignInModal's uses are top-level OAuth navigations (not fetches); middleware.ts runs server-side with a hand-set Cookie header; and the remaining fetches omit credentials entirely. So no cross-origin authed browser call exists — and COOKIE_DOMAIN genuinely isn't load-bearing. Had any of them passed credentials: 'include', COOKIE_DOMAINwould have been the mechanism and this ADR rewrite would be wrong.

Follow-ups (not done here)

  1. Remove the ?auth_token= channel — no client uses it; it accepts a 30-day token with no ttl/purpose check.
  2. A shared JSON fixture as the real cross-service lock. "Lock the cross-service contract" overstates what's here: _mint is a third Python re-implementation, so the test proves a Python-minted token is accepted, not a real frontend one. There's no frontend/src/lib/sessionToken.test.ts, and route.ts:29-68 hand-rolls verifyAuthToken as a duplicate of sessionToken.ts::verifySession — so there are two frontend copies that can drift. I softened the framing; a checked-in fixture consumed by both suites would close it properly.
  3. Assert the build-time BACKEND_URL precondition in CI — it's the one genuinely load-bearing config, it fails as a confusing 500 rather than a clear error, and nothing guards it.

Verification: ruff check . clean, 674 passed, auth contract 18 passed, and the import guard confirmed (TTL='abc'/'' → warn + 300; '86400' → 600; '60' → 60).

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. To restate what I said in the comment: the code and tests here were already sound, and the central finding is correct — sessions last 30 days, not 5 minutes, and #168 is properly closed as "not a bug." I verified the tests are real by mutation rather than reading (breaking the expiry check and the signature check each fail the right test). The changes above are the ADR mechanism fix, one import-time crash guard, and a test rename — nothing that touches the substance of your work. CI green on 350f1a7.

@AndresL230
AndresL230 merged commit 193a70f into mainJul 15, 2026
4 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/decisions/0018-session-token-lifecycle.md`:
- Line 7: Update the line beginning with “#168” in the session token lifecycle
document so it no longer starts with a bare hash; prefix the reference with
descriptive text or escape the hash while preserving the issue reference and
sentence meaning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5066a8b6-9219-4344-8b9f-e81226c0ea6d

📥 Commits

Reviewing files that changed from the base of the PR and between 32409a8 and 350f1a7.

📒 Files selected for processing (3)
  • backend/routes/auth.py
  • backend/tests/test_auth_session_contract.py
  • docs/decisions/0018-session-token-lifecycle.md


## The claim

#168 raised the concern that the backend session has a hard 5-minute lifetime

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the hash or add a prefix to prevent markdown linting errors.

Starting a line with #168 causes markdown linters to interpret it as a malformed ATX heading (triggering the MD018 rule). Consider escaping the hash or prefixing it with a word to ensure it renders correctly as text.

🛠️ Proposed fix
- `#168` raised the concern that the backend session has a hard 5-minute lifetime+ Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#168 raised the concern that the backend session has a hard 5-minute lifetime
Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 7-7: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0018-session-token-lifecycle.md` at line 7, Update the line
beginning with “#168” in the session token lifecycle document so it no longer
starts with a bare hash; prefix the reference with descriptive text or escape
the hash while preserving the issue reference and sentence meaning.

Source: Linters/SAST tools

@AndresL230
AndresL230 deleted the fix/session-token-lifecycle branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backend session token has a hard 5-minute lifetime with no refresh path (verify)

2 participants

@Jose-Gael-Cruz-Lopez@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168) - #255

Merged
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle
Jul 15, 2026
Merged

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168)#255
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 22, 2026

Copy link
Copy Markdown
Member

#168 was filed as a flagged "verify" finding: does the backend session really die after 5 minutes with no refresh? I traced the full path across both services. The worst case is not real — the 300s token is only a one-shot redirect handoff.

What actually happens

  1. Backend OAuth callback mints a short-lived HMAC token (exp = now + 300) and redirects to the frontend with ?auth_token=….
  2. The frontend session BFF (frontend/src/app/api/auth/session/route.ts) verifies it and re-mints a 30-day token (SESSION_MAX_AGE = 2592000) in a byte-identical format, set as the httpOnly/Securesapling_session cookie (scoped via COOKIE_DOMAIN).
  3. auth_guard._decode_session reads that cookie, verifies the HMAC with the shared SESSION_SECRET, and accepts it for the full 30 days.

So sessions persist 30 days, not 5 minutes. It holds as long as SESSION_SECRET matches across services and COOKIE_DOMAIN reaches the backend subdomain (both documented).

Changes

  • backend/routes/auth.py — named the magic 300 as _REDIRECT_TOKEN_TTL_SECONDS (env-overridable via SAPLING_AUTH_REDIRECT_TOKEN_TTL) and corrected the comment to say it's the redirect-handoff TTL, not the session TTL.
  • docs/decisions/0018-session-token-lifecycle.md — documents the full lifecycle, the verification outcome, the operational preconditions, and follow-ups (sliding refresh is a frontend-BFF concern, out of scope).
  • backend/tests/test_auth_session_contract.py — locks the cross-service contract: a frontend-style 30-day token is accepted by the backend decoder; expired, tampered, and wrong-secret tokens are rejected; the redirect TTL stays short.

Verification

  • ruff check . clean; gated suite green (+6 new). The acceptance criterion ("confirmed behavior documented; sessions persist for a configured TTL") is met.

Closes#168.

Summary by CodeRabbit

  • New Features

    • Added configurable expiration for one-time authentication handoff tokens, with safe limits and warnings for invalid settings.
    • Preserved support for long-lived sessions and legacy authentication flows.
  • Bug Fixes

    • Improved handling of expired, tampered, or incorrectly signed session tokens.
  • Documentation

    • Documented session-token lifecycle, authentication flow, configuration requirements, and operational considerations.
  • Tests

    • Added coverage for session compatibility, token validation, expiration, and configuration behavior.

@coderabbitai

coderabbitaiBot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The OAuth callback now uses a configurable, bounded redirect-token TTL. Contract tests validate long-lived session cookies and token rejection behavior, while a decision record documents the frontend/backend session lifecycle and operational requirements.

Changes

Session token lifecycle

Layer / File(s)Summary
Configurable redirect token TTL
backend/routes/auth.py
The OAuth handoff token TTL is read from SAPLING_AUTH_REDIRECT_TOKEN_TTL, defaults to 300 seconds, clamps to 30–600 seconds, warns on malformed values, and is applied during Google callback redirects.
Session contract validation
backend/tests/test_auth_session_contract.py
Tests cover 30-day session cookies, legacy query-token acceptance, expiry and signature rejection, shared-secret validation, and redirect TTL parsing and clamping.
Session lifecycle decision record
docs/decisions/0018-session-token-lifecycle.md
Documents the short redirect token, 30-day frontend session cookie, cookie forwarding path, shared-secret and rewrite requirements, verified outcome, and follow-ups.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: verifying and documenting the session-token lifecycle and locking the cross-service contract.
Description check✅ PassedThe description covers the purpose, key changes, linked issue, and testing, which is mostly aligned with the repository template.
Linked Issues check✅ PassedThe PR addresses #168 by verifying the 300s token is only a redirect handoff, documenting the lifecycle, and adding contract tests.
Out of Scope Changes check✅ PassedThe added TTL config, ADR documentation, and auth contract tests are all directly related to the verified session-token lifecycle.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/session-token-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitUpdated (UTC)
❌ Deployment failed
View logs
frontend28674bfJun 22 2026, 04:32 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 24, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging350f1a7Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:50 AM

_REDIRECT_TOKEN_TTL_SECONDS read SAPLING_AUTH_REDIRECT_TOKEN_TTL with no
upper bound, so an operator (or a bad env) could set it to hours/days.
That token is one-shot and travels in the OAuth-callback URL, so a long
TTL widens the window in which an intercepted URL can be replayed to mint
a session — it is not the session itself (ADR 0018). Clamp to [30, 600]s
via a small pure helper and add a test that asserts an extreme override
is clamped, so the "stays short" invariant holds at runtime, not just for
the default (the previous test only checked the unset-env default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_REDIRECT_TOKEN_TTL_SECONDS parsed SAPLING_AUTH_REDIRECT_TOKEN_TTL with a
bare int() at module scope. routes/auth.py is imported at router-mount time,
so `SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc` — or, realistically, declaring the
var in Railway/Wrangler with no value — raised ValueError at import and
stopped the app from booting.
Parse it in _parse_redirect_ttl() with try/except ValueError, falling back to
the 300s default and logging a warning. _clamp_redirect_ttl's docstring said
it defended against "a misconfigured override" but only clamped range, never
parseability; the parse guard makes that claim true.
Add coverage for _parse_redirect_ttl: malformed overrides ("abc", "", None)
fall back to 300s with a warning instead of raising at import; well-formed
ones are still parsed and clamped to [30, 600].
Correct two claims the tests did not support:
- _mint's docstring said it signs "exactly like the backend mint AND the
frontend signSession". It does not: Python's json.dumps emits
{"user_id": "x", "exp": 1} (spaces), JS JSON.stringify emits
{"user_id":"x","exp":1} (none), so the payloads differ byte-wise. It is
harmless — the verifier HMACs the received payload_b64 opaquely and never
re-serializes — but _mint mirrors only the backend mint, and is itself a
third Python re-implementation, so the suite proves a Python-minted token
is accepted, not a real frontend-minted one. Say so rather than claiming to
"lock the cross-service contract".
- test_redirect_auth_token_query_param_is_accepted read as an endorsement of
a constraint the code does not enforce. _decode_session reads ?auth_token=
before the cookie with no ttl/purpose check, so a 30-day session token in
the query string is accepted identically — and tokens in URLs leak via
access logs, Referer, and history. No client sends it: the redirect token
goes to the frontend, which POSTs it to the BFF in a JSON body. Rename to
test_legacy_unused_auth_token_query_param_is_still_accepted and document it
as characterization, so removing the channel is a deliberate, visible edit.
0018 documented the wrong mechanism. It claimed COOKIE_DOMAIN covers both
subdomains "so the browser sends sapling_session to the backend on
cross-origin API calls (credentials: 'include')", and that a host-only cookie
would not reach the backend. Neither is true:
- lib/api.ts sets API_URL = '', so all ~135 fetchJSON call sites are
same-origin, and next.config.ts rewrites /api/:path* to BACKEND_URL
server-side. The cookie reaches the backend because that server-side hop
forwards the Cookie header. A host-only cookie would work fine.
- No browser-side cross-origin authed call exists. The four
NEXT_PUBLIC_API_URL fetches all omit credentials entirely, and middleware.ts
runs server-side with a hand-set Cookie header.
This mattered because an ADR is authoritative: as written it would teach a dev
to point an authed fetch at NEXT_PUBLIC_API_URL, reintroducing the 2026-06-30
onboarding-loop bug — which is live at page.tsx:619 (#339), now cited as a
cautionary example. It also contradicted frontend/.env.example, which tells
you to leave NEXT_PUBLIC_API_URL empty in production.
Replace the precondition with the genuinely load-bearing one the ADR never
mentioned: BACKEND_URL must be set at *build* time for the CF Worker
(next.config.ts bakes it into the rewrite), or /api/* falls back to localhost
and 500s. Demote COOKIE_DOMAIN to what it actually governs — the cookie's
domain attribute — noting it is set (wrangler.toml:19), so nothing is broken.
Also drop the inaccurate "byte-identical format" claim (the two mints are
interoperable, not identical) and record two follow-ups: removing the unused
?auth_token= channel, and a shared JSON fixture consumed by both test suites
as the real cross-service lock.
The example cited frontend/src/app/page.tsx:619 as a live bug tracked in
#339. It is neither: the onboarding call was fixed weeks ago and now routes
through submitOnboardingProfile() -> fetchJSON, and the file moved to
(public)/page.tsx under the route-group refactor. #339 was filed against a
419-commit-stale branch and has been closed as invalid.
Describe the 2026-06-30 bug in the past tense and point at the comment in
(public)/page.tsx that records the real fix, which is a stronger cautionary
example because it actually happened.
@AndresL230

Copy link
Copy Markdown
Collaborator

@Jose-Gael-Cruz-Lopez I pushed 4 commits to this branch as part of a review sweep. Fast-forward only — nothing of yours was rewritten. Happy to back any of it out.

First, the headline: your central claim is correct and I verified it independently. Sessions last 30 days, not 5 minutes. The 300s token is a one-shot redirect handoff; the BFF (route.ts:99-106) re-mints a 30-day token into an httpOnly/Secure/SameSite=Lax cookie that auth_guard._decode_session accepts. Closing #168 as "not a bug" is right. The code and tests are sound — I did not rewrite them. I also confirmed the tests are real rather than testing conftest's stub, by mutation: disabling the expiry check at auth_guard.py:47 fails test_expired_token_is_rejected; disabling signature verification at :34 fails both tamper tests. conftest.py:88 captures _real_decode_sessionbefore the monkeypatch, so it's the genuine decoder.

The changes are confined to the ADR prose, one test name, and one small guard.

09b95bd — the ADR aside, this was the only real code bug

auth.py:93-95 crashed the app at import:

_REDIRECT_TOKEN_TTL_SECONDS=_clamp_redirect_ttl(int(os.getenv("SAPLING_AUTH_REDIRECT_TOKEN_TTL", "300")))

Reproduced: SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc and = (empty) both raise ValueError at module import → the router fails to import → the app won't boot. Empty-string is realistic — declaring the var in Railway/Wrangler without a value does it. Fails closed, so not a security hole, but a nasty deploy-time footgun in the one code path this PR adds. _clamp_redirect_ttl's docstring already promised to defend against "a misconfigured override" but only clamped range, not parseability — now it lives up to it via _parse_redirect_ttl → warn + fall back to 300.

187f419 + 350f1a7 — the ADR documented the wrong mechanism (the one that mattered)

0018:47-51 said COOKIE_DOMAIN covers both subdomains so the browser sends sapling_session cross-origin with credentials: 'include', and that a host-only cookie wouldn't reach the backend. That's not the topology: api.ts:12 sets API_URL = '' and next.config.ts:35 rewrites /api/:path*${BACKEND_URL}/api/:path*server-side, so all 135 fetchJSON sites are same-origin and the Next rewrite forwards the Cookie header. A host-only cookie would work fine.

The genuinely load-bearing precondition — BACKEND_URL must be set at build time — wasn't mentioned at all. Concretely: it sits in wrangler's [vars] (worker runtime vars) while next.config.ts reads it at build time, so [vars] alone doesn't feed the rewrite → falls back to localhost → /api/* 500s. That's now the documented precondition, with COOKIE_DOMAIN demoted to what it actually governs (the domain attribute; still set at wrangler.toml:19 via sanitizeCookieDomain#190, so nothing is broken).

This mattered because the ADR contradicted frontend/.env.example:5-8, which documents the opposite. An ADR is the authoritative reference — as written it would teach the next person to point an authed fetch at NEXT_PUBLIC_API_URL and reintroduce the 2026-06-30 onboarding-loop bug.

350f1a7 is me fixing my own mistake. My first pass cited that bug as "live right now at page.tsx:619, tracked in #339." It is neither. That finding came from an agent reading this branch, which is 419 commits behind main — where the raw fetch genuinely still exists. On main it was fixed weeks ago: the file moved to (public)/page.tsx and routes through submitOnboardingProfile()fetchJSON, with a comment recording the fix. I filed #339 off that and have since closed it as invalid. The ADR now describes the bug in past tense and points at that comment — a better cautionary example, since it actually happened.

1c1adf5 — renamed a test that blessed an unused credential channel

_decode_session (auth_guard.py:19) reads request.query_params.get("auth_token")before the cookie. I traced every occurrence: auth.py:452 puts it in a redirect URL to the frontend; callback/page.tsx:55 reads it and POSTs it to the BFF in a JSON body. No client ever sends ?auth_token= to the backend.

test_redirect_auth_token_query_param_is_accepted + its comment ("arrives as ?auth_token while fresh") asserted a constraint the code doesn't enforce — a 30-day session token in ?auth_token= is accepted identically, and URL-borne tokens leak via access logs, Referer, and history. Renamed to test_legacy_unused_auth_token_query_param_is_still_accepted and redocumented as characterization, not endorsement. Channel left in place — removing it is a follow-up, and the renamed test makes that removal a visible edit rather than a silent contract break.

Also corrected the "byte-identical format" claim: proved by running both serializers that Python emits {"user_id": "user_alice", …} (spaces) and JS JSON.stringify emits none → different base64. Harmless (the verifier HMACs the received payload_b64 opaquely and never re-serializes), but _mint's docstring claiming it signs "exactly like the backend mint AND the frontend signSession" was false — it mirrors the backend exactly (auth.py:440 uses default json.dumps), not signSession.

One nuance that sharpens your case

"The browser never makes a cross-origin authed API call" is true, but for a sharper reason than I first wrote. NEXT_PUBLIC_API_URLis consumed in four places, and wrangler sets it to https://api.saplinglearn.com. Checking each: SignInModal's uses are top-level OAuth navigations (not fetches); middleware.ts runs server-side with a hand-set Cookie header; and the remaining fetches omit credentials entirely. So no cross-origin authed browser call exists — and COOKIE_DOMAIN genuinely isn't load-bearing. Had any of them passed credentials: 'include', COOKIE_DOMAINwould have been the mechanism and this ADR rewrite would be wrong.

Follow-ups (not done here)

  1. Remove the ?auth_token= channel — no client uses it; it accepts a 30-day token with no ttl/purpose check.
  2. A shared JSON fixture as the real cross-service lock. "Lock the cross-service contract" overstates what's here: _mint is a third Python re-implementation, so the test proves a Python-minted token is accepted, not a real frontend one. There's no frontend/src/lib/sessionToken.test.ts, and route.ts:29-68 hand-rolls verifyAuthToken as a duplicate of sessionToken.ts::verifySession — so there are two frontend copies that can drift. I softened the framing; a checked-in fixture consumed by both suites would close it properly.
  3. Assert the build-time BACKEND_URL precondition in CI — it's the one genuinely load-bearing config, it fails as a confusing 500 rather than a clear error, and nothing guards it.

Verification: ruff check . clean, 674 passed, auth contract 18 passed, and the import guard confirmed (TTL='abc'/'' → warn + 300; '86400' → 600; '60' → 60).

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. To restate what I said in the comment: the code and tests here were already sound, and the central finding is correct — sessions last 30 days, not 5 minutes, and #168 is properly closed as "not a bug." I verified the tests are real by mutation rather than reading (breaking the expiry check and the signature check each fail the right test). The changes above are the ADR mechanism fix, one import-time crash guard, and a test rename — nothing that touches the substance of your work. CI green on 350f1a7.

@AndresL230
AndresL230 merged commit 193a70f into mainJul 15, 2026
4 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/decisions/0018-session-token-lifecycle.md`:
- Line 7: Update the line beginning with “#168” in the session token lifecycle
document so it no longer starts with a bare hash; prefix the reference with
descriptive text or escape the hash while preserving the issue reference and
sentence meaning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5066a8b6-9219-4344-8b9f-e81226c0ea6d

📥 Commits

Reviewing files that changed from the base of the PR and between 32409a8 and 350f1a7.

📒 Files selected for processing (3)
  • backend/routes/auth.py
  • backend/tests/test_auth_session_contract.py
  • docs/decisions/0018-session-token-lifecycle.md


## The claim

#168 raised the concern that the backend session has a hard 5-minute lifetime

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the hash or add a prefix to prevent markdown linting errors.

Starting a line with #168 causes markdown linters to interpret it as a malformed ATX heading (triggering the MD018 rule). Consider escaping the hash or prefixing it with a word to ensure it renders correctly as text.

🛠️ Proposed fix
- `#168` raised the concern that the backend session has a hard 5-minute lifetime+ Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#168 raised the concern that the backend session has a hard 5-minute lifetime
Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 7-7: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0018-session-token-lifecycle.md` at line 7, Update the line
beginning with “#168” in the session token lifecycle document so it no longer
starts with a bare hash; prefix the reference with descriptive text or escape
the hash while preserving the issue reference and sentence meaning.

Source: Linters/SAST tools

@AndresL230
AndresL230 deleted the fix/session-token-lifecycle branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backend session token has a hard 5-minute lifetime with no refresh path (verify)

2 participants

@Jose-Gael-Cruz-Lopez@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168) - #255

Merged
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle
Jul 15, 2026
Merged

fix(auth): verify + document session-token lifecycle; lock the cross-service contract (#168)#255
AndresL230 merged 11 commits into
mainfrom
fix/session-token-lifecycle

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jun 22, 2026

Copy link
Copy Markdown
Member

#168 was filed as a flagged "verify" finding: does the backend session really die after 5 minutes with no refresh? I traced the full path across both services. The worst case is not real — the 300s token is only a one-shot redirect handoff.

What actually happens

  1. Backend OAuth callback mints a short-lived HMAC token (exp = now + 300) and redirects to the frontend with ?auth_token=….
  2. The frontend session BFF (frontend/src/app/api/auth/session/route.ts) verifies it and re-mints a 30-day token (SESSION_MAX_AGE = 2592000) in a byte-identical format, set as the httpOnly/Securesapling_session cookie (scoped via COOKIE_DOMAIN).
  3. auth_guard._decode_session reads that cookie, verifies the HMAC with the shared SESSION_SECRET, and accepts it for the full 30 days.

So sessions persist 30 days, not 5 minutes. It holds as long as SESSION_SECRET matches across services and COOKIE_DOMAIN reaches the backend subdomain (both documented).

Changes

  • backend/routes/auth.py — named the magic 300 as _REDIRECT_TOKEN_TTL_SECONDS (env-overridable via SAPLING_AUTH_REDIRECT_TOKEN_TTL) and corrected the comment to say it's the redirect-handoff TTL, not the session TTL.
  • docs/decisions/0018-session-token-lifecycle.md — documents the full lifecycle, the verification outcome, the operational preconditions, and follow-ups (sliding refresh is a frontend-BFF concern, out of scope).
  • backend/tests/test_auth_session_contract.py — locks the cross-service contract: a frontend-style 30-day token is accepted by the backend decoder; expired, tampered, and wrong-secret tokens are rejected; the redirect TTL stays short.

Verification

  • ruff check . clean; gated suite green (+6 new). The acceptance criterion ("confirmed behavior documented; sessions persist for a configured TTL") is met.

Closes#168.

Summary by CodeRabbit

  • New Features

    • Added configurable expiration for one-time authentication handoff tokens, with safe limits and warnings for invalid settings.
    • Preserved support for long-lived sessions and legacy authentication flows.
  • Bug Fixes

    • Improved handling of expired, tampered, or incorrectly signed session tokens.
  • Documentation

    • Documented session-token lifecycle, authentication flow, configuration requirements, and operational considerations.
  • Tests

    • Added coverage for session compatibility, token validation, expiration, and configuration behavior.

@coderabbitai

coderabbitaiBot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The OAuth callback now uses a configurable, bounded redirect-token TTL. Contract tests validate long-lived session cookies and token rejection behavior, while a decision record documents the frontend/backend session lifecycle and operational requirements.

Changes

Session token lifecycle

Layer / File(s)Summary
Configurable redirect token TTL
backend/routes/auth.py
The OAuth handoff token TTL is read from SAPLING_AUTH_REDIRECT_TOKEN_TTL, defaults to 300 seconds, clamps to 30–600 seconds, warns on malformed values, and is applied during Google callback redirects.
Session contract validation
backend/tests/test_auth_session_contract.py
Tests cover 30-day session cookies, legacy query-token acceptance, expiry and signature rejection, shared-secret validation, and redirect TTL parsing and clamping.
Session lifecycle decision record
docs/decisions/0018-session-token-lifecycle.md
Documents the short redirect token, 30-day frontend session cookie, cookie forwarding path, shared-secret and rewrite requirements, verified outcome, and follow-ups.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main change: verifying and documenting the session-token lifecycle and locking the cross-service contract.
Description check✅ PassedThe description covers the purpose, key changes, linked issue, and testing, which is mostly aligned with the repository template.
Linked Issues check✅ PassedThe PR addresses #168 by verifying the 300s token is only a redirect handoff, documenting the lifecycle, and adding contract tests.
Out of Scope Changes check✅ PassedThe added TTL config, ADR documentation, and auth contract tests are all directly related to the verified session-token lifecycle.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/session-token-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitUpdated (UTC)
❌ Deployment failed
View logs
frontend28674bfJun 22 2026, 04:32 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 24, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging350f1a7Commit Preview URL

Branch Preview URL
Jul 15 2026, 05:50 AM

_REDIRECT_TOKEN_TTL_SECONDS read SAPLING_AUTH_REDIRECT_TOKEN_TTL with no
upper bound, so an operator (or a bad env) could set it to hours/days.
That token is one-shot and travels in the OAuth-callback URL, so a long
TTL widens the window in which an intercepted URL can be replayed to mint
a session — it is not the session itself (ADR 0018). Clamp to [30, 600]s
via a small pure helper and add a test that asserts an extreme override
is clamped, so the "stays short" invariant holds at runtime, not just for
the default (the previous test only checked the unset-env default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_REDIRECT_TOKEN_TTL_SECONDS parsed SAPLING_AUTH_REDIRECT_TOKEN_TTL with a
bare int() at module scope. routes/auth.py is imported at router-mount time,
so `SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc` — or, realistically, declaring the
var in Railway/Wrangler with no value — raised ValueError at import and
stopped the app from booting.
Parse it in _parse_redirect_ttl() with try/except ValueError, falling back to
the 300s default and logging a warning. _clamp_redirect_ttl's docstring said
it defended against "a misconfigured override" but only clamped range, never
parseability; the parse guard makes that claim true.
Add coverage for _parse_redirect_ttl: malformed overrides ("abc", "", None)
fall back to 300s with a warning instead of raising at import; well-formed
ones are still parsed and clamped to [30, 600].
Correct two claims the tests did not support:
- _mint's docstring said it signs "exactly like the backend mint AND the
frontend signSession". It does not: Python's json.dumps emits
{"user_id": "x", "exp": 1} (spaces), JS JSON.stringify emits
{"user_id":"x","exp":1} (none), so the payloads differ byte-wise. It is
harmless — the verifier HMACs the received payload_b64 opaquely and never
re-serializes — but _mint mirrors only the backend mint, and is itself a
third Python re-implementation, so the suite proves a Python-minted token
is accepted, not a real frontend-minted one. Say so rather than claiming to
"lock the cross-service contract".
- test_redirect_auth_token_query_param_is_accepted read as an endorsement of
a constraint the code does not enforce. _decode_session reads ?auth_token=
before the cookie with no ttl/purpose check, so a 30-day session token in
the query string is accepted identically — and tokens in URLs leak via
access logs, Referer, and history. No client sends it: the redirect token
goes to the frontend, which POSTs it to the BFF in a JSON body. Rename to
test_legacy_unused_auth_token_query_param_is_still_accepted and document it
as characterization, so removing the channel is a deliberate, visible edit.
0018 documented the wrong mechanism. It claimed COOKIE_DOMAIN covers both
subdomains "so the browser sends sapling_session to the backend on
cross-origin API calls (credentials: 'include')", and that a host-only cookie
would not reach the backend. Neither is true:
- lib/api.ts sets API_URL = '', so all ~135 fetchJSON call sites are
same-origin, and next.config.ts rewrites /api/:path* to BACKEND_URL
server-side. The cookie reaches the backend because that server-side hop
forwards the Cookie header. A host-only cookie would work fine.
- No browser-side cross-origin authed call exists. The four
NEXT_PUBLIC_API_URL fetches all omit credentials entirely, and middleware.ts
runs server-side with a hand-set Cookie header.
This mattered because an ADR is authoritative: as written it would teach a dev
to point an authed fetch at NEXT_PUBLIC_API_URL, reintroducing the 2026-06-30
onboarding-loop bug — which is live at page.tsx:619 (#339), now cited as a
cautionary example. It also contradicted frontend/.env.example, which tells
you to leave NEXT_PUBLIC_API_URL empty in production.
Replace the precondition with the genuinely load-bearing one the ADR never
mentioned: BACKEND_URL must be set at *build* time for the CF Worker
(next.config.ts bakes it into the rewrite), or /api/* falls back to localhost
and 500s. Demote COOKIE_DOMAIN to what it actually governs — the cookie's
domain attribute — noting it is set (wrangler.toml:19), so nothing is broken.
Also drop the inaccurate "byte-identical format" claim (the two mints are
interoperable, not identical) and record two follow-ups: removing the unused
?auth_token= channel, and a shared JSON fixture consumed by both test suites
as the real cross-service lock.
The example cited frontend/src/app/page.tsx:619 as a live bug tracked in
#339. It is neither: the onboarding call was fixed weeks ago and now routes
through submitOnboardingProfile() -> fetchJSON, and the file moved to
(public)/page.tsx under the route-group refactor. #339 was filed against a
419-commit-stale branch and has been closed as invalid.
Describe the 2026-06-30 bug in the past tense and point at the comment in
(public)/page.tsx that records the real fix, which is a stronger cautionary
example because it actually happened.
@AndresL230

Copy link
Copy Markdown
Collaborator

@Jose-Gael-Cruz-Lopez I pushed 4 commits to this branch as part of a review sweep. Fast-forward only — nothing of yours was rewritten. Happy to back any of it out.

First, the headline: your central claim is correct and I verified it independently. Sessions last 30 days, not 5 minutes. The 300s token is a one-shot redirect handoff; the BFF (route.ts:99-106) re-mints a 30-day token into an httpOnly/Secure/SameSite=Lax cookie that auth_guard._decode_session accepts. Closing #168 as "not a bug" is right. The code and tests are sound — I did not rewrite them. I also confirmed the tests are real rather than testing conftest's stub, by mutation: disabling the expiry check at auth_guard.py:47 fails test_expired_token_is_rejected; disabling signature verification at :34 fails both tamper tests. conftest.py:88 captures _real_decode_sessionbefore the monkeypatch, so it's the genuine decoder.

The changes are confined to the ADR prose, one test name, and one small guard.

09b95bd — the ADR aside, this was the only real code bug

auth.py:93-95 crashed the app at import:

_REDIRECT_TOKEN_TTL_SECONDS=_clamp_redirect_ttl(int(os.getenv("SAPLING_AUTH_REDIRECT_TOKEN_TTL", "300")))

Reproduced: SAPLING_AUTH_REDIRECT_TOKEN_TTL=abc and = (empty) both raise ValueError at module import → the router fails to import → the app won't boot. Empty-string is realistic — declaring the var in Railway/Wrangler without a value does it. Fails closed, so not a security hole, but a nasty deploy-time footgun in the one code path this PR adds. _clamp_redirect_ttl's docstring already promised to defend against "a misconfigured override" but only clamped range, not parseability — now it lives up to it via _parse_redirect_ttl → warn + fall back to 300.

187f419 + 350f1a7 — the ADR documented the wrong mechanism (the one that mattered)

0018:47-51 said COOKIE_DOMAIN covers both subdomains so the browser sends sapling_session cross-origin with credentials: 'include', and that a host-only cookie wouldn't reach the backend. That's not the topology: api.ts:12 sets API_URL = '' and next.config.ts:35 rewrites /api/:path*${BACKEND_URL}/api/:path*server-side, so all 135 fetchJSON sites are same-origin and the Next rewrite forwards the Cookie header. A host-only cookie would work fine.

The genuinely load-bearing precondition — BACKEND_URL must be set at build time — wasn't mentioned at all. Concretely: it sits in wrangler's [vars] (worker runtime vars) while next.config.ts reads it at build time, so [vars] alone doesn't feed the rewrite → falls back to localhost → /api/* 500s. That's now the documented precondition, with COOKIE_DOMAIN demoted to what it actually governs (the domain attribute; still set at wrangler.toml:19 via sanitizeCookieDomain#190, so nothing is broken).

This mattered because the ADR contradicted frontend/.env.example:5-8, which documents the opposite. An ADR is the authoritative reference — as written it would teach the next person to point an authed fetch at NEXT_PUBLIC_API_URL and reintroduce the 2026-06-30 onboarding-loop bug.

350f1a7 is me fixing my own mistake. My first pass cited that bug as "live right now at page.tsx:619, tracked in #339." It is neither. That finding came from an agent reading this branch, which is 419 commits behind main — where the raw fetch genuinely still exists. On main it was fixed weeks ago: the file moved to (public)/page.tsx and routes through submitOnboardingProfile()fetchJSON, with a comment recording the fix. I filed #339 off that and have since closed it as invalid. The ADR now describes the bug in past tense and points at that comment — a better cautionary example, since it actually happened.

1c1adf5 — renamed a test that blessed an unused credential channel

_decode_session (auth_guard.py:19) reads request.query_params.get("auth_token")before the cookie. I traced every occurrence: auth.py:452 puts it in a redirect URL to the frontend; callback/page.tsx:55 reads it and POSTs it to the BFF in a JSON body. No client ever sends ?auth_token= to the backend.

test_redirect_auth_token_query_param_is_accepted + its comment ("arrives as ?auth_token while fresh") asserted a constraint the code doesn't enforce — a 30-day session token in ?auth_token= is accepted identically, and URL-borne tokens leak via access logs, Referer, and history. Renamed to test_legacy_unused_auth_token_query_param_is_still_accepted and redocumented as characterization, not endorsement. Channel left in place — removing it is a follow-up, and the renamed test makes that removal a visible edit rather than a silent contract break.

Also corrected the "byte-identical format" claim: proved by running both serializers that Python emits {"user_id": "user_alice", …} (spaces) and JS JSON.stringify emits none → different base64. Harmless (the verifier HMACs the received payload_b64 opaquely and never re-serializes), but _mint's docstring claiming it signs "exactly like the backend mint AND the frontend signSession" was false — it mirrors the backend exactly (auth.py:440 uses default json.dumps), not signSession.

One nuance that sharpens your case

"The browser never makes a cross-origin authed API call" is true, but for a sharper reason than I first wrote. NEXT_PUBLIC_API_URLis consumed in four places, and wrangler sets it to https://api.saplinglearn.com. Checking each: SignInModal's uses are top-level OAuth navigations (not fetches); middleware.ts runs server-side with a hand-set Cookie header; and the remaining fetches omit credentials entirely. So no cross-origin authed browser call exists — and COOKIE_DOMAIN genuinely isn't load-bearing. Had any of them passed credentials: 'include', COOKIE_DOMAINwould have been the mechanism and this ADR rewrite would be wrong.

Follow-ups (not done here)

  1. Remove the ?auth_token= channel — no client uses it; it accepts a 30-day token with no ttl/purpose check.
  2. A shared JSON fixture as the real cross-service lock. "Lock the cross-service contract" overstates what's here: _mint is a third Python re-implementation, so the test proves a Python-minted token is accepted, not a real frontend one. There's no frontend/src/lib/sessionToken.test.ts, and route.ts:29-68 hand-rolls verifyAuthToken as a duplicate of sessionToken.ts::verifySession — so there are two frontend copies that can drift. I softened the framing; a checked-in fixture consumed by both suites would close it properly.
  3. Assert the build-time BACKEND_URL precondition in CI — it's the one genuinely load-bearing config, it fails as a confusing 500 rather than a clear error, and nothing guards it.

Verification: ruff check . clean, 674 passed, auth contract 18 passed, and the import guard confirmed (TTL='abc'/'' → warn + 300; '86400' → 600; '60' → 60).

@AndresL230AndresL230 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. To restate what I said in the comment: the code and tests here were already sound, and the central finding is correct — sessions last 30 days, not 5 minutes, and #168 is properly closed as "not a bug." I verified the tests are real by mutation rather than reading (breaking the expiry check and the signature check each fail the right test). The changes above are the ADR mechanism fix, one import-time crash guard, and a test rename — nothing that touches the substance of your work. CI green on 350f1a7.

@AndresL230
AndresL230 merged commit 193a70f into mainJul 15, 2026
4 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/decisions/0018-session-token-lifecycle.md`:
- Line 7: Update the line beginning with “#168” in the session token lifecycle
document so it no longer starts with a bare hash; prefix the reference with
descriptive text or escape the hash while preserving the issue reference and
sentence meaning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5066a8b6-9219-4344-8b9f-e81226c0ea6d

📥 Commits

Reviewing files that changed from the base of the PR and between 32409a8 and 350f1a7.

📒 Files selected for processing (3)
  • backend/routes/auth.py
  • backend/tests/test_auth_session_contract.py
  • docs/decisions/0018-session-token-lifecycle.md


## The claim

#168 raised the concern that the backend session has a hard 5-minute lifetime

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the hash or add a prefix to prevent markdown linting errors.

Starting a line with #168 causes markdown linters to interpret it as a malformed ATX heading (triggering the MD018 rule). Consider escaping the hash or prefixing it with a word to ensure it renders correctly as text.

🛠️ Proposed fix
- `#168` raised the concern that the backend session has a hard 5-minute lifetime+ Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#168 raised the concern that the backend session has a hard 5-minute lifetime
Issue `#168` raised the concern that the backend session has a hard 5-minute lifetime
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 7-7: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0018-session-token-lifecycle.md` at line 7, Update the line
beginning with “#168” in the session token lifecycle document so it no longer
starts with a bare hash; prefix the reference with descriptive text or escape
the hash while preserving the issue reference and sentence meaning.

Source: Linters/SAST tools

@AndresL230
AndresL230 deleted the fix/session-token-lifecycle branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backend session token has a hard 5-minute lifetime with no refresh path (verify)

2 participants

@Jose-Gael-Cruz-Lopez@AndresL230