|
| 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. |
0 commit comments