見出し画像

AWSの「IDプロバイダーとフェデレーション」の仕組みを利用して、GoogleアカウントでAWSを利用・操作してみた

はじめに

こんにちは、SHIFTの開発部門に所属している Katayama です。

AWS にはID プロバイダーとフェデレーションという機能があり、これは AWS のアカウント管理の仕組みである(IAM ユーザー)でユーザー認証をして AWS を利用できるようにするのではなく、外部の ID プロバイダー(IdP)で管理されている ID を使って認証して AWS を利用できるようにする仕組み(AWS が認証をするのではなく、AWS は認証後に渡されるもの= ID トークンなどをみて AWS にアクセスする事を許可する)。

今回はOpenID Connect (OIDC) ID プロバイダーの作成の手順が不要な Google の OIDC を利用して、この「ID プロバイダーとフェデレーション」の仕組みを使い、Google アカウントを持つユーザーで AWS を利用・操作してみようと思う(以下、OpenID Connect (OIDC) ID プロバイダーの作成からの引用)。

Google、Facebook、または Amazon Cognito の OIDC ID プロバイダーを使用している場合は、この手順を使用して別の IAM ID プロバイダーを作成しないでください。これらの OIDC ID プロバイダーは、AWS にすでに組み込まれており、使用できます

流れとしては、

①Google の OIDC にクライアントを登録(AWS にアクセスするアプリを登録する)
②AWS に IAM ロールを作成
③ 実際に Google アカウントで認証(ログイン)後、AWS を利用・操作

という感じ。

事前準備① GoogleのOIDCの設定

OAuth 同意画面の設定をする

https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid では先にクライアント ID の取得をしているが、先にOAuth 同意画面の設定にあるように設定していく。まず OAuth 同意画面がないと、クライアントを作成できないため。

OAuth 同意画面は、ユーザーが実際に認証をする際に、アプリケーションの規約を表示したりユーザー情報を利用する事に同意を求める画面が出てくるが、その画面の設定を行う部分(今回の ID トークンを払い出すインプリシットフローでは実際に画面が表示される事はないが、設定しないと利用できないので設定する)。

今回はデモなのでアプリのドメインの項目などは設定しない。スコープは openid のみで OK。テストユーザーは今回のデモで実際に認証をして AWS を利用するユーザーになる。

クライアントを登録するしてクライアントIDを払い出す

Google API クライアント ID を取得するに書かているような方法でクライアント ID を取得できる。

今回はローカルのサンプルアプリケーションになるので、実際には存在しないドメインを設定する(存在しないドメインにはなるが、これは windows のなんちゃって DNS を実現するhosts ファイルを利用してローカルで検証できるようにする)。

上記の手順で作成すると最後にクライアント ID が表示されるのでメモしておく(クライアントシークレットは今回は使用しない)。

ここまでで Google の OIDC の設定は完了になる。

事前準備② AWS上でIAMロールを作成する

IAM ロールの作成方法についてはウェブ ID または OpenID Connect フェデレーション用のロールの作成 (コンソール)に詳細が書かれている。

上記のような設定で IAM ロールを作成した後、信頼関係の信頼されたエンティティを確認してみると、特定のクライアント(アプリ)から Google アカウントを持つ特定のユーザーのみが利用できる権限になっている事が確認できる。

IAM ロールを作成後、そのロールの ARN をメモしておく(後で AWS SDK の@aws-sdk/credential-providersを利用して一時トークンと引き換える時に使う)。

※上記の Audience、条件 - (オプション)の accounts.google.com:sub の設定値は ID トークンの aud/sub クレームの値になる。aud はクライアント ID(事前準備 ① で作成したもの)で、sub クレームはユーザーを一意に特定できる識別子なので ID トークンの sub クレームの値を設定する。もし Audience のみの設定だと、アプリにログインできる Google アカウントを持つユーザー全員が IAM ロールを利用できるようになってしまう(OpenID Connect の各クレームの意味ついては7. ID トークンなどを参照)。

