Simple minds think alike

より多くの可能性を

Slack APIを通じてメッセージ投稿する

GolangのコードからSlack APIアクセスを試してみたくて、Slackの設定方法を調べてみました。今回はGolangのコードからではなくcurlを叩いてSlackメッセージ投稿できるところまでの手順を記載してみようと思います。

今回curlからアクセスするのはchat.postMessageというメッセージ投稿用のAPIメソッドです。

参考にしたドキュメント

公式Slack API 日本語版ページの「チュートリアル - Bolt フレームワークを使って Slack Bot を作ろう」と「権限 (スコープ) - ボットの OAuth スコープについて」を参考にしてみました。

これらのドキュメントは少し古く、現状のSlackのUIと異なっているところがあるため2022年6月時点のUIを元に記事を書きます。

アプリを作成し、ワークスペースにインストールする

Slack API: https://api.slack.com/appsにアクセスします。

「Create an App」を押します。

表示されるダイアログで「From scrach」を押します。これはSlackのUI上からアプリの設定を行う方法です。下の「From an app manifest」はマニフェストファイルを使ってアプリの設定を行う方法で、同じような設定を複数回行う場合にはこちらの方法が早いです。

アプリの名前とアプリをインストールするワークスペースを選択し、「Create App」を押します。

作成したアプリにOAuthスコープの追加

アプリがSlack APIにアクセスするためには、アプリで使用できるユーザートークンまたはボットトークンに対して適切なOAuthスコープを追加する必要があります。

ユーザートークンとボットトーク

トークンは、ユーザートークンとボットトークンに分かれます。Bolt入門ガイドに記載されている説明を引用します。

・ユーザートークン を使用すると、アプリをインストールまたは認証したユーザーに成り代わって API メソッドを呼び出せます。1 つのワークスペースに複数のユーザートークンが存在する可能性があります。
・ボットトークン はボットユーザーに関連づけられ、1 つのワークスペースでは最初に誰かがそのアプリをインストールした際に一度だけ発行されます。どのユーザーがインストールを実行しても、アプリが使用するボットトークンは同じになります。ほとんどのアプリで使用されるのは、ボットトークンです。

認証したユーザーに成り代わってメッセージ投稿するのではなく、ボットとしてメッセージ投稿させたいのでボットトークンの方にOAuthスコープを設定します。

スコープの概要と適切なスコープの探し方

ボットの OAuth スコープについて、という記事に記載されています。

今回curlからアクセスする chat.postMessage というAPIメソッドのドキュメント(下記リンク)を参照すると、Required scopesに「chat:write」が指定されているため、ボットトークンにこのOAuthスコープを追加します。
https://api.slack.com/methods/chat.postMessage

設定

左側メニューのFeaturesカテゴリにある「OAuth & Permissions」を選択。

ScopesのBot Token Scopesに、チャットへの書き込み権限「chat:write」を追加します。

「Install to Workspace」を押し、選択したワークスペースに対して作成したアプリをインストールします。

画面上部にBot User OAuth Tokenが表示されます。

Slackチャネルにアプリをインストールする

次にSlackワークスペース画面右上のアイコンを押し、チャネルの設定ダイアログを開きます。「インテグレーション」タブにある「アプリを追加する」ボタンを押します。

先ほど作成した「API TEST」アプリを追加します。

無事追加されると参加者としてメッセージが表示されます。

curlでSlackチャネルにメッセージを投稿する

発行したBot User OAuth Token、連携したチャネル名、投稿したいメッセージを指定してPOSTを送ります。これらの引数はAPIメソッドのドキュメントで必須(Required)として指定されている項目です。

$ curl -X POST 'https://slack.com/api/chat.postMessage' \
       -d 'token=your_bot_user_oauth_token' \
       -d 'channel=#general' \
       -d 'text=テスト'

投稿したメッセージが表示されます。

関連記事

simple-minds-think-alike.moritamorie.com

GitLab CI/CDのQuick Startチュートリアルをやってみた

仕事のソースコード管理にGitLabを使っていて、CI/CDに関してもGitLab CI/CDを使っているのですが、いまいち仕組みがよく分からなかったので学習のために以下のQuick Startチュートリアルをやってみました。その時の手順を書いてみます。

また、普段はセルフホストのGitLabを使っていますが、今回は手頃なGitlab.com環境で試してみます。

docs.gitlab.com

事前に行ったこと

作成したGitLabリポジトリ

以下の手順で作成したCI/CDプロジェクトを作ってみました。もし、よろしければ実際にどのように結果が出力されるか等確認してみてください。 gitlab.com

CI/CDプロセスの概要

GitLab CI/CDを使うために以下を行いました。

  • CI/CDジョブを動かすGitLab Runnerが利用可能か確認
  • リポジトリのルートに .gitlab-ci.yml(ジョブを定義するファイル) 作成

CI/CDジョブを動かすGitLab Runnerが利用可能か確認

GitLab公式のドキュメントによると、GitLab RunnerとはGitLab CI/CDと連携し、パイプラインでジョブを実行するためのアプリケーションとのことです。

GitLab.comのリポジトリを開き、[設定]=>[CI/CD]の Runnerの箇所で稼働中のRunnerを確認できます。 f:id:moritamorie:20220123004106p:plain

GitLab Runnerには以下の2種類のRunnerがあるようで、GitLabでリポジトリを作成した時点で最初からShared runnersは利用可能なようでした。

  • Shared runners (複数プロジェクトのジョブを共有のRunnerで処理する方式)
  • Specific runners (特定のプロジェクトのジョブのみ実行する方式)

Shared runnersでCI/CDジョブを処理する分には、gitlab.comでRunnerに関する事前作業は必要なさそうです。次に.gitlab-ci.yml(ジョブを定義するファイル)を作成していきます。

リポジトリのルートに.gitlab-ci.yml 作成

.gitlab-ci.yml はGitLab CI/CDの具体的な指示を設定するYAMLファイルとのことです。

リポジトリのルートに以下内容の .gitlab-ci.yml を作成します。作成方法は、GitLabのUI上で作成する、またはローカルでファイルを作成してgit pushするでもどちらでもOK。

build-job:
  stage: build
  script:
    - echo "Hello, $GITLAB_USER_LOGIN!"

test-job1:
  stage: test
  script:
    - echo "このジョブは何かをテストする"

test-job2:
  stage: test
  script:
    - echo "このジョブは何かをテストしますが、test-job1より時間がかかります"
    - echo "echoコマンドの後、20秒のsleepコマンドを実行します"
    - echo "これはtest-job1より20秒長いテスト実行をシミュレートしてます"
    - sleep 20

deploy-prod:
  stage: deploy
  script:
    - echo "このジョブは $CI_COMMIT_BRANCH ブランチから何かをデプロイします"

commitが作られると自動的にパイプラインが作られます。

パイプラインの確認

CI/CD > パイプラインを選択すると実行されているパイプラインを表示できます。 f:id:moritamorie:20220123013942p:plain

ステータスをクリックすると、パイプラインの詳細情報を表示できます。 f:id:moritamorie:20220123014042p:plain

stageがbuild, test, deployの順でジョブが実行され、全てのジョブが成功するとパイプラインのステータスも「成功」に変わります。

