バックエンド

OIDC に基づいたフローで Slack を IdP としてアプリケーションを認証する

tak

こんにちは、DWS2人目の大島です。


社内ハッカソンから開発を続けているアプリケーションにて、
Slack を IdP として使用して、OpenID Connect にて認証を設定する機会があったのですが、
インターネット上の検索では古い情報が散見されたため、最新の内容をまとめてみました。

Slack の OIDC フローについて

社内で使用するアプリケーションなどを開発する上で、
社内で利用している Slack のアイデンティティをそのまま、社内アプリケーションに流用したい、といったケースは多いかと思います。

Slack のアイデンティティを他のアプリケーションで利用する場合、以下リンクにあるように、OpenID Connect に基づいた新しいフローが整備されています。

あわせて読みたい
Sign in with Slack setup
Sign in with Slack setup


ただ、インターネット上で検索をかけたりすると、以下の OAuth を拡張した古いフローに基づいた記事がヒットすることが多いです。
現在は、上述の通り、OIDC をベースとした新しいフローとなっていますので、公式の推奨通り、新しいフローを使用していきましょう。

あわせて読みたい
Legacy: Sign in with Slack
Legacy: Sign in with Slack

では、新しいフローを確認していきましょう。

今回作成するアプリケーションのイメージ

今回フロントエンド(SPA)とバックエンドに分かれているアプリケーションを想定しており、バックエンドから Slack で認証するための API を用意します。

ただし、今回は簡易的な検証を目的とするので、
フロントエンドのコードは用意せず、バックエンドのみとします。
あくまで、フロントエンドを実装する前提でのバックエンドの準備となります。

使用する言語は nodejs (ver 20)で、フレームワークとして express.js を使用しています。

フロントエンド・バックエンド・Slack はそれぞれ独立したエンドポイントとなるので、認証が必要になります。

Slack の API を利用するためには、クライアントアプリケーション(アプリケーション用のアイデンティティ)で認証をする必要があります。
Slack でクライアントアプリケーションを作成しておき、使用可能な Slack の API のスコープなどを事前に登録しておきます。
このクライアントアプリケーションのクラアイントIDとクライアントシークレットPW(アプリケーション用のIDとPWと思ってください)をバックエンドで利用して、Slack の API を叩きます。

フロントエンドとバックエンドでは、バックエンド側でセッションID を払い出して、クッキーを使用してセッションを管理する想定です。
※今回このセッション管理の部分については、直接 Slack の OIDC フローには関わってこないので、触れません。あくまで実装をイメージだけしておきます。


独立したエンドポイントに対する認証制御

認可コードフローによるOIDC

Slack のOIDCのサインインフローは「認可コードフロー」と呼ばれるものとなります。
直接トークンを払い出すのではなく、
トークンと引き換え可能な認可コードを払い出して利用するフロー
です。

簡単に「認可コードフロー」ついて確認しておきましょう。
OpenID Connect には登場人物として「リライングパーティ(RP)」・「IDプロバイダ(IdP)」が存在します。
ざっくりと、「リライングパーティ(RP)」は、いわゆるアプリケーション、
「IDプロバイダ」は、IDを提供するサービス、と認識しておくと理解が早いかと思います。

認可コードを払い出す際には、ログインやアプリへの同意などの処理を完了した後に、
事前に設定などをした「リダイレクトURI」と呼ばれるエンドポイントへ、ユーザーをリダイレクトするのですが、その時にクエリパラメーターとして認可コードを返します。

その後、その認可コードをアプリケーションにてトークンと引き換えることでフローが完了します。


認可コードフロー

「認可コードフロー」についての概要を確認をしたところで、
次に実際に Slack を IdP として使用し、フロントとバックエンドを用意するアプリケーションの具体的なフローを見ます。

先ほどの図で、「リライングパーティ(RP)」はアプリケーションだったので、一つの図形で表していましたが、今回はフロントエンドとバックエンドを分けるので、図としてもそのように表現します。

フロー自体は大きく変わっている部分はないのですが、
フロントエンドとバックエンドで一部やり取りが発生したり、トークンリクエストを実施するのがバックエンドだったり、と細かい部分で意識しておく点があります。


Slack を IdP としたフロント&バックエンドアプリケーションにおけるフロー

フローは以下の通りです。

  1. /openid/connect/authorizeにユーザーをリダイレクトし、アプリケーションがユーザーの情報にアクセスする許可をする
  2. 認可が完了後、リダイレクトURIで指定した場所へ認可コードが返却されるので、
    バックエンドへ認可コードを渡し、バックエンドからIDトークンを要求する
    (この時 slack の API である/openid.connect.tokenメソッドを使用する)
  3. IDトークンを検証して、問題がなければセッションIDを払い出す

