T 1551265: Support public OAuth2 clients without a client secret - #178
Conversation
There was a problem hiding this comment.
Review — deep pass
Performed at head f17772c1a59fbc76fb3f981bfc2ac33cb1ffe690, against base main @ a2e9773 — computed as the true merge base locally rather than taken from the API, and it is exactly tag 1.4.6.
Verdict
No blocker. Two MAJORs, eight MINORs, four questions. Nothing here should stop this from merging on its own terms.
The change is additive and not source-breaking: both initializers are explicit so the memberwise init was never part of the API, isPublicClient defaults to false, and no confidential client's request changes by a single byte. Nothing in this PR changes behavior for an existing Conduit 1.4.6 consumer — every behavioral finding below is reachable only by a caller that adopts init(publicClientIdentifier:). Conduit has no feature flags, so that adoption boundary is the only gating there is. I've restated it per-finding where it matters.
Both MAJORs sit on the RFC-conformance and security-guidance axis; neither is a defect in what the diff does mechanically:
- A public client with no guest credentials reaches an unauthenticated
client_credentialsgrant, which RFC 6749 §4.4 restricts to confidential clients. The sharpest form is an asymmetry inside this PR: it hard-fails client-level.basicfor a public client because there is no secret to send, then lets client-level.bearer— in the identical situation — build the grant and fail at the server. At 1.4.6 that same branch was conformant, so the missing credential is new here. - The Security Impact section omits PKCE, and
authorization_code— implemented in-tree overSFSafariViewControllerwith a private-use scheme redirect — is the case that needs it. This one is documentation, not code: PKCE should not be built in this PR, and PKCE is already achievable by a consumer through the existing extension points. What's missing is the sentence saying so.
What was verified rather than assumed
- The diff was read against the true merge base
a2e9773, not the API's reported base. - All five in-tree grant strategies were traced to the single chokepoint —
OAuth2AuthorizationCodeTokenGrantStrategy.swift:52,OAuth2RefreshTokenGrantStrategy.swift:43,OAuth2ExtensionTokenGrantStrategy.swift:38,OAuth2PasswordTokenGrantStrategy.swift:47,OAuth2ClientCredentialsTokenGrantStrategy.swift:35. The body's "single chokepoint" claim holds as enumerated (with one narrowing, in the questions below). - Every claim in the PR body was checked — 45 in total. One is false as worded (posted inline); four are overstated.
- Every
file:linein this review was re-verified againstf17772c1immediately before posting.
Test credit, honestly
This is a genuinely well-tested PR by this channel's standards, and the structure is the reason. All ten grant-strategy tests in OAuth2PublicClientTokenGrantTests.swift are real public/confidential pairs covering all five grant types. The public halves each kill the behavioral mutants — drop the client_id injection, restore the unconditional Basic header, never set isPublicClient. The confidential halves are deliberate conditionality guards rather than padding: they are what catch the over-broad mutants ("always send client_id", "never send Basic", "default to public"), which are precisely the ways a diff like this could have broken every existing 1.4.6 consumer. testAppliesBasicHeaderForConfidentialClientLevelBasicAuthorization (OAuth2RequestPipelineMiddlewareTests.swift:357) adds coverage of a configuration no test at 1.4.6 ever constructed. No new test is vacuous outright, and every changed production line has at least one assertion that observes at least one mutation of it.
The exception is two surviving mutants, both posted inline: the guard !isPublicClient at OAuth2ClientConfiguration.swift:80 — the PR's one documented invariant, and the reason clientSecret staying a public var is acceptable — and the guest-credential pass-through at :71-72, which guards the first MAJOR's only conformant escape hatch.
Folded in here, because there is no diff line to anchor it
MINOR — "no SwiftLint warnings in changed files" isn't something this repo can substantiate. .swiftlint.yml exists, but no CI job invokes swiftlint: .github/workflows/ci.yml is fifteen lines whose only step is swift test on macos-latest (:11, :15). The one SwiftLint invocation in the repo, Dangerfile:16-17, is dead config — there is no Danger step, no Gemfile, no fastlane/, no Makefile.
To be even-handed about it: the test claim in the same sentence is substantiated by that check — 232 func test declarations at base, 247 at head, with one macOS-excluded behind a targetEnvironment guard at KeychainHybridKeyProviderTests.swift:212, which gives exactly the stated 246. And we could not run SwiftLint here either. So this is an observation about what CI enforces, not an accusation that the claim is wrong. The mechanical subset that could be checked by hand — line length against line_length: 160, trailing whitespace, single_test_class — is clean on every changed file.
Questions
- Should
init(publicClientIdentifier:)requireguestUsername/guestPassword? That is the only configuration in which client-level bearer works for a public client — the design question behind the first MAJOR. Related and not answerable from here: for the client being onboarded, is client-level bearer in use at all, and is the authorization server expected to acceptclient_credentialswith only aclient_id? If it enforces §4.4 the path fails permanently against a config that looks fine at the call site; if it doesn't, it will mint a client-level token for anyone who can read theclient_idout of the app binary. - "Single chokepoint for every grant type" holds for all five in-tree strategies, but
buildStandardTokenGrantRequestis internal (OAuth2TokenGrantStrategy.swift:30) while the protocol at:12is public and requires onlyissueToken(:18,:24) — andrefreshStrategyFactoryis a public var (OAuth2RequestPipelineMiddleware.swift:20). So an out-of-module conformer builds its own request, never seesisPublicClient, and keeps sending an empty-secret Basic header. Does anything in SharedLibraries-iOS implement its own grant strategy or refresh factory? We can't see that repo from here, so this is a question rather than an assertion. - The "a grant-supplied
client_idstill wins" ordering is structurally true —:44-46runs before the merge at:48-50, and the comment says so — but nothing tests it. Intentional, or worth pinning? CHANGELOG.md:7-11is correct and in the right section. The hard-wrapped multi-line bullet is the only one in the file — worth matching the surrounding single-line style?
Also worth deciding: eight tests in this area are DISABLED_ (six in OAuth2RequestPipelineMiddlewareTests.swift, two in AuthMigratorTests.swift), and two of them — :194 and :230 — are exactly the ones covering the grant selection that the first MAJOR turns on. They appear to need a live localhost:5000. Is re-enabling them in scope here, or worth a follow-up ticket?
Evidence limits
There is no Swift toolchain in this review environment, so nothing was compiled or executed. The mutation analysis is coverage-by-construction from exhaustive reading and grep, not an executed mutation run. The two RFC-conformance findings describe server-side outcomes that were not exercised: whether the authorization server enforces §4.4, whether it requires PKCE, whether it returns 400 or 401 on a refresh with absent client authentication, and whether the same client_id is being re-registered as public or a new one issued — none of that is observable from this repo, and each would change the real-world severity. SharedLibraries-iOS and the consumer app are likewise not reachable from here.
Generated by Claude Code
… tighten docs and tests
There was a problem hiding this comment.
Follow-up — re-verified at d23c0e3
Re-verified all ten findings against the code at this head. Both MAJORs and all eight MINORs are genuinely fixed, and each new test kills the mutant it targets rather than merely passing. Two worth naming:
testPublicClientNeverSendsBasicHeaderEvenWithAnAssignedSecretassigns a secret to a public configuration and asserts no Basic token — the first reference tobasicTokenfrom the test suite at all. Restore the unconditional accessor and it fails.testPublicClientLevelBearerWithoutGuestCredentialsFailsWithInternalFailurewould fail on revert for a non-obvious reason: the reverted path reaches the grant call againstlocalhost:5000, and connection-refused maps throughOAuth2TokenGrantManager.errorFromtonetworkFailure, notinternalFailure. It is not a test that passes either way.
CI is green on this head — 251 tests, 0 failures — which does confirm the new tests pass. It is not a mutation run, so the kill analysis above is by reading, as before.
The answer on the skipped PostFetch hook is reasonable: no token fetch is attempted, so there is nothing for a migrator to observe. Failing at request time rather than requiring guest credentials at construction is one of the two options offered, so that settles the design question.
Still open:
- Does anything in SharedLibraries-iOS implement its own
OAuth2TokenGrantStrategyor setrefreshStrategyFactory? Out-of-module conformers build their own request and never seeisPublicClient, so the public-client flag doesn't reach them. We still can't see that repo from here. - Will the authorization server accept or reject what the client now sends?
- The eight
DISABLED_tests remain a decision rather than a finding — two of them, OAuth2RequestPipelineMiddlewareTests.swift:193 and :229, are still the only coverage of grant selection.
One new MINOR and a folded nit inline. Both are documentation; the code is right.
Generated by Claude Code
|
Answers to the three open questions from the follow-up review:
|
|
Closing out from our side: everything from both reviews is addressed at The two declined nits are reasonable as declined: an added All 11 inline threads are still unresolved by anyone, but the author replied on every one; open threads here should not be read as open findings. Two things left, both explicitly non-blocking: the CHANGELOG bullet is still the only hard-wrapped one in the file, and the eight Generated by Claude Code |
This pull request includes (pick all that apply):
Summary
Support public OAuth2 clients, i.e. clients that hold no client secret (RFC 6749 §2.1). A public client identifies itself with
client_idin the token request body instead of anAuthorization: Basicheader. Confidential clients are unchanged. AB#1551265Implementation
OAuth2ClientConfigurationgainsinit(publicClientIdentifier:environment:guestUsername:guestPassword:)and a read-onlyisPublicClientflag. The flag can only be set through that initializer, so an existing configuration cannot drift into public mode.buildStandardTokenGrantRequestis the single chokepoint for the five in-tree grant strategies (authorization_code,refresh_token, extension grants,password,client_credentials). For a public client it addsclient_idto the body and skips the Basic header; a grant-suppliedclient_idstill wins.basicTokenaccessor returnsnilfor a public client, so a public client never emits a Basic header, even if a secret is assigned later. A confidential client is byte-identical to 1.4.6, including one built with an empty secret.OAuth2RequestPipelineMiddlewarerequires guest credentials (password grant). Client-level.basichas no secret to send, and client-level.bearerwithout guest credentials would need theclient_credentialsgrant, which is reserved for confidential clients (RFC 6749 §4.4), so the middleware fails both withOAuth2Error.internalFailurebefore any request is built. ConstructingOAuth2ClientCredentialsTokenGrantStrategydirectly is not guarded.internalFailureis used rather thanclientFailureso a misconfiguration is distinguishable from a server-side auth rejection.Test Plan
OAuth2PublicClientTokenGrantTests(new): public vs confidential request shape for all five grant types; a public client with an assigned secret still sends no Basic header; a grant-suppliedclient_idwins.OAuth2ClientConfigurationTests: confidential by default; public initializer holds an empty secret and passes guest credentials through; public and confidential configurations with the same identifier are not equal.OAuth2MiddlewareClientLevelAuthorizationTests(new): confidential client-level basic applies the header; public client-level basic and public client-level bearer without guest credentials both fail withinternalFailure.OAuth2TokenStoreTests: token identifier is unchanged across secret and public flag.swift test). SwiftLint run locally: no warnings in changed files (CI runsswift testonly).Security Impact
Enables clients to operate without an embedded client secret, which removes a secret from the client binary where this configuration is adopted. A public client sends only its
client_id, which identifies the client but does not authenticate it (RFC 6749 §3.2.1). Confidential clients keep sendingAuthorization: Basicexactly as before. No stored-token format or key changes.For a public client the client secret no longer binds an authorization code to the client, so a public client using
authorization_codemust use PKCE and the authorization server must enforce it (RFC 8252 §6). Conduit already carriescode_challengethroughOAuth2AuthorizationRequest.additionalParametersandcode_verifierthroughtokenGrantRequestAdditionalBodyParameters; first-class PKCE support is a follow-up. Client-level bearer for a public client through the middleware requires guest credentials; the middleware never sends an unauthenticatedclient_credentialsgrant.