f:id:moritamorie:20220123014715p:plain

.gitlab-ci.yml tips

ここまででQuick Startは終わりですが、記事中の.gitlab-ci.yml tipsという項目を読んでおくと、色々なCI/CDを実装する上でのベースとなる知識が得られて良さそうでした。内容を記載しておきます。

  • .gitlab-ci.ymlファイルを作成したら、それ以降の編集はすべてパイプラインエディタで行ってください。パイプラインエディタを使用すると、次のことができます。
    • パイプラインの設定を、自動的な構文強調表示と検証を使用して編集する。
    • CI/CD設定の視覚化、.gitlab-ci.ymlファイルのグラフィック表示の表示。
  • ランナーがDockerコンテナを使ってジョブを実行する場合は、.gitlab-ci.ymlファイルを編集してイメージ名を記述してください。
    • default:
        image: ruby:2.7.4
      
  • 各ジョブにはスクリプトとステージが含まれます。
    • default キーワードは、before_scriptafter_script などのデフォルトの設定をカスタムするためのものです。
    • ステージは、ジョブの順次実行を記述します。1つのステージのジョブは、利用可能なランナーがある限り、並行して実行されます。
    • ステージの順序から外れてジョブを実行するには、Directed Acyclic Graphs (DAG)キーワードを使用します。
  • ジョブやステージの実行方法をカスタマイズするために、追加で設定できます。
    • rules キーワードを使用して、ジョブを実行またはスキップするタイミングを指定します。従来のキーワードであるonlyとexceptはまだサポートされていますが、同じジョブでルールと一緒に使用することはできません。
    • キャッシュアーティファクトを使用して、ジョブやステージ間の情報をパイプラインで永続的に維持します。これらのキーワードは、各ジョブでエフェメラルランナーを使用している場合でも、依存関係やジョブ出力を保存するための方法です。
    • .gitlab-ci.ymlの完全な構文については、.gitlab-ci.ymlの完全なリファレンス・トピックを参照してください。

実際のプロジェクトへの適用に際して

WEB IDEでテンプレートが用意されているため、プロジェクトに適したテンプレートを選択すると楽にCI/CDを導入できそうです。 f:id:moritamorie:20220123160552p:plain

感想

Github Actionsの.github/workflows とあまり変わらないのでYAMLの定義は難しくなさそうと感じましたが、日本語のドキュメントが少なく細かい設定を調べるのが少し大変そうだなと思いました。

また、Github Actionsと異なる点として、Runnerの種類(Shared Runners・Specific Runners)があったりと色々なことができそうですが、その反面設定が複雑で分かりにくく感じました。

参考資料

【Golang】go-oidcでHS256に対応する

以下の記事でgo-oidcというサードパーティライブラリを使用してOpenID ConnectでAuth0連携アプリケーションを構築する記事を書きましたが、JWT(JSON Web Token)署名に非対称暗号アルゴリズムを使っているRS256の対応方法だけしか書いていませんでした。今回は対称暗号アルゴリズムを使っているHS256に対応する方法を書いてみようと思います。

simple-minds-think-alike.moritamorie.com

前提

仕様

go-oidcを利用した実装の前にどのような署名検証を実現したいか、仕様の面を書いていきたいと思います。

HS256はどんなアルゴリズム

まず、HS256はどのようなアルゴリズムかというOpenID Connectとは直接的には関係がない一般的な話を書いてみようと思います。

HS256はHMAC-SHA-256 のことです。

HMACとは

まず、MAC (Message Authentication Code)はメッセージ認証コードのことです。

メッセージ認証コードは、暗号技術の一つです。対称・非対称暗号を用いてメッセージを暗号化すると機密性を得られますが、例えば通信経路の中で改竄が行われるとそれを検知できないので、暗号化によって完全性は得られません。メッセージ認証コードを使うと完全性を得られるようになります。

メッセージ認証コードは様々な方法で実現可能ですが、HMACは一方向ハッシュ関数(先頭HはHashの頭文字を取ったもの)を使って実現します。 HMAC-SHA-256(HS256) は、一方向ハッシュ関数にSHA-256を使ってメッセージ認証コードを計算する方法のことです。

HMACのプロセスと完全性の検証方法

計算方法など具体的な解説は省きますが、ResearchGateから引用したHMACのプロセス図を掲載しておきます。 https://www.researchgate.net/publication/346634579/figure/fig2/AS:965112139636738@1607112073858/Hash-Message-Authentication-Code-HMAC-process-332-Overview-of-SHA-256-SHA-256.png

メッセージ(Message)と共通鍵(Key)を元に、この図のプロセスで計算すると最終的にMAC値が算出されます。一方向ハッシュ関数の性質により、同じ鍵を持つ人ではないと同じMAC値を算出できないので、メッセージの受信者は改竄されたかを後から検証できます。(完全性が得られる。)

図の一方向ハッシュ関数(HASH Function)にSHA-256を使ったものがHMAC-SHA-256(HS256)というアルゴリズムです。

OpenID ConnectにおけるJWT署名

さて、話を一般的なメッセージ認証コードの話からOpenID Connectに戻し、JWTの署名がどのようなものになっているか、その中でHS256がどのように使われているかを確認していきます。

JWTのEncoding

JWTのEncodingの例は以下に記載されています。

ざっくり書くと以下の図のように、ヘッダー・ペイロード・署名の内容をBase64エンコードして.(ピリオド)で繋げて表現します。

JWTの最後の部分が署名部分だということが分かります。

JWTの具体的な例

ざっくりとしたEncodingの説明では具体的なイメージが湧かないと思いますので、jwt.ioというツールを使ってJWTを作ってみたいと思います。

jwt.ioを開いて、右側のDecodedのところに上からJWTのヘッダー・ペイロード・共通鍵(以下の図ではmy-common-keyの箇所)を入力すると、左側のEncodedでJWTが得られます。

ヘッダーのalgHS256になっているので、ペイロードをメッセージ、my-common-keyを共通鍵としてHS256で算出されたMAC値が署名部分になります。

署名検証の方法は以下に記載されており、JWTの受信者は共通鍵を使用することで署名検証できます。

実装

ここまで go-oidc を使って実現したいHS256の署名検証の方法を書いてきましたが、2022年1月時点で go-oidcHS256 をサポートしていないため部分的に独自で実装する必要があります。(参照)

HS256がサポートされていない背景

以下のissueを読んだところ、対称アルゴリズムと非対称アルゴリズムを混ぜるとAPIの誤用に繋がりかねないため、意図的な制約としてサポートしていないようです。

github.com

しかし、内部で署名検証に利用している go-jose ではHS256をサポートしているため、これを利用して実装できます。

実装

以下のように独自のVerifierを実装すると実現できます。 go-joseのREADMEを読むとSupported key types(Algorithm: HMAC)は []byte と記載されているので、共通鍵をバイトのスライスに変換して Verify関数の引数に渡します。

type HS256KeySet struct {
    CommonKey string
}

func (r *HS256KeySet) verify(_ context.Context, jws *jose.JSONWebSignature) ([]byte, error) {
    return jws.Verify([]byte(r.CommonKey))
}

