💡

UWSCR/UWSCでGoogle Sheets APIを操作する(OAuth2.0+JWT認証)

に公開

UWSCR/UWSCを使って、サービスアカウントキーによるOAuth2.0認証(JWTベアラーフロー)を実装し、Googleスプレッドシートに自動で書き込みを行う処理を作成しました。

なお、UWSC版についても参考までに末尾で掲載しています(機能的には同じ構成ですが、実装にはやや工夫が必要です)。


はじめに

業務の中でGoogleスプレッドシートを活用する場面は多くあります。たとえば「基幹システムからCSV形式で出力 → スプレッドシートに転記 → 担当者が内容を確認」といった流れです。

こうした自動化にはGoogle Apps Script(GAS)を使うのが王道ですが、GASには以下のような煩わしさもあります

  • ローカルのファイルを直接扱えない
  • ブラウザ経由での操作が必要
  • UIがややこしい

そのため私は「ローカル完結」で「ちょっとした自動化」をUWSCRで済ませたい場面がよくあります。

ただし、GoogleのAPIを使うにはOAuth2.0認証が必要です。今回は思うところもあって認可コードフローではなく、JWTベアラーフロー(サービスアカウント+秘密鍵)による認証としました。


なぜJWTベアラーフローを選んだか

一般的なOAuth2.0では、ユーザーがブラウザで認可コードを取得し、それを使ってアクセストークンを得る必要があります。UWSCRだとDevtools Protocolを利用したブラウザ操作が可能なので、認可コードのフローも問題なく構築できそうです。

ただ、ブラウザ経由だとどうしても安定性の問題がつきまといます。

一方、JWT(JSON Web Token)ベアラーフロー では、サービスアカウントの秘密鍵を使って署名付きのトークンを作成し、それをPOSTすることでアクセストークンを取得できます。

この方式の利点は以下の通りです:

  • 完全に自動化できる(人の手を介さない)
  • 再認証が不要(トークンの更新もコード内で完結)
  • GUI不要、ローカル完結

UWSCRでJWTを扱うには

UWSCRには暗号化や署名機能は備わっていませんが、UWSCの頃からの文化として「できないことは外部ツールに任せる」という思想が根付いた言語です(と思っている)今回はその特性を活かし、PowerShellとOpenSSLを連携させて署名付きJWTを生成する方法を採用しました。


処理の全体像

今回の処理は、以下のようなステップで構成されています:

  1. Google Cloud Consoleでサービスアカウントと秘密鍵(JSON)を作成
  2. JSONから client_emailprivate_key を読み出し、JWTを構築
  3. JWTのヘッダーとペイロードをBase64URLでエンコード(PowerShell)
  4. エンコード済み文字列にOpenSSLで署名を付ける
  5. JWTをPOSTしてアクセストークンを取得
  6. アクセストークンをAuthorizationヘッダーに付けて、Sheets APIを実行
  7. 任意のセルに値を書き込む

事前準備

このスクリプトを動かすためには、以下の事前準備が必要です。

1. GCPプロジェクトの準備

  1. Google Cloud Console にログインし、新しいプロジェクトを作成
  2. 「APIとサービス」→「ライブラリ」から Google Sheets API を有効化
  3. 「IAMと管理」→「サービスアカウント」からサービスアカウントを作成(権限はスキップでOK)
  4. サービスアカウントの「鍵」タブから 秘密鍵(JSON形式)を作成・ダウンロード

2. スプレッドシート側の共有設定

作成したスプレッドシートを開き、「共有」からサービスアカウントの client_email編集者として追加

"client_email": "my-bot-123@myproject.iam.gserviceaccount.com"

3. OpenSSLのインストールとパスの設定

JWT署名を行うには、openssl コマンドが必要です。Windowsでは手動インストールとパス設定が必要になります。


UWSCRでの実装例

gcpJsonPath = "gcpKey.json"               // GCPのサービスアカウントの秘密鍵(JSON)を指定
accessToken = GetAccessToken(gcpJsonPath) // JWT認証を使ってアクセストークンを取得

spreadsheetID = "スプレッドシートID"       // 書き込み対象のスプレッドシートIDを指定
url = "https://sheets.googleapis.com/v4/spreadsheets/" + spreadsheetID + "/values/シート1!A1?valueInputOption=RAW"

