Repository navigation
oauth: three ways to supply the app — tokenExchangeUrl, ${secret:} client ids, google literal secret; sample + docs - #347
Merged
Merged
Conversation
…ient ids, google literal secret; sample + docs
Who supplies the OAuth app a login uses was undefined: a client id could only
be a literal or ${env:VAR} (which a host-spawned server never sees) and a
client secret only a ${secret:NAME} reference in the user's keystore, so every
provider that demands a secret silently required each user to register an app.
- Developer-provided: literal clientId; no secret (public client), a literal
clientSecret for a preset whose provider documents the installed-app secret
as not confidential (google, `clientSecretPublic`), or `tokenExchangeUrl` —
the developer's own token exchange service. The engine sends the code
exchange, refresh and device polling there with client_id and never a
secret; the service adds it and returns the provider's answer unchanged.
Device authorization and revocation stay with the provider as a public
client. `auth login` prints "Tokens are exchanged through <url> (the
connector's token exchange service)".
- User-registered: clientId may be exactly one ${secret:NAME} reference (each
user stores the id with `secrets set`). install warns per unset name
(`login "<key>" (<provider>) references secret "<NAME>" which is not set —
run `secrets set <NAME>` before `auth login <key>`), doctor's `<id>: logins`
reports `secrets not set for login(s) …` with the fix line, `auth login`
exits 1 with the SecretResolutionError message.
- Validation (ConnectorConfigError): clientId message reworded; a literal
clientSecret only for google; tokenExchangeUrl must be an https URL (loopback
http passes); clientSecret and tokenExchangeUrl are exclusive. Every endpoint
URL, tokenExchangeUrl included, is refused when it carries userinfo, a
fragment or a control character, at define time and at resolve time.
- Hardening: token, device and revocation POSTs never follow a redirect
(`redirect: "error"`; every preset endpoint answers without one); the
sanitizer also drops Unicode format characters; a fetch failure keeps its
cause; one `wholeSecretRefName` parser in core/secrets for define time and
resolve time; the store is opened once per resolution.
- examples/seo-connector: google literal id + literal public secret
(`GOCSPX-replace-me`), posthog literal metadata-document URL, bing
user-registered (with the tokenExchangeUrl variant commented), plus a runnable
token-exchange-service.mjs (node:http; refuses foreign client ids, repeated
parameters, unknown grants and inbound secrets; forwards only the accepted
grant's parameters; no redirects; 20 s upstream timeout; body-free logs).
- Docs: README OAuth paragraph + presets notes, llms.txt bullet, llms-full
(field, messages, "Who supplies the app", the wire contract and duties,
auth/install/doctor strings, §9.1), site Operate guide section 8 (supply-mode
table, `ships` per preset, wire contract), authoring reference.
- Tests: define-connector-oauth, oauth-presets, oauth-engine (relay, redirect,
secret client id, literal secret, loginSecretNames), oauth-modules,
installer-oauth, cli/auth, cli/doctor-oauth, docs pins that drive
installConnector and doctor's loginsCheck, seo-connector smoke (10, incl. the
sample service end to end against the mock provider), shared
tests/support/oauth-fixtures.ts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaBc6LGBc3UYz54jY5m16P
…points, docs pins drive install and doctor, README env row - defineConnector answers `<where>.<field>: must not carry credentials or a fragment` for an https endpoint with userinfo or a fragment (a control character keeps `must be an https URL`); documented in llms-full and pinned. - tests/docs/oauth-docs: the doctor and install pins run doctor's loginsCheck and installConnector in a throwaway root (shared withTempRoot harness) — no source grep left for those strings. - examples/seo-connector/README: the TOKEN_EXCHANGE_TOKEN_ENDPOINT row states the https / loopback rule and that credentials and fragments are refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaBc6LGBc3UYz54jY5m16P
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Who supplies the OAuth app was undefined after #344/#346: a login's client id could only be a literal or
${env:VAR}(a host-spawned server never sees a shell export, so the first refresh failed), and a client secret only a${secret:NAME}reference in the user's keystore — so every provider that demands a secret silently required each end user to register their own app. This PR makes the three ways explicit and supported, without the project hosting any relay.1. Developer-provided (zero user setup)
clientId; no secret for a public client (Entra, PostHog, GitHub device flow);clientSecretonly for a preset whose provider documents the installed-app secret as not confidential —google(OAuthPreset.clientSecretPublic; the Google sentence is quoted in the preset);tokenExchangeUrl— the developer's own token exchange service. The engine sends the authorization-code exchange, the refresh and device-code polling there withclient_idand never a secret; the service adds the secret and returns the provider's status + body unchanged. Device authorization and revocation stay with the provider as a public client.auth loginprintsTokens are exchanged through <url> (the connector's token exchange service).2. User-registered
clientId: "${secret:NAME}"(+clientSecret: "${secret:NAME}"): each user registers their own app and stores both withsecrets set. The tools name what is still missing:install:login "<key>" (<provider>) references secret "<NAME>" which is not set — run \secrets set ` before `auth login `` (before the absent-login line; exit 1 by the warn convention)doctor<id>: logins:secrets not set for login(s) <keys>: <names> — run secrets set <name>+ fixrun \secrets set --connector-id ` for each of: `auth login: exit 1 with theSecretResolutionErrormessage.3. Validation and hardening
clientIdmessage reworded; a literalclientSecretis rejected for every preset but google (… or a non-empty literal (provider "google" documents an installed app's client secret as not confidential));tokenExchangeUrlmust be an https URL (loopback http passes, as for every endpoint);clientSecretandtokenExchangeUrlare exclusive.tokenExchangeUrlincluded) is refused when it carries userinfo, a fragment or a control character — at define time and at resolve time; echoed values are sanitized (the sanitizer now also drops Unicode format characters).redirect: "error") — a 307/308 would otherwise re-send the code + verifier or the refresh token to whateverLocationnames. Checked first: every preset token/device/revocation endpoint answers a credential-free POST with 400/404, none with a 3xx. A fetch failure keeps its cause (network error: fetch failed (unexpected redirect)).wholeSecretRefNameparser (core/secrets) for define time and resolve time; the store is opened once per resolution.Sample and docs
examples/seo-connector: google = literal id + literal public secret (GOCSPX-replace-me), posthog = literal metadata-document URL, bing = user-registered (thetokenExchangeUrlvariant commented), and a runnabletoken-exchange-service.mjs(node:http; own client id only, the three grants only, refuses repeated parameters and inbound secrets, forwards only the accepted grant's parameters, no redirects, 20 s upstream timeout, https upstream required, body-free one-line logs).Tests
Built with the trio process (contract → 3 implementers → security + correctness reviewers → findings applied → delta re-check). New/extended: define-connector-oauth (25), oauth-presets (45), oauth-engine (36: relay end to end, redirect fails closed,
${secret:}client id, literal secret,loginSecretNames), oauth-modules, installer-oauth (13), cli/auth (26), cli/doctor-oauth (11), docs pins that runinstallConnectorand doctor'sloginsCheck(no source grep), seo-connector smoke (10, incl. the sample service against the mock provider), sharedtests/support/oauth-fixtures.ts. README test-file count stays 174.Verification
vitestlocally: every file passes excepttests/cli/sdk.test.ts(8 cases), which fails only on this machine (pre-existing, unrelated).tsc,npm run build, sitetsc -b+npm run build: clean.Not exercised here: a live login against a real provider (the user's Bing app is the acceptance check) — the engine paths are covered by the mock provider and the relay tests.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UaBc6LGBc3UYz54jY5m16P