Go 言語のライブラリ slack-go を使って Sign in with Slack の動作を見てみました。
準備
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 のサインインに誘導するためのエントリーポイント
- アクセストークンを取得するためのコールバック
実装
$ 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_scope
と client_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 モジュールについて
ヘルパーとして env
、securerandom
、session
モジュールを作成しました。簡単に説明します。
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 のサインイン画面に遷移します。
サインイン完了後、ユーザーの許可を求める画面が表示されます。
「許可する」をクリックすると、CallbackAPI へのリダイレクトを経て、取得されたユーザー情報 (slack.UserIdentityResponse
) が表示されます。
まとめ
slack-go を使って Sign in with Slack のシーケンスを確認しました。
slack-go の例があまり見つからなかったので今回記事にしてみましたが、シンプルに扱えそうなライブラリかと思います。