func (r *HS256KeySet) VerifySignature(ctx context.Context, jwt string) ([]byte, error) {
    jws, err := jose.ParseSigned(jwt)
    if err != nil {
        return nil, fmt.Errorf("oidc: malformed jwt: %v", err)
    }
    return r.verify(ctx, jws)
}

func callback(code, state string) {

    // rawIDToken取得

    oidcConfig := &oidc.Config{
        ClientID:             "my-client-id",
        SupportedSigningAlgs: []string{"HS256"},
    }

    verifier := oidc.NewVerifier("https://issuer-domain.com/", 
        &HS256KeySet{CommonKey: "my-common-key"}, oidcConfig)
    idToken, err := verifier.Verify(context.Background(), rawIDToken)

    // 署名検証後のNonceの検証等
}

独自に作ったVerifierのVerifySignature関数は以下のタイミングで実行されます。

このように実装することで、OIDC認証の要件に必要なExpiryやIssuer, ClientIDのチェック等はサードパーティライブラリ(go-oidc)に任せられるようになり、署名検証の部分だけ独自のVerifierに実装できます。

参考資料

【Golang】Auth0のOpenID Connect連携アプリを作るチュートリアルをやってみた

仕事でOpenID Connect(以降OIDC)連携の実装した際、勉強のためにAuth0連携のサンプルアプリを作ってみたので、その一連の手順をまとめてみたいと思います。

手順は基本的にAuth0のチュートリアル通りなのですが、補足文章をつけて書いてみたいと思います。

手順は2022年1月時点のもので、今後Auth0の仕様変更により手順が変わる可能性がありますので、最新の情報はAuth0のドキュメントを確認してください。

サンプルで実現するもの

以下のような画面遷移のサンプルを作ります

  • ログイン画面を表示し、サインインリンクを押すとOIDC認証を開始
  • Auth0のログイン画面を表示
  • Auth0にログインしたユーザーのプロフィール情報(ニックネーム・写真)をユーザー画面に表示

f:id:moritamorie:20220110141908p:plain

既にAuth0にログインした状態で「サインイン」リンクが押された場合は、Auth0のログイン画面を経由せずにログイン後のユーザー画面に遷移します。

サンプルで実現するシステムの構成

サンプルで作る構成全体は以下のようになり、認可コードフローによってOIDC認証します。

f:id:moritamorie:20220110143321j:plain

環境

Goと使用するOIDCライブラリのバージョンは以下の通りです

  • Go 1.16
  • go-oidc 3.1.0

サンプル作成手順

Auth0側の設定、サンプルアプリケーション(Goプロジェクト側のコード)という順番で手順を書いていきます。

Auth0側の設定

Auth0側でアプリケーションを作成し、Auth0への接続情報を取得します。またAuth0ログイン後のリダイレクト先であるサンプルアプリケーションのコールバックURLを設定します。

アプリケーションの作成

メニューの「Applications」から「+Create Application」ボタンを押して、アプリケーションを作成するダイアログを表示します。

ダイアログ上でアプリケーション名を入力し、「Regular Web Application」を選択後「Create」ボタンを押し、アプリケーションを作成します。 f:id:moritamorie:20220105152048p:plain

アプリケーションの設定から取得する情報

作成されたアプリケーションの「Settings」タブを選択し、以下の3つの情報を控えておきます。

  • Domain
  • Client ID
  • Client Secret

f:id:moritamorie:20220105235044p:plain

アプリケーションに設定する内容

また「Settings」タブにおいて、Auth0にログインした後にリダイレクトするコールバックURLを設定します。

  • Callback URL
    • http://localhost:3000/callback

f:id:moritamorie:20220106115154p:plain

デフォルトの設定に関して

大体の要件では上記の設定でうまくいくと思いますが、要件に応じて変更する必要がありますがある以下の2点に関しては押さえておく必要があると思います。

  • 署名アルゴリズム
    • デフォルトではRS256で署名がされます。
    • HS256にしたい場合は設定変更及びHS256で署名検証するように実装も変更する必要があります。
  • OIDC Comfrontの設定
    • 厳密にOIDCの仕様に従うようになる設定です。デフォルトはONです。
    • 既にOIDC連携のアプリケーションの実装がある状態でAuth0に関しても連携できるようにしたいという場合で、厳密にOIDCの仕様に則っていない場合はOFFにする必要があるかもしれません。

これらはアプリケーションの「Settings」タブの下部にある「Advanced Settings」の「Auth」タブで設定できます。

サンプルアプリケーション(Goプロジェクト)

以下の機能を持つサンプルアプリケーションを作っていきます。

  • アプリケーションのログイン画面(home.html)を返すだけの無名ハンドラ
  • ログイン画面でサインインリンクを押した時のログインハンドラ
  • Auth0ログイン後のコールバック処理のハンドラ
  • コールバック処理でOIDC認証が終わった後にリダイレクトされるユーザー画面(user.html)を返すハンドラ

ディレクトリ構成

最終的なディレクトリ・ファイル構成は以下のようになります。

├── go.mod
├── go.sum
├── .env
├── main.go(認証器、ルーターを生成し、ポート3000でブラウザからのリクエストを待ち受ける)
├── web
│     ├── app
│     │     ├── user
│     │     │     └── user.go (ユーザー画面表示のハンドラ)
│     │     ├── login
│     │     │     └──login.go (ログイン処理のハンドラ)
│     │     └── callback
│     │           └── callback.go (コールバック処理のハンドラ)
│     └── template
│           ├──user.html (ユーザー画面のテンプレート)
│           └──home.html (ログイン画面のテンプレート)
└── platform
      ├── authenticator
      │     └── auth.go (認証器:Outh2設定、OIDCプロバイダー生成)
      └── router
            └── router.go (ルーター:セッション初期設定、HTMLテンプレートパス設定、パスとハンドラの対応付けを行う)

go.mod作成

空のgo.modファイルを作成し、依存関係があるライブラリを記載します。

$ go mod init github.com/moritamori/go-oidc-sample
module github.com/moritamori/go-oidc-sample

go 1.16

require (
  github.com/coreos/go-oidc/v3 v3.1.0
  github.com/gin-contrib/sessions v0.0.3
  github.com/gin-gonic/gin v1.7.4
  github.com/joho/godotenv v1.4.0
  golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
)

go.sum生成

$ go mod download

.envファイル作成

Auth0のsettings tabで取得・設定した内容を.envファイルに記載します。

# Auth0テナントドメイン
AUTH0_DOMAIN='dev-a4jtqeow.us.auth0.com'

# Auth0アプリケーションのClient ID.
AUTH0_CLIENT_ID='YOUR_CLIENT_ID'

# Auth0アプリケーションのClient Secret.
AUTH0_CLIENT_SECRET='YOUR_CLIENT_SECRET'

# アプリケーションのコールバックURL
AUTH0_CALLBACK_URL='http://localhost:3000/callback'

アプリケーションのエントリーポイント( main.go )

main.go では主に認証器、ルーターを生成し、ポート3000でブラウザからのリクエストを待ち受けます。

package main

