Skip to content

OAuth client secret candidate evidence

Task: d85fcf41-fbd3-4c13-99f2-7df01ef68ca2, frozen version 17.

Continues the previous local candidate b1e6c09f68d5230ea3f7b8d3f705fd9059088c2d from task version 3. The earlier Python-access blocker is resolved in this session.

Repository: h8v6/heyaira; branch: heyaira/d85fcf41. Base: 4f487bbdf9006cf0a37390205f1721d7c6bbeb71.

This is a local candidate, not release acceptance. The result commit is recorded in the execution's HeyAira RESULT/receipt. No push, PR, merge, install, deployment, or production database operation was performed.

Changes and review evidence

  • src/heyaira/server.py sets client_secret_expiry_seconds=None, requesting the SDK's non-expiring secret representation, and installs the credential hashing adapter in the production app.
  • src/heyaira/oauth.py excludes client_secret from registration JSON while retaining client_secret_hash. Loaded confidential clients expose the digest to the SDK. The adapter hashes incoming form/Basic credentials at /token and /revoke; presenting the stored digest as a password hashes it again. Basic passwords are URL-decoded exactly once before hashing, matching the installed SDK's RFC 6749 behavior. Registration still returns the original issued secret.
  • Loading a legacy row removes the plaintext key and retains its existing hash, deriving a hash if missing. A JSON snapshot condition prevents overwriting a concurrently replaced registration. The old expiry equal to issuance time is recognized and repaired on load; other finite expirations are preserved.
  • _client_for_token_exchange is unchanged. Clients with authentication method none, including normalized ChatGPT, Claude, and HeyAira Bridge clients, retain public authentication. Original OAuth tests are unchanged.

Targeted regressions

tests/test_oauth_client_secrets_e2e.py reuses the real HTTP server/PostgreSQL fixture from tests/test_project_resource_e2e.py. It skips through that fixture when HEYAIRA_PROJECT_RESOURCE_E2E_DATABASE_URL is unset, and is enabled by the existing scripts/test_postgres.py matrix without runner changes.

The new database tests register a client_secret_post client, read its stored JSON/hash, wait 2.1 seconds, complete S256 PKCE and consent, and expect an access token from /token. They reject wrong secrets and digest-as-password attempts, then exercise revocation. Seeded legacy rows, with and without a preexisting hash and with the old immediate-expiry value, are loaded, checked for plaintext removal, and authenticated through the complete PKCE flow.

tests/test_oauth_client_secrets.py adds storage regressions, repeated legacy loads, finite-expiry preservation, concurrent replacement, the four public client shapes, and actual SDK authentication through an ASGI app using synthetic client storage. An additional SDK regression covers encoded client IDs and passwords containing reserved characters and Unicode. The Basic-auth SDK test supplies the form client_id, as required by the installed SDK.

Actual local checks

The exact version-17 suite command ran with Python 3.12.7. The allowed Xcode Git executable was selected on PATH so deployment-fixture Git calls avoid the Apple launcher attempting to write a forbidden external cache. No dependencies were installed.

export PATH="/Applications/Xcode.app/Contents/Developer/usr/bin:$PATH"
PYTHONPATH=src python -m pytest -q -p no:cacheprovider \
  --ignore=tests/test_server_beat_audit.py \
  --ignore=tests/test_server_beat_corrections.py \
  --deselect tests/test_deployment_guard.py::test_required_override_passes_preflight \
  --deselect tests/test_deployment_guard.py::test_missing_extra_compose_file_is_rejected \
  --deselect tests/test_ios_companion_contract.py::test_inbox_logic_check_passes_with_the_swift_toolchain
301 passed, 110 skipped, 3 deselected in 7.08s
exit code: 0

The two ignored modules and three deselections are precisely the unrelated sandbox-incompatible checks excluded by task v17; no extra exclusions were added. The skipped cases require opt-in isolated PostgreSQL databases.

Focused OAuth check:

PYTHONPATH=src python -m pytest -q -p no:cacheprovider \
  tests/test_oauth_client_secrets.py tests/test_oauth_client_secrets_e2e.py tests/test_oauth.py
22 passed, 3 skipped in 1.96s
exit code: 0

The three focused skips are the new database regressions; their shared fixture requires HEYAIRA_PROJECT_RESOURCE_E2E_DATABASE_URL.

Regression sensitivity was checked against an isolated copy of source from 4f487bbdf9006cf0a37390205f1721d7c6bbeb71, leaving the working source untouched:

test_registration_returns_original_secret_but_stores_only_its_hash: FAILED
  original client_info still contains client_secret
test_sdk_accepts_the_secret_but_rejects_wrong_missing_or_hash_credentials[client_secret_post]: FAILED
  original client_secret_expires_at equals issuance time instead of 0
2 failed in 1.49s (expected; exit code 1)

Before correcting Basic credential decoding, the added URL-encoding regression also failed with invalid_client instead of authenticated invalid_grant (1 failed in 1.40s). It passes with the final adapter in the successful suite.

git diff --check passed (exit 0). git diff --exit-code against the base for the original tests/test_oauth.py, tests/test_oauth_instance_binding.py, and tests/test_project_resource_e2e.py passed (exit 0), confirming they are unchanged. An AST comparison of _client_for_token_exchange with the base commit also passed, confirming the public-client normalization implementation is unchanged.

The version-17 local suite criterion passes with no failures, and the actual SDK authentication regressions execute locally. No PostgreSQL is available for this assignment, so the real database/PKCE regressions remain unexecuted locally. Independent verification and the complete zero-skip PostgreSQL CI gate remain required; Bridge owns subsequent publication.

Follow-up after running against PostgreSQL (PR #83)

  • The legacy-row database test seeded a row without scope, which SDK registration always fills with the default scopes, so /authorize answered invalid_scope and the test found no request_id. The seed is now the exact record the old register_client stored.
  • The body-rewriting middleware let the stored digest work as the password: it hashed only UTF-8 urlencoded bodies (the SDK also reads multipart/form-data and latin-1 bytes), and it matched the full URL path, so behind a non-empty ASGI root_path it hashed nothing at all. The middleware is gone. HashedSecretClientAuthenticator is the SDK's client authenticator with the presented secret hashed before the constant-time comparison, and use_hashed_client_authentication rebuilds the /token and /revoke routes with the SDK's own handlers and wrappers around it, so the check happens where the client is authenticated, whatever the path prefix or body shape.
  • Rows are scrubbed on load only when the client returns. Migration 038_oauth_client_secret_scrub.sql removes the plaintext from every row.