feat: constrain the OAuth2 client type column - #27931
Conversation
client_type decides whether the token endpoint validates a client secret at all, and the column accepted any text: nullable, no CHECK, no enum. No Go path can write a bad value, and IsPublic fails closed on anything unrecognized, so the read side is safe today. The point is what the schema still permits: a future migration writing 'public' onto a row that holds a secret turns off client authentication for that app with nothing to catch it, no constraint, no log, no audit entry, no test. Two columns also describe the same fact and could contradict each other. token_endpoint_auth_method is the client's own declaration, registered metadata under RFC 7591 §2 where "none" means the client is public and has no secret; client_type is the derived value enforced on. Registration used to persist the declaration verbatim while hardcoding client_type, so rows exist declaring "none" on a client stored confidential that holds a real secret. A client reading its own metadata concludes it is public, drops the secret, and stops being able to exchange codes. The backfill aligns the declaration to what is enforced; deriving enforcement from the declaration instead would reclassify those clients as public and stop requiring the secret they were issued. SET NOT NULL changes the generated field from sql.NullString to string, so the three write sites are updated to match. That is the whole application change; no behavior depends on it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
An RFC 7591 registration requesting `token_endpoint_auth_method: "none"` now produces a public client: no secret is minted, `client_type` is persisted as `public`, and the token endpoint accepts that client's authorization_code exchange with PKCE alone. Discovery advertises `"none"` as a supported auth method. PKCE was already mandatory for every authorization_code flow, so public clients inherit it unchanged. That makes the code ownership check (`dbCode.AppID != app.ID`) the sole binding between the exchange and the app named by `client_id` for a public client, where it was defense in depth for confidential ones. It is retained and covered with a public client, along with the refresh and revocation paths, which are the first to see a NULL `app_secret_id`. Verifier length is now checked against RFC 7636 §4.1, since for a public client PKCE is the only client authentication and a one-character verifier hashes to a well-formed challenge. Registration writes the app and its secret in one transaction. Two independently committed inserts could leave a permanently committed app that can never authenticate while still holding a registration access token. An RFC 7592 update can no longer move a client between public and confidential, and secret creation is rejected for a public app: the token endpoint would never validate that secret, and deleting it revokes nothing because a public client's tokens carry a NULL `app_secret_id` instead of cascading from the secret. Clients registered with `"none"` before it was honored are stored confidential and still require their secret, so an update that resends their own metadata is accepted rather than rejected, and the auth method reported back is the one the server actually enforces. Depends on #27931 for the client_type constraint and the schema-level alignment of the two columns. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
/coder-agents-review |
|
Chat: Review posted | View chat Review historydeep-review v0.9.0 | Round 2 | Last posted: Round 2, 17 findings (3 P2, 4 P3, 7 Nit, 3 Note), COMMENT. Review Finding inventoryFinding inventory: PR 27931Findings
Contested and acknowledgedCRF-2 (P3, 000566_oauth2_auth_method_backfill.up.sql:24) - contradiction recreated by registration until #27873
CRF-3 (Note, coderd/oauth2provider/apps.go:95) - client_type bare string literals
Round logRound 1Netero-only (P2 present, panel gated). 1 P2, 1 P3, 1 Note. Reviewed against 5064006..8b0eb45. Round 2Churn guard: PROCEED. CRF-1 author fixed (c258668, +227 test lines), CRF-2 acknowledged, CRF-3 deferred to #27873. 0 silent. Round 2 panelNetero (1 new Note, CRF-4) then full panel: 18 trigger-matched + 2 wildcards (Zoro, Killua). CRF-1 fix verified by 12 reviewers running the tests; CRF-2 closed by unanimous panel acceptance with residual spun to CRF-8. New: 2 P2, 3 P3, 7 Nit, 1 Note (CRF-5..17). Gon's 10 comment-bloat P2s consolidated and downgraded to one Nit (CRF-10). Reviewed against 5064006..c258668. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
This is a first-pass review only: the findings below are mechanical checks from the automated first-pass reviewer. The full review panel has not yet reviewed this PR and will review after these findings are addressed.
The change itself is well shaped: the migration ordering in 000565 (UPDATE, ADD CONSTRAINT, SET NOT NULL) fails loudly on an unexpected value instead of rewriting it, the PR description's factual claims about migration 000344 check out, all three sql.NullString write sites were updated, and build, vet, migration round-trip, and audit tests all pass. As the first-pass reviewer put it: "the 000565 ordering (UPDATE, ADD CONSTRAINT, SET NOT NULL) fails the migration loudly on an unexpected value rather than rewriting it, which is correct."
Severity count: 1 P2, 1 P3, 1 Note.
🤖 This review was automatically generated with Coder Agents.
…d backfill Addresses CRF-1 on #27931. Neither migration had a dedicated test, and the shared fixture cannot reach the case that matters: testdata/fixtures/ 000182_oauth2_provider.up.sql inserts one app with no token_endpoint_auth_method, so the backfill's IS NULL branch matches it while the '= none' branch, the one repairing the actual bug, matches zero rows in CI. Follows the existing Stepper-to-prior-version, seed, migrate, assert pattern of TestMigration000542ChatReasoningEffortBackfill, TestMigration000562OAuth2PublicClientTokensBackfill and TestMigration000563TemplateAgentsAllowedBackfill. The backfill test seeds all six combinations that matter: a confidential client declaring "none" (the known bug), one with no declaration at all, a public client declaring a secret-based method, and the three already consistent shapes. It asserts client_secret_post is not flattened to basic, that client_type itself is never rewritten, and states the resulting invariant directly by counting rows where the declaration contradicts the enforced type. The constraint test asserts a NULL client_type is backfilled to confidential rather than blocking SET NOT NULL, that the column becomes NOT NULL, and that the CHECK actually rejects "Public", "PUBLIC", "public ", "bogus", "" and NULL while both canonical values remain insertable. Both are mutation-checked. Removing the backfill's '= none' branch fails the legacy case and the invariant count; removing the CHECK fails the rejection assertions. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
/coder-agents-review |
There was a problem hiding this comment.
Round 2: full panel review (Netero first pass, then 20 reviewers). The round-1 findings resolved well: CRF-1's fix was verified by twelve reviewers independently running both migration tests against Postgres, with the mutation checks the author claimed reproduced by one of them; CRF-2's accepted window was evaluated by thirteen reviewers and the defense holds unanimously, since the failure direction is fail-closed, the window strictly shrinks the contradictory-row population versus base, and a forgotten backfill in the future cross-column CHECK migration fails loudly at ADD CONSTRAINT rather than shipping silently. As Knuckle put it: "The interest rate on this loan is low and the balloon payment is enforced by the database itself."
The change itself drew repeated praise: correct alignment direction (declaration follows enforcement, never the reverse), honest irreversible down migration, a constraint test that proves rejection instead of trusting existence, and per-case reason strings that make failures self-diagnosing.
New findings this round: 2 P2, 3 P3, 8 Nit, 2 Note.
One item needs a human decision rather than agent acceptance: the permanent fix that the CRF-2 closure leans on (cross-column CHECK, SET NOT NULL on token_endpoint_auth_method, and the backfill for window-created rows) is tracked nowhere except a PR comment (CRF-8). Either confirm ENG-3029 itemizes that third migration or file a ticket.
Process observation (Leorio): the subject of commit c258668 is 79 characters and truncates in log and GitHub views; "test(coderd/database/migrations): cover client_type constraint and backfill" fits within 72. The commit bodies, by contrast, are exemplary: alternatives considered, mutation checks documented.
🤖 This review was automatically generated with Coder Agents.
… this tree Addresses part of CRF-6 on #27931. The comment named IsPublic, which is added in #27873 and greps to nothing here, so a reader during the window between the two PRs goes looking for a function that does not exist. Ten panel reviewers flagged it independently. States the property directly instead, which is true at this commit and stays true afterwards. The finding's other four instances describe enforcement that arrives with #27873 and are deliberately left: unlike a dangling symbol they are accurate statements about where the column is headed, and each becomes true when that PR lands. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… method Addresses CRF-9 on #27931. The two UPDATEs were asymmetric: the public branch excluded the one valid value and repaired everything else, while the confidential branch enumerated the two bad shapes known today, so a confidential row holding '' or an unrecognized method passed through untouched. That row would then be invisible permanently, not just unrepaired. The cross-column constraint this is heading towards, (method = 'none') = (client_type = 'public'), evaluates false = false for it and passes, so nothing looks at it again while RFC 7592 GET keeps handing the client a declaration it cannot use. Both branches now name the values valid for their client type and repair everything else, which makes the migration idempotent against history it cannot inspect rather than against the one bug we know about. Reachability is unverified and stated as such in the comment: ApplyDefaults maps "" to client_secret_basic and Valid() rejects unknown methods, so such a row needs a write path predating those guards. The IS NULL arm is kept deliberately. NULL NOT IN (...) evaluates to NULL and WHERE admits only true, so the widening alone would have stopped repairing NULL rows, which the narrow predicate did handle. Both regressions are mutation-checked: restoring the narrow predicate fails the empty and unrecognized cases, and dropping the IS NULL arm fails the NULL case. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… assertions Addresses CRF-7 and CRF-5 on #27931. CRF-7: the parallel subtests queried through the parent's context. Its deadline starts when it is created, but a parallel subtest does not run until a -parallel slot frees, so most of the budget can be spent queued behind other tests in the package before the first query runs. The reported failure would be "context deadline exceeded" on a sub-20ms single-row read, naming no code and worsening as the package grows. paralleltestctx does not catch it because the context arrives through a helper's return value rather than a direct call in the subtest. Each subtest now creates its own. CRF-5, first half: no public + NULL row was seeded, so the second UPDATE's IS NULL arm matched nothing and removing it left the test green. That shape is now seeded and asserted. CRF-5, second half: the closing invariant used <>, which is NULL-blind. With a NULL declaration the comparison is NULL, WHERE drops the row, and an unrepaired NULL counts as consistent. Now IS DISTINCT FROM. This matters past this test: the predicate is the obvious candidate for the permanent cross-column CHECK after #27873, and a CHECK is more forgiving still, since NULL reads as not-violated. Mutation-checked. Removing the second UPDATE's IS NULL arm now fails both the publicNull case and the invariant count; with the old <> form only the former fires, which is what made the blind spot invisible. Full package passes at -parallel=1, the worst case for queue wait. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…nt type migrations Addresses CRF-4 and CRF-10 through CRF-16 on #27931. CRF-11: the CHECK-violation assertion matched the constraint name as a raw string, while this migration generates a typed constant for it. Now uses database.IsCheckViolation with CheckOauth2ProviderAppsClientTypeCheck, the established idiom, which matches the pq error code and constraint name rather than error text, and which make gen keeps correct through a rename. CRF-13: the stepper loop treated exhaustion as success, so a renumbered or squashed 564 would silently apply 000565 and 000566 during setup and fail later on a confusing "constraint already exists". Now t.Fatalf, matching every other stepper loop in the file. CRF-14: the NULL-insert probe asserted only that some error occurred, while its siblings pin the constraint. Now pins the not-null violation. CRF-15: the down migration opened with "Deliberately empty." and then ran SELECT 1. Comment-only down migrations run fine (000506 is the precedent), so the statement is gone and the first line is true. CRF-12: the helper doc claimed it seeds every combination the two migrations care about, but 000565's NULL client_type row is seeded in that test. Narrowed to what it actually covers. CRF-10: the declaration-versus-enforcement rationale was written four times and 000565's twice. Kept the migration headers as the owning location and removed the copies that restated an adjacent require message, map literal, or header. Also folded in CRF-17's deploy-blocking consequence, which was the one thing the 000565 header did not say. CRF-16: "section 2" throughout, the majority form in the diff. CRF-4: the local strPtr closure replaced with the shared ptr.Ref. Re-verified after the assertion changes: dropping the CHECK still fails the constraint test, and the full package passes at -parallel=1. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three of the ten cited sites survived 1a54ff7. migrate_test.go:2874: "Both canonical values must still be insertable." restated the loop and its require message, which is the canonical copy because it prints on failure. Deleted. TestMigration000566's doc: paragraph two repeated the 000566 header verbatim, so it is gone. Paragraph one said "the shared fixture", which read as setupMigration000565Apps, called on the next line and seeding token_endpoint_auth_method on every row, making the sentence false. The intended referent is the testdata/fixtures run, whose only oauth2_provider_apps row (000182) sets no auth method. Named directly. The invariant assertion's comment opened by restating its own require message. Removed those two lines; the IS DISTINCT FROM rationale is the part that is not derivable from the code and it stands alone. TestMigration000565 and TestMigration000566 pass; go vet clean. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… change CRF-13 asked for the stepper loop in setupMigration000565Apps to treat exhaustion as fatal. An earlier pass applied the same fix to TestMigration000546ChatHistoryAPIKeyConstraints, which predates this branch and has nothing to do with the OAuth2 client type work. The convention gap there is real, but it belongs in its own change rather than widening this diff into an unrelated test. migrate_test.go now differs from main only by the ptr import and the new 000565/000566 coverage. TestMigration000546, TestMigration000565 and TestMigration000566 pass.
…lient_type
The subtest passed a zero-value InsertOAuth2ProviderAppParams. That was fine
while client_type was sql.NullString, where the zero value inserts NULL into
a nullable column. This branch makes the column NOT NULL with a CHECK for
'confidential' and 'public', and the generated insert always names the
column, so the DEFAULT never applies and the zero value now inserts '' and
fails the constraint:
pq: new row for relation "oauth2_provider_apps" violates check constraint
"oauth2_provider_apps_client_type_check"
Seeds 'confidential', the value every production call site already passes.
The subtest asserts RBAC, not column contents, so this does not change what
it verifies.
Fixes test-go-pg, test-go-pg-17 and test-go-race-pg on b254294, which all
failed on this one subtest.
|
The eventual cross-column CHECK, Posted by Coder Agents on behalf of @Emyrk. |
There was a problem hiding this comment.
Coder Agents found no blocking comments.
Review details
Walked the full diff at 0f40876:
- 000565: correct shape — NULL backfill fails closed to
confidential, lock is brief on a tiny table, and blocking the upgrade on a stranger value is the right failure mode for an auth-enforcement column. On Coder-written data it cannot fail. - 000566: declaration→enforcement is the correct alignment direction; the reverse would waive secrets that legacy clients hold and must present. Predicates repair every invalid shape per branch,
IS NULLarms are present, andclient_secret_postis preserved. - Down migrations: 565 is a clean structural inverse; 566's documented no-op is right — the old values are unrecoverable and restoring them would only reinstate metadata the server rejects.
- Re-contradiction window: acceptable — metadata-only (the token endpoint requires the secret unconditionally at this commit and never reads
client_type), bounded by #27873. See my comment about tracking the follow-up cross-column CHECK as a ticket. - App diff: mechanical
sql.NullString→string; no read site depended on NULL-ness. - Tests: verified all deep-review R2 findings (CRF-4, 5, 7, 9, 11–16) are fixed at head — per-subtest contexts,
publicNullseed,IS DISTINCT FROMinvariant,IsCheckViolationwith the generated constant, widened predicates.
One accuracy note for the record: 000565's opening comment and the PR body describe client_type deciding token-endpoint secret validation and reference IsPublic — both describe the post-#27873 world, not this commit. Not blocking per discussion.
Review conducted with Coder Agents on behalf of @Emyrk.
|
Tracked as PLAT-473. |
Extracted from #27873 so the schema change can be reviewed for migration safety on its own. #27873 will rebase onto this.
client_typedecides whether the token endpoint validates a client secret at all, and the column accepts any text: nullable, noCHECK, no enum. No Go path can write a bad value today, andIsPublicfails closed on anything unrecognized, so the read side is safe. What the schema still permits is the problem: a future migration writing'public'onto a row that holds a secret turns off client authentication for that app with nothing to catch it, no constraint, no log, no audit entry, no test.000565addsCHECK (client_type IN ('confidential', 'public'))andNOT NULL. TheUPDATEahead of it should touch zero rows, since migration000344added the column with a default of'confidential'and backfilled withCOALESCE; it is there soSET NOT NULLcannot fail on an unexpected row. BothALTERs takeACCESS EXCLUSIVEand scan a table holding one row per registered OAuth2 client, so the lock is brief.The second migration, and why it aligns the way it does
Two columns describe the same fact and can currently contradict each other.
token_endpoint_auth_methodis the client's own declaration: registered client metadata under RFC 7591 §2, where"none"is defined to mean the client is public and has no secret.client_typeis Coder's derived copy, and it is what the token endpoint enforces on. RFC 7591 defines noclient_typemetadata field; the column exists only as a denormalization.Registration used to persist the declaration verbatim while hardcoding
client_typeto'confidential', so rows exist declaring"none"on a client stored confidential that was issued, and still requires, a real secret. A client that reads its own metadata and believes it is public will drop that secret and stop being able to exchange codes.000566aligns the declaration to what is enforced, not the reverse. Deriving enforcement from the declaration would reclassify every such client as public and stop requiring the secret it holds, which is a silent authentication downgrade. The down migration is deliberately empty: the previous values are not recorded, and restoring them would only reinstate metadata that tells a client to authenticate in a way the server rejects.Application changes
SET NOT NULLchanges the generated field fromsql.NullStringtostring, so the three write sites are updated to match. That is the entire application diff and no behavior depends on it.Refs https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client