// 書き込む内容をJSON形式で定義
json = @{
  "values": [
    ["UWSCRからスプレッドシートを更新"]
  ]
}@

// HTTP PUTリクエストでスプレッドシートにデータを書き込み
request = WebRequestBuilder().bearer(accessToken).header('Content-Type', 'application/json')
request.body(json).put(url)


// JWT認証フローでアクセストークンを取得する関数
function GetAccessToken(gcpJsonPath)
    // JSONファイルを読み込んでキーとメールアドレスを抽出
    fid = fopen(gcpJsonPath, F_READ)
    json = FromJson(fget(fid, F_ALLTEXT)) 
    fclose(fid)

    privateKey  = json.private_key
    clientEmail = json.client_email

    // JWTに必要な発行時間(iat)と有効期限(exp)をUNIX時間で算出
    UNIX_EPOCH_DIFF = 946684800  // UWSCRのgettime()は2000/1/1起点のため補正
    JST_OFFSET      = 32400      // JST → UTC の補正(9時間)
    iat = gettime() + UNIX_EPOCH_DIFF - JST_OFFSET
    exp = iat + 3600             // トークンの有効期限は1時間後

    // JWTのヘッダー部
    header = @{
        "alg" : "RS256",
        "typ" : "JWT"
    }@

    // JWTのペイロード部(Googleの仕様に準拠)
    payload = @{
        "iss" : "<#clientEmail>",
        "sub" : "<#clientEmail>",
        "aud" : "https://oauth2.googleapis.com/token",
        "scope" : "https://www.googleapis.com/auth/spreadsheets",
        "iat" : <#iat>,
        "exp" : <#exp>
    }@

    // JWT構築のためのBase64URLエンコード関数(PowerShell経由で処理)
    base64 = function(data, isFile = false)
        if isFile then
            byteInitCode = "$Bytes = [System.IO.File]::ReadAllBytes('<#data>')"
        else
            byteInitCode = "$Bytes = [System.Text.Encoding]::UTF8.GetBytes('<#data>')"
        endif

        textblockex ps
            <#byteInitCode>
            $Base64 = [Convert]::ToBase64String($Bytes)
            $Base64Url = $Base64 -replace '\+', '-' -replace '/', '_' -replace '=', ''
            $Base64Url
        endtextblock
        result = trim(pwsh(ps)) // PowerShellで実行し、URLセーフなBase64を返す
    fend

    // 一時ファイルのパスを定義
    keyFile       = GET_CUR_DIR + "\private_key.pem"
    unsigneFile   = GET_CUR_DIR + "\unsigned.jwt"
    signatureFile = GET_CUR_DIR + "\signature.bin"

    // 秘密鍵を.pem形式で保存(OpenSSLが必要とする形式)
    fopen(keyFile, F_WRITE8 or F_APPEND or F_NOCR, privateKey)

    // JWTの「ヘッダー.ペイロード」形式の文字列を作成
    unsignedJwt = base64(header) + "." + base64(payload)

    // 署名対象の文字列を一時ファイルに保存
    fopen(unsigneFile, F_WRITE8 or F_APPEND or F_NOCR, unsignedJwt)

    // OpenSSLでSHA256+RSA署名を作成(PowerShell経由で呼び出し)
    pwsh("openssl dgst -sha256 -sign '<#keyFile>' -out '<#signatureFile>' '<#unsigneFile>'")

    // JWTを最終的に構築(ヘッダー.ペイロード.署名)
    jwt = unsignedJwt + "." + base64(signatureFile, true)

    // 一時ファイルは削除
    deletefile(keyFile)
    deletefile(unsigneFile)
    deletefile(signatureFile)

    // JWTを使ってアクセストークンを取得するリクエストを構築
    body = "grant_type=" + encode("urn:ietf:params:oauth:grant-type:jwt-bearer", CODE_URL) + "&assertion=" + jwt
    url = "https://oauth2.googleapis.com/token"

    // アクセストークン取得のためのPOSTリクエストを送信
    request = WebRequestBuilder().header('Content-Type', 'application/x-www-form-urlencoded')
    response = FromJson(request.body(body).post(url))

    // レスポンスからアクセストークンを抽出して返却
    accessToken = response.access_token
    result = accessToken
