Skip to content

oauth: three ways to supply the app — tokenExchangeUrl, ${secret:} client ids, google literal secret; sample + docs - #347

Merged
ken-jo merged 2 commits into
mainfrom
feat/oauth-supply
Sep 10, 2026
Merged

ken-jo merged 2 commits into
mainfrom
feat/oauth-supply

Conversation

@ken-jo

@ken-jo ken-jo commented Sep 10, 2026

Copy link
Copy Markdown
Owner

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)

  • literal clientId; no secret for a public client (Entra, PostHog, GitHub device flow);
  • a literal clientSecret only for a preset whose provider documents the installed-app secret as not confidential — google (OAuthPreset.clientSecretPublic; the Google sentence is quoted in the preset);
  • or tokenExchangeUrl — the developer's own token exchange service. The engine sends the authorization-code exchange, the refresh and device-code polling there with client_id and 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 login prints Tokens 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 with secrets 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> + fix run \secrets set --connector-id ` for each of: `
  • auth login: exit 1 with the SecretResolutionError message.

3. Validation and hardening

  • clientId message reworded; a literal clientSecret is rejected for every preset but google (… or a non-empty literal (provider "google" documents an installed app's client secret as not confidential)); tokenExchangeUrl must be an https URL (loopback http passes, as for every endpoint); 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; echoed values are sanitized (the sanitizer now also drops Unicode format characters).
  • Token, device and revocation POSTs never follow a redirect (redirect: "error") — a 307/308 would otherwise re-send the code + verifier or the refresh token to whatever Location names. 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)).
  • One wholeSecretRefName parser (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 (the tokenExchangeUrl variant commented), and a runnable token-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).
  • README OAuth paragraph + presets notes, llms.txt bullet, llms-full (field, messages, "Who supplies the app", the wire contract with the three request bodies and the service duties, auth/install/doctor strings, §9.1), site Operate guide §8 (supply-mode table, "ships" line per preset, wire contract + trust boundary), authoring reference.

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 run installConnector and doctor's loginsCheck (no source grep), seo-connector smoke (10, incl. the sample service against the mock provider), shared tests/support/oauth-fixtures.ts. README test-file count stays 174.

Verification

  • Full vitest locally: every file passes except tests/cli/sdk.test.ts (8 cases), which fails only on this machine (pre-existing, unrelated).
  • Root tsc, npm run build, site tsc -b + npm run build: clean.
  • Stub / history-wording scan of every added line: nothing.

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

ken-jo and others added 2 commits September 10, 2026 23:18
…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
@ken-jo
ken-jo merged commit 539eba1 into main Sep 10, 2026
4 checks passed
@ken-jo
ken-jo deleted the feat/oauth-supply branch September 10, 2026 14:24
@ken-jo ken-jo mentioned this pull request Sep 11, 2026
Sign up for free to 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.

1 participant