Exchanging an OAuth Token in Power BI Custom Data Connectors

Custom Data Connectors can support multiple kinds of authentication. For most modern web apps, OAuth is the obvious choice and the Power Query SDK, coupled with the authentication examples (here and here), make it easy to setup.

Unfortunately, two apps that I have recently created Custom Data Connectors for were partway through an OAuth implementation. While the circumstances were unique to each application, I had to authenticate the user with the OAuth Identity Provider (IdP) and then exchange the access_token for one that the application could use. This isn’t an unusual OAuth “flow”, particularly when the application uses a custom payload in the access_token JWT for application functions.

Whether you agree with an exchange flow or not, I had to figure out how to make the Custom Data Connector work within these constraints.

To accomplish this, I had to interrupt the normal OAuth response flow and exchange that IdP provided access_token for one that the application would accept as through Bearer authorization. I also needed to ensure that the refresh_token flow would continue to work when Power BI encountered a HTTP 401 (unauthorized) response from and API, causing it to perform a refresh grant to retrieve a new access_token and keep data flowing without the user being interrupted.

It just wasn’t clear to me exactly where to do all of this work.

In other OAuth frameworks or SDK’s, it’s usually pretty easy to see where you’d drop into the flow and make the exchange before moving ahead. Given the Github authentication example, my mind wasn’t thinking functionally at the time (it’s still a challenge for me!). It was unclear that the response from the FinishLogin function was being consumed and stored by Power BI directly. Once I made that connection it was easy to accomplish what I needed.

The Basic OAuth Setup

I started with the examples from the articles I’ve linked to above, enabling OAuth and setting up each of the functions.

...    
Authentication = [
        OAuth = [
            StartLogin=StartLogin,
            FinishLogin=FinishLogin,
            Refresh=Refresh,
            Logout=Logout
        ]
...

Inside of the FinishLogin function there is a call to the TokenMethod which takes the code grant and exchanges it with the IdP for an access_token.

TokenMethod = (code) =>
    let
        Response = Web.Contents("https://auth.example.com/oauth2/token", [
            Content = Text.ToBinary(Uri.BuildQueryString([
                client_id = client_id,
                client_secret = client_secret,
                code = code,
                redirect_uri = redirect_uri])),
            Headers=[#"Content-type" = "application/x-www-form-urlencoded",
                                 #"Accept" = "application/json"]]),
        LoginResult = Json.Document(Response)
    in
        LoginResult ;

It took me a while to realize that the Json.Document(Response) was simply parsing the OAuth payload (id_token, access_token, and refresh_token) into a Power Query record that the connector/runtime was storing and using later. Therefore, I had to do my exchange somewhere in this function before returning the Record that Power BI needed.

Exchanging the Access Token

In order to exchange the trusted access_token, another function was needed to call the actual Exchange endpoint of each application. YMMV, but here’s what a basic function might look like.

TokenExchange = (id_token as text) =>
    let
         NewToken = Web.Contents("https://auth.example.com/api/exchangeToken",[
            Content = Text.ToBinary("{id_token: '" & id_token & "'}"),
            Headers = [#"Content-Type"="application/json"]]),
         LoginResult = Json.Document(NewToken),
         AccessToken = LoginResult[token]
    in
        AccessToken

With that in place, I could modify the TokenMethod to do the exchange mid-stream for me like so.

TokenMethod = (code) =>
    let
        Response = Web.Contents("https://Github.com/login/oauth/access_token", [
            Content = Text.ToBinary(Uri.BuildQueryString([
                client_id = client_id,
                client_secret = client_secret,
                code = code,
                redirect_uri = redirect_uri])),
            Headers=[#"Content-type" = "application/x-www-form-urlencoded",
                                 #"Accept" = "application/json"]]),
        body = Json.Document(Response),
        result = if (Record.HasFields(body, {"error", "error_description"})) then 
                    error Error.Record(body[error], body[error_description], body)
                 else
                    // This is a code exchange, we expect a refresh_token
                    if (Record.HasFields(body, {"refresh_token"})) then 
                    [
                        refresh_token=body[refresh_token],
                        access_token = ExchangeToken(body[id_token])
                    ]
                    // This token was obtained by a Refresh request. No refresh_token             
                  else
                    [
                        access_token = ExchangeToken(body[id_token])
                    ]
    in
        result

The key was understanding that the result of the TokenMethod, which is what the FinishLogin method will ultimately respond with, needs to provide a Record with all of the fields necessary to continue the OAuth flow. In this case, simply replacing the access_token and refresh_token allowed Power BI to store this for later use.

Whenever the token expires, producing a HTTP 401 (Unauthorized) response, the RefreshToken logic kicks in, goes back through this same logic, and data keeps flowing.

Conclusion

Learning how to use Power Query to get data from REST APIs has been a fun (if not frustrating at times) learning experience. The documentation continues to improve and the Power Query team and community are very helpful. Each new challenge helps me better understand the building blocks they have provided and how to get to the next milestone.

Now, if I could just have something like a “finally” query that gets executed after an import to create the relationships I need and additional linking tables if needed. Wow, I’d be in heaven! πŸ˜‰

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.