Skip to content

feat(oauth): self-service tenant OAuth clients - #1489

Closed
jhgaylor wants to merge 2 commits into
mainfrom
feat/oauth-self-service-1125
Closed

feat(oauth): self-service tenant OAuth clients#1489
jhgaylor wants to merge 2 commits into
mainfrom
feat/oauth-self-service-1125

Conversation

@jhgaylor

@jhgaylor jhgaylor commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • let an account register and manage OAuth clients through the console, API, and CLI
  • keep new clients in owner-only development mode until an operator publishes them
  • derive browser CORS origins from registered redirects and support RFC 8252 loopback ports
  • narrow consent CSP to the validated redirect and record client audit events
  • keep operator-configured clients authoritative and prevent owners from editing published registrations
  • update ADR 0021, user docs, generated CLI docs, OpenAPI, and SDK contracts

This replaces #1265 with an implementation based on current main and the extracted Managoat.OAuth state machine.

Closes #1125.

Verification

  • 150 focused OAuth, CORS, API, LiveView, audit, and schema tests pass
  • compile with warnings as errors, Credo, Dialyzer, and Sobelow pass
  • Go tests and vet pass
  • TypeScript: contract verification, typecheck, and 101 tests pass
  • Python, Elixir, and Swift SDK contract checks pass
  • docs style, Vale, and destink pass
  • full Elixir suite ran 4,226 tests; 9 parallel database checkout timeouts all passed when rerun serially

Review of #1489 found four defects and one missing ceiling.

An owner could delete an operator-published client. `update_client/3`
guarded publication, `delete_client/2` did not, and the row stays owned
after publication, so the owner could remove a registration every account
signs in through. The `client_id` is random, so nobody could recreate it.
`delete_client/2` now refuses, the console hides Delete on a published
client the way it already hid Edit, and the operation declares the 422.

`get_client_record/2` cast a raw path segment to `:binary_id`, so
`/api/oauth/clients/foo` raised a CastError that phoenix_ecto turned into a
400 rather than the 404 the operation documents. It takes the same
`valid_uuid?/1` guard `Fountain.Connections` uses.

The clients LiveView had no `save` clause for `editing: nil`, so a submit
arriving behind the cancel that closed the form killed the view with a
CaseClauseError. It is a no-op now.

The migration was numbered 20260829010000, before five migrations already
merged. Renumbered to sort last, so `--strict-version-order` and
`ecto.rollback -n` still walk the repo in authoring order.

Registration was unbounded, and every row widens the deployment CORS
allowlist. Twenty-five clients per account, an abuse ceiling rather than an
allowance, matching how TEAM_CONTACT_CEILING reads.

The global-CORS and any-port loopback behaviour the review also raised is
ADR 0021's deliberate decision, and stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qk6BWNeqca5SAh7nWeJhg8

@BinaryBourbon BinaryBourbon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes — the branch is too far behind to review, and there's a validation claim in the description I don't want to let pass.

210 commits behind main, conflicting in 10 files, including sdk/contract/contract.json, sdk/contract/omissions.json, sdk/typescript/src/generated/openapi.ts, both SDK version files, docs/api.md and audit_guardrail_test.exs. The last full run here was September 3rd, so every green check is against a main that has since taken the acp runtime stack, the apply-kinds and labels stacks, and two SDK minor releases. Those checks aren't evidence any more.

The contract and generated-openapi conflicts are the ones to be careful with: contract.json and openapi.ts are both generated, so resolving them by hand produces something that looks plausible and is wrong. They need regenerating on the rebased tree, not merging.

~3,074 lines across 41 files. This is the largest open PR we have, and it's past the line we just drew — #1678 was closed and re-cut as nine PRs at less than half this size. The seams here are unusually clean: the client registry + migration, development-vs-published mode, CORS origins derived from redirects (RFC 8252 loopback included), the consent CSP narrowing, the audit events, then console / API / CLI / docs. Any one of those is a reviewable PR. All of them together is not.

Separately, this line needs following up rather than shipping:

full Elixir suite ran 4,226 tests; 9 parallel database checkout timeouts all passed when rerun serially

Nine checkout timeouts is not a rerun-and-move-on result. CLAUDE.md is explicit that a test which fails and then passes with no code change gets investigated and filed, and pool exhaustion in particular is called out — the pool is 20 and the instruction is not to lower it precisely because this is what it looks like when something holds connections too long. Nine at once, in a PR that adds OAuth client registration and LiveView surfaces, is more likely to be this branch holding checkouts than ambient flakiness. Worth finding out which before it becomes someone else's intermittent CI failure.

Recommendation: close and re-cut on current main as a stack. If you keep it open, rebase first and regenerate the contract artifacts, then I'll review it properly.

@jhgaylor

Copy link
Copy Markdown
Collaborator Author

Re-cut as a stack of nine on main: #1875 to #1885, under the no-big-PRs rule. Closing this one in favour of them.

main had moved 210 commits, so none of this is a rebase or a cherry-pick — every piece was re-derived against today's tree and re-tested there.

PR branch covers
#1875 stack/1125-1-client-row the oauth_clients table and Fountain.OAuth.Client: generated client_id, the redirect-URI rules, the derived origin_keys lookup column
#1877 stack/1125-2-registry tenant-scoped CRUD on Fountain.OAuth, audited in the context, the 25-per-account ceiling, the published-client refusals
#1878 stack/1125-3-development-mode the owner-only boundary, config-wins client lookup, RFC 8252 loopback matching, the consent page's development badge
#1879 stack/1125-4-cors CORS admits a registered client's redirect origins
#1880 stack/1125-5-consent-csp the form-action header narrowed to this request's validated redirect origin — its own PR, as a security boundary
#1882 stack/1125-6-api /api/oauth/clients, the OpenAPI schemas, the regenerated wire contract
#1883 stack/1125-7-console Account, then OAuth apps
#1884 stack/1125-8-cli fountain oauth-client, and the two CLI doc pages
#1885 stack/1125-9-docs ADR 0021's amendment, the manual, the app skill, SDK 1.28.0