ここからそれぞれのフローを詳細に見ていきます。

今回、本筋ではないので簡単にではありますが、
Slack のクライアントアプリケーションの払い出しを Step 0 として以下に置いておきます。
※ + ボタンを押して開くと中身が見えます。

Q
Step 0. クライアントアプリケーションの払い出し

こちらのリンクから、Slack のアプリケーション管理画面へ移ります。

あわせて読みたい
Slack Apps
Slack Apps

↓の画面から、クライアントアプリケーションを作成していきます。

ポップアップが表示されるので、画像の通り選択します。

クライアントアプリケーションが作成できたら、画面左のOAuth & Permissionより、
リダイレクトURIを入力します。

これでクライアントアプリケーションの準備は完了です!
最後に、クライアントIDとクライアントシークレットをコピーしておきましょう。

それでは、本筋へ戻ります。


Step 1. /openid/connect/authorizeでの認証リクエスト

まず最初に、/openid/connect/authorizeへユーザーをリダイレクトし、
アプリケーションが各種情報にアクセスしても良いか、ユーザー側の承諾を得ます。


ログイン〜認証リクエスト完了

黄色部分が対象の Step

この時、ログインをしていなければ、IdP へのログインが求められます。


ログイン画面

ログインが完了する、あるいは、ログインのセッションがある場合は、
アプリケーションを利用しているとよく目にする以下のような、ユーザーに許可を求める画面が出てきます。


アプリケーションへの同意画面

アプリケーションのコードは以下のような感じです。

今回、バックエンドで認証リクエストを開始するための、/sign-in-with-slackというエンドポイントを用意しました。
認証リクエストで必要となる各種パラメーターをセットした上で、ユーザーをhttps://slack.com/openid/connect/authorizeへリダイレクトします。

ドキュメントより、エンドポイントに対して送るパラメータは以下の通りです。

GET /openid/connect/authorize?
    response_type=code
    &scope=openid%20profile%20email
    &client_id=s6BhdRkqt3
    &state=af0ifjsldkj
    &team=T1234
    &nonce=abcd
    &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb HTTP/1.1
  Host: https://slack.com
参照:https://api.slack.com/authentication/sign-in-with-slack#request

各種パラメーターの説明は以下の通りです。

パラメーター名概要
response_typeOIDCをどういったフローで行うかを指定します。Slack は認可コードフローなので、code一択となります。
scopeユーザーが認可するアプリケーションがアクセス可能なリソースを記載します。
openidは OIDC を使う際に必須です。
client_idSlack に用意したアプリケーションのIDです
stateCSRF 攻撃を防止するためのランダムな値です。
ユーザーとレスポンスを紐づけます。
team自分が所属する Slack の組織のIDです。
nonceリプレイアタックを防止するためのランダムな値です。ユーザーとIDトークンを紐づけます。
redirect_uri認可コードを返却するためのURIです。
ユーザーをリダイレクトする際に、認可コードをパラメータとして返却します。
注意点

nonce や state については、今回の趣旨からは外れるので、詳細には取り扱いません。

アプリのコードは単純にパラメータを付与してリダイレクトするだけですが、以下の通りです。
フロントエンドから/sign-in-with-slackを叩くと、必要なパラメーターを付与してリダイレクトするような想定です。

var router = express.Router();
const querystring = require('querystring');

router.get('/sign-in-with-slack', function(req, res, next) {
  const queryParams = querystring.stringify({
    response_type: 'code',
    scope: 'openid profile email',
    client_id: 'client_id',
    state: 'hogehoge', 
    team: 'team_id"',
    nonce: 'hogehoge', 
    redirect_uri: 'https://hogehoge'
  });


  const slackAuthUrl = `https://slack.com/openid/connect/authorize?${queryParams}`;

  res.redirect(slackAuthUrl);
});

Step 2. 認可コードの取得とトークンリクエスト

Step 1 でアプリケーションに対する認可を完了すると、リダイレクトURIに対して、
Slack から認可コードが、クエリパラメーターとしてリダイレクトURIに付与される形で返却
されます。

この認可コードをバックエンドへ渡し、
バックエンドからSlack API の/openid.connect.tokenを使用して、トークンリクエストを実施します。


トークンリクエスト

黄色部分が対象の Step


トークンリクエストをする際には、ざっくりと以下のパラメーターを渡します。

パラメーター名概要
code先述のフローで受け取った認可コードを渡します
client_idSlack に用意したアプリケーションのIDです
client_secretアプリケーションのシークレットです。
パスワードと同じようなものです。
なぜフロントエンドから直接リクエストを行わないのか?

諸々理由はありますが、
大きな理由の一つが、上述の
client_secretです。

