slack-go で Sign in with Slack を試す

Go 言語のライブラリ slack-go を使って Sign in with Slack の動作を見てみました。

github.com

準備

Slack App を作成します。

  • Basic Information > App Credentials で、クライアント ID とシークレットを確認しておきます。

  • OAuth & Permissions で、Redirect URLs に後述の Callback 関数の URL (API Gateway エンドポイント) を追加します。 また、Scopes > User Token Scopes (取得可能な権限) については、今回は identity.basic のみ設定します。

"Fetch User Identity" という名前にしました。

構成

AWS Lambda で2つの関数を作ります。

  • Slack のサインインに誘導するためのエントリーポイント
  • アクセストークンを取得するためのコールバック

実装

AWS SAM CLI で作成していきます。

$ sam init --name sign-in-with-slack --runtime go1.x --package-type Zip

以下の構成にしたいと思います。

sign-in-with-slack/
├── Makefile
├── README.md
├── src/
│   ├── go.mod
│   └── handler/
│       ├── callback/
│       │   └── main.go
│       └── sign-in/
│           └── main.go
└── template.yaml
# サンプルは不要なので消す

$ rm -fr hello-world

# go module を構成する

$ mkdir -p src/handler
$ cd src
$ mkdir -p handler/sign-in handler/callback
$ go mod init linkode.co.jp/sign-in-with-slack
$ cd -

SignIn 関数

Slack のサインインへのリンクを表示します。

今回は、以下の形式でリンクを構成します。user_scopeclient_id は必須です。

https://slack.com/oauth/v2/authorize?user_scope=identity.basic&client_id=123&state=abc

Slack の OAuth は PKCE に対応してないため、セキュリティ要素は state だけです。セッションと紐づけて管理することになるかと思います。

Slack App のクライアント ID については、環境変数として渡すことにします。

package main

import (
        "fmt"
        "log"
        "net/http"
        "net/url"

        "linkode.co.jp/sign-in-with-slack/internal/env"
        "linkode.co.jp/sign-in-with-slack/internal/securerandom"
        "linkode.co.jp/sign-in-with-slack/internal/session"

        "github.com/aws/aws-lambda-go/events"
        "github.com/aws/aws-lambda-go/lambda"
)

const (
        slackSignInEndpoint = "https://slack.com/oauth/v2/authorize"
)

var (
        slackAppClientID = env.MustGet("SLACK_APP_CLIENT_ID")
)

// Slack サインイン URL を作成
func makeSlackSignInURL(state string) string {
        u, err := url.Parse(slackSignInEndpoint)
        if err != nil {
                log.Fatal(err)
        }
        q := u.Query()
        q.Set("user_scope", "identity.basic")
        q.Set("client_id", slackAppClientID)
        q.Set("state", state)
        u.RawQuery = q.Encode()
        return u.String()
}

// セッション ID の Cookie を作成
func makeCookie(sessionId string) string {
        cookie := http.Cookie{
                Name:     "session_id",
                Value:    sessionId,
                Secure:   true,
                HttpOnly: true,
                Path:     "/",
        }
        return cookie.String()
}

func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
        log.Printf("Request: %+v\n", request)

        // セッションを作成
        sess := session.New()
        // ランダムな state を作成
        sess.State = securerandom.NewRandom(36)
        // セッションを保存
        if err := session.Put(&sess); err != nil {
                log.Print(err)
                return events.APIGatewayProxyResponse{
                        StatusCode: http.StatusInternalServerError,
                }, err
        }

        headers := map[string]string{
                "Set-Cookie":   makeCookie(sess.ID),
                "Content-Type": "text/html; charset=utf-8",
        }
        body := fmt.Sprintf(`
<html>
<head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8">
</head>
<body>
    <a href="%s"><img src="https://api.slack.com/img/sign_in_with_slack.png" /></a>
</body>
</html>
        `, makeSlackSignInURL(sess.State))

        return events.APIGatewayProxyResponse{
                Headers:    headers,
                Body:       body,
                StatusCode: http.StatusOK,
        }, nil
}

func main() {
        lambda.Start(handler)
}

internal モジュールをインポートしていますが、後で説明します。

Callback 関数

ユーザーから認可を得たあと、Slack はユーザーへこの関数へのリダイレクションを返します。

URL には User Token を取得するための code パラメータと、検証用の state パラメータが付属しています。

slack-go でアクセストークンを取得するには slack.GetOAuthV2Response() を呼び出します。