import (
    "log"
    "net/http"

    "github.com/joho/godotenv"

    "github.com/moritamori/go-oidc-sample/platform/authenticator"
    "github.com/moritamori/go-oidc-sample/platform/router"
)

func main() {
    if err := godotenv.Load(); err != nil {
        log.Fatalf("envファイルの読み込みに失敗しました: %v", err)
    }

    auth, err := authenticator.New()
    if err != nil {
        log.Fatalf("認証器の生成に失敗しました: %v", err)
    }

    rtr := router.New(auth)

    log.Print("Server listening on http://localhost:3000/")
    if err := http.ListenAndServe("0.0.0.0:3000", rtr); err != nil {
        log.Fatalf("HTTPサーバーの起動時にエラーが発生しました: %v", err)
    }
}

ルーター作成

セッションの初期設定とHTMLテンプレートパス設定、パスとハンドラの対応付けをしています。 ログイン画面だけルーターの中で無名関数を作り、ルートパスにアクセスされた時にログイン画面のテンプレート(home.html) を返します。

package router

import (
    "encoding/gob"
    "net/http"

    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/cookie"
    "github.com/gin-gonic/gin"

    "github.com/moritamori/go-oidc-sample/platform/authenticator"
    "github.com/moritamori/go-oidc-sample/web/app/callback"
    "github.com/moritamori/go-oidc-sample/web/app/login"
    "github.com/moritamori/go-oidc-sample/web/app/user"
)

// New はroutesを登録し、ルーターを返す
func New(auth *authenticator.Authenticator) *gin.Engine {
    router := gin.Default()

    // セッション初期設定
    gob.Register(map[string]interface{}{})
    store := cookie.NewStore([]byte("secret"))
    router.Use(sessions.Sessions("auth-session", store))

    // HTMLテンプレートパス設定
    router.LoadHTMLGlob("web/template/*")

    // 無名ハンドラ
    router.GET("/", func(ctx *gin.Context) {
        ctx.HTML(http.StatusOK, "home.html", nil)
    })
    // ログイン処理のハンドラ
    router.GET("/login", login.Handler(auth))
    // コールバックハンドラ
    router.GET("/callback", callback.Handler(auth))
    // ユーザーハンドラ
    router.GET("/user", user.Handler)

    return router
}

ログイン画面のHTML(home.html)

ルートパスにアクセスされた時に返るログイン画面のHTML。「サインイン」リンクがあるだけのシンプルな画面。

<div>
    <h3>Auth0 OpenID Connectサンプル</h3>
    <a href="/login">サインイン</a>
</div>

ログイン処理のハンドラ

ログイン画面の「サインイン」リンクを押すと/loginにGETリクエストが送られ、ログイン処理のハンドラで処理されます。

ログイン処理のハンドラでは、ランダムなstate値を生成し、セッションに保存後、AuthCodeURLにリダイレクトします。AuthCodeURLは、Discovery Endpoint(http://dev-a4jtqeow.us.auth0.com/.well-known/openid-configuration)のauthorization_endpointから取得したURLを使います。

package login

import (
    "crypto/rand"
    "encoding/base64"
    "net/http"

    "github.com/gin-contrib/sessions"
    "github.com/gin-gonic/gin"

    "github.com/moritamori/go-oidc-sample/platform/authenticator"
)

// Handler はログイン処理のハンドラ
func Handler(auth *authenticator.Authenticator) gin.HandlerFunc {
    return func(ctx *gin.Context) {
        state, err := generateRandomState()
        if err != nil {
            ctx.String(http.StatusInternalServerError, err.Error())
            return
        }

        // セッションにstateを保存
        session := sessions.Default(ctx)
        session.Set("state", state)
        if err := session.Save(); err != nil {
            ctx.String(http.StatusInternalServerError, err.Error())
            return
        }

        // 認証コードURLにリダイレクト
        ctx.Redirect(http.StatusTemporaryRedirect,
            auth.AuthCodeURL(state))
    }
}

func generateRandomState() (string, error) {
    b := make([]byte, 32)
    _, err := rand.Read(b)
    if err != nil {
        return "", err
    }

    state := base64.StdEncoding.EncodeToString(b)

    return state, nil
}

コールバックのハンドラ

コールバックハンドラは、Auth0の認証後にリダイレクトされてアクセスされます。

クエリパラメータによって渡される認可コードをアクセストークン、IDトークンと交換し、トークン内の署名検証(RS256)して問題がなければ、プロフィール情報をセッションに保存し、ログイン後のユーザー画面にリダイレクトします。

package callback

import (
    "net/http"

    "github.com/gin-contrib/sessions"
    "github.com/gin-gonic/gin"

    "github.com/moritamori/go-oidc-sample/platform/authenticator"
)

// Handler はコールバックのハンドラ
func Handler(auth *authenticator.Authenticator) gin.HandlerFunc {
    return func(ctx *gin.Context) {
        session := sessions.Default(ctx)
        if ctx.Query("state") != session.Get("state") {
            ctx.String(http.StatusBadRequest,
                "stateパラメータが無効です")
            return
        }

        // 認可コードをトークンに交換する
        token, err := auth.Exchange(ctx.Request.Context(), ctx.Query("code"))
        if err != nil {
            ctx.String(http.StatusUnauthorized,
                "認可コードからトークンへの交換に失敗しました")
            return
        }

        idToken, err := auth.VerifyIDToken(ctx.Request.Context(), token)
        if err != nil {
            ctx.String(http.StatusInternalServerError,
                "IDトークンの検証に失敗しました")
            return
        }

        var profile map[string]interface{}
        if err := idToken.Claims(&profile); err != nil {
            ctx.String(http.StatusInternalServerError, err.Error())
            return
        }

        session.Set("access_token", token.AccessToken)
        session.Set("profile", profile)
        if err := session.Save(); err != nil {
            ctx.String(http.StatusInternalServerError, err.Error())
            return
        }

        // ログイン後のユーザー画面にリダイレクト
        ctx.Redirect(http.StatusTemporaryRedirect, "/user")
    }
}

認証情報作成(Outh2設定、OIDCプロバイダー生成)

以下の2つを生成し、それらのデータを持つ認証器を返します。

  • OpenID Discoveryを元にOIDC認証を行うプロバイダー
  • OAuth接続の設定情報(Auth0側で取得・設定したClient ID、Client Secret、Callback URLなどです)

また、この認証器にはIDトークンを検証し、問題がなければIDトークンを返す関数(VerifyIDToken)があります。

package authenticator

import (
    "context"
    "errors"
    "os"

    "github.com/coreos/go-oidc/v3/oidc"
    "golang.org/x/oauth2"
)

// Authenticator はユーザーを認証するための認証器
type Authenticator struct {
    *oidc.Provider
    oauth2.Config
}

// New はユーザーを認証するための認証器を生成する
func New() (*Authenticator, error) {
    provider, err := oidc.NewProvider(
        context.Background(),
        "https://"+os.Getenv("AUTH0_DOMAIN")+"/",
    )
    if err != nil {
        return nil, err
    }

    conf := oauth2.Config{
        ClientID:     os.Getenv("AUTH0_CLIENT_ID"),
        ClientSecret: os.Getenv("AUTH0_CLIENT_SECRET"),
        RedirectURL:  os.Getenv("AUTH0_CALLBACK_URL"),
        Endpoint:     provider.Endpoint(),
        Scopes:       []string{oidc.ScopeOpenID, "profile"},
    }

    return &Authenticator{
        Provider: provider,
        Config:   conf,
    }, nil
}

// VerifyIDToken は*oauth2.Tokenが有効なトークンであることを検証する
func (a *Authenticator) VerifyIDToken(ctx context.Context,
    token *oauth2.Token) (*oidc.IDToken, error) {
    rawIDToken, ok := token.Extra("id_token").(string)
    if !ok {
        return nil, errors.New("oauth2 tokenにid_tokenフィールドがありませんでした")
    }

    oidcConfig := &oidc.Config{
        ClientID: a.ClientID,
    }

    return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)
}