以下は今回デモアプリでログインするユーザーの ID トークン(セキュアな部分はマスクしている)。

{
  header: {
    alg: 'RS256',
    kid: '402f305b70581329ff289b5b3a67283806eca893',
    typ: 'JWT'
  },
  payload: {
    iss: 'https://accounts.google.com',
    azp: '...',
    aud: '91...',
    sub: '10...',
    nonce: 'QvLR92clreEA_EONAc9wuh_ED8u3y4JsQX7wz9jj_F8',
    iat: 1661496020,
    exp: 1661499620,
    jti: 'ce4ad3888bc2f4a5cd40e4e33cff92bbb0f0c8f9'
  },
  signature: ...
}

これで後は、実際に Google アカウントで認証をした後、ID トークンを元に AWS を利用するための一時トークンを払い出し、それを利用して AWS を利用・操作すればいい。

実際にGoogleアカウントでAWSを利用してみる

今回はデモ用に簡単なアプリを作成した。以下の「ログイン」をクリックすると、Google の OIDC に認証認可リクエストが飛ぶ。

ログインをクリック=認証認可リクエストが送られると、Google の OIDC が認証画面を返してくるので、Google アカウントでの認証が始まる。

認証が完了すると Google の OIDC から応答がある。今回は response=mode=form_post なので、リダイレクト URI に登録したエンドポイントに POST リクエストが実行され、ID トークンを受け取る事ができる。

以下の動画のように、「実際に AWS を操作」をクリックすると、今度はその ID トークンを使って、AWS の STS にリクエストを送り、一時的なトークン(クレデンシャル)を発行してもらい、その一時的なトークンを利用してGetFunctionリクエストを行う。リクエストの結果、ちゃんと Lambda 関数の情報が取得でき、動画のように画面に表示される(表示している項目は一部のみに敢えてしている)。

また、IAM Access Analyzerから外部エンティティと共有されているリソース(今回だと IAM ロール)の利用状況が確認できるが、以下の画像の通り、今回使用しているアプリケーションから IAM ロール aws-identity-providers-federation-test-role が利用されている事が確認できる。

さらに CloudTrail の方でも確認してみると、以下のようにGetFunctionの実行が一時的なトークン(test_session)で行われている事が確認できる。

上記のように「ID プロバイダーとフェデレーション」の仕組みを利用して、Google アカウントで AWS を利用する事ができる事が確認できた。上記の裏のサーバーの実装としては以下のようになっている(フロントについてはあくまで試行という事もあり、テンプレートエンジンを利用しつつ、さらに script 内で Vue.js を利用するという簡易的な実装をした。フロントエンドのコードについては「おまけ」の「フロントエンドのコード」を参照ください)。

// 省略
import { Issuer, generators } from "openid-client";

import { fromWebToken } from "@aws-sdk/credential-providers";
import { LambdaClient, GetFunctionCommand } from "@aws-sdk/client-lambda";

const app = express();
app.use(express.urlencoded({ extended: true }));

const server =
  callback.protocol === "https:"
    ? https.createServer(
        {
          key: fs.readFileSync("./ssl/server.key"),
          cert: fs.readFileSync("./ssl/server.crt"),
        },
        app
      )
    : app;

app.set("view engine", "ejs");
app.set("views", appRoot.resolve("src/views"));

// 省略
app.use(
  expressSession({
    ...config.get("redis.session"),
    secret: process.env.COOKIE_SECRET,
    store,
  })
);

Issuer.discover(config.get("discovery")).then((issuer) => {
  const oidcClient = new issuer.Client({
    client_id: process.env.CLIENT_ID,
    redirect_uri: config.get("redirectUri"),
    response_type: ["id_token"],
  });

  app.locals.client = oidcClient;
});

app.get("/", (req, res) => {
  res.render("./index.ejs");
});

