🖱️

社内の人しか押せないボタン(Google OIDC + AWS Lambda 関数URL)

2022/10/17に公開約5,900字

概要

前にApp RunnerでSGを操作する やつ作ったけどLambdaの関数URLでもっとシンプルに出来そうなので試してみました

仕組みはシンプルにGoogleのOIDC でもらえるid_tokenをつけてLambdaへリクエストするだけ
使うのはS3バケットとLambdaのみ

flow

例として開発環境の起動ボタンを作ります

※この手の要件だとcognitoを使う例が多いかと思いますが今回はLambda側で署名の検証をします

lambda側

必要なロールを割り当ててlambdaを作成
関数URLも設定します

フロント側でhtmxを使用するので、必要なCORS設定も行います

cors

Lambdaのソースコード

"use strict";
import jwt from "jsonwebtoken";
import fetch from "node-fetch";
import jwkToPem from "jwk-to-pem";

// 許可するGoogle workspaceのドメインを指定します
const validHd = "example.jp";

export async function handler(event) {
  try {
    const request = JSON.parse(event.body);
    const idToken = request["id-token"];
    const sigKey = await getSigKey(
      "https://accounts.google.com/.well-known/openid-configuration",
      jwt.decode(idToken, { complete: true }).header.kid
    );
    const idTokenParsed = jwt.verify(idToken, sigKey);
    console.log(idTokenParsed);
    if (idTokenParsed.hd !== validHd) {
      throw new Error("hd is invalid");
    }
    // 何らかの処理
    console.log("done");
  } catch (e) {
    console.log(e);
    return {
      statusCode: 400,
      headers: {
        "Content-Type": "text/html",
      },
      body: "<p>失敗しました</p>",
    };
  }
  return {
    statusCode: 200,
    headers: {
      "Content-Type": "text/html",
    },
    body: "<p>起動しました</p>",
  };
}
async function getJwksUri(url) {
  const res = await fetch(url);
  const resJson = await res.json();
  return resJson.jwks_uri;
}
async function getSigKey(url, kid) {
  const jwkURL = await getJwksUri(url);
  const response = await fetch(jwkURL);
  const responseJson = await response.json();
  for (const k of responseJson.keys) {
    if (k.kid === kid) {
      return jwkToPem(k);
    }
  }
}

S3側

バケットを作成、以下ポリシーを適用して外から見れるようにしておきます
今回s3のURLそのまま使用するので静的ウェブサイトホスティングは設定しなくてOKです

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::example-hogehoge/*"
        }
    ]
}

APIキーを取得
認証情報を作成 => OauthクライアントID => ウェブアプリケーション

client-id

以下のHTMLとjavascriptファイルを用意
htmxを使っているのでタグの中にPOST先のLambda関数URLを書きます

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>button</title>
    <link rel="icon" href="data:,">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
  </head>
  <body>
    <h1>開発環境開始ボタン</h1>
    <form
      hx-target="this"
      hx-ext="json-enc"
      hx-indicator="#indicator"
      hx-post="https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.lambda-url.ap-northeast-1.on.aws/"
    >
    <input type="hidden" id="id-token" name="id-token" value="">
      <p>
        <label for="env-select">環境を選択</label>
        <select id="env-select" name="env">
          <option value="dev">dev</option>
          <option value="stg">stg</option>
        </select>
      </p>
      <button>起動</button>
      <div id="indicator" class="htmx-indicator">送信中...</div>
    </form>
    <script type="module" src="main.js"></script>
  </body>
</html>
import "sakura.css";
import "htmx.org/dist/htmx.js";
import "htmx.org/dist/ext/json-enc.js";

function decodeJwt(token) {
  try {
    const base64Url = token.split(".")[1];
    const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
    return JSON.parse(decodeURIComponent(escape(window.atob(base64))));
  } catch (e) {
    console.log(e);
    return {};
  }
}
function redirectGoogle() {
  let url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
  url.searchParams.set("response_type", "id_token");
  url.searchParams.set(
    "client_id",
    "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com"
  );
  url.searchParams.set("scope", "openid email");
  url.searchParams.set(
    "redirect_uri",
    "https://example-bucket.s3.ap-northeast-1.amazonaws.com/index.html"
  );
  sessionStorage.setItem("nonce", Math.random().toString(32).substring(2));
  url.searchParams.set("nonce", sessionStorage.getItem("nonce"));
  console.log(`redirect to ${url.toString()}`);
  window.location.href = url.toString();
}
{
  const fragment = location.href.split("#")[1];
  if (fragment) {
    sessionStorage.setItem("id_token", fragment.match(/token=([^&]+)/)[1]);
  }
  const token = sessionStorage.getItem("id_token");
  // 他人のURLフラグメント付きリンクを踏むとそのtokenを保存してしまうので防止するため
  // nonceの一致をチェック(あと有効期限も)
  if (token) {
    const tokenDecoded = decodeJwt(token);
    console.log(tokenDecoded);
    if (
      tokenDecoded.nonce !== sessionStorage.getItem("nonce") ||
      tokenDecoded.exp < Math.floor(Date.now() / 1000)
    ) {
      console.log("id_token is not valid");
      sessionStorage.removeItem("id_token");
    }
  }
  if (!sessionStorage.getItem("id_token")) {
    redirectGoogle();
  }
}
document.getElementById("id-token").value = sessionStorage.getItem("id_token");
// lambda側で検証失敗した場合は、Googleログインからやり直す
document.body.addEventListener("htmx:responseError", function () {
  redirectGoogle();
});

あとはHTML/jsを置くだけです
適当なbundlerで固めてアップロードします

npx parcel build index.html --no-source-maps
aws s3 sync --delete dist/ s3://example-bucket/

動作確認

トップページを開くとGoogleログインに飛ばされます

redirect-to-google

認証後、Lambdaにpost

click

Discussion

ログインするとコメントできます