ユーザー画面のハンドラ

OIDC認証後にコールバックハンドラからリダイレクトされてアクセスされます。

セッションからログインユーザーのプロフィール情報を取り出し、値をテンプレートuser.htmlに渡し、生成したHTMLのレスポンスを返します。

package user

import (
    "net/http"

    "github.com/gin-contrib/sessions"
    "github.com/gin-gonic/gin"
)

// Handler はログイン済みユーザー画面のハンドラ
func Handler(ctx *gin.Context) {
    session := sessions.Default(ctx)
    profile := session.Get("profile")

    ctx.HTML(http.StatusOK, "user.html", profile)
}

ユーザー画面のHTML(user.html)

ユーザー画面のハンドラから読み込まれるユーザー画面のHTMLテンプレート。ハンドラからプロフィール情報を受け取り、展開します。

<div>
    <img class="avatar" src="{{ .picture }}"/>
    <h2>Welcome {{.nickname}}</h2>
</div>

gitリポジトリにpushする

事前にGithubリポジトリを作って、PUSHしておきます。

作ったサンプルを動かしてみる

$ go run main.go
[GIN-debug] Loaded HTML Templates (3):
    -
    - home.html
    - user.html

[GIN-debug] GET    /            --> github.com/moritamori/go-oidc-sample/platform/router.New.func1 (4 handlers)
[GIN-debug] GET    /login       --> github.com/moritamori/go-oidc-sample/web/app/login.Handler.func1 (4 handlers)
[GIN-debug] GET    /callback    --> github.com/moritamori/go-oidc-sample/web/app/callback.Handler.func1 (4 handlers)
[GIN-debug] GET    /user        --> github.com/moritamori/go-oidc-sample/web/app/user.Handler (4 handlers)
2022/01/06 14:56:21 Server listening on http://localhost:3000/

http://localhost:3000Webブラウザからアクセスするとログイン画面が表示され、サインインリンクをクリックし、OIDC連携の動作を確認できます。

f:id:moritamorie:20220106102448p:plain

リポジトリ

コードをGithubリポジトリにあげてみたのでよろしければ参考にしてみてください。 github.com

関連記事

本記事内では署名検証にHS256に対応する方法も記載してみましたので、もしよろしければご参考ください。

simple-minds-think-alike.moritamorie.com

参考資料 

PDFファイル構造の概要

PDFファイルの構造を知りたくてAdobe社から公開されているPDF 1.7の仕様書を少し読んだのですが、ボリュームが多く、英語で書かれていて理解が進みづらいと感じていました。

日本語の本とかないのかなと探していたらO'REILLYから「PDF構造解説」という書籍が2012年に出版されていていて、248ページという適度なボリュームで良さそうだと思って読んでみました。

また、この本の特徴として、PDFコンテンツのオブジェクト間の繋がりを有向グラフで表現していて、PDFの表示の仕組みに関しても理解が進みやすくなっているように工夫されている点が良いなと感じました。

この書籍の主に3章(ファイル構造)を読んで把握できた点をまとめてみようかと思います。

環境

  • macOS Catalina 10.15.7
  • pdftk 2.02

PDFファイルの構造

PDFファイルはテキストデータとバイナリデータの両方を含むことができます。

組み込みのフォントや画像などのデータはバイナリデータですが、PDFファイルの構造や文章はテキストデータになっているので、テキストエディタで開くことでざっくり内容を確認することができます。

PDFファイルを開くと以下の4つの部分から構成されていることが分かります。

  • ヘッダー
  • ボディ
  • クロスリファレンステーブル
  • トレイラー

f:id:moritamorie:20211024004420p:plain

ヘッダー

最初の行では準拠するPDF仕様のバージョン(ここでは1.0)を指定します。

%PDF-1.0

ボディ

ボディ部分は一連のオブジェクト(ページ、注釈、画像、署名など)で構成され、PDFビューワーで表示されるテキストや画像などのコンテンツはボディ内のオブジェクトとして指定します。

それぞれのオブジェクトの1行目で「オブジェクト番号世代番号objキーワード」を指定します。最後の行で「endobjキーワード」を指定し、その間がオブジェクトのコンテンツになります。以下のオブジェクトでは <</Kids [2 0 R] /Count 1 /Type /Pages >> という辞書(後述)がコンテンツです。

1 0 obj
<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>
endobj

クロスリファレンステーブル

クロスリファレンステーブルでは、ボディ内の各オブジェクト毎にファイルの先頭からのバイトオフセットを保持しています。

この情報によってランダムアクセスが可能になるため、PDFビューワーはファイルの一番上から順番に処理しなくても、任意のページを高速に表示することができます。

xrefキーワードから始まり、次の行(0 7)でテーブル内のエントリ(0から始まる7つのエントリがある)を表します。 その後に続く3列の項目では、各オブジェクトの バイトオフセット世代番号最後の1文字で使用・未使用(fが未使用、nが使用する)を表します。

xref
0 7
0000000000 65535 f 
0000000015 00000 n 
(以下、省略)

※最初のエントリ(0000000000 65535 f)はスペシャルエントリを表しており、使用しません。次のエントリからオブジェクト1、オブジェクト2…とそれぞれのバイトオフセットを保持します。

トレイラー

トレイラーは、クロスリファレンステーブルへのバイトオフセットを保持しており、直接アクセス可能にすることで高速にPDFファイルを開くための役割を果たしています。

trailerキーワードから始まり、次にトレーラー辞書が続きます。

トレーラー辞書は、以下2つのエントリが必須です。

  • /Size エントリ(クロスリファレンステーブルのエントリ数)
  • /Rootエントリ(ルート要素であるドキュメントカタログのオブジェクト番号)

その後、startxrefキーワードが続き、次の行でクロスリファレンステーブルのバイトオフセット、最後行を表す%%EOFが記述されます。

trailer
<< /Size 7
   /Root 5 0 R
>>
startxref
612
%%EOF

PDFファイルの基本要素

ここからはボディやトレイラーといったPDFファイルの一部ではなく、PDFファイル全体を通して使われる基本要素に関してまとめてみます。

単一の要素

整数や文字列といったそれ単体で表現される要素です。 整数や実数、文字列、名前、ブーリアン値、nullの5つがあります。

整数や実数

例: 43, 1.4142

文字列