app.get("/begin", async (req, res) => {
  const { session } = req;
  const { client } = req.app.locals;

  const nonce = generators.nonce();
  session.nonce = nonce;

  const uri = client.authorizationUrl({
    scope: "openid",
    response_mode: "form_post",
    nonce,
  });

  res.redirect(uri);
});

app.post("/oidc/callback", async (req, res) => {
  const { session } = req;
  const { client } = req.app.locals;

  try {
    const params = client.callbackParams(req);
    const { id_token: idToken } = await client.callback(
      config.get("redirectUri"),
      params,
      {
        nonce: session.nonce,
      }
    );

    const regenerate = (oldSession) => {
      return new Promise((resolve, reject) => {
        oldSession.regenerate((err) => {
          if (err) throw reject(err);
          const { session: newSession } = req;
          newSession.idToken = idToken;
          resolve(newSession);
        });
      });
    };
    await regenerate(session);

    res.render("./redirect.ejs", { idToken });
  } catch (error) {
    res.end(error.message);
  }
});

app.get("/getFunction", async (req, res) => {
  const { session } = req;

  try {
    const credentials = fromWebToken({
      roleArn: process.env.ROLE_ARN,
      webIdentityToken: session.idToken,
      roleSessionName: "test_session",
    });

    const client = new LambdaClient({
      region: "ap-northeast-1",
      credentials,
    });
    const command = new GetFunctionCommand({
      FunctionName: "rds-proxy-lambda-func",
    });
    const response = await client.send(command);

    res.status(200).json(response);
  } catch (error) {
    console.log(error);
    res.status(500).json(error.message);
  }
});

server.listen(8080);
// config/default.json
{
  "discovery": "https://accounts.google.com/.well-known/openid-configuration",
  "authRequest": {
    "scopes": ["openid"]
  },
  "redirectUri": "https://example.com:8080/oidc/callback",
  "redis": {
    "session": {
      "name": "op.sid",
      "resave": false,
      "saveUninitialized": true
    }
  }
}

実装の補足

OpenID Connect 周りの実装についてはImplicit ID Token Flowにある実装を参考にしているので、詳細はそちらを参照ください。

その他の部分については以下の通り。

express-session の設定

今回、バックエンドで ID トークンを受け取るために response*mode=form_post にしていたが、form_post では HTML の form が POST リクエストされるため SameSite の問題(Cookie にはSameSiteがあり、これが lax になっていると自身以外の外部サイトからの POST メソッドの際には Cookie が設定されなくなるので、セッションの仕組みが使えなくなってしまう)が発生する。
そのため、今回の実装においては敢えて SameSite は設定せず、Chrome のデフォルトの仕組みの SameSite=Lax になるようにしている(公式に書かれている通り、何も設定しない場合、cookie.sameSite=false になる)。

Specifies the boolean or string to be the value for the SameSite Set-Cookie attribute. By default, this is false.(SameSite Set-Cookie 属性の値となるブーリアンまたは文字列を指定します。デフォルトでは、これは false である。)

※通常の SameSite=Lax では、別ドメインから自分のサイト(ドメイン)に対する POST リクエストの際に Cookie は設定されない仕様になっているが、デフォルト値としての SameSite=Laxに書かれている通り、Chrome のデフォルトの SameSite=Lax では少々仕様が異なっており、トップレベルの POST リクエストでは Cookie が送信されるため、今回のように express-session で何も設定しない(= Chrome デフォルトの SameSite=Lax)にすることで Cookie が送信され、セッションの仕組みが利用できる。

The default behaviour applied by Chrome is slightly more permissive than an explicit SameSite=Lax as it will allow certain cookies to be sent on top-level POST requests.(Chrome が適用するデフォルトの動作は、トップレベルの POST リクエストで特定のクッキーを送信することを許可するため、明示的な SameSite=Lax よりもわずかに寛容的です)