fend

検証環境:UWSCR(x64) Ver. 1.1.2

UWSCでの実装例

コメントは割愛していますが、処理の流れはUWSCRと同一です。
UWSCRとの違いとしては

  • JSONからの値取り出しは強引・適当・汎用性のない自作関数GetJsonValue(json, key)
  • BASE64URLへのエンコードはCOMで実装
  • opensslはdoscmd()で実行

といった感じでしょうか。

UWSCでのJSONモジュールの取り扱いはUWSCR作者のstuncloudさんが、かつて作られたものがあったりはします。https://gist.github.com/stuncloud/38786fec654b0522839ca764aa6c97d8

gcpJsonPath = "gcpKey.json"
accessToken = GetAccessToken(gcpJsonPath)

spreadsheetID = "スプレッドシートID"
url = "https://sheets.googleapis.com/v4/spreadsheets/" + spreadsheetID + "/values/A1?valueInputOption=RAW"
textblock data
{
  "values": [
    ["UWSCからスプレッドシートを更新"]
  ]
}
endtextblock

with createoleobj("Msxml2.XMLHTTP")
    .open("PUT", url, FALSE)
    .setRequestHeader("Authorization", "Bearer " + accessToken)
    .setRequestHeader("Content-Type", "application/json")
    .send(data)  
    response = .responseText
endwith

function GetAccessToken(gcpJsonPath)
    fid = fopen(gcpJsonPath, F_READ)
    json = fget(fid, F_ALLTEXT)
    fclose(fid)

    privateKey  = GetJsonValue(json, "private_key")
    clientEmail = GetJsonValue(json, "client_email")

    UNIX_EPOCH_DIFF = 946684800
    JST_OFFSET      = 32400
    iat = gettime() + UNIX_EPOCH_DIFF - JST_OFFSET
    exp = iat + 3600

    header = "{<#DBL>alg<#DBL>:<#DBL>RS256<#DBL>,<#DBL>typ<#DBL>:<#DBL>JWT<#DBL>}"

    dim kv[5]
    kv[0] = "<#DBL>iss<#DBL>:<#DBL>" + clientEmail + "<#DBL>"
    kv[1] = "<#DBL>sub<#DBL>:<#DBL>" + clientEmail + "<#DBL>"
    kv[2] = "<#DBL>aud<#DBL>:<#DBL>https://oauth2.googleapis.com/token<#DBL>"
    kv[3] = "<#DBL>scope<#DBL>:<#DBL>https://www.googleapis.com/auth/spreadsheets<#DBL>"
    kv[4] = "<#DBL>iat<#DBL>:" + iat
    kv[5] = "<#DBL>exp<#DBL>:" + exp

    payload = "{" + join(kv, ",") + "}"

    keyFile       = GET_CUR_DIR + "\private_key.pem"
    unsigneFile   = GET_CUR_DIR + "\unsigned.jwt"
    signatureFile = GET_CUR_DIR + "\signature.bin"

    keyFid = fopen(keyFile, F_WRITE8 or F_NOCR)
    fput(keyFid, privateKey)
    fclose(keyFid)

    unsignedJwt =  Base64UrlEncode(header) + "." + Base64UrlEncode(payload)
    unsigneFid = fopen(unsigneFile, F_WRITE8 or F_NOCR)
    fput(unsigneFid, unsignedJwt)
    fclose(unsigneFid)

    cmd = "openssl dgst -sha256 -sign <#DBL>" + keyFile + "<#DBL> -out <#DBL>" + signatureFile + "<#DBL> <#DBL>" + unsigneFile + "<#DBL>"
    doscmd(cmd)
    repeat
        sleep(0.05)
    until fopen(signatureFile, F_EXISTS)

    jwt = unsignedJwt + "." + Base64UrlEncode(signatureFile, true)

    deletefile(keyFile)
    deletefile(unsigneFile)
    deletefile(signatureFile)

    body = "grant_type=" + encode("urn:ietf:params:oauth:grant-type:jwt-bearer", CODE_URL) + "&assertion=" + jwt
    url = "https://oauth2.googleapis.com/token"

    with createoleobj("Msxml2.XMLHTTP")
        .open("POST", url, FALSE)
        .setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
        .send(body)  
        response = .responseText
    endwith

    result = GetJsonValue(response, "access_token")