丸括弧で囲まれると文字列になります。
例: (Lucy in the Sky)

名前

"/"から始まります。項目の識別子で、辞書のキーや様々な用途に用いられます。
例: /Kids/Count/Size

ブーリアン

trueキーワード または falseキーワード

null

nullキーワード指定されます。

複合的な要素

配列や辞書、ストリームの3つがあり、要素を複合的に使用します。

配列

ブランケットで囲まれると配列になります。 整数や文字列、間接参照といった要素以外にも配列や辞書も含めることができます。
例: [2 0 R 1 0 R]
上記は、1つ目の要素が 間接参照2 0 R、2つ目の要素が間接参照 1 0 R、という2つの要素から構成される長さ2の配列。

辞書

不等号を2つ続けたもの( << , >> )で囲うと辞書になります。名前から他の値に対応づけられます。配列や辞書も値にできます。

例:

<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>

上記は、/Kids が間接参照2 0 Rを含む配列、 /Countが整数1、/Typeが名前Pages に対応づけられた辞書。

ストリーム

ストリームは、フォントや画像、図形などを格納するものです。
streamendstreamの間に画像のバイナリデータなどの実データを指定し、辞書とセットで記述されます。
辞書には、データ長や圧縮方式といったストリームに関するメタデータを格納します。

3 0 obj
<<
 /Length 2817
>>
stream
(実データ)
endstream
endobj

参照する要素

関節参照

他のオブジェクトのリンクを作成します。
例: 1 0 R (オブジェクト番号1、世代番号0のオブジェクトへのリンクを表します。)

テキストエディタとpdftkを使ってPDFファイルを作る

ここまでのPDFファイルの構造、基本要素の内容を把握した段階で、大部分はテキストデータで記述できることが分かったので、簡単なPDFファイルをテキストエディタで作ってみました。

ただ、クロスリファレンステーブル内、トレーラー内のバイトオフセットを自分で算出して記載するのは手間だったので pdftk というコマンドラインツールを補完的に使いました。

pdftkのインストール

pdftk のインストール方法を調べたところ Homebrewを使った方法は動かなくなっているようだったので以下のパッケージファイルをダウンロードしてインストールしました。(Stackoverflow参照)

https://www.pdflabs.com/tools/pdftk-the-pdf-toolkit/pdftk_server-2.02-mac_osx-10.11-setup.pkg

テキストエディタでPDF作成

以下のグラフような構造のPDFファイルを作ってみます。

f:id:moritamorie:20211024100512p:plain

%PDF-1.0

1 0 obj 
<< /Type /Pages
   /Count 1
   /Kids [2 0 R] 
>>
endobj

2 0 obj 
<< /Type /Page
   /MediaBox [0 0 650 840]
   /Resources 3 0 R
   /Parent 1 0 R
   /Contents [4 0 R]
>>
endobj

3 0 obj 
<< /Font
     << /DF
          << /Type /Font
             /BaseFont /Helvetica
             /Subtype /Type1 >>
     >>
>>
endobj

4 0 obj
<< >>
stream
1. 0. 0. 1. 10. 800. cm
BT
  /DF 30. Tf
  (Hello, PDF!) Tj
ET 
endstream
endobj

5 0 obj
<< /Type /Catalog
   /Pages 1 0 R
>>
endobj
xref

0 6
trailer
<< /Size 6
   /Root 5 0 R
>>
startxref
0
%%EOF

PDFビューアーで開くと以下のような表示になります。 f:id:moritamorie:20211024102013p:plain

sample.pdfの内容

トレーラー

トレーラー辞書の/Rootには間接参照5 0 Rが対応づけられており、ルート要素であるドキュメントカタログのオブジェクト番号は5であることが分かります。

trailer
<< /Size 6
   /Root 5 0 R
>>
ドキュメントカタログ

ページツリーのオブジェクトの参照( 1 0 R )が対応づけられています。

5 0 obj
<< /Type /Catalog
   /Pages 1 0 R
>>
endobj
ページツリー(Pages)

オブジェクト番号2であるのページだけから構成されるページツリーです。

1 0 obj 
<< /Type /Pages
   /Count 1
   /Kids [2 0 R] 
>>
endobj
ページ(Page)

用紙サイズ650x840、リソースオブジェクトの参照(3 0 R )、親ページツリーの参照( 1 0 R )、コンテンツの参照( 4 0 R )が指定されています。

2 0 obj 
<< /Type /Page
   /MediaBox [0 0 650 840]
   /Resources 3 0 R
   /Parent 1 0 R
   /Contents [4 0 R]
>>
endobj
フォントリソース

名前が /DF、BaseFontが Helvetica のフォントリソースを指定しています。

3 0 obj 
<< /Font
     << /DF
          << /Type /Font
             /BaseFont /Helvetica
             /Subtype /Type1 >>
     >>
>>
endobj
コンテンツ(Hello, PDF!)

streamキーワードの次の行で、座標を右に10、上に800移動しています。また、BT(Begin Text)とET(End Text)の間で、30ポイントのフォント/DFを選択し、テキスト文字列(Hello, PDF!)を描画しています。

4 0 obj
<< >>
stream
1. 0. 0. 1. 10. 800. cm
BT
  /DF 30. Tf
  (Hello, PDF!) Tj
ET 
endstream
endobj

pdftkを使って補完

このままでもPDFファイルとして開くことはできるのですが、バイトオフセットが入っておらず任意のページにランダムアクセス可能なPDFになっていないのでpdftkを使って補完してみます。(といっても、作成したsample.pdfのページ数1はなので体感できるような表示速度の向上はありません。)

以下を実行しました。

$ pdftk sample.pdf output converted.pdf

出力されたPDFファイルをテキストエディタで開くと以下のようになり、クロスリファレンステーブルが作成され各オブジェクトのバイトオフセット、startxrefの箇所にクロスリファレンステーブルへのバイトオフセット等の情報が入っていることが確認できます。

感想

膨大な英語のドキュメントを読んでいると辛くなってくるので、日本語で書いてある資料は貴重だと思いました。

ただこの本だけでPDF仕様全体を把握できないので入門的な位置付けで捉えておいて、都度SDKのドキュメントを読んだり、仕様書を読んだりするのが良さそうです。

参考資料

【Golang】UniPDFでPDFファイルにデジタル署名する

PDFファイルにデジタル署名ができるGo言語のライブラリ( UniPDF )を使ってみました。

今年(2021年)4月頃に従量課金プランにフリー層ができたとブログ記事に掲載があり、月100件は無料で使える状態になったようです。

以下の公式のブログ記事を参考にサンプルコードを試してみました。 unidoc.io

UniPDF の署名機能を使うと、具体的には以下のようなことができるようです。 (参照)

  1. RSAキーペアによるドキュメントへの署名
  2. PKCS#12ファイルを使用したドキュメントへの署名
  3. 外部の署名生成サービスを利用して文書に署名し、空白の署名欄に署名を付加する
  4. SoftHSMとCrypto11パッケージを使ったPKCS#11サービスによる文書の署名
  5. PDFの電子署名の検証

今回は 「1. RSAキーペアによるドキュメントへの署名」のExampleコードを実行し、内容を確認してみようと思います。