※response_mode=form_post の詳細は13. 応答モード (response_mode)response_mode=form_postなどを参照。

※ちなみに、HTML のフォームを受け取らないといけないので、Express では以下のようにパーサーを利用している。

app.use(express.urlencoded({ extended: true }));

・参考:CSRF 攻撃対策について Node.js Express でアプリを構築して実例で理解する

ExpressのSSL化

Express サーバの HTTPS 化にあるような理由で SSL で HTTPS にしている。

※今回は Nginx 等のプロキシを利用していないが、今後 Nginx などのプロキシを利用して SSL で HTTPS 化するのも試してみたいと思う。

まとめとして

今回は実際に Google という外部の ID プロバイダーのアカウントを利用して、AWS の利用・操作を行ってみた。
利用するまでの準備として OpenID Connect 周りの知識が必要そうな部分があるが、実際に利用するのは特段煩わしい部分もなく、すんなり利用できるのではないかと思った。

次回は GitHub Actions で CICD を行う際に、今回試してみたような仕組みに似た方法を利用する事で、AWS のクレデンシャルを GitHub Actions に渡さず、STS から一時的なトークンを払い出してもらい、それを利用して AWS へ Deploy するというのを試してみたいと思う。

※今回 AWS を利用していたが、その利用にあたっては一部料金がかかる場合がある。課金について気にするのであれば作成したリソースの削除はお忘れなく。

おまけ

IAM ロールのポリシールールがちゃんと効いているか?を検証してみる

IAM ロールのポリシールールがちゃんと効いているか?以下のようなパターンでそれぞれ検証してみた。

Lambda関数名を別のものにする

FunctionName を変更してリクエストを送ると、403(AccessDeniedException)になる事が確認できる。

const command = new GetFunctionCommand({
  FunctionName: "test",
});
AccessDeniedException: User: arn:aws:sts::************:assumed-role/aws-identity-providers-federation-test-role/test_session is not authorized to perform: lambda:GetFunction on resource: arn:aws:lambda:ap-northeast-1:************:function:test because no identity-based policy allows the lambda:GetFunction action

 ...(省略)

 {
  '$fault': 'client',
  '$metadata': {
    httpStatusCode: 403,
    requestId: 'eb6879c7-f229-4814-9ff0-142ab0c57d84',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  }
}

別のユーザーで認証後、Lambda関数の情報を取得しようとする

まず、別のテストユーザーで認証できるようにする必要があるので、以下のようにユーザーを追加。

その上で上記と同じように認証してリクエストを実行してみると、以下のように Not authorized となり、一時的なセキュリティ認証情報を発行してもらう権限がないになる事が確認できる。

AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity

 ...(省略)

