fix(oauth): a provider connect must send the credential the remote authenticates - #76
fix(oauth): a provider connect must send the credential the remote authenticates#76rimusz wants to merge 3 commits into
Conversation
…thenticates
Connecting any OAuth provider fails at the final step with a raw database
error shown in a user-facing dialog:
Connection Error
Failed to connect to Github: SQLITE_CONSTRAINT: NOT NULL constraint
failed: oauth_tokens.user_id
The OAuth handshake itself succeeds. The failure is on the remote's write:
it stores the token with user_id = NULL against a `user_id TEXT NOT NULL`
column, so SQLite refuses the row and the connection is lost after the user
has already authorised the app.
The remote is not reading the bearer token on these routes. Against
api.agnt.gg, POST /auth/callback returns that identical error with a valid
JWT, with a deliberately malformed JWT, and with no Authorization header at
all -- three requests that differ only in the credential all produce the
same row-level failure, so the credential is not what identifies the caller
there. Its CORS reply says what does:
access-control-allow-origin: http://localhost:3333 (echoed, not *)
access-control-allow-credentials: true
A server only echoes a specific origin and sets allow-credentials when it
expects cookie credentials on the request. The sign-in flow already talks to
that host that way -- store/auth/userAuth.js passes withCredentials on every
magic-link call -- but none of the OAuth connect/callback callsites did, so
the one request that has to be attributed to a user was the one request that
carried nothing the remote reads.
This sends credentials on those calls. Ten callsites, not the three a reader
of the composable would find: connectOAuthApp and the callback exchange are
duplicated across six screens (Connectors, Settings' OAuth manager, chat
integration health, onboarding, chat provider setup) in addition to the
shared composable and the service. Patching a subset would leave the fix
working or failing depending on which screen the user happened to click
Connect from, which is worse than not fixing it, so all ten are changed
together.
WHAT IS NOT VERIFIED HERE. No Set-Cookie was observed from api.agnt.gg on
any endpoint reachable without completing a fresh sign-in, so the existence
and attributes of the session cookie are inferred from the CORS
configuration and the sign-in code, not observed. If that cookie turns out
to be SameSite=Lax it will not be sent cross-site and this change is inert.
It cannot regress anything either way: it adds a credential to requests to
the app's own auth host and changes no control flow.
The server-side defect stands regardless of this change and is the more
important one: /auth/callback accepts a request it cannot attribute to any
user, attempts the insert anyway, and returns the storage error rather than
refusing with 401. That is why this took a database error message to
diagnose instead of an auth one.
Test: the request shape for the callback exchange was already pinned, so it
is updated, and the cookie is asserted separately -- a refactor of the
options object cannot drop it without a failing test that names what broke.
Removing withCredentials from the service fails both.
…orget
The previous commit changed ten call sites. Only one of them -- the service
-- had a test; the other nine were found by reading. That is the same
position the codebase was in before the bug, so the same omission can happen
again the next time `connectOAuthApp` is copy-pasted into a new screen.
This derives the call sites from the source rather than from a list: any
call to `${API_CONFIG.REMOTE_URL}/auth/connect/` or `/auth/callback` must
send credentials, whichever client it uses. Run against the tree without the
fix it names all ten, which is also an independent check on the claim that
ten is the whole set -- it was reached by scanning, not by the grep that
found them originally.
Following apiAuthContract.spec.js, whose reason for existing is the same
shape: a rule that no single module owns, copy-pasted until someone forgets
it.
Three details worth their weight, all of them failure modes of this kind of
guard rather than of the code:
- Call text is paren-matched, not a fixed-width window. Connectors.vue holds
two of these calls; a window bleeds into the next one and lets a
credentialed neighbour vouch for an uncredentialed call. That is a false
negative, the only direction that matters here, so there is a self-test
for exactly it.
- The backward scan for the enclosing call counts depth, because the URLs
embed `encodeURIComponent(...)` and a naive search finds that instead of
the fetch.
- A minimum call-site count is asserted. If `API_CONFIG.REMOTE_URL` is ever
renamed the detector would find nothing and the contract would pass
vacuously, which is worse than failing.
There was a problem hiding this comment.
🟡 Changes recommended
The new contract test can yield false negatives because it treats comment text as satisfying the credential flag check, weakening the intended CI protection.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes OAuth provider connection failures by ensuring all frontend calls to the remote auth host (/auth/connect/* and /auth/callback) include cookie credentials (credentials: 'include' / withCredentials: true) so the remote can attribute the request to the signed-in user session.
Changes:
- Add
credentials: 'include'to allfetch()call sites hitting the remote OAuth connect/callback routes. - Add
withCredentials: trueto the axios-based callback exchange and update its unit tests accordingly. - Add a guard test (
oauthCredentialContract.spec.js) that scans the source tree to prevent future regressions where a new call site forgets to include credentials.
File summaries
| File | Description |
|---|---|
| frontend/src/views/Terminal/RightPanel/types/ChatPanel/components/IntegrationHealth.vue | Include cookie credentials on remote /auth/connect/* fetch. |
| frontend/src/views/Terminal/CenterPanel/screens/Settings/components/_OauthManager/OauthManager.vue | Include cookie credentials on remote /auth/connect/* and /auth/callback fetches. |
| frontend/src/views/Terminal/CenterPanel/screens/Connectors/Connectors.vue | Include cookie credentials on remote /auth/connect/* and /auth/callback fetches. |
| frontend/src/views/Terminal/CenterPanel/screens/Chat/components/ProviderSetup.vue | Include cookie credentials on remote /auth/connect/* fetch. |
| frontend/src/views/_components/utility/OAuthCallback.vue | Include cookie credentials on remote /auth/callback fetch. |
| frontend/src/services/providerAuthService.js | Add withCredentials: true for axios POST to remote /auth/callback. |
| frontend/src/services/providerAuthService.spec.js | Update assertions and add coverage to ensure withCredentials is preserved. |
| frontend/src/composables/useProviderConnection.js | Include cookie credentials on remote /auth/connect/* fetch. |
| frontend/src/components/OnboardingModal.vue | Include cookie credentials on remote /auth/connect/* fetch. |
| frontend/src/guards/oauthCredentialContract.spec.js | Add source-scanning contract test enforcing credentials on remote OAuth routes. |
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const open = openingParenBefore(source, at); | ||
| const call = open === -1 ? '' : callTextAt(source, open); | ||
| const sendsCredentials = | ||
| /credentials:\s*['"]include['"]/.test(call) || /withCredentials:\s*true/.test(call); |
There was a problem hiding this comment.
Confirmed and fixed in 741ffb8 — thank you, this was a real hole.
I reproduced it before changing anything. The detector returned sendsCredentials = true for:
await fetch(`${API_CONFIG.REMOTE_URL}/auth/connect/${id}`, {
// credentials: 'include', <- disabled while debugging
headers: { Authorization: 'x' },
});A guard whose whole purpose is catching an omission must not be satisfied by a comment describing the thing it checks for.
One wrinkle worth recording: the obvious fix is unsafe. A //.*$ sweep turns
'github:http://localhost:3333' into 'github:http:
which corrupts the source being read and would delete any flag sitting after a URL on the same line — a false positive, the opposite error. Since every one of these call sites is built around a URL, that would have bitten quickly. So stripComments is string/template-aware and skips over quoted regions.
Three self-tests now cover both directions: line-commented and block-commented flags must fail the contract, and a URL inside a string must still read as compliant. Verified by mutation — reducing stripComments to the identity function fails exactly the two comment tests, and the contract still names all ten call sites when run against the tree without the fix.
Known limit, documented in the source: a regex literal containing // would confuse it. None of these call sites contain one, and that would surface as a loud failure rather than a silent pass.
Copilot's review caught a real hole in the guard added by the previous
commit: the flag was matched against the raw call text, comments included, so
// credentials: 'include',
left behind by someone debugging read as compliant. Confirmed before fixing
-- the detector returned sendsCredentials = true for exactly that input. A
guard whose entire purpose is catching an omission cannot be satisfied by a
comment describing the thing it is checking for.
The strip has to be string-aware rather than a `//.*$` sweep. These call
sites are built around URLs, and a naive strip turns
'github:http://localhost:3333' into 'github:http:
which corrupts the source the guard is reading and would delete any flag
sitting after a URL on the same line. That is a false positive -- the
opposite error, and the one that makes people stop trusting a guard and
delete it. Both directions now have self-tests: two for commented-out flags
(line and block), one for a URL inside a string that must still be seen as
compliant.
Verified in both directions: with stripComments reduced to the identity
function the two comment self-tests fail; run against the tree without the
fix the contract still names all ten call sites.
|
Thanks for this one — and for flagging your own uncertainty in "What is not verified". That note is the reason this got checked properly rather than merged on plausibility. The premise doesn't hold, so I'm closing this. The remote does read the bearer on these routes: // api.agnt.gg/src/routes/AuthRoutes.js
router.post('/callback', authenticateToken, rateLimiter, AuthController.handleCallback);
// api.agnt.gg/src/routes/Middleware.js — authenticateToken
const token = req.headers['authorization'].split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = { id: decoded.id, ... };
The I also read all ten call sites this PR touches — every one already sends Your diagnosis in #75 was right, and the real fix has shipped server-side. The cause was that Fixed by putting the hard guard on the four routes that read caller identity, plus proper error mapping (401/404/502/500 with a stable Genuinely appreciate the work in this series — thank you. |
Prepares the client and the tenant path for the token-proof flip, and writes
down what the /auth surface now actually does.
remoteTokenVerifier — THE BLOCKER
A hosted tenant delegates verification to api.agnt.gg, and every non-2xx from
the issuer was classified "unreachable". Once token-proof enforcement is
turned on, a token without a proof claim starts getting 401 there — which
would have meant:
- the stale-grace window kept serving a REFUSED token for up to 30 more
minutes, on exactly the installs that are reachable from the internet;
- the refusal was counted as remoteFail, so a wave of legitimate denials
would read on the dashboard as "the issuer is down";
- it was never cached as a denial, so a rejected client re-asked on every
request.
401/403 is now a denial: no grace, cached, counted as remoteDeny. 429 and 5xx
still mean UNKNOWN, because a rate-limited issuer must not log everyone out.
Four new cases fail against the previous code.
OAuth callback copy
POST /auth/callback answers a stable `reason`; the client rendered
`errorData.error` verbatim, which is what put a SQLite constraint string in
front of a user. New services/oauthCallbackErrors.js maps each reason to a
sentence that ends in an instruction, shared by the two consumers. A body with
no reason falls back rather than surfacing prose nobody wrote, and a transport
failure is passed through untouched — axios already describes it accurately.
OAuthCallback.spec.js asserted the raw server string appeared on screen, so it
was pinning the defect. It now asserts the opposite, plus that no driver string
can reach the screen and that a non-JSON error body still renders.
NOT DONE, DELIBERATELY: `reason: 'proof'` was not added to the axios
interceptor. That interceptor is scoped to BASE_URL, the local backend verifies
with the shared secret and cannot produce that reason, and every path that can
already resolves correctly — desktop's fetchUserData classifies the remote 401
as http_401 (definitive), and a hosted tenant now returns 'invalid' via the fix
above. Adding it would have been inert, which is the same mistake #76 made.
Documentation
- The two middlewares are distinguished: `authenticateToken` identifies and
continues, `requireAuth` refuses. The doc previously said "Required" for both.
- The `provider:nonce:origin` state format, and why the nonce is in the middle.
- The full callback reason vocabulary, with which statuses are retryable.
- Remote Feedback Routes: live and entirely undocumented until now.
- Stream base path corrected from /streams to /stream — verified against
production, /streams 404s.
- The /start, /stop and /:id/status contract change from #71 and #73, including
that the SDK is axios and will now throw where it used to swallow.
- 4 further undocumented live routes closed. A parse-the-router audit now
reports 35/35 documented and 0 broken TOC anchors.
Backend 4735 tests (1 pre-existing flake in runJournal.heartbeat, filed,
passes 13/13 in isolation). Frontend 3943, all pass.
Connecting any OAuth provider fails at the last step with a raw database error in a user-facing dialog, after the user has already authorised the third-party app:
The handshake with the provider succeeds. The failure is on the remote's write: it stores the token with
user_id = NULLagainst auser_id TEXT NOT NULLcolumn.The remote does not read the bearer token on these routes. Against
api.agnt.gg,POST /auth/callbackreturns that identical error with a valid JWT, with a deliberately malformed JWT, and with noAuthorizationheader at all — three requests differing only in the credential, one response. Its CORS reply says what it does read:A server only echoes a specific origin and sets
allow-credentials: truewhen it expects cookie credentials. Sign-in already talks to that host that way —store/auth/userAuth.jspasseswithCredentials: trueon every magic-link call — but none of the OAuth call sites did. The one request that has to be attributed to a user was the one carrying nothing the remote reads.What this changes
credentials: 'include'(fetch) /withCredentials: true(axios) on every call to/auth/connect/and/auth/callback.Ten call sites, not three.
connectOAuthAppand the callback exchange are copy-pasted across six screens:views/_components/utility/OAuthCallback.vue:67services/providerAuthService.js:84…/screens/Connectors/Connectors.vue:2083…/_OauthManager/OauthManager.vue:360composables/useProviderConnection.js:331…/screens/Connectors/Connectors.vue:1209…/_OauthManager/OauthManager.vue:223…/ChatPanel/components/IntegrationHealth.vue:356components/OnboardingModal.vue:590…/Chat/components/ProviderSetup.vue:190Fixing a subset would be worse than fixing none: connecting would work or fail depending on which screen the user clicked Connect from. I found this the hard way — my first pass patched three, and inspecting the built bundle showed the Connectors chunk still shipping an unpatched call.
What is not verified
This is a hypothesis fix and should be reviewed as one. No
Set-Cookieis observable fromapi.agnt.ggon any endpoint reachable without completing a fresh sign-in, so the existence andSameSiteattributes of the session cookie are inferred from the CORS configuration and the sign-in code, not observed. If that cookie isSameSite=Laxit will not be sent cross-site and this change is inert.A maintainer can settle this instantly — does
/auth/callbackresolve the user from a session cookie? If no, close this; the real bug is entirely #75.It cannot regress anything either way: it adds a credential to requests to the app's own auth host and changes no control flow.
The server-side defect stands regardless
Filed as #75.
/auth/callbackaccepts a request it cannot attribute to any user, attempts the insert anyway, and returns the storage error rather than refusing with401. Reproducible with onecurland no account. That is why this took a database error to diagnose instead of an auth one — same shape as #71, where an infrastructure failure was reported as a domain value.Tests
frontend/src/services/providerAuthService.spec.jsalready pinned the exact request shape for the callback exchange, so it correctly failed on this change and is updated. The cookie is additionally asserted on its own, so a refactor of the options object cannot drop it without a failing test that names what broke. RemovingwithCredentialsfrom the service fails both (verified).That covers 1 of the 10 call sites. The second commit adds
frontend/src/__guards__/oauthCredentialContract.spec.js, followingapiAuthContract.spec.js, whose reason for existing is the same shape: a rule no single module owns, copy-pasted until someone forgets it. It derives the call sites from the source, so a seventh copy cannot be added without either carrying the credential or failing CI.Run against the tree without the fix, the guard names all ten — an independent check on the claim that ten is the whole set, since it scans rather than reusing the grep that found them:
Three of its self-tests target failure modes of the guard rather than of the code: call text is paren-matched rather than windowed (Connectors.vue holds two calls, and a window lets a credentialed neighbour vouch for an uncredentialed one — a false negative, the only direction that matters); the backward scan counts depth because the URLs embed
encodeURIComponent(...); and a minimum call-site count is asserted so that renamingAPI_CONFIG.REMOTE_URLcannot make the contract pass vacuously.Full frontend suite: 224 files / 3925 tests, all passing.
Review round
Copilot caught a real hole in the guard: the flag was matched against the raw call text, so a commented-out
// credentials: 'include',read as compliant. Confirmed by reproducing it, then fixed in741ffb81. The obvious fix is unsafe in the other direction — a//.*$sweep turns'github:http://localhost:3333'into'github:http:, corrupting the source and deleting any flag after a URL on the same line — so the strip is string-aware, and both directions now have self-tests.Relationship to other open PRs
Independent of #70–#74 — no shared files (those are backend workflow/plugin changes; this is frontend only). Branched from
origin/mainat2bffa7e2.