Every decision in this PR is carried, including the ones the description does not spell out: the identity check running before the redirect check so a stranger's error page discloses no registration, the form-action header following the requested loopback port rather than the registered one, get_client_record/2 refusing a non-UUID so the documented 404 is reachable, and full scope rather than the sprite scope on the registration routes.

Three things changed.

  1. The sandbox-queue hunks are not carried. This branch was cut from the unmerged Queue sandbox requests at the concurrency cap instead of refusing them #1033 work, so its CHANGELOG.md, docs/configuration.md, sdk/contract/contract.json, sdk/contract/omissions.json and sdk/typescript/src/generated/openapi.ts diffs were mostly that feature rather than OAuth. Queue sandbox requests at the concurrency cap instead of refusing them #1033 is still its own change.

  2. The SDK contract gap is fixed. Because those files were all Queue sandbox requests at the concurrency cap instead of refusing them #1033, /api/oauth/clients appears in no SDK manifest and in no omissions entry here, which means scripts/sdk-contract/build.sh --check would have failed on this branch. feat(api): register and manage OAuth clients over /api (#1125) #1882 records the routes as a deliberate omission: registering an app is a once-per-app setup step a person does in the console or with fountain oauth-client, and an SDK caller's work starts after it, with the key the flow mints.

  3. The Go file would not compile. cli/internal/cmd/oauthclient.go imported github.com/BinaryBourbon/fountain/cli/internal/api. The module is github.com/managoat/fountain/cli now, and api has left internal/.

On the nine checkout timeouts in the description. They are worth more than the sentence they got, and they are not this branch. Three local full-suite runs, same machine, same private database: main at seed 424242 is green; this branch's changes applied to main at seed 424242 failed 2,918 of 4,920 tests; the identical tree at the identical seed then ran green. The failing run was a ConversationServer outliving its SQL Sandbox owner, crash-looping on DBConnection.OwnershipError, taking the Horde supervisor and then the whole :fountain application down — after which every remaining test failed on "could not lookup Ecto repo". That is #1862, and the evidence is on that issue rather than fixed inside this campaign. The nine timeouts reported here are the same family and were, correctly, never explained.

The stack's tip runs the full core suite at 5,007 tests, 0 failures.

🤖 Generated with Claude Code

https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW

@jhgaylor jhgaylor closed this Sep 11, 2026
jhgaylor added a commit that referenced this pull request Sep 11, 2026
`GET/POST /api/oauth/clients` and `GET/PATCH/DELETE /api/oauth/clients/:id`,
with the OpenAPI operations, the three schemas and the regenerated wire
contract.

Full scope, not the sprite scope #1125 first proposed. A registered client is
a standing way to obtain a full-scope thirty-day key with one consent, which
is exactly the escalation the sprite scope exists to prevent: a sandbox's
per-conversation token must not be able to leave one behind. Registering from
the console, the CLI or the API with the owner's own key still removes the
operator, which was the point.

Two details worth naming. Another account's client answers 404 rather than
403, like every other tenant-scoped resource, and so does an id that is not a
UUID -- casting a path segment to `:binary_id` raises, and phoenix_ecto turns
that into a 400, so the documented 404 would never reach anybody who mistyped
an id. `origins` is derived for the response rather than being the stored
lookup key, so a caller sees the origins it will actually be calling `/api`
from.

The routes are recorded in `sdk/contract/omissions.json` rather than claimed
by an SDK: registration is a once-per-app setup step a person does in the
console or with `fountain oauth-client`, and an SDK caller's work starts
after it, with the key the flow mints. #1489 declared them in neither place,
which would have failed `scripts/sdk-contract/build.sh --check`.

`sdk-no-release`: `src/generated/openapi.ts` is regenerated here with no
version bump. The bump is in the last PR of the stack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
jhgaylor added a commit that referenced this pull request Sep 11, 2026
`GET/POST /api/oauth/clients` and `GET/PATCH/DELETE /api/oauth/clients/:id`,
with the OpenAPI operations, the three schemas and the regenerated wire
contract.

Full scope, not the sprite scope #1125 first proposed. A registered client is
a standing way to obtain a full-scope thirty-day key with one consent, which
is exactly the escalation the sprite scope exists to prevent: a sandbox's
per-conversation token must not be able to leave one behind. Registering from
the console, the CLI or the API with the owner's own key still removes the
operator, which was the point.

Two details worth naming. Another account's client answers 404 rather than
403, like every other tenant-scoped resource, and so does an id that is not a
UUID -- casting a path segment to `:binary_id` raises, and phoenix_ecto turns
that into a 400, so the documented 404 would never reach anybody who mistyped
an id. `origins` is derived for the response rather than being the stored
lookup key, so a caller sees the origins it will actually be calling `/api`
from.

The routes are recorded in `sdk/contract/omissions.json` rather than claimed
by an SDK: registration is a once-per-app setup step a person does in the
console or with `fountain oauth-client`, and an SDK caller's work starts
after it, with the key the flow mints. #1489 declared them in neither place,
which would have failed `scripts/sdk-contract/build.sh --check`.

`sdk-no-release`: `src/generated/openapi.ts` is regenerated here with no
version bump. The bump is in the last PR of the stack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
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.

OAuth apps people can register themselves: tenant-owned clients in dev mode, CORS from the registry

2 participants