func GetOAuthV2Response(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthV2Response, err error)

成功すると OAuthV2Response が得られ、OAuthV2Response.AuthedUser.AccessToken にユーザーのアクセストークンが格納されているので、これを使って slack.Client オブジェクトを生成し、ユーザー情報を取得します。

// code を渡してユーザーのアクセストークンを取得
// redirectURI は Slack App の Redirect URLs に登録した URL か、空文字列にする
// 複数の URL を登録してある場合は明示的に指定する
resp, _ := slack.GetOAuthV2Response(http.DefaultClient, slackAppClientID, slackAppClientSecret, code, "")

// アクセストークンから slack.Client オブジェクトを生成
api := slack.New(resp.AuthedUser.AccessToken)

// ユーザー情報を取得
ui, _ := api.GetUserIdentity()

全体は以下のようになります。

package main

import (
        "errors"
        "fmt"
        "log"
        "net/http"

        "linkode.co.jp/sign-in-with-slack/internal/env"
        "linkode.co.jp/sign-in-with-slack/internal/session"

        "github.com/aws/aws-lambda-go/events"
        "github.com/aws/aws-lambda-go/lambda"
        "github.com/slack-go/slack"
)

var (
        slackAppClientID     = env.MustGet("SLACK_APP_CLIENT_ID")
        slackAppClientSecret = env.MustGet("SLACK_APP_CLIENT_SECRET")

        errOAuth2StateMismatch = errors.New("Oauth2 state mismatch")
)

// Cookie からセッション ID を取得する
func getSessionID(header http.Header) (string, error) {
        parser := &http.Request{
                Header: http.Header{"Cookie": header["Cookie"]},
        }
        cookie, err := parser.Cookie("session_id")
        if err != nil {
                return "", err
        }
        return cookie.Value, nil
}

func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
        log.Printf("Request: %+v\n", request)

        // Cookie からセッション ID を取得
        sessionId, err := getSessionID(request.MultiValueHeaders)
        if err != nil {
                log.Print(err)
                return events.APIGatewayProxyResponse{
                        StatusCode: http.StatusForbidden,
                }, err
        }

        // セッションを取得
        sess, err := session.Get(sessionId)
        if err != nil {
                log.Print(err)
                return events.APIGatewayProxyResponse{
                        StatusCode: http.StatusForbidden,
                }, err
        }

        q := request.QueryStringParameters
        // state パラメータを検証
        if state := q["state"]; state != sess.State {
                log.Printf("state: %s != %s\n", sess.State, state)
                return events.APIGatewayProxyResponse{
                        StatusCode: http.StatusForbidden,
                }, errOAuth2StateMismatch

        }

        // code パラメータを渡してユーザーのアクセストークンを取得する
        code := q["code"]
        resp, err := slack.GetOAuthV2Response(http.DefaultClient, slackAppClientID, slackAppClientSecret, code, "")
        if err != nil {
                log.Print(err)
                return events.APIGatewayProxyResponse{
                        StatusCode: http.StatusForbidden,
                }, err
        }

        // アクセストークンを使ってユーザー情報を取得
        api := slack.New(resp.AuthedUser.AccessToken)
        ui, err := api.GetUserIdentity()
        if err != nil {
                log.Print(err)
                return events.APIGatewayProxyResponse{
                        StatusCode: http.StatusForbidden,
                }, err
        }

        headers := map[string]string{
                "Content-Type": "text/html; charset=utf-8",
        }
        body := fmt.Sprintf(`
<html>
<head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8">
</head>
<body>
    <div><pre>%#v</pre></div>
</body>
</html>
        `, ui)

        return events.APIGatewayProxyResponse{
                Headers:    headers,
                Body:       body,
                StatusCode: http.StatusOK,
        }, nil
}

func main() {
        lambda.Start(handler)
}

Internal モジュールについて

ヘルパーとして envsecurerandomsession モジュールを作成しました。簡単に説明します。

env モジュール

env.MustGet() は、環境変数に空ではない値が設定されていることを要求します。

package env

import (
        "fmt"
        "os"
)

// 環境変数の設定を必須とする
func MustGet(key string) string {
        v := os.Getenv(key)
        if v == "" {
                panic(fmt.Sprintf(`couldn't find environment variable "%s"`, key))
        }
        return v
}
securerandom モジュール

セッション ID と state 生成用です。URL-safe なランダム文字列を生成します。