結果

既存のPDFファイルに対してExampleコードを実行したところ、以下のような署名付きのPDFファイルが出力されました。

ページ右下に背景が黄色の署名フィールドが追加され、署名パネルで追加した署名の内容を確認できました。

f:id:moritamorie:20211010232009g:plain

環境

  • Go v1.16.2
  • UniPDF v3.29.0

事前準備

実行するには事前にAPIキーを発行して、環境変数 UNIDOC_LICENSE_API_KEY を設定する必要があります。 Uni Cloudのsignup画面から登録し、以下のAPIキーの画面からAPIキーを発行します。

f:id:moritamorie:20211010233047p:plain

コードの確認

サンプルコードがやっていることをコードスニペットに分解して見ていこうと思います。

①パッケージの読み込み

unidoc/pdf/core, unidoc/pdf/modelは基本的なPDFタイプとドキュメントモデルを提供してくれているようです。 (詳細はUniPDF v2のPress Releaseを参照)

import (
    "crypto/rand"
    "crypto/rsa"
    "crypto/x509"
    "crypto/x509/pkix"
    "fmt"
    "log"
    "math/big"
    "os"
    "time"

    "github.com/unidoc/unipdf/v3/annotator"
    "github.com/unidoc/unipdf/v3/common/license"
    "github.com/unidoc/unipdf/v3/core"
    "github.com/unidoc/unipdf/v3/model"
    "github.com/unidoc/unipdf/v3/model/sighandler"
)

②ライセンスAPIキー(環境変数)の読み込み

先ほど設定した UNIDOC_LICENSE_API_KEY環境変数から読み込みます。

func init() {
    err := license.SetMeteredKey(os.Getenv(`UNIDOC_LICENSE_API_KEY`))
    if err != nil {
        panic(err)
    }
}

RSAキーペアとX.509証明書

generateKeys()を実行し、RSAキーペアとX.509証明書を生成します。generateKeys()の中身は折りたたんで掲載しておきます。

   priv, cert, err := generateKeys()
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

④PDFリーダーと署名の生成

PDFリーダー、署名を付加するためのアペンダーを作成します。また、③で作成した秘密鍵と証明書を元に署名ハンドラを作成し、署名ハンドラを元にデジタル署名を作成します。最後にデジタル署名を初期化します。

   file, err := os.Open(inputPath)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }
    defer file.Close()

    reader, err := model.NewPdfReader(file)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

    appender, err := model.NewPdfAppender(reader)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

    handler, err := sighandler.NewAdobePKCS7Detached(priv, cert)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

    signature := model.NewPdfSignature(handler)
    signature.SetName("Test Self Signed PDF")
    signature.SetReason("TestSelfSignedPDF")
    signature.SetDate(now, "")

    if err := signature.Initialize(); err != nil {
        log.Fatal("Fail: %v\n", err)
    }

⑤署名の挿入

最後にPDFファイルにデジタル署名を行い、アノテーションを挿入し、ファイルを出力します。

   opts := annotator.NewSignatureFieldOpts()
    opts.BorderSize = 1
    opts.FontSize = 10
    opts.Rect = []float64{475, 25, 590, 80}
    opts.FillColor = model.NewPdfColorDeviceRGB(255, 255, 0)
    opts.TextColor = model.NewPdfColorDeviceRGB(0, 0, 200)

    field, err := annotator.NewSignatureField(
        signature,
        []*annotator.SignatureLine{
            annotator.NewSignatureLine("Name", "Takashi Morita"),
            annotator.NewSignatureLine("Date", "2021.10.10"),
            annotator.NewSignatureLine("Reason", "Looks good to me"),
            annotator.NewSignatureLine("Location", "Tokyo"),
        },
        opts,
    )
    field.T = core.MakeString("Self signed PDF")

    if err = appender.Sign(1, field); err != nil {
        log.Fatal("Fail: %v\n", err)
    }

    err = appender.WriteToFile(outputPath)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

コード全体

最後にコード全体を掲載しておきます。

サンプルコードの実行

$ go run main.go sample.pdf signed.pdf

出力結果の確認

出力されたPDFファイルをテキストエディタで開いたところ署名オブジェクトは以下のようになっていました。

14 0 obj
<<   /Type /Sig
     /Filter /Adobe.PPKLite
     /SubFilter /adbe.pkcs7.detached
     /Contents (省略)
     /Name (Test Self Signed PDF)
     /Reason (TestSelfSignedPDF)
     /M (D:20211010231753+09'00')
     /ByteRange [0 3457 19843 740]
>>

PDF 1.7の仕様書のDigital Signatureの章を参考にすると以下の値が入るようです。ByteRangeに関しては仕様書読んでもよくわからなかった。。

キー タイプ
Type name (オプション)Sigが入る
Filter name (必須) この署名を検証する際に使用する優先的な署名ハンドラの名前。適合するリーダーは、指定されたサブフィルターフォーマットをサポートしている限り、署名を検証する際に別のハンドラーで代用することができる。署名ハンドラーの例としては、Adobe.PPKLITEEntrust.PPKEFCICI.SignItVerisign.PPKVSがある。
SubFilter name (オプション) 署名辞書の署名値とキー情報のエンコーディングを記述した名前。適合するリーダーは、このフォーマットをサポートする任意のハンドラーを使用して、署名を検証することができる。
Contents byte string (必須) 署名の値。ByteRangeが存在する場合、値はバイトレンジダイジェストの値を表す16進文字列。
M date (オプション)署名の時間。署名ハンドラによって、検証されない通常のコンピュータの時刻か検証可能な方法で安全なタイムサーバから生成された時刻になる。
ByteRange array (署名フィールドの一部であるすべての署名、および権限辞書のUR3エントリから参照される使用権署名に必要) ダイジェスト計算のための正確なバイト範囲を記述する整数のペア(開始バイトオフセット、バイト単位の長さ)の配列。

また、最近のAdobeプロダクトのバージョン(11.x以降)では、RSAだと adbe.pkcs7.detachedETSI.CAdES.detached がSubFilterとして適合しているようでした。

感想

思ったより簡単に既存のPDFにデジタル署名することができました。RSAやX.509証明書を作る部分はGo言語の標準パッケージ(crypto/rsa, crypto/x509)に任せることができるのが理由なのかもと思いました。

また、長期署名である PAdES に関しても将来的にサポートを予定しているようなので、UniPDFの今後のアップデートに期待したいです。

関連記事

PDFファイルの内容の確認の仕方は以下の記事にまとめていますので、よろしければご参照ください。

simple-minds-think-alike.moritamorie.com

参考資料

【Github actions】DependabotのPull RequestでSecretsが参照できずワークフローがFailになった場合の対処

Dependabot のPull Request(以下PR)が作られた際に開始したGithub Actionsワークフローが Secrets を参照できずに失敗していたので原因を調べてみました。

f:id:moritamorie:20210317022417p:plain

2021/3/1から適用になった以下のUpdateが影響していて、 Dependabot から実行される Github Actionsワークフローは読み取りだけが可能な GITHUB_TOKEN のみ使うことができ、いかなる Secrets も使えなくなるという変更が原因でした。

github.blog

