命名って難しい

変数、関数、クラスなどなど実装より命名に毎回悩むタイプの人間による技術についてのメモ。

PowerShell5.1でLINEWORKS API2.0を叩く!JWTで苦労したService Accountのアクセストークン取得編

弊社採用のLINE WORKSなのですが、今までAPI1.0を利用していたのですが、廃止予定が迫ってきたのでAPI 2.0を触りはじめました。

developers.worksmobile.com

LINE WORKS API 2.0のリリースに伴い、LINE WORKS API 1.0は2023年4月30日をもって提供を終了いたします。
提供の終了前であっても、サービスの仕様変更等によりLINE WORKS API 1.0の一部が正常に利用できない場合があります。できるだけ早いLINE WORKS API 2.0への移行をご検討ください。

とのこと。

弊社はテック企業ではないので、マシンの環境をおいそれとアップデートしたりできませんし、開発用端末も他部署の業務用PCと同様の環境となっています。

また、作成したPowerShellスクリプトも配布する時があるので、PowerShell5.1(Win10標準)環境での実現にこだわっています。

本記事は下記の環境で LINE WORKS API 2.0のService Account認証し、アクセストークン取得するまでを実現したソースコードを説明します。

もくじ

アクセストークン取得までの流れ

公式の説明は以下のようになっています。

  1. アプリ開発者は、Developer ConsoleよりService Accountを発行する
  2. アプリは、Service Accountを使用してJWT生成する(RFC-7519)
  3. アプリは、JWTを電子署名する (signature) (RFC-7515)
  4. アプリは、LINE WORKSにAccess Tokenの発行を要求する (RFC-7523)
  5. アプリは、Access Tokenの有効期限が過ぎた場合、Refresh Tokenをもとに再発行する

developers.worksmobile.com

順を追って説明します。

1. アプリ開発者は、Developer ConsoleよりService Accountを発行する

こちらは実装ではなく、LINE WORKSのDeveloper Consoleでの作業となります。

下記のURLを参考にするとわかりやすいです。

qiita.com

2. アプリは、Service Accountを使用してJWT生成する(RFC-7519)

JWTは以下の3要素からなります

  • Header
  • Payload
  • Signiture

共通して利用する、Base64URLを実装する。

.Net FrameworkではBase64文字列に変換する処理はありますが、URL-Safeな形式にする処理はありません。 そのため、以下の関数を実装しました。

  • 文字列をBASE64文字列に変換する関数
  • BASE64文字列をURL-Safeな文字列に変換する関数
function Convert-Base64StringToUrlSafe {
    <#
    .SYNOPSIS
        Base64文字列をBase64Url形式に変換する。
    .LINK
        Base64Urlについてはwikipedia参照:
            https://ja.wikipedia.org/wiki/Base64#%E5%A4%89%E5%BD%A2%E7%89%88
    .EXAMPLE
        Convert-Base64StringToUrlSafe -InputObject "AB/CD+EF==" # "AB_CD-EF"
    #>
    param(
        # BASE64文字列
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]
        $InputObject
    )
    return $InputObject.TrimEnd('=').Replace('+', '-').Replace('/', '_')
}
function Convert-StringToBase64Url {
    <#
    .SYNOPSIS
        文字列をBase64Url形式にエンコードする。
    .EXAMPLE
        Convert-StringToBase64Url -InputObject "AB/CD+EF==" # "AB_CD-EF"
    #>
    param (
        # 文字列
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]
        $InputObject
    )
    $bytes = ([System.Text.Encoding]::UTF8).GetBytes($InputObject)
    return [Convert]::ToBase64String($bytes) | Convert-Base64StringToUrlSafe
}

ヘッダーは固定なのでHashtableで定義して、-Compressを付けたConvertTo-Json で変換したJSON文字列を Base64Url に変換します。

# ヘッダー作成
Write-Host 'ヘッダー作成'
$header = @{
    alg = 'RS256'
    typ = 'JWT'
}
$headerBase64 = $header | ConvertTo-Json -Compress | Convert-StringToBase64Url

Payload

ペイロードは一部動的に生成する要素があります。 iadexp です。

これはUNIX時間ですが Get-Date-UFormat 引数で簡単に取得できます。

Write-Host 'ペイロード作成'
$now = Get-Date
$payload = @{
    iss = $config.clientId
    sub = $config.serviceAccount
    iat = "$([int](Get-Date($now) -UFormat '%s'))"
    exp = "$([int](Get-Date($now.AddMinutes(60)) -UFormat '%s'))"
}
$payloadBase64 = $payload | ConvertTo-Json -Compress | Convert-StringToBase64Url

Signiture

上記コードの例で言うと、Header($headerBase64)Payloadd($payloadBase64) をピリオドで接続した文字列を電子署名したものが Signitureとなります。

この3要素をピリオドで連結したものがJWTです。

Header.Payload.Signature

ここの電子署名が難所でした。 詳細は 3. アプリは、JWTを電子署名する (signature) (RFC-7515) に記載します。

3. アプリは、JWTを電子署名する (signature) (RFC-7515)