SPA のようなフロントエンドでは、シークレットなどを安全には保管できません。
(見ようと思えばユーザーが見ることができてしまいます)
なので、トークンリクエストはバックエンドで実装して、API を叩くようにしています。

ちなみに、
このようにシークレットを安全に保管できるアプリケーションを confidential client、安全に保管できないアプリケーションを public client と呼びます。

アプリのコードは以下の通りです。
今回リダイレクト先として、バックエンドに/callbackというエンドポイントを用意して、ここで認可コードを受け取ります。

router.get('/callback', async (req, res) => {

  // 認可コードをクエリパラメータから取得
  const code = req.query.code;

  if (!code) {
    return res.status(400).send('認可コードがありません');
  }


  try {
    // 認可コードをIDトークンに交換
    const axios = require('axios');
    const tokenResponse = await axios.post('https://slack.com/api/openid.connect.token', querystring.stringify({
      client_id: 'client_id',
      client_secret: 'client_secret',
      code: code,
      redirect_uri: 'redirect_uri'
    }), {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      }
    });

Step 3. IDトークンの検証

Step 2 で、トークンリクエストを実施すると、Slack からIDトークンが返却されます。
ここで、IDトークンの検証を行います。


トークンの検証

黄色部分が対象の Step


IDトークンは、 OIDCの仕様により、JWT(Json Web Token)で返却されます。

The ID Token is represented as a JSON Web Token (JWT) [JWT].

参照:https://openid.net/specs/openid-connect-core-1_0.html#IDToken

JWT は、ヘッダ・ペイロード・署名の3パートから構成されるトークンで、
それぞれを Base64 エンコードし、" . " で繋いだものとなります。


JWTの構成


IDトークンは、JWTで表現される(=署名を持つ)・仕様としてペイロードに持たなければならない要素、属性(クレームと呼ばれます)が決まっているので、トークンの正当性を検証することができる仕様となっています。
このため、別サービスを IdP として利用して、別アプリケーションの認証を行うといったことが安全にできるようになっています。

IDトークンの中身は以下のような感じです。(ヘッダーとペイロード)
マスクされた情報ばかりになってしまいますが、このように必要なクレームが格納されています。
それぞれのクレームについてはここでは取り扱いません。

// ヘッダー
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "mB2MAyKSn555isd0EbdhKx6nkyAi9xLq8rvCEb_nOyY"
}
// ペイロード
{
  "iss": "https://slack.com",
  "sub": "******",
  "aud": "**********",
  "exp": 1703657507,
  "iat": 1703657207,
  "auth_time": 1703657207,
  "nonce": "hogehoge",
  "at_hash": "********",
  "https://slack.com/team_id": "********",
  "https://slack.com/user_id": "********",
  "https://slack.com/enterprise_id": "********",
  "email": "********@********",
  "email_verified": true,
  "date_email_verified": 1695965304,
  "locale": "en-US",
  "name": "********",
  "picture": "hoge.png",
  "given_name": "********",
  "family_name": "********",
  "https://slack.com/enterprise_name": "********",
  "https://slack.com/enterprise_domain": "********",
  "https://slack.com/team_name": "********",
  "https://slack.com/team_domain": "********",
  "https://slack.com/team_image_230": "********.png",
  "https://slack.com/team_image_default": false
}

IDトークンの検証については、以下の仕様にまとまってます。
ここでは検証となるので簡単に、署名の検証・iss/audクレームの検証だけ行ってみようと思います。(今回使用している、jsonwebtokenライブラリでは、iat / expなども勝手に検証はしてくれています)

あわせて読みたい
OpenID Connect Core 1.0 incorporating errata set 2
OpenID Connect Core 1.0 incorporating errata set 2

アプリのコードは以下のような形になります。

    // ID token を取得
    // tokenResponse は Step 2 にあるトークンリクエストのレスポンス
    const idToken = tokenResponse.data.id_token;
    console.log('IDトークン:', idToken);

    // ID token の検証用の関数
    const jwksClient = require('jwks-rsa');
    const client = jwksClient({
      jwksUri: 'https://slack.com/openid/connect/keys'
    });

    function getKey(header, callback){
      client.getSigningKey(header.kid, function(err, key) {
        var signingKey = key.publicKey || key.rsaPublicKey;
        callback(null, signingKey);
      });
    }

    // クレームの検証、well-known endpoint から確認できる
    const verifyOptions = {
      audience: 'client_id',
      issuer: 'https://slack.com',
      algorithms: ['RS256']
    }

    // ID token のデコード(確認用)
    const decoded = jwt.decode(idToken);
    console.log('デコードされたIDトークン:', decoded);

    // ID token の検証
    jwt.verify(idToken, getKey, verifyOptions, function(err, decoded){
      if (err) {
        console.log('ID token の検証に失敗しました:', err);
      } else {
        console.log('ID token の検証に成功しました:', decoded);
      }
    });

    // 本来ここでさらにセッションIDの払い出しなどを行いますが、検証なのでユーザーを単純にリダイレクトします
    res.redirect('/index');

  } catch (error) {
    console.log('アクセストークンの取得中にエラーが発生しました:', error);
    res.status(500).send('内部サーバーエラー');
  }