なので、例えばpushイベントトリガーで実行されるワークフローの中で Secrets として追加しておいたPersonal access tokensを使って、取得したカバレッジのサマリをコメントで追加したり、自動でラベルを追加するといった書き込み(write)権限が必要な場合は、ワークフローが落ちる状況になっていました。

結論

以下のように

  • dependabotから実行されされた場合はpull_request_targetトリガーの時のみ実行
  • dependabot以外から実行された場合は pull_request_targetトリガー以外の時に実行

するようにしました。

on: 
  push:
  pull_request_target:
  workflow_dispatch:

name: Test
jobs:
  test:
    runs-on: ubuntu-latest
    if: (github.event_name == 'pull_request_target' && github.actor == 'dependabot[bot]') || (github.event_name != 'pull_request_target' && github.actor != 'dependabot[bot]') 
    steps:
      - uses: actions/checkout@v2
        if: (github.event_name == 'pull_request_target' && github.actor == 'dependabot[bot]')
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          fetch-depth: 0
      - uses: actions/checkout@v2
        if: (github.event_name != 'pull_request_target' && github.actor != 'dependabot[bot]')
        with:
          fetch-depth: 0
      - name: SonarCloud Scan
        uses: SonarSource/sonarcloud-github-action@master
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

actions/checkoutのオプションに ref: ${{ github.event.pull_request.head.sha }} がないと、 mainブランチをチェックアウトしてしまうようなので pull_request_targetの場合は同時にこのオプションを付けるようにすると良いようです。(参照

このように対応することに至った具体的な経緯を共有していきます。

失敗していた原因

実行結果の詳細を見るとSet the SONAR_TOKEN env variable.というエラーが発生しており、 Secrets に設定していた値が取れていないことが確認できました。

f:id:moritamorie:20210317025933p:plain

以下のリポジトリで、同様の状況を再現してみましたので、よろしければ参考にしてみてください。

Bump axios from 0.19.1 to 0.21.1 by dependabot · Pull Request #1 · moritamori/dependabot-testing · GitHub

対応方針の検討

主に以下の3つのドキュメント・Github issueを参照しました。

これらの情報から大きく分けて3通りの対応方法があることが分かりました。

また、①と②のどちらを適用するかは場合によって異なります。いずれの場合もwrite権限が付与され Secrets にアクセスできるようになる、という点は変わりません。

  • PR自体を更新する必要がある場合
    • => ②workflow_run
  • コメントやラベル等を追加しPRを構成するだけで更新しない場合
    • => ①pull_request_target

pull_request_target トリガーを使う

2021/2/19に公開されたGithubブログの記事 によると、write(書き込み)権限があるトークンが必要な場合、以下の2020/12/15に公開されたGithubブログの記事に書いているリスクを把握したうえで pull_request_target を使ってください、という記述があったのでリスクに関する記述を読んでみました。

リスクに関する記述1: 適用用途

pull_request_target トリガーに関して以下の記述がありました。

The reason to introduce the pull_request_target trigger was to enable workflows to label PRs (e.g. needs review) or to comment on the PR. The intent is to use the trigger for PRs that do not require dangerous processing, say building or running the content of the PR.

pull_request_targetトリガを導入した理由は、ワークフローでPRにラベルを付けたり(例:needs review)、PRにコメントを付けたりできるようにするためでした。このトリガーは、PRの内容を構築したり実行したりするような危険な処理を必要としないPRに使用することを意図しています。

今回遭遇したエラーは、この用途に該当するGithubワークフローで発生したものでした。

リスクに関する記述2: 注意点

pull_request_target トリガーの中でどのような処理を実行しても良いかを考えるうえで、以下の記述が参考になりました。

a workflow triggered on pull_request_target still has the read/write repository token in memory that is potentially available to any running program.

If the workflow uses actions/checkout and does not pass the optional parameter persist-credentials as false, it makes it even worse.

The default for the parameter is true. It means that in any subsequent steps any running code can simply read the stored repository token from the disk.

日本語にすると

pull_request_target でトリガーされたワークフローでは、メモリ内に読み取り/書き込み可能なリポジトリトークンが残っており、実行中のプログラムから利用できる可能性があります。

ワークフローが actions/checkout を使用していて、オプションのパラメータ persist-credentials を false にしていない場合は、さらに悪い状況になります。このパラメータのデフォルトはtrueです。

これは、後続のステップで、実行中のコードがディスクから保存されたリポジトリトークンを単純に読み取れることを意味します。リポジトリへの書き込みアクセスやシークレットが必要ない場合は、pull_request トリガーを使用してください。

という感じかと思います。

workflow_run を使う

リスクに関する記述1: 適用用途

workflow_run トリガーに関して以下の記述がありました。

Together with the pull_request_target, a new trigger workflow_run was introduced to enable scenarios that require building the untrusted code and also need write permissions to update the PR with e.g. code coverage results or other test results.

pull_request_target と共に、新しいトリガ workflow_run が導入され、信頼できないコードの構築を必要とするシナリオや、コードカバレッジの結果やその他のテスト結果などで PR を更新するための書き込み権限を必要とするシナリオを可能にしました。

Dependabot によって実行される Github actions ワークフローの中でPRを更新するようなケースでは workflow_run を使うと良さそうです。

リスクに関する記述2: 注意点

workflow_run トリガーの中でどのような処理を実行しても良いかを考えるうえで、以下の記述が参考になりました。

To do this in a secure manner, the untrusted code must be handled via the pull_request trigger so that it is isolated in an unprivileged environment.

これを安全に行うためには、信頼できないコードは、権限のない環境で隔離されるように、pull_requestトリガを介して処理されなければなりません。

③Dependabotを諦めてRenovateに乗り換える

対応が煩雑なので Renovate への置き換える方もでてきているようです、合わせて検討すると良いかもしれません。

github.com

対応したコード

用途に適した対応が①pull_request_target トリガーを使うだったので、Github issueコメント記載されていた対応コードのサンプルを参考に対応してみました。

Dependabot cant read secrets anymore · Issue #3253 · dependabot/dependabot-core · GitHub

ワークフローが正常に動くようになることを確認した検証用リポジトリも共有しておきます。 github.com

private registriesを使う場合

2021/3/15に以下のリリースがあり、GitHub Packagesやnpm等のプライベートレジストリを使ってパッケージの更新を行っている場合はよりセキュアに Secrets を使うことができるようになったので、こちらの方法と併せて対応すると良さそうです。

github.blog

Dependabot secretsの検証

もしかすると、 private registriesで使用可能な以下の DependabotSecrets を設定することで、Github actions ワークフローから参照できるようにならないかな、と思って試してみたのですがうまくいきませんでした。dependabot.ymlの中からしか参照できないようです。

f:id:moritamorie:20210317125542p:plain

Github actions ワークフローで行っている Secrets を使った処理を Dependabot の機能に移せる場合に、 Dependabot のprivate registriesの使用を検討すると良さそうです。

検証した際のGithubリポジトリを以下に記載しておきます。

Bump axios from 0.19.1 to 0.21.1 by dependabot · Pull Request #1 · moritamori/dependabot-testing3 · GitHub

参考資料