1414from datetime import datetime , timedelta
1515from enum import Enum
1616from http .server import BaseHTTPRequestHandler , HTTPServer
17- from typing import Any , Dict , List , Optional
17+ from typing import Any , Callable , Dict , List , Optional
1818
1919import requests
2020import requests .auth
3232logger = logging .getLogger (__name__ )
3333
3434
35+ @dataclass
36+ class AuthorizationDetail :
37+ type : str
38+ object_type : str
39+ object_path : str
40+ actions : List [str ]
41+
42+ def as_dict (self ) -> dict :
43+ return {
44+ "type" : self .type ,
45+ "object_type" : self .object_type ,
46+ "object_path" : self .object_path ,
47+ "actions" : self .actions ,
48+ }
49+
50+ def from_dict (self , d : dict ) -> "AuthorizationDetail" :
51+ return AuthorizationDetail (
52+ type = d .get ("type" ),
53+ object_type = d .get ("object_type" ),
54+ object_path = d .get ("object_path" ),
55+ actions = d .get ("actions" ),
56+ )
57+
58+
3559class IgnoreNetrcAuth (requests .auth .AuthBase ):
3660 """This auth method is a no-op.
3761
@@ -706,18 +730,21 @@ class ClientCredentials(Refreshable):
706730 client_secret : str
707731 token_url : str
708732 endpoint_params : dict = None
709- scopes : List [ str ] = None
733+ scopes : str = None
710734 use_params : bool = False
711735 use_header : bool = False
712736 disable_async : bool = True
737+ authorization_details : str = None
713738
714739 def __post_init__ (self ):
715740 super ().__init__ (disable_async = self .disable_async )
716741
717742 def refresh (self ) -> Token :
718743 params = {"grant_type" : "client_credentials" }
719744 if self .scopes :
720- params ["scope" ] = " " .join (self .scopes )
745+ params ["scope" ] = self .scopes
746+ if self .authorization_details :
747+ params ["authorization_details" ] = self .authorization_details
721748 if self .endpoint_params :
722749 for k , v in self .endpoint_params .items ():
723750 params [k ] = v
@@ -731,6 +758,67 @@ def refresh(self) -> Token:
731758 )
732759
733760
761+ @dataclass
762+ class PATOAuthTokenExchange (Refreshable ):
763+ """Performs OAuth token exchange using a Personal Access Token (PAT) as the subject token.
764+
765+ This class implements the OAuth 2.0 Token Exchange flow (RFC 8693) to exchange a Databricks
766+ Internal PAT Token for an access token with specific scopes and authorization details.
767+
768+ Args:
769+ get_original_token: A callable that returns the PAT to be exchanged. This is a callable
770+ rather than a string value to ensure that a fresh Internal PAT Token is retrieved
771+ at the time of refresh.
772+ host: The Databricks workspace URL (e.g., "https://my-workspace.cloud.databricks.com").
773+ scopes: Space-delimited string of OAuth scopes to request (e.g., "all-apis offline_access").
774+ authorization_details: Optional JSON string containing authorization details as defined in
775+ AuthorizationDetail class above.
776+ disable_async: Whether to disable asynchronous token refresh. Defaults to True.
777+ """
778+
779+ get_original_token : Callable [[], Optional [str ]]
780+ host : str
781+ scopes : str
782+ authorization_details : str = None
783+ disable_async : bool = True
784+
785+ def __post_init__ (self ):
786+ super ().__init__ (disable_async = self .disable_async )
787+
788+ def refresh (self ) -> Token :
789+ token_exchange_url = f"{ self .host } /oidc/v1/token"
790+ params = {
791+ "grant_type" : "urn:ietf:params:oauth:grant-type:token-exchange" ,
792+ "subject_token" : self .get_original_token (),
793+ "subject_token_type" : "urn:databricks:params:oauth:token-type:personal-access-token" ,
794+ "requested_token_type" : "urn:ietf:params:oauth:token-type:access_token" ,
795+ "scope" : self .scopes ,
796+ }
797+ if self .authorization_details :
798+ params ["authorization_details" ] = self .authorization_details
799+
800+ resp = requests .post (token_exchange_url , params )
801+ if not resp .ok :
802+ if resp .headers ["Content-Type" ].startswith ("application/json" ):
803+ err = resp .json ()
804+ code = err .get ("errorCode" , err .get ("error" , "unknown" ))
805+ summary = err .get ("errorSummary" , err .get ("error_description" , "unknown" ))
806+ summary = summary .replace ("\r \n " , " " )
807+ raise ValueError (f"{ code } : { summary } " )
808+ raise ValueError (resp .content )
809+ try :
810+ j = resp .json ()
811+ expires_in = int (j ["expires_in" ])
812+ expiry = datetime .now () + timedelta (seconds = expires_in )
813+ return Token (
814+ access_token = j ["access_token" ],
815+ expiry = expiry ,
816+ token_type = j ["token_type" ],
817+ )
818+ except Exception as e :
819+ raise ValueError (f"Failed to exchange PAT for OAuth token: { e } " )
820+
821+
734822class TokenCache :
735823 BASE_PATH = "~/.config/databricks-sdk-py/oauth"
736824
0 commit comments