Go
Azure
golang
OAuth

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

More than 1 year has passed since last update.

概要

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

アプリケーションの登録


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

1.png

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

2.png

今回作成するのは,デスクトップアプリケーションなので,アプリの種類としてネイティブを選ぶ.
リダイレクト 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

}