{
  '$fault': 'client',
  '$metadata': {
    httpStatusCode: 403,
    requestId: '56efe1e7-4ebb-46e3-ba58-0f324d00db19',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  Type: 'Sender',
  Code: 'AccessDenied'
}

※もし以下のように信頼されたエンティティのポリシーで sub を指定していないと、このアプリケーション利用できるユーザー全員が Lambda 関数の情報を取得できるような設定になる。この辺りは実現したい事に応じて変えればいい。

フロントエンドのコード

<!DOCTYPE html>
<html lang="ja">
  <head>
    省略
  </head>
  <body>
    <v-app id="app">
      省略

      <v-main>
        <v-container>
          <v-row>
            <v-col cols="8">
              <v-card>
                <form action="/begin" method="GET">
                  <v-card-title>
                    Googleアカウントを利用したAWS利用(認証認可リクエストを行う)
                  </v-card-title>
                  <v-card-text>
                    <p>(例として…)</p>
                    <p>Googleアカウントでログイン</p>
                  </v-card-text>
                  <v-card-actions>
                    <v-spacer></v-spacer>
                    <v-btn type="submit" color="primary">
                      ログイン(認証認可リクエスト送る)
                    </v-btn>
                  </v-card-actions>
                </form>
              </v-card>
            </v-col>
          </v-row>
        </v-container>
      </v-main>

      省略
    </v-app>

    <script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
    <script>
      new Vue({
        el: "#app",
        vuetify: new Vuetify(),
        data: () => ({
          drawer: false,
          dumyMenus: ["Foo", "Bar", "Fizz", "Buzz"],
        }),
      });
    </script>
  </body>
</html>
<!DOCTYPE html>
<html lang="ja">
  <head>
    省略
  </head>
  <body>
    <v-app id="app">
      省略

      <v-main>
        <v-container>
          <v-row>
            <v-col cols="8">
              <v-card>
                <v-card-title>
                  Google OIDCから受け取ったIDトークン
                </v-card-title>
                <v-card-text>
                  <v-simple-table>
                    <template v-slot:default>
                      <thead>
                        <tr>
                          <th class="text-left">key</th>
                          <th class="text-left">value</th>
                        </tr>
                      </thead>
                      <tbody>
                        <tr>
                          <td>IDトークン(id_token)</td>
                          <td><%= idToken %></td>
                        </tr>
                      </tbody>
                    </template>
                  </v-simple-table>
                </v-card-text>
                <v-card-actions>
                  <v-spacer></v-spacer>
                  <v-btn color="primary" @click="getFunction">
                    実際にAWSを操作
                  </v-btn>
                </v-card-actions>
              </v-card>
            </v-col>
            <v-col cols="8" v-if="resData">
              <v-card>
                <v-card-title> getFunctionの結果 </v-card-title>
                <v-card-text>
                  <v-simple-table>
                    <template v-slot:default>
                      <thead>
                        <tr>
                          <th class="text-left">key</th>
                          <th class="text-left">value</th>
                        </tr>
                      </thead>
                      <tbody>
                        <tr>
                          <td>$metadata.httpStatusCode</td>
                          <td>{{ resData.$metadata.httpStatusCode }}</td>
                        </tr>
                        <tr>
                          <td>$metadata.requestId</td>
                          <td>{{ resData.$metadata.requestId }}</td>
                        </tr>
                        <tr>
                          <td>Configuration.CodeSize</td>
                          <td>{{ resData.Configuration.CodeSize }}</td>
                        </tr>
                        <tr>
                          <td>Configuration.FunctionName</td>
                          <td>{{ resData.Configuration.FunctionName }}</td>
                        </tr>
                        <tr>
                          <td>Configuration.Handler</td>
                          <td>{{ resData.Configuration.Handler }}</td>
                        </tr>
                        <tr>
                          <td>Configuration.PackageType</td>
                          <td>{{ resData.Configuration.PackageType }}</td>
                        </tr>
                        <tr>
                          <td>Configuration.State</td>
                          <td>{{ resData.Configuration.State }}</td>
                        </tr>
                      </tbody>
                    </template>
                  </v-simple-table>
                </v-card-text>
              </v-card>
            </v-col>
          </v-row>
        </v-container>
      </v-main>

      省略
    </v-app>

    <script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
    <script>
      new Vue({
        el: "#app",
        vuetify: new Vuetify(),
        data: () => ({
          drawer: false,
          dumyMenus: ["Foo", "Bar", "Fizz", "Buzz"],
          resData: null,
        }),
        methods: {
          async getFunction() {
            try {
              const response = await fetch("/getFunction", { method: "GET" });
              const data = await response.json();
              this.resData = data;
              console.log(this.resData);
            } catch (error) {
              console.log(error);
            }
          },
        },
      });
    </script>
  </body>
</html>

《この公式ブロガーの記事一覧》


執筆者プロフィール:Katayama Yuta
SaaS ERPパッケージベンダーにて開発を2年経験。 SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。 昨年に開発部門へ異動し、再び開発エンジニアに。そろそろ開発エンジニアに復帰して1年になるところ。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/