IDトークンの署名の検証には IdP 側が署名に使用した秘密鍵に対応する公開鍵が必要になります。(正確には、署名アルゴリズムも必要です)
ここで出てくるのが、OpenID Connect Discoveryjwks_urlというものです。

IdP は、自身の提供する OpenID に関する情報を公開するエンドポイントを用意していることがあります。
これは OpenID Connect Discovery と呼ばれる仕様にまとめられたもので、
ユーザーはこのエンドポイントにアクセスすることで、 OpenID Connect を使用するために必要となる情報をJSON形式で得ることができます。

あわせて読みたい
OpenID Connect Discovery
OpenID Connect Discovery

Slack の場合、エンドポイントはhttps://slack.com/.well-known/openid-configurationとなります。

あわせて読みたい
Discover information on Slack OpenID endpoints
Discover information on Slack OpenID endpoints

試しにcurlを使ってエンドポイントへアクセスしてみると、以下のような情報が確認できます。

$ curl https://slack.com/.well-known/openid-configuration | jq .
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   776  100   776    0     0   1531      0 --:--:-- --:--:-- --:--:--  1548
{
  "issuer": "https://slack.com",
  "authorization_endpoint": "https://slack.com/openid/connect/authorize",
  "token_endpoint": "https://slack.com/api/openid.connect.token",
  "userinfo_endpoint": "https://slack.com/api/openid.connect.userInfo",
  "jwks_uri": "https://slack.com/openid/connect/keys",
  "scopes_supported": [
    "openid",
    "profile",
    "email"
  ],
  "response_types_supported": [
    "code"
  ],
  "response_modes_supported": [
    "query"
  ],
  "grant_types_supported": [
    "authorization_code"
  ],
  "subject_types_supported": [
    "public"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "claims_supported": [
    "sub",
    "auth_time",
    "iss"
  ],
  "claims_parameter_supported": false,
  "request_parameter_supported": false,
  "request_uri_parameter_supported": true,
  "token_endpoint_auth_methods_supported": [
    "client_secret_post",
    "client_secret_basic"
  ]
}

トークン発行者(issuer)や認可エンドポイント(authorization_endpoint)、トークンエンドポイント(token_endpoint)などの情報が返ってきています。

ここで今回確認したかった情報が、jwks_uriというものです。
jwks_uriとは、JWT を検証するために使われる公開鍵の情報を配布しているエンドポイントです。
このエンドポイントから署名の検証に必要な鍵を取得して、IDトークンの内容が改ざんされてないか、確認をします。

IDトークンの完全性が確認できたら、トークンの中のissaudのクレームの検証も実行します。
issは、issuerで、トークンの発行者(https://slack.com)が正しいのかどうかを確認します。
audは、audienceでトークンの対象者(ここではクライアントID)が正しいのかどうかを確認しています。

この検証によって、IDトークンの正当性が確認できたので、
セッションIDを払い出して、ユーザーのログインセッションを管理します。

ただ、今回は Slack の OIDC のフローに直接関わってくる部分ではないので割愛します。

なぜ

わざわざセッションIDを払い出さずとも、
フロントエンドへ直接トークンを渡して、IDトークンの保有をキーとしてセッション管理をしても良いのではないか?という疑問も出てくるかもしれません。

ただ
基本的に、基本的にトークンの有効期限は漏洩対策として短かく設定されており、ログインセッションの管理には向きません。

Slack の IDトークンの有効期限は5分ほどですので、しっかりセッションIDを使用して管理しましょう。

Slack からIDトークンを受け取って、検証を完了しました。
これで、Slack を IdP として利用して、アプリケーションの認証をすることができました!

最後に、今回使用したコードを置いてあるレポジトリを載せておきます。

あわせて読みたい
slack-oidc-test
slack-oidc-test

※ローカルでも起動はしますが、slack はリダイレクトURI を http で設定できないため、トークンの確認を行うためには、サーバーなどへのデプロイが必要です。

終わりに

Slack を IdP として利用する方法について、ご紹介いたしました。
OIDC のフローは、なかなか最初は理解が難しく、取り付き辛さがあるかと思いますので、
こちらの記事が理解の役に立てば、と思います。

AUTHOR
tak
tak
記事URLをコピーしました