Microsoft Azureを利用するデスクトップアプリのデバイス認証
Posted: , Modified: Go Azure OAuth Qiita
本稿は Qiita 投稿記事 のバックアップです.
概要
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
}
アクセストークンの更新
得られたトークンには,ExpiresIn
や ExpiresOn
という属性があるように,
有効期限が定められている.
有効期限が切れた場合,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
}