fend

function GetJsonValue(json, key)
    key = key + "<#DBL>"
    buf = copy(json, pos(key, json) + length(key))
    buf = copy(buf, pos("<#DBL>", buf) + 1, (pos("<#DBL>", buf, 2) - pos("<#DBL>", buf) - 1))
    result = replace(buf, "\n", "<#CR>")
fend

function Base64UrlEncode(data, isFile = false)
    dom = createoleobj("MSXML2.DOMDocument")
    dom.loadXML("<root/>")
    node = dom.createElement("tmp")
    node.dataType = "bin.base64"

    if isFile then
        bin = createoleobj("ADODB.Stream")
        bin.Type = 1 // Binary
        bin.Open()
        bin.LoadFromFile(data)
        bin.Position = 0
        node.nodeTypedValue = bin.Read()
        bin.Close()
    else
        txt = createoleobj("ADODB.Stream")
        bin = createoleobj("ADODB.Stream")
        txt.Type = 2 // Text
        txt.Charset = "utf-8"
        txt.Open()
        txt.WriteText(data)
        txt.Position = 3 // skip UTF-8 BOM
        bin.Type = 1 // Binary
        bin.Open()
        txt.CopyTo(bin)
        txt.Close()
        bin.Position = 0
        node.nodeTypedValue = bin.Read()
        bin.Close()
    endif

    base64 = node.text

    // 改行除去
    re = createoleobj("VBScript.RegExp")
    re.pattern = "[\r\n]+"
    re.global = true
    base64 = re.Replace(base64, "")

    // URLセーフ置換
    base64 = Replace(Replace(Replace(base64, "+", "-"), "/", "_"), "=", "")

    result = base64
fend

実装してみて感じたこと

まず最初にUWSCRでスクリプトを組み上げ、その後あえてUWSC版にも書き直してみました。結果として、UWSCRの使いやすさ・実装のしやすさを改めて実感することになりました

特に強く感じたのは以下のポイントです:

  • 文字列中の変数展開("<#変数>")が素直で、複雑な構築が楽に書ける
  • JSONの読み書きがUObject による構造化データで扱える
  • WebRequestBuilder() によってHTTPリクエストが非常に簡潔に書ける
  • PowerShell連携が pwsh() 一発で完結する

また、私の職場ではセキュリティポリシー上 powershell.exe が起動できず、UWSCからはスクリプト実行が制限される環境です。それに対して、UWSCRは pwsh.exe をネイティブで呼び出せるため、良し悪しはともかく職場環境にもフィットしやすい構成になっています。

すこし手こずったこと:整形済みJSONとBase64URL

UWSCでは最初に textblock で整形済みのJSONを作り、replace()で置換した後にBase64URL化していました。一見正しいのにUWSCR出力分と内容が一致せず「なぜ…?」とかなり悩みました。原因は、整形済みJSONに含まれる改行やスペース

UWSCRでは、UObject でJSONを組んでそのまま無名関数 base64() に渡していました。 こちらは改行や余計なスペースが入らず、結果的に正しい文字列が生成されていたことに後から気づきました。
※ JWTの署名対象は文字列としてのペイロードなので、内容が同じでも改行や空白の有無で署名値が変わります。

なのでUWSCではKey/Valueの配列を作った後に文字列とjoin()を結合して未整形JSONを作っています。

おわりに

これまでは、更新データをUWSCRで生成し、Googleドライブにファイルをアップロードして、反映はGAS側で処理するという二段構えの構成を取っていました。
それでも動いてはいたのですが、処理が分散していて管理しづらい・GAS側の挙動が不安定といった悩みもありました。

今回のようにUWSCRだけで認証から書き込みまで完結できる構成が取れるようになったことで、
メンテナンス性・安定性の面でもかなり大きな改善
になったと感じています。

今後も、外部APIとの連携や認証処理のような“ややこしい部分”も含めて、
UWSCRを使ったローカル完結型の自動化を積極的に活用していきたいと思います。

Discussion