package securerandom

import (
        "crypto/rand"
        "encoding/base64"
)

func Must(s string, err error) string {
        if err != nil {
                panic(err)
        }
        return s
}

func New(size int) (string, error) {
        b := make([]byte, size)
        _, err := rand.Read(b)
        if err != nil {
                return "", err
        }
        return base64.RawURLEncoding.EncodeToString(b), nil
}

// URL-safe なランダム文字列を生成する
func NewRandom(size int) string {
        return Must(New(size))
}
session モジュール

セッション管理用モジュールです。実装は省略します。

package session

type Session struct {
        ID    string
        State string
}

func New() Session {
        // ...
}

func Get(id string) (Session, error) {
        // ...
}

func Put(sess *Session) error {
        // ...
}

デプロイ

最終的な構成は以下の通りです。

sign-in-with-slack/
├ ─ ─  Makefile
├ ─ ─  README.md
├ ─ ─  src/
│    ├ ─ ─  go.mod
│    ├ ─ ─  go.sum
│    ├ ─ ─  handler/
│    │    ├ ─ ─  callback/
│    │    │    └ ─ ─  main.go
│    │    └ ─ ─  sign-in/
│    │        └ ─ ─  main.go
│    └ ─ ─  internal/
│        ├ ─ ─  env/
│        │    └ ─ ─  get.go
│        ├ ─ ─  securerandom/
│        │    └ ─ ─  new.go
│        └ ─ ─  session/
│            └ ─ ─  session.go
└ ─ ─  template.yaml

template.yaml は以下の通りです。

Slack App で確認したクライアント ID とシークレットを、環境変数として SLACK_APP_CLIENT_ID SLACK_APP_CLIENT_SECRET に設定します。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sign-in-with-slack

  Sample SAM Template for sign-in-with-slack

Globals:
  Function:
    Timeout: 5

Resources:
  SignInFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/handler/sign-in/
      Handler: sing-in
      Runtime: go1.x
      Tracing: Active
      Events:
        CatchAll:
          Type: Api
          Properties:
            Path: /sign-in
            Method: GET
      Environment:
        Variables:
          SLACK_APP_CLIENT_ID: "<cllient-id>"

  CallbackFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/handler/callback/
      Handler: callback
      Runtime: go1.x
      Tracing: Active
      Events:
        CatchAll:
          Type: Api
          Properties:
            Path: /callback
            Method: GET
      Environment:
        Variables:
          SLACK_APP_CLIENT_ID: "<client-id>"
          SLACK_APP_CLIENT_SECRET: "<client-secret>"

Outputs:
  SignInAPI:
    Description: "API Gateway endpoint URL for Prod environment for Sign-in Function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/sign-in/"
  SignInFunction:
    Description: "Sign-in Lambda Function ARN"
    Value: !GetAtt SignInFunction.Arn
  SignInFunctionIamRole:
    Description: "Implicit IAM Role created for Sign-in function"
    Value: !GetAtt SignInFunctionRole.Arn

  CallbackAPI:
    Description: "API Gateway endpoint URL for Prod environment for Callback Function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/callback/"
  CallbackFunction:
    Description: "Callback Lambda Function ARN"
    Value: !GetAtt CallbackFunction.Arn
  CallbackFunctionIamRole:
    Description: "Implicit IAM Role created for Callback function"
    Value: !GetAtt CallbackFunctionRole.Arn

ビルドしてデプロイします。

$ sam build
$ sam deploy --guided

デプロイ完了時の Outputs について、

  • SignInAPI の値がエントリーポイントの URL になります。

  • CallbackAPI の値を Slack App の Redirect URLs に登録します。

動作確認

SignInAPI の URL にアクセスします。

Sign in with Slack ボタンが表示され、クリックすると Slack のサインイン画面に遷移します。

f:id:ozaki-linkode:20210618172417p:plain

サインイン完了後、ユーザーの許可を求める画面が表示されます。

f:id:ozaki-linkode:20210618172352p:plain

「許可する」をクリックすると、CallbackAPI へのリダイレクトを経て、取得されたユーザー情報 (slack.UserIdentityResponse) が表示されます。

まとめ

slack-go を使って Sign in with Slack のシーケンスを確認しました。

slack-go の例があまり見つからなかったので今回記事にしてみましたが、シンプルに扱えそうなライブラリかと思います。

参考文献

Sign in with Slack | Slack

slack · pkg.go.dev