Authorization integration pattern
External provider hand-offs use one shared tenant-safe pattern. Google Search Console is the reference implementation; future OAuth, installation, and signed authorization flows should reuse the same state primitive.
Required invariants
- Authenticated initiation: only a principal with the provider's connect permission can start.
- Subject ownership: verify the target site or resource with both
account_idand subject ID. - Opaque state: send 32 random bytes as base64url; do not encode account or subject IDs.
- Hash-only persistence: store only
SHA-256(raw_state). - Provider binding: bind state to provider, tenant, subject, redirect URI, metadata, and expiry.
- One use: consume with an atomic update and reject expiry, replay, malformed input, and wrong-provider state identically.
- Public callback, private authority: callbacks need no bearer header; valid state is their only authority.
- Exact redirect: exchange the code with the redirect URI recovered from state.
- Server-only credentials: never return or log client secrets, codes, refresh tokens, access tokens, ciphertext, or nonces.
- Inert completion page: return generic no-store HTML with no request-controlled redirect.
Shared runtime and table
crates/crawlkit-api/src/oauth_state.rs
public.oauth_authorization_states
| Field | Purpose |
|---|---|
token_hash | Unique 32-byte SHA-256 hash; raw state is never stored. |
account_id | Authenticated tenant that initiated authorization. |
provider | Stable identifier such as google_search_console. |
subject_type, subject_id | Tenant-owned resource receiving the connection. |
redirect_uri | Exact URI used for authorization and token exchange. |
metadata | Non-secret provider context. |
expires_at, consumed_at | Ten-minute expiry and atomic replay protection. |
RLS is enabled. Browser roles cannot inspect or modify state bindings.
Standard flow
Authenticated client
→ POST provider /auth/url with tenant-owned subject
→ oauth_state::issue(...)
→ provider authorization URL + opaque state
Provider
→ GET public callback?code=...&state=...
→ oauth_state::consume(provider, state)
→ recover tenant + subject + exact redirect URI
→ exchange code server-side
→ encrypt/store durable credentials
→ optional provider-resource discovery
→ no-store completion page
Provider implementation checklist
- Choose lowercase provider and subject identifiers.
- Define distinct configure, connect, read, and submit permissions.
- Verify subject ownership before issuing state.
- Call
oauth_state::issueand append the returned state to the provider URL. - Merge the GET callback outside the bearer-authenticated router.
- Call
oauth_state::consume, confirm subject type, and use the stored redirect URI. - Encrypt durable client secrets with provider/account/client domain-separated AES-GCM AAD.
- Return redacted status only.
- Add OpenAPI, SDK/agent wrappers, setup and usage guides, and callback security tests.
Required tests
- Non-HTTPS production redirects, credentials, and fragments are rejected.
- State is 256-bit random and hash-only at rest.
- Wrong provider, malformed state, expiry, replay, and concurrent callbacks fail closed.
- Subject/account mismatch cannot issue state.
- Callback works without bearer auth; initiation does not.
- Completion pages are non-cacheable and frame-denied.
- No secrets or codes appear in responses, logs, docs examples, or snapshots.
Do not
- Put tenant or subject IDs in unsigned callback parameters.
- Store raw state or authorization codes.
- Let callbacks choose their tenant, provider, subject, or redirect URI.
- Place browser callbacks behind bearer authentication.
- Return provider error descriptions or arbitrary
return_toredirects. - Store tenant client secrets in deployment manifests.
For implementation-level detail and non-OAuth adaptations, see docs/guides/authorization-integration-pattern.md in the CrawlKit repository.