今まで電子署名と無縁の生活をしてきたのでここが最大の難所でした。。。

LINE WORKS側から提供された秘密鍵はPEM形式 ですが、 .Netで読み込める公開or秘密鍵の形式はXML という点がスムーズに行かないところです。

色々調べましたが、私の前提とする環境では実施できないものがありました。

PowerShell 6.2 なら簡単らしい(私の環境では動作未検証)

下記の記事で説明されているように、モジュールを使えば簡単にRSA署名のJWTが作成できます。

qiita.com

.NET 5以降なら簡単らしい(私の環境では動作未検証)

下記の記事で説明されているように、.NET 5以降ではPEM形式のファイルを読み込むメソッドがあります。

wakwak-koba.hatenadiary.jp

docs.microsoft.com

前提とした環境での解決方法【PEMをopensslkey.csで読み込む】

解決策として考えたのはいくつかありました。

  1. 他の.NET 5以降の環境でPEMをXMLに変換して秘密鍵を運用する。
    • そのための環境を用意できない。
    • それ以外はウェブ上での変換となるが、セキュリティリスクがありそう。
  2. opensslコマンドをインストールし、PSから実行し署名データを作る。
    • 今までopensslコマンドを使っていなかったので、適宜ググりながら署名して署名後のファイルをGetBytesしたりしたのですが、うまくいかず断念。

最終的にopensslkey.csという実装で読み込めることがわかったので、それを利用することにしました。

opensslkey.cs は gist にあるソースコードであり、NuGetPackageにもあります。 ちなみにインストールすると同じソースコードが既存のプロジェクトの名前空間に書き換えられて追加されるだけです。

http://www.jensign.com/opensslkey/opensslkey.cs · GitHub

www.nuget.org

opensslkey.cs をPowerShellで利用して署名処理を実装する

opensskey.csを利用するため、以下のようにC#ソースを Add-Type 追加し、.Net で記述するように署名の処理を記載します。

それを「文字列をRSAで署名したデータをBase64Url文字列に変換する関数」として実装しました。

function Convert-StringToSignedBase64Url {
    <#
    .SYNOPSIS
        指定の文字列をPEM形式の秘密鍵でRSA署名したBASE64URLエンコードの文字列に変換する。
    .EXAMPLE
        Convert-StringToSignedBase64Url `
            -InputObject "ABCDEFG" `
            -PrivateKeyPath "C:\PrivateKey.pem"
    #>
    param (
        # 署名対象文字列
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]
        $InputObject,
        # 秘密鍵のパス
        [Parameter(Mandatory)]
        [string]
        $PrivateKeyPath
    )
    # opensslkey.csを追加する。
    if (-not ('JavaScience.opensslkey' -as [type])) {
        $source = Get-Content .\opensslkey.cs -Encoding UTF8 -Raw
        Add-Type -ReferencedAssemblies System.Security -TypeDefinition "$source"
    }

    # PEMのテキストから RSACryptoServiceProvider を作成する。
    $privateKey = [System.IO.File]::ReadAllText($PrivateKeyPath);
    [byte[]]$pkcs8privatekey = [JavaScience.opensslkey]::DecodePkcs8PrivateKey($privateKey);
    [System.Security.Cryptography.RSACryptoServiceProvider]$rsa =
    [JavaScience.opensslkey]::DecodePrivateKeyInfo($pkcs8privatekey);

    # 署名
    $StringBytes = [System.Text.Encoding]::ASCII.GetBytes($InputObject);
    $signedBytes = $rsa.SignData($StringBytes, [System.Security.Cryptography.SHA256]::Create());
    [string]$signedBase64 = [Convert]::ToBase64String($signedBytes);

    return Convert-Base64StringToUrlSafe $signedBase64
}

この関数を使って以下のようにSignatureを作ります。

# 署名作成
Write-Host '署名作成'
$signature = "$headerBase64.$payloadBase64" |
    Convert-StringToSignedBase64Url -PrivateKeyPath 'private.key'

これでJWT作成までが完了しました。

4. アプリは、LINE WORKSにAccess Tokenの発行を要求する (RFC-7523)

これはPOSTリクエストを行うだけです。PowerShellでは Invoke-WebRequestを利用します。

ContentTypeapplication/x-www-form-urlencoded を指定すれば Bodyに渡したHashtableをそれに合わせた形に自動で整形してリクエスト送信してくれます。

$body = @{
    assertion     = $JWT
    grant_type    = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
    client_id     = $ClientID
    client_secret = $ClientSecret
    scope         = $Scope
}

Invoke-WebRequest `
    -Method Post `
    -Uri 'https://auth.worksmobile.com/oauth2/v2.0/token' `
    -ContentType 'application/x-www-form-urlencoded' `
    -Body $body

5. アプリは、Access Tokenの有効期限が過ぎた場合、Refresh Tokenをもとに再発行する

ここまででアクセストークンは取得できたので、次の機会に書きます。

それぞれの処理をまとめたソースコードGitHub に置いています。

ぜひご覧になってください。

github.com

以上!