Microsoft Azureを利用するデスクトップアプリのデバイス認証

Posted: , Modified:   Go Azure OAuth Qiita

本稿は Qiita 投稿記事 のバックアップです.

概要

English

Go から Microsoft Azure を利用する場合,アクセストークン使用する. このアクセストークンの取得には複数の方法が用意されているが, CLI コマンドでも利用されているデバイスコードを用いた認証方法についてまとめる.

アプリケーションの登録


認証プロセスで必要となるクライアント ID を取得するために, Azure ポータルにてアプリケーションの登録を行う.

アプリの登録は,ポータルのセキュリティ + ID カテゴリにある「アプリの登録」から行える.

今回作成するのは,デスクトップアプリケーションなので,アプリの種類としてネイティブを選ぶ. リダイレクト URL は文字列として有効な URL であれば適当で良いので, 例えば,http://localhost:18230 などとしておく.

デバイスコードの取得

デバイスコードの取得には, https://login.microsoftonline.com/common/oauth2/devicecode にクライアント ID とアクセスを要求するリソースをクエリとして追加し問い合わせることで取得できる. レスポンスは次のような構造を持つ JSON 形式で取得できる.

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"`
}

次の関数はデバイスコードを取得し上記の構造体として返す.

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

}

アクセストークンの取得

次に,ユーザにこのデバイスコードにある VerificationURL へアクセスしてもらい, UserCode を入力してもらう必要がある. このユーザによる認証操作が終了すると, https://login.microsoftonline.com/common/oauth2/token からアクセストークンを取得できるようになる.

したがって,上記 URL のポーリングを行いユーザの操作を待つ. ポーリング間隔は Interval で指定されている秒数を使う. なお,このリクエストにはクライアント ID とデバイスコードを含める必要があり, GET ではなく POST で問い合わせる必要がある.

ユーザがアクセスを承認すると下記の構造体からなる JSON オブジェクトが返ってくる.

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"`
}

一方,ユーザが認証を却下した場合は,次の構造体からなる JSON オブジェクトが返ってくる.

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 として使えるように Error() string を定義しておく
func (e *TokenError) Error() string {
  return e.ErrorDescription
}

次の関数はデバイスコードを取得し,ユーザの操作が終わるまでポーリングを行いアクセストークンを取得する.

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

  // デバイスコードの取得
  code, err := GetDeviceCode(ctx, clientID)
  if err != nil {
    return
  }

  // ユーザへ認証 URL へのアクセスと UserCode の入力を促すメッセージを表示
  fmt.Println(code.Message)

  // ExpiresIn はデバイスコードの有効期限(秒)
  expire, err := strconv.Atoi(code.ExpiresIn)
  if err != nil {
    return
  }
  interval, err := strconv.Atoi(code.Interval)
  if err != nil {
    return
  }

  // デバイスコードの有効期限が切れる前に以降の操作をキャンセルさせる
  ctx, cancel := context.WithDeadline(
    ctx, time.Now().Add(time.Duration(expire-30)*time.Second))
  defer cancel()

  // ユーザの操作が終わるまでポーリングする
  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

}

アクセストークンの更新

得られたトークンには,ExpiresInExpiresOn という属性があるように, 有効期限が定められている. 有効期限が切れた場合,RefreshToken を用いて新しいトークンを取得する.

なお,アクセストークンを更新する場合,ユーザのテナント ID (Active Directory のディレクトリ ID) が必要になる.

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

}