From 3a1d3be256803642f66fad084078704d3faca38d Mon Sep 17 00:00:00 2001 From: Colin Patrick McCabe Date: Tue, 15 Sep 2026 16:35:37 -0700 Subject: [PATCH] feat(oidc): support resource and audience (#4193) Support configuring resource and audience for OAuth authorization, token exchange, and refresh requests. --- docs/src/js/interfaces/NativeOAuthConfig.md | 20 ++ docs/src/js/interfaces/OAuthConfig.md | 35 +++ docs/src/js/interfaces/SessionStatus.md | 20 ++ docs/src/js/interfaces/TokenCacheOptions.md | 2 +- nodejs/__test__/oauth.test.ts | 81 ++++-- nodejs/lancedb/oauth.ts | 33 ++- nodejs/src/remote.rs | 24 ++ python/python/lancedb/_lancedb.pyi | 4 + python/python/lancedb/remote/oauth.py | 15 ++ python/src/oauth.rs | 26 ++ python/tests/test_oauth.py | 44 ++- rust/lancedb/src/connection.rs | 4 + rust/lancedb/src/remote/oauth.rs | 280 +++++++++++++++++++- rust/lancedb/src/remote/token_cache.rs | 141 +++++++++- 14 files changed, 683 insertions(+), 46 deletions(-) diff --git a/docs/src/js/interfaces/NativeOAuthConfig.md b/docs/src/js/interfaces/NativeOAuthConfig.md index afe05de9f..074131808 100644 --- a/docs/src/js/interfaces/NativeOAuthConfig.md +++ b/docs/src/js/interfaces/NativeOAuthConfig.md @@ -15,6 +15,16 @@ All token acquisition and refresh is handled in the Rust layer. ## Properties +### audience? + +```ts +optional audience: string; +``` + +Optional provider-specific audience for authorization and token requests. + +*** + ### callbackPort? ```ts @@ -99,6 +109,16 @@ the TTL, each request refreshes the token. *** +### resource? + +```ts +optional resource: string; +``` + +Optional resource indicator for authorization and token requests. + +*** + ### scopes ```ts diff --git a/docs/src/js/interfaces/OAuthConfig.md b/docs/src/js/interfaces/OAuthConfig.md index f7d61c663..0615fb8c5 100644 --- a/docs/src/js/interfaces/OAuthConfig.md +++ b/docs/src/js/interfaces/OAuthConfig.md @@ -26,6 +26,18 @@ const config: OAuthConfig = { }; ``` +Providers requiring an explicit target can set `resource` and/or `audience`: +```typescript +const targeted: OAuthConfig = { + issuerUrl: "https://issuer.example.com", + clientId: "app-id", + clientSecret: "secret", + scopes: ["read"], + resource: "https://api.example.com", + audience: "lancedb-api", +}; +``` + ```typescript const config: OAuthConfig = { issuerUrl: "https://login.microsoftonline.com/{tenant}/v2.0", @@ -51,6 +63,17 @@ before polling begins. ## Properties +### audience? + +```ts +optional audience: string; +``` + +Provider-specific audience, forwarded to authorization and token endpoints, +including refresh requests. Not supported for Azure managed identity. + +*** + ### callbackPort? ```ts @@ -134,6 +157,18 @@ the TTL, each request refreshes the token. *** +### resource? + +```ts +optional resource: string; +``` + +Resource indicator (RFC 8707), forwarded verbatim to authorization and token +endpoints, including refresh requests. Must be an absolute URI without a +fragment. Not supported for Azure managed identity. + +*** + ### scopes ```ts diff --git a/docs/src/js/interfaces/SessionStatus.md b/docs/src/js/interfaces/SessionStatus.md index 3844bfae6..260233d6a 100644 --- a/docs/src/js/interfaces/SessionStatus.md +++ b/docs/src/js/interfaces/SessionStatus.md @@ -11,6 +11,16 @@ Safe, non-secret view of a cached OAuth session, returned by ## Properties +### audience? + +```ts +optional audience: string; +``` + +Provider-specific audience used to obtain the cached session, if configured. + +*** + ### clientId ```ts @@ -65,6 +75,16 @@ prompt. *** +### resource? + +```ts +optional resource: string; +``` + +Resource indicator used to obtain the cached session, if configured. + +*** + ### scopes ```ts diff --git a/docs/src/js/interfaces/TokenCacheOptions.md b/docs/src/js/interfaces/TokenCacheOptions.md index 95b96ef2f..d96337468 100644 --- a/docs/src/js/interfaces/TokenCacheOptions.md +++ b/docs/src/js/interfaces/TokenCacheOptions.md @@ -13,7 +13,7 @@ The cache is opt-in: it is only used when set as `tokenCache` on directory with owner-only permissions, so short-lived processes can reuse an authenticated session instead of re-prompting on every start. -Multiple identities (issuer, client, scopes, flow, client authentication) +Multiple identities (issuer, client, scopes, resource, audience, flow, client authentication) get separate cache entries. Within one identity the most recent login wins. ## Properties diff --git a/nodejs/__test__/oauth.test.ts b/nodejs/__test__/oauth.test.ts index 83ea2791b..0a04bb2aa 100644 --- a/nodejs/__test__/oauth.test.ts +++ b/nodejs/__test__/oauth.test.ts @@ -67,41 +67,64 @@ describe("OAuthSession", () => { expect(() => new OAuthSession(config)).toThrow(/AzureManagedIdentity/); }); - it("logs in via device flow, caches, and logs out", async () => { - const server = new MockIdp(); - await server.start(); - try { - const cacheDir = tempCacheDir(); - const issuerUrl = server.issuerUrl(); + it.each([ + {}, + { + resource: "https://api.example.com/a?x=1&y=two", + audience: "audience + & / ü", + }, + ])( + "logs in via device flow with target %j, caches, and logs out", + async (target) => { + const server = new MockIdp(); + await server.start(); + try { + const cacheDir = tempCacheDir(); + const issuerUrl = server.issuerUrl(); - const session = new OAuthSession(deviceConfig(issuerUrl, cacheDir)); - const status = await session.login(); - expect(status.refreshable).toBe(true); - expect(status.obtainedAt).toBeGreaterThan(0); - expect(server.state.deviceAuthorizations).toBe(1); + const config = { ...deviceConfig(issuerUrl, cacheDir), ...target }; + const session = new OAuthSession(config); + const status = await session.login(); + expect(status.refreshable).toBe(true); + expect(status.resource).toBe(config.resource); + expect(status.audience).toBe(config.audience); + expect(status.obtainedAt).toBeGreaterThan(0); + expect(server.state.deviceAuthorizations).toBe(1); - // An independent session (a fresh "process") sees the cached login. - const other = new OAuthSession(deviceConfig(issuerUrl, cacheDir)); - const cached = await other.status(); - expect(cached.refreshable).toBe(true); + // An independent session (a fresh "process") sees the cached login. + const other = new OAuthSession(config); + const cached = await other.status(); + expect(cached.refreshable).toBe(true); - const logout = await other.logout(); - expect(logout.removed).toBe(true); - const again = await session.logout(); - expect(again.removed).toBe(false); - expect((await session.status()).refreshable).toBe(false); + const logout = await other.logout(); + expect(logout.removed).toBe(true); + const again = await session.logout(); + expect(again.removed).toBe(false); + expect((await session.status()).refreshable).toBe(false); - // Only the initial login used the interactive device flow. - expect(server.state.deviceAuthorizations).toBe(1); - expect(server.state.refreshGrants).toBe(0); - } finally { - server.close(); - } - }, 15000); + // Only the initial login used the interactive device flow. + expect(server.state.deviceAuthorizations).toBe(1); + expect(server.state.refreshGrants).toBe(0); + expect(server.requests).toHaveLength(2); + for (const params of server.requests) { + expect(params.getAll("resource")).toEqual( + config.resource === undefined ? [] : [config.resource], + ); + expect(params.getAll("audience")).toEqual( + config.audience === undefined ? [] : [config.audience], + ); + } + } finally { + server.close(); + } + }, + 15000, + ); }); /** Mock IdP with discovery, device authorization, and rotating refresh. */ class MockIdp { + readonly requests: URLSearchParams[] = []; readonly state = { deviceAuthorizations: 0, refreshGrants: 0, @@ -159,6 +182,10 @@ class MockIdp { return; } + if (url === "/device" || url === "/token") { + this.requests.push(params); + } + if (url === "/device") { this.state.deviceAuthorizations += 1; respond(200, { diff --git a/nodejs/lancedb/oauth.ts b/nodejs/lancedb/oauth.ts index c9c78afa6..162f38997 100644 --- a/nodejs/lancedb/oauth.ts +++ b/nodejs/lancedb/oauth.ts @@ -28,7 +28,7 @@ export enum OAuthFlowType { * directory with owner-only permissions, so short-lived processes can reuse * an authenticated session instead of re-prompting on every start. * - * Multiple identities (issuer, client, scopes, flow, client authentication) + * Multiple identities (issuer, client, scopes, resource, audience, flow, client authentication) * get separate cache entries. Within one identity the most recent login wins. */ export interface TokenCacheOptions { @@ -67,6 +67,18 @@ export interface TokenCacheOptions { * }; * ``` * + * Providers requiring an explicit target can set `resource` and/or `audience`: + * ```typescript + * const targeted: OAuthConfig = { + * issuerUrl: "https://issuer.example.com", + * clientId: "app-id", + * clientSecret: "secret", + * scopes: ["read"], + * resource: "https://api.example.com", + * audience: "lancedb-api", + * }; + * ``` + * * @example Azure Managed Identity: * ```typescript * const config: OAuthConfig = { @@ -109,6 +121,19 @@ export interface OAuthConfig { */ scopes: string[]; + /** + * Resource indicator (RFC 8707), forwarded verbatim to authorization and token + * endpoints, including refresh requests. Must be an absolute URI without a + * fragment. Not supported for Azure managed identity. + */ + resource?: string; + + /** + * Provider-specific audience, forwarded to authorization and token endpoints, + * including refresh requests. Not supported for Azure managed identity. + */ + audience?: string; + /** Authentication flow (default: ClientCredentials). */ flow?: OAuthFlowType; @@ -166,6 +191,12 @@ export interface SessionStatus { /** Canonical (sorted, de-duplicated) scope set of the cached session. */ scopes: string[]; + /** Resource indicator used to obtain the cached session, if configured. */ + resource?: string; + + /** Provider-specific audience used to obtain the cached session, if configured. */ + audience?: string; + /** Flow that produced the cached session. */ flow: string; diff --git a/nodejs/src/remote.rs b/nodejs/src/remote.rs index c619aca35..1db00e832 100644 --- a/nodejs/src/remote.rs +++ b/nodejs/src/remote.rs @@ -188,6 +188,10 @@ pub struct OAuthConfig { /// OAuth scopes to request. For Azure managed identity, exactly one scope /// or resource is required. For example: `["api://{app_id}/.default"]` pub scopes: Vec, + /// Optional resource indicator for authorization and token requests. + pub resource: Option, + /// Optional provider-specific audience for authorization and token requests. + pub audience: Option, /// Authentication flow: "client_credentials", "authorization_code", /// "device_code", or "azure_managed_identity" pub flow: Option, @@ -216,6 +220,8 @@ impl std::fmt::Debug for OAuthConfig { .field("issuer_url", &self.issuer_url) .field("client_id", &self.client_id) .field("scopes", &self.scopes) + .field("resource", &self.resource) + .field("audience", &self.audience) .field("flow", &self.flow) .field( "client_secret", @@ -269,6 +275,8 @@ impl TryFrom for lancedb::remote::oauth::OAuthConfig { client_id: config.client_id, client_secret: config.client_secret, scopes: config.scopes, + resource: config.resource, + audience: config.audience, flow, refresh_buffer_secs: config.refresh_buffer_secs.map(|v| v as u64), token_cache: config.token_cache.map(Into::into), @@ -290,6 +298,10 @@ pub struct SessionStatus { pub client_id: String, /// Canonical (sorted, de-duplicated) scopes of the cached session. pub scopes: Vec, + /// Optional resource indicator for authorization and token requests. + pub resource: Option, + /// Optional provider-specific audience for authorization and token requests. + pub audience: Option, /// Flow that produced the cached session. pub flow: String, /// When the cached session was obtained, as Unix seconds. @@ -374,6 +386,8 @@ impl From for SessionStatus { issuer_url: status.issuer_url, client_id: status.client_id, scopes: status.scopes, + resource: status.resource, + audience: status.audience, flow: status.flow, obtained_at: status.obtained_at.map(|secs| secs as f64), } @@ -418,6 +432,8 @@ mod tests { use_pkce: None, managed_identity_client_id: None, refresh_buffer_secs: None, + resource: None, + audience: None, token_cache: None, }; @@ -442,6 +458,8 @@ mod tests { use_pkce: None, managed_identity_client_id: None, refresh_buffer_secs: None, + resource: None, + audience: None, token_cache: None, }; @@ -463,6 +481,8 @@ mod tests { use_pkce: Some(false), managed_identity_client_id: None, refresh_buffer_secs: None, + resource: Some("urn:resource".into()), + audience: Some("audience".into()), token_cache: None, }; @@ -476,6 +496,8 @@ mod tests { ); assert_eq!(options.callback_port, Some(9000)); assert!(!options.use_pkce); + assert_eq!(converted.resource.as_deref(), Some("urn:resource")); + assert_eq!(converted.audience.as_deref(), Some("audience")); } #[test] @@ -491,6 +513,8 @@ mod tests { use_pkce: None, managed_identity_client_id: None, refresh_buffer_secs: None, + resource: None, + audience: None, token_cache: None, }; diff --git a/python/python/lancedb/_lancedb.pyi b/python/python/lancedb/_lancedb.pyi index 32159dd5a..e18657eb7 100644 --- a/python/python/lancedb/_lancedb.pyi +++ b/python/python/lancedb/_lancedb.pyi @@ -284,6 +284,10 @@ class JobInfo: def created_at_millis(self) -> int: ... class SessionStatus: + @property + def resource(self) -> Optional[str]: ... + @property + def audience(self) -> Optional[str]: ... @property def refreshable(self) -> bool: ... @property diff --git a/python/python/lancedb/remote/oauth.py b/python/python/lancedb/remote/oauth.py index ad9fd2b0e..e8531db1c 100644 --- a/python/python/lancedb/remote/oauth.py +++ b/python/python/lancedb/remote/oauth.py @@ -86,6 +86,13 @@ class OAuthConfig: Protect AUTHORIZATION_CODE with S256 PKCE (default: True). managed_identity_client_id : Optional[str] Client ID for user-assigned managed identity (AZURE_MANAGED_IDENTITY). + resource : Optional[str] + Resource indicator (RFC 8707), forwarded verbatim to authorization and + token endpoints, including refresh requests. Must be an absolute URI + without a fragment. Not supported for Azure managed identity. + audience : Optional[str] + Provider-specific audience, forwarded to authorization and token + endpoints, including refresh requests. Not supported for Azure managed identity. refresh_buffer_secs : Optional[int] Seconds before expiry to trigger proactive refresh (default: 300). Keep this well below the token TTL; if it is greater than or equal to @@ -106,6 +113,12 @@ class OAuthConfig: ... scopes=["api://lancedb-api/.default"], ... ) + Providers that require an explicit target can use ``resource`` and/or + ``audience`` (these are forwarded unchanged): + + >>> config.resource = "https://api.example.com" + >>> config.audience = "lancedb-api" + Azure Managed Identity: >>> config = OAuthConfig( @@ -150,6 +163,8 @@ class OAuthConfig: managed_identity_client_id: Optional[str] = None refresh_buffer_secs: Optional[int] = None token_cache: Optional[TokenCacheOptions] = None + resource: Optional[str] = None + audience: Optional[str] = None class OAuthSession: diff --git a/python/src/oauth.rs b/python/src/oauth.rs index ff24a9566..080ea4d95 100644 --- a/python/src/oauth.rs +++ b/python/src/oauth.rs @@ -36,6 +36,10 @@ pub struct PyOAuthConfig { pub issuer_url: String, pub client_id: String, pub scopes: Vec, + /// Optional resource indicator for authorization and token requests. + pub resource: Option, + /// Optional provider-specific audience for authorization and token requests. + pub audience: Option, pub flow: String, pub client_secret: Option, pub redirect_uri: Option, @@ -78,6 +82,8 @@ impl TryFrom for OAuthConfig { client_id: py.client_id, client_secret: py.client_secret, scopes: py.scopes, + resource: py.resource, + audience: py.audience, flow, refresh_buffer_secs: py.refresh_buffer_secs, token_cache: py.token_cache.map(TokenCacheOptions::from), @@ -119,6 +125,18 @@ impl PySessionStatus { self.inner.scopes.clone() } + /// Resource indicator used to obtain the cached session. + #[getter] + pub fn resource(&self) -> Option { + self.inner.resource.clone() + } + + /// Provider-specific audience used to obtain the cached session. + #[getter] + pub fn audience(&self) -> Option { + self.inner.audience.clone() + } + /// Flow that produced the cached session. #[getter] pub fn flow(&self) -> String { @@ -242,6 +260,8 @@ mod tests { use_pkce: true, managed_identity_client_id: None, refresh_buffer_secs: None, + resource: None, + audience: None, token_cache: None, } } @@ -269,6 +289,8 @@ mod tests { redirect_uri: Some("http://127.0.0.1:9000/callback".to_string()), callback_port: Some(9000), use_pkce: false, + resource: Some("urn:resource".into()), + audience: Some("audience".into()), ..base_config() }; @@ -282,6 +304,8 @@ mod tests { ); assert_eq!(options.callback_port, Some(9000)); assert!(!options.use_pkce); + assert_eq!(converted.resource.as_deref(), Some("urn:resource")); + assert_eq!(converted.audience.as_deref(), Some("audience")); } #[test] @@ -294,6 +318,8 @@ mod tests { #[test] fn test_token_cache_conversion() { let config = PyOAuthConfig { + resource: None, + audience: None, token_cache: Some(PyTokenCacheOptions { cache_dir: Some("/tmp/oauth-cache".to_string()), lock_timeout_secs: Some(5), diff --git a/python/tests/test_oauth.py b/python/tests/test_oauth.py index 36f687042..583f5a35f 100644 --- a/python/tests/test_oauth.py +++ b/python/tests/test_oauth.py @@ -72,6 +72,8 @@ def test_token_cache_options_default_to_memory_only(): scopes=["openid"], ) assert config.token_cache is None + assert config.resource is None + assert config.audience is None options = oauth.TokenCacheOptions() assert options.cache_dir is None @@ -121,6 +123,7 @@ class _MockIdpState: self.invalid_grant_rejections = 0 self.access_tokens_issued = 0 self.current_refresh = None + self.requests = [] class _MockIdpHandler(BaseHTTPRequestHandler): @@ -156,6 +159,8 @@ class _MockIdpHandler(BaseHTTPRequestHandler): length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length).decode() params = urllib.parse.parse_qs(body) + with self.state.lock: + self.state.requests.append(params) if self.path == "/device": with self.state.lock: @@ -212,11 +217,11 @@ def _start_mock_idp() -> tuple[_MockIdpState, HTTPServer]: return state, server -def _run_subprocess(script: Path, issuer_url: str, cache_dir: Path): +def _run_subprocess(script: Path, issuer_url: str, cache_dir: Path, target: dict): env = dict(os.environ) env["LANCEDB_OAUTH_BROWSER"] = "/usr/bin/true" result = subprocess.run( - [sys.executable, str(script), issuer_url, str(cache_dir)], + [sys.executable, str(script), issuer_url, str(cache_dir), json.dumps(target)], capture_output=True, text=True, timeout=120, @@ -230,6 +235,7 @@ def _run_subprocess(script: Path, issuer_url: str, cache_dir: Path): LOGIN_SCRIPT = """ import asyncio +import json import sys from lancedb.remote import OAuthConfig, OAuthFlowType, OAuthSession, TokenCacheOptions @@ -241,15 +247,19 @@ config = OAuthConfig( scopes=["openid"], flow=OAuthFlowType.DEVICE_CODE, token_cache=TokenCacheOptions(cache_dir=cache_dir), + **json.loads(sys.argv[3]), ) session = OAuthSession(config) status = asyncio.run(session.login()) assert status.refreshable, "login must cache a refresh token" +assert status.resource == config.resource +assert status.audience == config.audience print("LOGIN-OK") """ REUSE_SCRIPT = """ import asyncio +import json import sys import lancedb @@ -262,6 +272,7 @@ config = OAuthConfig( scopes=["openid"], flow=OAuthFlowType.DEVICE_CODE, token_cache=TokenCacheOptions(cache_dir=cache_dir), + **json.loads(sys.argv[3]), ) session = OAuthSession(config) @@ -292,7 +303,17 @@ print("REUSE-OK") """ -def test_cross_process_session_reuse_without_new_prompt(tmp_path): +@pytest.mark.parametrize( + "target", + [ + {}, + { + "resource": "https://api.example.com/a?x=1&y=two", + "audience": "audience + & / ü", + }, + ], +) +def test_cross_process_session_reuse_without_new_prompt(tmp_path, target): pytest.importorskip("lancedb") state, server = _start_mock_idp() try: @@ -303,11 +324,11 @@ def test_cross_process_session_reuse_without_new_prompt(tmp_path): reuse_script.write_text(REUSE_SCRIPT) cache_dir = tmp_path / "oauth-cache" - result = _run_subprocess(login_script, issuer_url, cache_dir) + result = _run_subprocess(login_script, issuer_url, cache_dir, target) assert "LOGIN-OK" in result.stdout assert state.device_authorizations == 1 - result = _run_subprocess(reuse_script, issuer_url, cache_dir) + result = _run_subprocess(reuse_script, issuer_url, cache_dir, target) assert "REUSE-OK" in result.stdout assert "DATABASE-UNREACHABLE-AS-EXPECTED" in result.stdout @@ -317,11 +338,14 @@ def test_cross_process_session_reuse_without_new_prompt(tmp_path): assert state.device_authorizations == 1 assert state.invalid_grant_rejections == 0 - logout = asyncio.run( - _remote_oauth() - .OAuthSession(_device_config(_remote_oauth(), issuer_url, cache_dir)) - .logout() - ) + assert len(state.requests) == 3 + for params in state.requests: + for key in ("resource", "audience"): + assert params.get(key) == ([target[key]] if key in target else None) + config = _device_config(_remote_oauth(), issuer_url, cache_dir) + config.resource = target.get("resource") + config.audience = target.get("audience") + logout = asyncio.run(_remote_oauth().OAuthSession(config).logout()) assert logout.removed is True finally: server.shutdown() diff --git a/rust/lancedb/src/connection.rs b/rust/lancedb/src/connection.rs index b0446919b..e136a0923 100644 --- a/rust/lancedb/src/connection.rs +++ b/rust/lancedb/src/connection.rs @@ -1546,6 +1546,8 @@ mod tests { scopes: vec!["scope".to_string()], flow: crate::remote::OAuthFlow::ClientCredentials, refresh_buffer_secs: None, + resource: None, + audience: None, token_cache: None, }; @@ -1589,6 +1591,8 @@ mod tests { scopes: vec!["scope".to_string()], flow: crate::remote::OAuthFlow::ClientCredentials, refresh_buffer_secs: None, + resource: None, + audience: None, token_cache: None, }; let client_config = crate::remote::ClientConfig { diff --git a/rust/lancedb/src/remote/oauth.rs b/rust/lancedb/src/remote/oauth.rs index 75a8878a8..516219a14 100644 --- a/rust/lancedb/src/remote/oauth.rs +++ b/rust/lancedb/src/remote/oauth.rs @@ -187,6 +187,17 @@ pub struct OAuthConfig { /// For example: `["api://{app_id}/.default"]` pub scopes: Vec, + /// Resource indicator sent to the authorization and token endpoints (RFC 8707). + /// The value is forwarded verbatim, including on refresh requests, and must + /// be an absolute URI without a fragment. + /// Not supported for Azure managed identity. + pub resource: Option, + + /// Provider-specific audience sent to the authorization and token endpoints, + /// including refresh requests. + /// Not supported for Azure managed identity. + pub audience: Option, + /// Authentication flow to use. pub flow: OAuthFlow, @@ -214,6 +225,8 @@ impl std::fmt::Debug for OAuthConfig { &self.client_secret.as_deref().map(|_| ""), ) .field("scopes", &self.scopes) + .field("resource", &self.resource) + .field("audience", &self.audience) .field("flow", &self.flow) .field("refresh_buffer_secs", &self.refresh_buffer_secs) .field("token_cache", &self.token_cache) @@ -366,6 +379,8 @@ struct OidcClient { client_id: String, client_secret: Option, scopes: Vec, + resource: Option, + audience: Option, http_client: Client, discovery: RwLock>, } @@ -380,6 +395,8 @@ impl std::fmt::Debug for OidcClient { &self.client_secret.as_ref().map(|_| ""), ) .field("scopes", &self.scopes) + .field("resource", &self.resource) + .field("audience", &self.audience) .finish() } } @@ -390,6 +407,8 @@ impl OidcClient { client_id: String, client_secret: Option, scopes: Vec, + resource: Option, + audience: Option, ) -> Result { Self::validate_issuer_transport(&issuer_url)?; @@ -413,6 +432,8 @@ impl OidcClient { client_id, client_secret, scopes, + resource, + audience, http_client, discovery: RwLock::new(None), }) @@ -483,6 +504,14 @@ impl OidcClient { self.get_discovery().await.map(|disc| disc.token_endpoint) } + fn target_params(&self) -> impl Iterator { + self.resource + .as_deref() + .map(|value| ("resource", value)) + .into_iter() + .chain(self.audience.as_deref().map(|value| ("audience", value))) + } + fn scopes_string(&self) -> String { self.scopes.join(" ") } @@ -492,10 +521,15 @@ impl OidcClient { endpoint: &str, params: &[(String, String)], ) -> Result { + let mut params = params.to_vec(); + params.extend( + self.target_params() + .map(|(key, value)| (key.to_owned(), value.to_owned())), + ); let resp = self .http_client .post(endpoint) - .form(params) + .form(¶ms) .send() .await .map_err(|e| Error::Runtime { @@ -527,6 +561,10 @@ impl OidcClient { if let Some(secret) = self.client_secret.as_ref() { params.push(("client_secret".to_string(), secret.clone())); } + params.extend( + self.target_params() + .map(|(key, value)| (key.to_owned(), value.to_owned())), + ); let response = self .http_client .post(&endpoint) @@ -581,6 +619,8 @@ impl ClientCredentialsSource { client_id: String, client_secret: Option, scopes: Vec, + resource: Option, + audience: Option, ) -> Result { if client_secret.is_none() { return Err(Error::InvalidInput { @@ -588,7 +628,14 @@ impl ClientCredentialsSource { }); } Ok(Self { - oidc: OidcClient::new(issuer_url, client_id, client_secret, scopes)?, + oidc: OidcClient::new( + issuer_url, + client_id, + client_secret, + scopes, + resource, + audience, + )?, }) } } @@ -719,11 +766,20 @@ impl AuthorizationCodeSource { client_id: String, client_secret: Option, scopes: Vec, + resource: Option, + audience: Option, options: AuthorizationCodeOptions, ) -> Result { let redirect = ResolvedRedirect::new(&options)?; Ok(Self { - oidc: OidcClient::new(issuer_url, client_id, client_secret, scopes)?, + oidc: OidcClient::new( + issuer_url, + client_id, + client_secret, + scopes, + resource, + audience, + )?, options, redirect, }) @@ -750,6 +806,7 @@ impl AuthorizationCodeSource { .append_pair("redirect_uri", &self.redirect.uri) .append_pair("scope", &self.oidc.scopes_string()) .append_pair("state", &state); + query.extend_pairs(self.oidc.target_params()); if let Some(verifier) = code_verifier.as_ref() { let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD .encode(Sha256::digest(verifier.as_bytes())); @@ -895,9 +952,18 @@ impl DeviceCodeSource { client_id: String, client_secret: Option, scopes: Vec, + resource: Option, + audience: Option, ) -> Result { Ok(Self { - oidc: OidcClient::new(issuer_url, client_id, client_secret, scopes)?, + oidc: OidcClient::new( + issuer_url, + client_id, + client_secret, + scopes, + resource, + audience, + )?, }) } @@ -917,6 +983,11 @@ impl DeviceCodeSource { if let Some(secret) = self.oidc.client_secret.as_ref() { params.push(("client_secret".to_string(), secret.clone())); } + params.extend( + self.oidc + .target_params() + .map(|(key, value)| (key.to_owned(), value.to_owned())), + ); let response = self .oidc .http_client @@ -980,6 +1051,11 @@ impl DeviceCodeSource { params.push(("client_secret".to_string(), secret.clone())); } + params.extend( + self.oidc + .target_params() + .map(|(key, value)| (key.to_owned(), value.to_owned())), + ); let response = match self .oidc .http_client @@ -1287,6 +1363,13 @@ impl TokenSource for AzureImdsSource { /// Shared by [`OAuthHeaderProvider`] and /// [`OAuthSession`](crate::remote::OAuthSession). pub(crate) fn build_token_source(config: &OAuthConfig) -> Result> { + if matches!(config.flow, OAuthFlow::AzureManagedIdentity { .. }) + && (config.resource.is_some() || config.audience.is_some()) + { + return Err(Error::InvalidInput { + message: "resource and audience are not supported for AzureManagedIdentity; configure its resource through scopes".to_string(), + }); + } if config.scopes.is_empty() { return Err(Error::InvalidInput { message: "At least one OAuth scope is required".to_string(), @@ -1298,12 +1381,16 @@ pub(crate) fn build_token_source(config: &OAuthConfig) -> Result Box::new(AuthorizationCodeSource::new( config.issuer_url.clone(), config.client_id.clone(), config.client_secret.clone(), config.scopes.clone(), + config.resource.clone(), + config.audience.clone(), options.clone(), )?), OAuthFlow::DeviceCode => Box::new(DeviceCodeSource::new( @@ -1311,6 +1398,8 @@ pub(crate) fn build_token_source(config: &OAuthConfig) -> Result Box::new(AzureImdsSource::new( config.scopes.clone(), @@ -1441,6 +1530,145 @@ mod tests { use tokio::net::{TcpListener, TcpStream}; use tokio::task::JoinHandle; + #[tokio::test] + async fn test_target_parameters_across_oauth_flows() { + for (resource, audience) in [ + (None, None), + (Some("https://api.example.com/a?x=1&y=two"), None), + (None, Some("audience + & / ü")), + (Some("urn:example:resource"), Some("audience + & / ü")), + ] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let issuer = format!("http://{addr}"); + let server = tokio::spawn(async move { + let mut grants = Vec::new(); + // Three discovery requests and six form submissions. + for _ in 0..9 { + let (mut stream, _) = listener.accept().await.unwrap(); + let (line, body) = read_http_request(&mut stream).await; + let response = if line.starts_with("GET ") { + serde_json::json!({ + "token_endpoint": format!("http://{addr}/token"), + "authorization_endpoint": format!("http://{addr}/authorize"), + "device_authorization_endpoint": format!("http://{addr}/device"), + }) + } else { + let params: Vec<_> = url::form_urlencoded::parse(body.as_bytes()).collect(); + for (key, expected) in [("resource", resource), ("audience", audience)] { + let values: Vec<_> = params + .iter() + .filter(|(name, _)| name == key) + .map(|(_, value)| value.as_ref()) + .collect(); + assert_eq!(values, expected.into_iter().collect::>()); + } + if line.starts_with("POST /device ") { + serde_json::json!({ + "device_code": "device-code", "user_code": "ABCD", + "verification_uri": format!("http://{addr}/verify"), + "expires_in": 60, "interval": 1, + }) + } else { + grants.push( + params + .iter() + .find(|(key, _)| key == "grant_type") + .unwrap() + .1 + .to_string(), + ); + serde_json::json!({"access_token": "access", "refresh_token": "refresh", "expires_in": 3600}) + } + }; + write_json_response(&mut stream, "200 OK", &response.to_string()).await; + } + assert_eq!( + grants, + [ + "client_credentials", + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code", + "refresh_token" + ] + ); + }); + let credentials = ClientCredentialsSource::new( + issuer.clone(), + "client".into(), + Some("secret".into()), + vec!["scope".into()], + resource.map(str::to_owned), + audience.map(str::to_owned), + ) + .unwrap(); + credentials.fetch_token().await.unwrap(); + let browser = AuthorizationCodeSource::new( + issuer.clone(), + "client".into(), + None, + vec!["scope".into()], + resource.map(str::to_owned), + audience.map(str::to_owned), + AuthorizationCodeOptions::new(), + ) + .unwrap(); + let request = browser.build_authorization_request().await.unwrap(); + for (key, expected) in [("resource", resource), ("audience", audience)] { + let values: Vec<_> = request + .url + .query_pairs() + .filter(|(name, _)| name == key) + .map(|(_, value)| value.into_owned()) + .collect(); + assert_eq!( + values, + expected.into_iter().map(str::to_owned).collect::>() + ); + } + browser + .exchange_code("code", Some("verifier")) + .await + .unwrap(); + browser.refresh_token("refresh").await.unwrap(); + let device = DeviceCodeSource::new( + issuer, + "client".into(), + None, + vec!["scope".into()], + resource.map(str::to_owned), + audience.map(str::to_owned), + ) + .unwrap(); + let authorization = device.request_device_authorization().await.unwrap(); + device.poll_for_token(&authorization).await.unwrap(); + device.refresh_token("refresh").await.unwrap(); + server.await.unwrap(); + } + } + + #[test] + fn test_managed_identity_rejects_target_parameters() { + for (resource, audience) in [(Some("urn:resource"), None), (None, Some("audience"))] { + let config = OAuthConfig { + issuer_url: "https://issuer.example.com".into(), + client_id: "client".into(), + client_secret: None, + scopes: vec!["api://app/.default".into()], + flow: OAuthFlow::AzureManagedIdentity { client_id: None }, + resource: resource.map(str::to_owned), + audience: audience.map(str::to_owned), + refresh_buffer_secs: None, + token_cache: None, + }; + let error = OAuthHeaderProvider::new(config).unwrap_err().to_string(); + assert!( + error.contains("resource and audience are not supported for AzureManagedIdentity") + ); + } + } + #[test] fn test_token_state_expiry() { let mut state = TokenState::new(); @@ -1529,6 +1757,8 @@ mod tests { "app-id".to_string(), Some("secret".to_string()), vec!["scope1".to_string(), "scope2".to_string()], + None, + None, ) .unwrap(); @@ -1692,6 +1922,8 @@ mod tests { "client-id".to_string(), None, vec!["openid".to_string()], + None, + None, AuthorizationCodeOptions::new() .redirect_uri(format!("http://127.0.0.1:{port}/callback")), ) @@ -1768,6 +2000,8 @@ mod tests { "client-id".to_string(), None, vec!["openid".to_string(), "profile".to_string()], + None, + None, AuthorizationCodeOptions::new(), ) .unwrap(); @@ -1799,6 +2033,8 @@ mod tests { "client-id".to_string(), None, vec!["openid".to_string()], + None, + None, AuthorizationCodeOptions::new(), ) .unwrap(); @@ -1820,6 +2056,8 @@ mod tests { "client-id".to_string(), Some("secret".to_string()), vec!["openid".to_string()], + None, + None, AuthorizationCodeOptions::new().use_pkce(false), ) .unwrap(); @@ -1840,6 +2078,8 @@ mod tests { "client-id".to_string(), Some("secret".to_string()), vec!["openid".to_string()], + None, + None, AuthorizationCodeOptions::new(), ) .unwrap(); @@ -1866,6 +2106,8 @@ mod tests { "client-id".to_string(), None, vec!["openid".to_string()], + None, + None, AuthorizationCodeOptions::new(), ) .unwrap(); @@ -1889,6 +2131,8 @@ mod tests { "client-id".to_string(), None, vec!["openid".to_string()], + None, + None, AuthorizationCodeOptions::new(), ) .unwrap(); @@ -1910,6 +2154,8 @@ mod tests { "client-id".to_string(), Some("secret".to_string()), vec!["openid".to_string()], + None, + None, ) .unwrap(); @@ -1930,6 +2176,8 @@ mod tests { "client-id".to_string(), None, vec!["openid".to_string()], + None, + None, ) .unwrap(); @@ -1952,6 +2200,8 @@ mod tests { "client-id".to_string(), None, vec!["openid".to_string()], + None, + None, ) .unwrap(); let device = test_device_authorization_response(10, 1); @@ -1971,6 +2221,8 @@ mod tests { "client-id".to_string(), None, vec!["openid".to_string()], + None, + None, ) .unwrap(); let device = test_device_authorization_response(60, 1); @@ -1992,6 +2244,8 @@ mod tests { "client-id".to_string(), None, vec!["openid".to_string()], + None, + None, ) .unwrap(); let device = test_device_authorization_response(60, 1); @@ -2013,6 +2267,8 @@ mod tests { "client-id".to_string(), None, vec!["openid".to_string()], + None, + None, ) .unwrap(); let device = test_device_authorization_response(1, 5); @@ -2192,6 +2448,8 @@ mod tests { scopes: vec!["scope".to_string()], flow: OAuthFlow::ClientCredentials, refresh_buffer_secs: None, + resource: None, + audience: None, token_cache: None, }; @@ -2209,6 +2467,8 @@ mod tests { scopes: vec!["scope".to_string()], flow: OAuthFlow::ClientCredentials, refresh_buffer_secs: None, + resource: None, + audience: None, token_cache: None, }; @@ -2246,6 +2506,8 @@ mod tests { ], flow: OAuthFlow::AzureManagedIdentity { client_id: None }, refresh_buffer_secs: None, + resource: None, + audience: None, token_cache: None, }; assert!(OAuthHeaderProvider::new(config).is_err()); @@ -2259,6 +2521,8 @@ mod tests { "client-id".to_string(), Some("secret".to_string()), vec!["scope".to_string()], + None, + None, ) .unwrap(); @@ -2280,6 +2544,8 @@ mod tests { scopes: vec!["scope".to_string()], flow: OAuthFlow::ClientCredentials, refresh_buffer_secs: None, + resource: None, + audience: None, token_cache: None, }; assert!(OAuthHeaderProvider::new(config).is_err()); @@ -2294,6 +2560,8 @@ mod tests { scopes: vec!["scope".to_string()], flow: OAuthFlow::ClientCredentials, refresh_buffer_secs: None, + resource: None, + audience: None, token_cache: None, }; @@ -2315,6 +2583,8 @@ mod tests { scopes: vec![], flow: OAuthFlow::AzureManagedIdentity { client_id: None }, refresh_buffer_secs: None, + resource: None, + audience: None, token_cache: None, }; assert!(OAuthHeaderProvider::new(config).is_err()); @@ -2330,6 +2600,8 @@ mod tests { scopes: vec!["scope".to_string()], flow: OAuthFlow::ClientCredentials, refresh_buffer_secs: Some(0), + resource: None, + audience: None, token_cache: None, }; let provider = OAuthHeaderProvider::new(config).unwrap(); diff --git a/rust/lancedb/src/remote/token_cache.rs b/rust/lancedb/src/remote/token_cache.rs index b67ced651..7d305b3cb 100644 --- a/rust/lancedb/src/remote/token_cache.rs +++ b/rust/lancedb/src/remote/token_cache.rs @@ -22,7 +22,8 @@ //! owner-checked, and symlink-rejected on Unix; records are replaced //! atomically via `rename` so a crash can never leave a torn file. //! - Cache filenames are SHA-256 hashes of the canonical issuer, client, -//! scope, flow, and client-auth identity. No secret appears in a filename. +//! scope, resource, audience, flow, and client-auth identity. No secret appears +//! in a filename. //! - Refresh-token rotation is serialized across processes with a per-key //! advisory file lock (`flock` on Unix, `LockFileEx` on Windows). The //! operating system releases these locks when a process dies, so a crash @@ -44,6 +45,8 @@ //! scopes: vec!["openid".to_string()], //! flow: OAuthFlow::DeviceCode, //! refresh_buffer_secs: None, +//! resource: Some("https://api.example.com".to_string()), +//! audience: None, //! token_cache: Some( //! TokenCacheOptions::new().cache_dir("/tmp/my-app/oauth-cache"), //! ), @@ -184,6 +187,10 @@ struct CachedTokenRecord { issuer_url: String, client_id: String, scopes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + resource: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + audience: Option, flow: String, client_auth: String, refresh_token: String, @@ -197,6 +204,8 @@ impl std::fmt::Debug for CachedTokenRecord { .field("issuer_url", &self.issuer_url) .field("client_id", &self.client_id) .field("scopes", &self.scopes) + .field("resource", &self.resource) + .field("audience", &self.audience) .field("flow", &self.flow) .field("client_auth", &self.client_auth) .field("refresh_token", &"") @@ -240,13 +249,15 @@ fn client_auth_key(client_secret: Option<&str>) -> &'static str { } } -/// Identity of one cached session: canonical issuer, client, scopes, flow, -/// and client-auth mode, plus the hashed filename derived from it. +/// Identity of one cached session: canonical issuer, client, scopes, resource, +/// audience, flow, and client-auth mode, plus the hashed filename derived from it. #[derive(Clone, Debug)] struct CacheKey { issuer_url: String, client_id: String, scopes: Vec, + resource: Option, + audience: Option, flow: &'static str, client_auth: &'static str, file_stem: String, @@ -272,11 +283,32 @@ impl CacheKey { flow, client_auth ); + // Keep existing sessions reachable when no target was specified. Targeted + // sessions use a structured encoding so parameter contents cannot collide. + let identity = if config.resource.is_none() && config.audience.is_none() { + identity + } else { + serde_json::to_string(&( + "v2", + &issuer_url, + &config.client_id, + &scopes, + flow, + client_auth, + &config.resource, + &config.audience, + )) + .map_err(|error| Error::Runtime { + message: format!("Failed to encode OAuth cache identity: {error}"), + })? + }; let file_stem = hex_sha256(identity.as_bytes()); Ok(Self { issuer_url, client_id: config.client_id.clone(), scopes, + resource: config.resource.clone(), + audience: config.audience.clone(), flow, client_auth, file_stem, @@ -394,6 +426,8 @@ impl TokenCache { issuer_url: self.key.issuer_url.clone(), client_id: self.key.client_id.clone(), scopes: self.key.scopes.clone(), + resource: self.key.resource.clone(), + audience: self.key.audience.clone(), flow: self.key.flow.to_string(), client_auth: self.key.client_auth.to_string(), refresh_token, @@ -761,6 +795,12 @@ pub struct SessionStatus { /// Canonical (sorted, de-duplicated) scope set of the cached session. pub scopes: Vec, + /// Resource indicator used to obtain the cached session, if configured. + pub resource: Option, + + /// Provider-specific audience used to obtain the cached session, if configured. + pub audience: Option, + /// Flow that produced the cached session. pub flow: String, @@ -803,6 +843,8 @@ pub struct SessionLogout { /// scopes: vec!["openid".to_string()], /// flow: OAuthFlow::DeviceCode, /// refresh_buffer_secs: None, +/// resource: None, +/// audience: None, /// token_cache: Some(TokenCacheOptions::new()), /// }; /// let session = OAuthSession::new(config)?; @@ -873,6 +915,8 @@ impl OAuthSession { issuer_url: record.issuer_url, client_id: record.client_id, scopes: record.scopes, + resource: record.resource, + audience: record.audience, flow: record.flow, obtained_at: Some(record.obtained_at), }, @@ -881,6 +925,8 @@ impl OAuthSession { issuer_url: self.cache.key.issuer_url.clone(), client_id: self.cache.key.client_id.clone(), scopes: self.cache.key.scopes.clone(), + resource: self.cache.key.resource.clone(), + audience: self.cache.key.audience.clone(), flow: self.cache.key.flow.to_string(), obtained_at: None, }, @@ -960,6 +1006,8 @@ mod tests { scopes: vec!["openid".to_string()], flow: OAuthFlow::DeviceCode, refresh_buffer_secs: None, + resource: None, + audience: None, token_cache: Some(TokenCacheOptions::new().cache_dir(cache_dir)), } } @@ -970,6 +1018,7 @@ mod tests { /// `invalid_grant`, which is exactly what real providers do on rotation. struct MockIdp { issuer_url: String, + requests: Arc>>, device_authorizations: Arc, refresh_attempts: Arc, invalid_grant_rejections: Arc, @@ -986,6 +1035,7 @@ mod tests { let issuer_url = format!("http://{addr}"); let server = Self { issuer_url: issuer_url.clone(), + requests: Arc::new(std::sync::Mutex::new(Vec::new())), device_authorizations: Arc::new(AtomicUsize::new(0)), refresh_attempts: Arc::new(AtomicUsize::new(0)), invalid_grant_rejections: Arc::new(AtomicUsize::new(0)), @@ -994,6 +1044,7 @@ mod tests { fail_refreshes: Arc::new(AtomicBool::new(false)), issue_refresh_tokens: Arc::new(AtomicBool::new(true)), }; + let requests = Arc::clone(&server.requests); let device_authorizations = Arc::clone(&server.device_authorizations); let refresh_attempts = Arc::clone(&server.refresh_attempts); let invalid_grant_rejections = Arc::clone(&server.invalid_grant_rejections); @@ -1007,6 +1058,7 @@ mod tests { let Ok((mut stream, _)) = listener.accept().await else { return; }; + let requests = Arc::clone(&requests); let device_authorizations = Arc::clone(&device_authorizations); let refresh_attempts = Arc::clone(&refresh_attempts); let invalid_grant_rejections = Arc::clone(&invalid_grant_rejections); @@ -1016,6 +1068,9 @@ mod tests { let issue_refresh_tokens = Arc::clone(&issue_refresh_tokens); tokio::spawn(async move { let (request_line, body) = read_http_request(&mut stream).await; + if request_line.starts_with("POST ") { + requests.lock().unwrap().push(body.clone()); + } if request_line.starts_with("GET /.well-known/openid-configuration ") { let discovery = format!( r#"{{"token_endpoint":"http://{addr}/token","device_authorization_endpoint":"http://{addr}/device"}}"# @@ -1391,6 +1446,84 @@ mod tests { assert!(!format!("{status:?}").contains("refresh-")); } + #[tokio::test] + async fn test_targeted_cache_refresh_and_logout_isolation() { + let dir = cache_tempdir(); + let idp = MockIdp::start().await; + let mut config = idp.config(dir.path()); + let options = config.token_cache.clone().unwrap(); + let untargeted_key = CacheKey::new(&config).unwrap().file_stem; + assert_eq!( + untargeted_key, + hex_sha256( + format!( + "v1\n{}\nclient-id\nopenid\ndevice_code\npublic", + idp.issuer_url + ) + .as_bytes() + ) + ); + let mut sessions = Vec::new(); + let mut keys = std::collections::HashSet::new(); + for (resource, audience) in [ + (None, None), + (Some("urn:one"), None), + (None, Some("audience & ü")), + (Some("urn:one"), Some("audience & ü")), + (Some("urn:two"), Some("audience & ü")), + (Some("urn:one"), Some("other")), + ] { + config.resource = resource.map(str::to_owned); + config.audience = audience.map(str::to_owned); + let cache = TokenCache::new(&config, &options).unwrap(); + assert!(keys.insert(cache.key.file_stem.clone())); + let record = cache + .record_from_response(&TokenResponse { + access_token: "unused".into(), + refresh_token: Some("seed-refresh".into()), + expires_in: Some(3600), + token_type: None, + }) + .unwrap(); + cache.store(&record).await.unwrap(); + *idp.current_refresh.lock().unwrap() = Some("seed-refresh".into()); + let provider = OAuthHeaderProvider::new(config.clone()).unwrap(); + provider.get_headers().await.unwrap(); + let request = idp.requests.lock().unwrap().last().unwrap().clone(); + let params: std::collections::HashMap<_, _> = + url::form_urlencoded::parse(request.as_bytes()).collect(); + assert_eq!(params.get("resource").map(|s| s.as_ref()), resource); + assert_eq!(params.get("audience").map(|s| s.as_ref()), audience); + assert_eq!(params.get("grant_type").unwrap(), "refresh_token"); + let session = OAuthSession::new(config.clone()).unwrap(); + let status = session.status().await.unwrap(); + assert!(status.refreshable); + assert_eq!(status.resource, config.resource); + assert_eq!(status.audience, config.audience); + sessions.push(session); + } + assert!(sessions.pop().unwrap().logout().await.unwrap().removed); + for session in sessions { + assert!(session.status().await.unwrap().refreshable); + } + assert_eq!(idp.device_authorizations.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn test_legacy_cache_record_without_target_fields() { + let dir = cache_tempdir(); + let config = device_config(dir.path()); + let cache = TokenCache::new(&config, config.token_cache.as_ref().unwrap()).unwrap(); + let legacy = br#"{"version":1,"issuer_url":"https://issuer.example.com","client_id":"client-id","scopes":["openid"],"flow":"device_code","client_auth":"public","refresh_token":"legacy","obtained_at":1}"#; + write_record(dir.path(), &cache.record_path(), legacy).unwrap(); + let session = OAuthSession::new(config).unwrap(); + let status = session.status().await.unwrap(); + assert!(status.refreshable); + assert_eq!(status.resource, None); + assert_eq!(status.audience, None); + assert!(session.logout().await.unwrap().removed); + } + #[test] fn test_cache_key_canonicalizes_scopes_and_issuer() { let mut config = device_config(Path::new("/tmp/cache")); @@ -1744,6 +1877,8 @@ mod tests { issuer_url: "https://issuer.example.com".to_string(), client_id: "client-id".to_string(), scopes: vec!["openid".to_string()], + resource: None, + audience: None, flow: "device_code".to_string(), obtained_at: Some(100), };