Skip to content

Commit 4dd8f51

Browse files
docs: add OAuth implementation sprint plan
3-task breakdown covering foundation + HTTP client changes, M2M provider, and U2M provider with full test coverage. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f478b62 commit 4dd8f51

File tree

2 files changed

+127
-9
lines changed

2 files changed

+127
-9
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Sprint Plan: OAuth U2M + M2M Implementation
2+
3+
**Sprint dates:** 2026-03-07 to 2026-03-20
4+
**Sprint goal:** Implement complete OAuth 2.0 authentication (U2M + M2M) for the Rust ADBC driver as described in the [OAuth design doc](oauth-u2m-m2m-design.md).
5+
6+
---
7+
8+
## Story
9+
10+
**Title:** Implement OAuth U2M and M2M authentication
11+
12+
**Description:**
13+
Add OAuth 2.0 support to the Rust ADBC driver, covering:
14+
- M2M (Client Credentials) flow for service principal authentication
15+
- U2M (Authorization Code + PKCE) flow for interactive browser-based login
16+
- Shared infrastructure: OIDC discovery, token lifecycle management, file-based token caching
17+
- Integration with `Database` config and `DatabricksHttpClient` (two-phase init via `OnceLock`)
18+
- ODBC-aligned numeric config values (`AuthMech`/`Auth_Flow` scheme)
19+
20+
**Acceptance Criteria:**
21+
- [ ] M2M flow works end-to-end: `Database` config -> `new_connection()` -> `get_auth_header()` returns valid Bearer token from client credentials exchange
22+
- [ ] U2M flow works end-to-end: browser launch -> callback server captures code -> PKCE token exchange -> cached token reused on subsequent connections
23+
- [ ] Token refresh state machine works: FRESH (no-op) -> STALE (background refresh) -> EXPIRED (blocking refresh)
24+
- [ ] File-based token cache persists U2M tokens across connections with correct permissions (0o600)
25+
- [ ] `DatabricksHttpClient` supports two-phase init: created without auth, auth set later via `OnceLock`
26+
- [ ] All config options from the design doc are parseable via `set_option()`
27+
- [ ] Invalid config combinations fail with clear error messages at `new_connection()`
28+
- [ ] `cargo fmt`, `cargo clippy -- -D warnings`, `cargo test` all pass
29+
30+
---
31+
32+
## Sub-Tasks
33+
34+
### Task 1: Foundation + HTTP Client Changes
35+
36+
**Scope:** Shared OAuth infrastructure and modifications to existing code to support two-phase auth initialization.
37+
38+
**Files to create:**
39+
- `src/auth/oauth/mod.rs` — module root, re-exports
40+
- `src/auth/oauth/token.rs``OAuthToken` struct with `is_expired()`, `is_stale()`, JSON serialization
41+
- `src/auth/oauth/oidc.rs``OidcEndpoints` struct, `discover()` function hitting `{host}/oidc/.well-known/oauth-authorization-server`
42+
- `src/auth/oauth/cache.rs``TokenCache` with `load()`/`save()`, SHA-256 hashed filenames, 0o600 permissions
43+
- `src/auth/oauth/token_store.rs``TokenStore` with `RwLock<Option<OAuthToken>>`, `AtomicBool` for refresh coordination, FRESH/STALE/EXPIRED state machine
44+
45+
**Files to modify:**
46+
- `Cargo.toml` — add `oauth2 = "5"`, `sha2 = "0.10"`, `open = "5"`, `dirs = "5"`
47+
- `src/client/http.rs` — change `auth_provider` from `Arc<dyn AuthProvider>` to `OnceLock<Arc<dyn AuthProvider>>`, add `set_auth_provider()`, update `execute()` to read from `OnceLock`
48+
- `src/database.rs` — add `AuthMechanism`/`AuthFlow` enums (with `TryFrom<i64>`), new config fields (`auth_mechanism`, `auth_flow`, `auth_client_id`, `auth_client_secret`, `auth_scopes`, `auth_token_endpoint`, `auth_redirect_port`), `set_option()`/`get_option_string()` for all new keys
49+
- `src/auth/mod.rs` — add `pub mod oauth;`, remove old `pub use oauth::OAuthCredentials`, delete `src/auth/oauth.rs` (replaced by directory module)
50+
51+
**Tests:**
52+
- `token.rs`: `test_token_fresh_not_expired`, `test_token_stale_threshold`, `test_token_expired_within_buffer`, `test_token_serialization_roundtrip`
53+
- `oidc.rs`: `test_discover_workspace_endpoints`, `test_discover_invalid_response`, `test_discover_http_error`
54+
- `cache.rs`: `test_cache_key_deterministic`, `test_cache_save_load_roundtrip`, `test_cache_missing_file`, `test_cache_file_permissions`, `test_cache_corrupted_file`
55+
- `token_store.rs`: `test_store_fresh_token_no_refresh`, `test_store_expired_triggers_blocking_refresh`, `test_store_concurrent_refresh_single_fetch`, `test_store_stale_returns_current_token`
56+
- `http.rs`: `test_execute_without_auth_works_before_auth_set`, `test_execute_fails_before_auth_set`, `test_execute_succeeds_after_auth_set`, `test_set_auth_provider_twice_panics_or_errors`
57+
- `database.rs`: `test_set_auth_mechanism_valid`, `test_set_auth_mechanism_invalid`, `test_set_auth_flow_valid`, `test_set_auth_flow_invalid`, `test_new_connection_missing_mechanism`, `test_new_connection_oauth_missing_flow`, `test_new_connection_pat_missing_token`
58+
59+
**Definition of done:** All foundation modules compile, unit tests pass, `DatabricksHttpClient` two-phase init works, database config parsing works for all auth options.
60+
61+
---
62+
63+
### Task 2: M2M Provider (Client Credentials)
64+
65+
**Scope:** Implement the M2M OAuth flow using `oauth2::BasicClient` for client credentials exchange.
66+
67+
**Files to create:**
68+
- `src/auth/oauth/m2m.rs``ClientCredentialsProvider` implementing `AuthProvider`
69+
70+
**Files to modify:**
71+
- `src/auth/oauth/mod.rs` — add `pub mod m2m;`, re-export `ClientCredentialsProvider`
72+
- `src/auth/mod.rs` — re-export `ClientCredentialsProvider`
73+
- `src/database.rs` — wire `AuthFlow::ClientCredentials` match arm in `new_connection()` to create `ClientCredentialsProvider`
74+
75+
**Implementation details:**
76+
- Construct `oauth2::BasicClient` from OIDC-discovered endpoints
77+
- Implement `oauth2` HTTP adapter that routes through `DatabricksHttpClient::execute_without_auth()`
78+
- Use `TokenStore` for token lifecycle (no disk cache for M2M)
79+
- `get_auth_header()``TokenStore::get_or_refresh()``client.exchange_client_credentials()` if needed
80+
81+
**Tests:**
82+
- `m2m.rs`: `test_m2m_token_exchange`, `test_m2m_auto_refresh`, `test_m2m_oidc_discovery`
83+
- Wiremock integration (`tests/`): `test_m2m_full_flow_discovery_and_token_exchange`, `test_m2m_token_refresh_on_expiry`, `test_m2m_discovery_failure_propagates`
84+
- Database validation: `test_new_connection_client_credentials_missing_secret`
85+
86+
**Definition of done:** M2M flow works end-to-end from `Database` config through `new_connection()` to `get_auth_header()`. Wiremock tests verify the full OIDC discovery -> token exchange -> refresh cycle.
87+
88+
---
89+
90+
### Task 3: U2M Provider (Authorization Code + PKCE)
91+
92+
**Scope:** Implement the U2M OAuth flow with browser-based login, PKCE, callback server, and token caching.
93+
94+
**Files to create:**
95+
- `src/auth/oauth/callback.rs``CallbackServer` with localhost HTTP listener, state validation, timeout
96+
- `src/auth/oauth/u2m.rs``AuthorizationCodeProvider` implementing `AuthProvider`
97+
98+
**Files to modify:**
99+
- `src/auth/oauth/mod.rs` — add `pub mod callback;`, `pub mod u2m;`, re-export `AuthorizationCodeProvider`
100+
- `src/auth/mod.rs` — re-export `AuthorizationCodeProvider`
101+
- `src/database.rs` — wire `AuthFlow::Browser` match arm in `new_connection()` to create `AuthorizationCodeProvider`
102+
103+
**Implementation details:**
104+
- On creation: try loading cached token via `TokenCache::load()`
105+
- If cached token has valid `refresh_token`: store in `TokenStore`, refresh on first `get_auth_header()` if stale/expired
106+
- If no cache: generate PKCE via `PkceCodeChallenge::new_random_sha256()`, build auth URL via `client.authorize_url()`, start `CallbackServer`, launch browser via `open::that()`, wait for callback, exchange code via `client.exchange_code().set_pkce_verifier()`
107+
- Save token to cache after every successful acquisition/refresh
108+
- Refresh uses `client.exchange_refresh_token()` through `DatabricksHttpClient::execute_without_auth()`
109+
- If refresh token is expired, fall back to full browser flow
110+
111+
**Tests:**
112+
- `callback.rs`: `test_callback_captures_code`, `test_callback_validates_state`, `test_callback_timeout`
113+
- `u2m.rs`: `test_u2m_refresh_token_flow`, `test_u2m_cache_hit`, `test_u2m_cache_miss_with_expired_refresh`
114+
- Wiremock integration: `test_u2m_refresh_token_full_flow`
115+
- Database validation: `test_new_connection_token_passthrough_missing_token`
116+
- E2E (ignored): `test_m2m_end_to_end`, `test_u2m_end_to_end`
117+
118+
**Definition of done:** U2M flow works end-to-end. Token cache persists across connections. Refresh path works via wiremock tests. Browser flow verified manually via `#[ignore]` E2E test.

