Device authorization for Microsoft Azure

Posted: , Modified:   Go Microsoft Azure OAuth

Summary

We need an OAuth access token to access APIs of Microsoft Azure. Azure provides several ways to obtain access tokens but this blog post focuses to device authorization, which is used in Azure’s CLI command.

Register application

We need a client ID to run device authorization protocol. Those information is issued from Azure’s portal after registering our application.

“App registrations” is in Security + Identity section. Click “New application registration” button and type some application name; and choose native in application type. Sign-on is also required but we don’t use it and any random URL, such as http://localhost:18230, is fine.

After the registration has been success, we can fine a client ID as an application ID.

Get a device code

In the device authentication protocol, at first, we need to get a device code by requesting https://login.microsoftonline.com/common/oauth2/devicecode with the client ID and resource names to be used in the application.

The response of the request has the following structure:

type DeviceCode struct {
  UserCode        string `json:"user_code"`
  DeviceCode      string `json:"device_code"`
  VerificationURL string `json:"verification_url"`
  ExpiresIn       string `json:"expires_in"`
  Interval        string `json:"interval"`
  Message         string `json:"message"`
}

and the following function requests a device code and returns an object in the above structure:

import (
  "context"
  "encoding/json"
  "fmt"
  "net/http"
  "net/url"

  "golang.org/x/net/context/ctxhttp"
)


func GetDeviceCode(ctx context.Context, clientID string) (code *DeviceCode, err error) {

  u := fmt.Sprintf(
    "https://login.microsoftonline.com/common/oauth2/devicecode?client_id=%v&resource=%v",
    clientID,
    url.QueryEscape("https://management.core.windows.net/"))

  req, err := http.NewRequest("Get", u, nil)
  if err != nil {
    return
  }
  req.Header.Add("Accept", "application/json")

  res, err := ctxhttp.Do(ctx, nil, req)
  if err != nil {
    return
  }
  defer res.Body.Close()

  code = new(DeviceCode)
  err = json.NewDecoder(res.Body).Decode(&code)
  return

}

Get an access token

Next, we need to ask users to open VerificationURL and input UserCode in their browser. After users finish this process, we can get an access token from https://login.microsoftonline.com/common/oauth2/token. In other words, we need to poll the above URL until uses finishes to access the verification URL. Note that, the polling request needs to include the client ID and the device code.

An successful response of the polling has the following structure:

type Token struct {
  AccessToken  string `json:"access_token"`
  TokenType    string `json:"token_type"`
  ExpiresIn    string `json:"expires_in"`
  ExpiresOn    string `json:"expires_on"`
  Resource     string `json:"resource"`
  Scope        string `json:"scope"`
  RefreshToken string `json:"refresh_token"`
  IDToken      string `json:"id_token"`
}

on the other hand, if the polling request has been failed, the following structured message will be returned:

type TokenError struct {
  ErrorSummary     string `json:"error"`
  ErrorDescription string `json:"error_description"`
  ErrorCodes       []int  `json:"error_codes"`
  Timestamp        string `json:"timestamp"`
  TraceID          string `json:"trace_id"`
  CorrelationID    string `json:"correlation_id"`
}

// Error implements interface `error` to the above structure.
func (e *TokenError) Error() string {
  return e.ErrorDescription
}

The following function requests a device code and polls an access token until a user finishes authorization:

func AuthorizeDeviceCode(ctx context.Context, clientID string) (token *Token, err error) {

  // Get a device code.
  code, err := GetDeviceCode(ctx, clientID)
  if err != nil {
    return
  }

  // Ask users to the verification URL and input the user code.
  fmt.Println(code.Message)

  // ExpiresIn means when the device code is expired.
  expire, err := strconv.Atoi(code.ExpiresIn)
  if err != nil {
    return
  }
  interval, err := strconv.Atoi(code.Interval)
  if err != nil {
    return
  }

  // Cancel polling request before the device code is expired via context.
  ctx, cancel := context.WithDeadline(
    ctx, time.Now().Add(time.Duration(expire-30)*time.Second))
  defer cancel()

  // Start polling.
  var req *http.Request
  var res *http.Response
  for {

    body := fmt.Sprintf(
      "resource=%v&client_id=%v&grant_type=device_code&code=%v",
      url.QueryEscape("https://management.core.windows.net/"),
      clientID,
      code.DeviceCode)
    req, err = http.NewRequest("Post", "https://login.microsoftonline.com/common/oauth2/token", strings.NewReader(body))
    if err != nil {
      return
    }
    req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Add("Accept", "application/json")

    res, err = ctxhttp.Do(ctx, nil, req)
    if err != nil {
      break
    }

    if res.StatusCode == 400 {

      var autherror TokenError
      err = json.NewDecoder(res.Body).Decode(&autherror)
      res.Body.Close()
      if err != nil {
        break
      }
      if strings.ToLower(autherror.ErrorSummary) != "authorization_pending" {
        break
      }

    } else {

      token = new(Token)
      err = json.NewDecoder(res.Body).Decode(token)
      res.Body.Close()
      if err != nil {
        return
      }
      break

  	}

    select {
    case <-ctx.Done():
      err = ctx.Err()
      break
    case <-time.After(time.Duration(interval) * time.Second):
    }

  }

  return

}

Renew an access token

A received access token has ExpiresIn and ExpiresOn; and the token will be expired in those time. We can renew the token by RefreshToken. Note that, to renew the token, we need the user’s tenant ID (Active Directory’s directory ID).

The following function renews an access token:

func RefreshToken(token *Token, clientID, tenantID string) (newToken *Token, err error) {

  request := make(url.Values)
  request.Add("grant_type", "refresh_token")
  request.Add("client_id", clientID)
  request.Add("refresh_token", token.RefreshToken)
  request.Add("resource", "https://management.core.windows.net/")

  res, err := http.PostForm(fmt.Sprintf(tokenEndpoint, tenantID), request)
  if err != nil {
  	return
  }
  defer res.Body.Close()

  if res.StatusCode != 200 {
    var info TokenError
    err = json.NewDecoder(res.Body).Decode(&info)
    if err != nil{
      return nil, err
    }
    return nil, &info
  }

  newToken = new(Token)
  err = json.NewDecoder(res.Body).Decode(newToken)
  return

}