OAuth2 でレスポンスタイプがコードもしくはトークンの場合、ブラウザで認証を行ってコードやトークンを自前サーバで受け取る事になる。モバイルアプリだと組み込みブラウザが前提になっておりリダイレクトの最終 URL からアクセスコードやトークンを得る。ただコマンドラインアプリの場合、認証の為に起動したブラウザの最終 URL を得る方法はない。また1コマンドラインアプリケーションの為にドメイン付きのコールバックサーバを用意するのも面倒だし、作ったサーバをユーザに信用して貰う必要がある。あとそもそも外部のサーバで受け取ったトークンをどうやってコマンドラインアプリに渡すかという問題がある。
そこで使うのがローカルサーバを立てる方法。認証後のコールバック先をコマンドラインアプリから起動したローカルサーバにし、そこにリダイレクトさせてアクセストークンを貰い保存する。
今日はこれが伝わり易い用に Microsoft の OneNote API で認証するサンプルを書いてみる。
まずは Microsoft Application Registration Portal でアプリケーションを登録する。
マイ アプリケーション
Microsoft developer Windows Windows Dev Center Windows apps Desktop Internet of Things Games Hologra...
https://apps.dev.microsoft.com/#/appList
アプリケーションシークレットでパスワードを発行しそれを ClientSecret とすること。ちなみに ClientID はアプリケーションIDと一緒。またプラットフォームとしては Web アプリケーションを選びコールバックサーバ(Microsoft の API は省略不可)の URL として http://localhost:8989
を指定しておくこと。
本来ならばコールバック先がアプリケーション側から指定できるため、Golang であれば以下の oauth2.Config にて RedirectURL を自由に決められる。
oauthConfig := &oauth2.Config{
Scopes: []string{
"wl.signin",
"wl.basic",
"Office.onenote",
"Office.onenote_update",
"Office.onenote_update_by_app",
},
Endpoint: oauth2.Endpoint{
AuthURL: "https://login.live.com/oauth20_authorize.srf",
TokenURL: "https://login.live.com/oauth20_token.srf",
},
ClientID: config["ClientID"],
ClientSecret: config["ClientSecret"],
RedirectURL: "http://localhost:8989",
}
なので例えば、空いているポートを使ってローカルサーバを立ち上げる事ができる。
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
return "", err
}
defer l.Close()
/* l.Attr().String() がサーバのアドレスになる */
ここで http.ListenAndServe
でサーバを起動するのではなく、net.Listen
で作ったリスナでサーバを起動しているのは、アクセストークンを受け取った後にサーバを停止しないといけないからです。go1.8 では graceful shutdown がサポートされているが、まだ go1.8 はリリースされていない。次に以下の様にお決まりの手順で認証 URL へアクセスする。
stateBytes := make([]byte, 16)
_, err = rand.Read(stateBytes)
if err != nil {
return "", err
}
state := fmt.Sprintf("%x", stateBytes)
err = open.Start(oauthConfig.AuthCodeURL(state, oauth2.SetAuthURLParam("response_type", "token")))
if err != nil {
return "", err
}
ここで Microsoft の認証方式だとクエリ文字列ではなく、URL フラグメントとして帰ってくるのでサーバが直接アクセストークンを読み取れない。よって以下のハックを使ってフラグメントをクエリ文字列に変えて自分にループバックさせる。
quit := make(chan string)
go http.Serve(l, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/" {
w.Write([]byte(`<script>location.href = "/close?" + location.hash.substring(1);</script>`))
} else {
w.Write([]byte(`<script>window.open("about:blank","_self").close()</script>`))
w.(http.Flusher).Flush()
quit <- req.URL.Query().Get("access_token")
}
}))
/
でリクエストされるとフラグメント文字列をクエリ文字列に変えてアクセスし直す。そしてそのハンドラで access_token
を取得して、ブラウザは自らのタブを消滅させる。なのでコマンドラインアプリのユーザは勝手に起動したブラウザでログインすればいいだけになる。全体のコードはおおよそ以下の様になる。
ただし Microsoft の API アクセストークンは発行後1時間で揮発するのでリフレッシュトークン等の処理は必要。
package main
import (
"crypto/rand"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"time"
"golang.org/x/oauth2"
"github.com/skratchdot/open-golang/open"
)
type Page struct {
Title string `json:"title"`
CreatedByAppID string `json:"createdByAppId"`
Links struct {
OneNoteClientURL struct {
Href string `json:"href"`
} `json:"oneNoteClientUrl"`
OneNoteWebURL struct {
Href string `json:"href"`
} `json:"oneNoteWebUrl"`
} `json:"links"`
ContentURL string `json:"contentUrl"`
LastModifiedTime time.Time `json:"lastModifiedTime"`
CreatedTime time.Time `json:"createdTime"`
ID string `json:"id"`
Self string `json:"self"`
ParentSectionOdataContext string `json:"parentSection@odata.context"`
ParentSection struct {
ID string `json:"id"`
Name string `json:"name"`
Self string `json:"self"`
} `json:"parentSection"`
}
func get(url, token string, val interface{}) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Add("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if val != nil {
r := io.TeeReader(resp.Body, os.Stdout)
return json.NewDecoder(r).Decode(val)
}
_, err = io.Copy(os.Stdout, resp.Body)
return err
}
func getConfig() (string, map[string]string, error) {
dir := os.Getenv("HOME")
if dir == "" && runtime.GOOS == "windows" {
dir = os.Getenv("APPDATA")
if dir == "" {
dir = filepath.Join(os.Getenv("USERPROFILE"), "Application Data", "onenote")
}
dir = filepath.Join(dir, "onenote")
} else {
dir = filepath.Join(dir, ".config", "onenote")
}
if err := os.MkdirAll(dir, 0700); err != nil {
return "", nil, err
}
file := filepath.Join(dir, "settings.json")
config := map[string]string{}
b, err := ioutil.ReadFile(file)
if err != nil && !os.IsNotExist(err) {
return "", nil, err
}
if err != nil {
config["ClientID"] = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
config["ClientSecret"] = "XXXXXXXXXXXXXXXXXXXXXXX"
} else {
err = json.Unmarshal(b, &config)
if err != nil {
return "", nil, fmt.Errorf("could not unmarshal %v: %v", file, err)
}
}
return file, config, nil
}
func getAccessToken(config map[string]string) (string, error) {
l, err := net.Listen("tcp", "localhost:8989")
if err != nil {
return "", err
}
defer l.Close()
oauthConfig := &oauth2.Config{
Scopes: []string{
"wl.signin",
"wl.basic",
"Office.onenote",
"Office.onenote_update",
"Office.onenote_update_by_app",
},
Endpoint: oauth2.Endpoint{
AuthURL: "https://login.live.com/oauth20_authorize.srf",
TokenURL: "https://login.live.com/oauth20_token.srf",
},
ClientID: config["ClientID"],
ClientSecret: config["ClientSecret"],
RedirectURL: "http://localhost:8989",
}
stateBytes := make([]byte, 16)
_, err = rand.Read(stateBytes)
if err != nil {
return "", err
}
state := fmt.Sprintf("%x", stateBytes)
err = open.Start(oauthConfig.AuthCodeURL(state, oauth2.SetAuthURLParam("response_type", "token")))
if err != nil {
return "", err
}
quit := make(chan string)
go http.Serve(l, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/" {
w.Write([]byte(`<script>location.href = "/close?" + location.hash.substring(1);</script>`))
} else {
w.Write([]byte(`<script>window.open("about:blank","_self").close()</script>`))
w.(http.Flusher).Flush()
quit <- req.URL.Query().Get("access_token")
}
}))
return <-quit, nil
}
func main() {
file, config, err := getConfig()
if err != nil {
log.Fatal("failed to get configuration:", err)
}
if config["AccessToken"] == "" {
token, err := getAccessToken(config)
if err != nil {
log.Fatal("faild to get access token:", err)
}
config["AccessToken"] = token
b, err := json.MarshalIndent(config, "", " ")
if err != nil {
log.Fatal("failed to store file:", err)
}
err = ioutil.WriteFile(file, b, 0700)
if err != nil {
log.Fatal("failed to store file:", err)
}
}
var pages struct {
Value []Page `json:"value"`
}
err = get("https://www.onenote.com/api/v1.0/me/notes/pages", config["AccessToken"], &pages)
if err != nil {
log.Fatal(err)
}
for _, item := range pages.Value {
err = get("https://www.onenote.com/api/v1.0/me/notes/pages/"+item.ID+"/preview?includeIDs=true", config["AccessToken"], nil)
if err != nil {
log.Fatal(err)
}
}
}