rust/docs/designs/oauth-u2m-m2m-design.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ pub(crate) struct TokenStore {
180180
```
181181

182182
**Contract:**
183-
- `get_or_refresh(refresh_fn)`: Returns a valid token. If STALE, spawns background refresh via `std::thread::spawn` and returns current token. If EXPIRED, blocks caller until refresh completes.
183+
- `get_or_refresh(refresh_fn)`: Returns a valid token. If STALE, spawns background refresh via `tokio::spawn` and returns current token. If EXPIRED, blocks caller until refresh completes.
184184
- Thread-safe: `RwLock` for read-heavy access, `AtomicBool` to prevent concurrent refresh.
185185
- Only one refresh runs at a time; concurrent callers receive the current (stale) token.
186186

@@ -291,29 +291,29 @@ sequenceDiagram
291291
participant DB as Database
292292
participant M2M as ClientCredsProvider
293293
participant OIDC as OIDC Discovery
294-
participant TE as Token Endpoint
294+
participant TokenEP as Token Endpoint
295295
296296
App->>DB: new_connection()
297297
DB->>M2M: new(host, client_id, client_secret, scopes)
298298
M2M->>OIDC: GET {host}/oidc/.well-known/oauth-authorization-server
299299
OIDC-->>M2M: OidcEndpoints
300300
301-
Note over App,TE: First get_auth_header() call
301+
Note over App,TokenEP: First get_auth_header() call
302302
App->>M2M: get_auth_header()
303-
M2M->>TE: client.exchange_client_credentials()
304-
TE-->>M2M: access_token (no refresh_token)
303+
M2M->>TokenEP: client.exchange_client_credentials()
304+
TokenEP-->>M2M: access_token (no refresh_token)
305305
M2M-->>App: "Bearer {access_token}"
306306
307-
Note over App,TE: Subsequent calls (token FRESH)
307+
Note over App,TokenEP: Subsequent calls (token FRESH)
308308
App->>M2M: get_auth_header()
309309
M2M-->>App: "Bearer {cached_token}"
310310
311-
Note over App,TE: Token becomes STALE
311+
Note over App,TokenEP: Token becomes STALE
312312
App->>M2M: get_auth_header()
313313
M2M->>M2M: Spawn background refresh
314314
M2M-->>App: "Bearer {current_token}"
315-
M2M->>TE: client.exchange_client_credentials() (background)
316-
TE-->>M2M: new access_token
315+
M2M->>TokenEP: client.exchange_client_credentials() (background)
316+
TokenEP-->>M2M: new access_token
317317
```
318318

319319
### Token Exchange (M2M)

0 commit comments

Comments
 (0)