Amazon Grafanaへのシングルサインオンのため、簡易な試験環境として、Lambda(関数URL)でSAML 2.0 Identity Provider (IdP) を作成してみました

Amazon Grafanaへのシングルサインオンのため、簡易な試験環境として、Lambda(関数URL)でSAML 2.0 Identity Provider (IdP) を作成してみました

2025.09.17

1. はじめに

製造ビジネステクノロジー部の平内(SIN)です。

Amazon Grafanaでワークスペースのコンソールを開くには、下記のいずれかの認証が必要です。

  • SAML 2.0 をサポートする IdP
  • AWS SSO

ちょっと試してみたい時など、上記の認証環境が利用できれば問題ありませんが、簡単に準備できない場合は困ってしまいます。

今回は、そのような場面をイメージして、LambdaでSAML Idpを実装してみました。

Lambdaは、関数URLでホストされており、必要な証明書及び秘密鍵は、パラメータストアに配置しました。Lambdaは、CDKでデプロイできます。

001

※ 注意

今回の実装は、概念実証・学習目的として作成されたもので、本番環境での利用は想定されておりません。
本番では、企業向けIdPソリューション(Okta、Auth0等)をご利用下さい。

2. デプロイ

環境構築の手順は以下のとおりです。

(1) Git Clone

リポジトリをCloneします。

			
			% git clone https://github.com/furuya02/lambda-saml-idp.git
% cd lambda-saml-idp

		

013

(2) Generate Certificates

証明書及び、秘密鍵を作成します。下記の手順で、public.crt(証明書)及び、private.key(秘密鍵)を作成することができます。

			
			% mkdir cert && cd cert

// Generate private key
% openssl genpkey -algorithm RSA -out private.key -pkeyopt rsa_keygen_bits:2048

// Generate certificate signing request(質問項目は、すべてEnterキーで進んでも大丈夫です)
% openssl req -new -key private.key -out server.csr

// Generate self-signed certificate (valid for 1 year)
% openssl req -x509 -days 365 -key private.key -in server.csr -out public.crt

// Clean up CSR file
% rm server.csr

// Verify files created
% ls -la
 -rw-r--r--  public.crt   (certificate)
 -rw-------  private.key  (private key)

% cd ..

		

(3) Store Certificates in Parameter Store

作成した証明書及び秘密鍵を次の名前でSecureStringとしてパラメータストアに保存します。

Name Content
lambda-saml-idp-public-crt 証明書
lambda-saml-idp-private-key 秘密鍵

002
003

(4) CDK Deploy

ソースコードに、関数URLのエンドポイントを設定する必要があるため、一度、CDKをdeployします。

			
			% cd cdk

// Install dependencies
% npm install

// Build TypeScript
% npm run build

// Preview changes (optional)
% npx cdk diff

// Deploy the stack
% npx cdk deploy

		

deployされると、Outputsで関数URLのエンドポイントが確認できます。
004

この関数URLのエンドポイントを、cdk.json の endpointに書き込んで下さい。(再デプロイは、この後実施します)

			
			{
  "context": {
    "app": {
      "projectName": "lambda-saml-idp",
      "endpoint": "https://xxxxx.lambda-url.ap-northeast-1.on.aws/",
      "entityId": "urn:example:idp",
      "publicCrt": "lambda-saml-idp-public-crt",
      "privateKey": "lambda-saml-idp-private-key"
    },
    "@aws-cdk/aws-lambda:recognizeLayerVersion": true
  }
}

		

(5) Grafana Setting

続いて、Grafanaのworkspaceで、SAML設定を行います。

005

「SAMLの設定」を開くと「サービスプロパイダの応答URL」が確認できます

015

このURLを cdk/lambda/const.tsのconfig.acsUrlに転記します。(これで、このLambdaは、このワークスペース専用のidpとなっています)

			
			import { CONFIG, USER_ACCOUNT } from "./types";

export const config: CONFIG = {
  issuer: "urn:example:idp",
  endpoint: "http://localhost:8888",
  acsUrl:
    "https://g-xxxxxx0881.grafana-workspace.ap-northeast-1.amazonaws.com/saml/acs",
};

		

ここで、CDKの変更内容を反映するため、再デプロイを行います。

			
			% npm run build
% npx cdk diff
% npx cdk deploy

		

メタデータのインポートは、インポート方法をURLとし、先程取得した関数URLの後ろに「/metadata」を付加したものを設定します。

007

アサーション属性のマッピングは、以下のとおりです。

設定項目 設定値 説明
Assertion Attribute Role role アサーション属性ロール
Admin Role Values Admin 管理者ロールの値
Assertion Attribute Name displayName アサーション属性名
Assertion Attribute Login email アサーション属性のログイン
Assertion Attribute Email email アサーション属性 E メール
Login Validity Period 1440 ログイン有効期間(分)

006

3 動作確認

Grafana ワークスペースのURLを開きます

008

Sign in with SAMLをクリックすると、Lambdaから認証画面が返されます。

009

ユーザー情報は、cdk/lambda/const.tsのuserAccountListに設定されています。変更する場合は、ここを編集して、再デプロイして下さい。

012

ユーザ名及びパスワードを入力してLoginボタンを押すと、GrafanaのWorkspaceが表示されることを確認できます。

010

Profileを確認すると、認証したユーザの情報を確認できます。

011

今回の実装を利用する場合、ここまでの説明で終わりです。

以下は、実装に関して少し詳細に記載させて頂きました。もし、興味がございましたら読み進めて頂ければ幸いです。

4 Lambda SAML IdP の設計と実装

今回実装したLambda SAML IdPは、TypeScriptとAWS CDKを使用して実装されています。

Lambda関数はFunction URLを通じてHTTPSエンドポイントとして動作し、SAMLプロトコルを実装しています。証明書などの機密情報はSystems Manager Parameter Storeに暗号化して保存されます。

(1) エンドポイント

Lambdaで実装したエンドポイントは、以下のとおりです。

メタデータエンドポイント(GET /metadata

このメタデータを取得して、SPは、idpのentityIDや、証明書、そして、リクエストの送信先(/sso)を知ることになります。

idpのメタデータをSPへ受け渡すには、(1) 取得したXMLをコピーして貼り付ける方法、及び(2) メタデータを返すURLを指定する方法、のいずれかを選択しますが、公開されたLambdaで実装されているため、どちらの方法も採用可能です。

lambda-saml-idp/cdk/lambda/saml.ts

			
			export async function createSamlMetadata(
  endpoint: string,
  entityId: string
): Promise<string> {
  const publicCrt = await getSecret("PUBLIC_CRT");
  const X509Certificate = publicCrt
    .replace(/-----BEGIN CERTIFICATE-----/, "")
    .replace(/-----END CERTIFICATE-----/, "")
    .replace(/\n/g, "");

  return `<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor entityID="${entityId}">
  <md:IDPSSODescriptor>
    <md:KeyDescriptor use="signing">
      <ds:X509Certificate>${X509Certificate}</ds:X509Certificate>
    </md:KeyDescriptor>
    <md:SingleSignOnService Location="${endpoint}sso"/>
  </md:IDPSSODescriptor>
</md:EntityDescriptor>`;
}

		

SSOエンドポイント(GET /sso

lambda-saml-idp/cdk/lambda/index.ts

GrafanaからのSAMLAuthRequestを受信し、Lambdaで用意したログインページにリダイレクトしています。

/sso?の後に続くクエリー文字列には、認証のリクエスト情報(SampRequestrelayState)が含まれますが、Lambda関数では、セッション情報を保持できないため。そのまま、リダイレクト先に転送しています。

			
			 } else if (method === "GET" && path.startsWith("/sso")) {
    return {
      statusCode: 302,
      headers: {
        Location: `/login?${event.rawQueryString}`,
      },
      body: "",
    };

		

認証エンドポイント(GET /login

ユーザー認証フォームを表示し、ログインボタンが押されたら、入力値を POST /loginに送信します。
なお、受け継いだ クエリー文字列は、ここでも、そのまま転送しています。

			
			} else if (method === "GET" && path.startsWith("/login")) {
    // Loginフォームの表示
    const loginForm = createLoginForm(event.rawQueryString, "");

    return {
      statusCode: 200,
      headers: {
        "Content-Type": "text/html; charset=utf-8",
      },
      body: loginForm,
    };

		

認証エンドポイント(POST /login

lambda-saml-idp/cdk/lambda/index.ts

ユーザ認証の入力値を受け取り、ユーザー情報に一致しているかの確認を行っています。
一致しない場合は、エラーメッセージとともに、再びログインフォームを表示しています。

			
			  } else if (method === "POST" && path.startsWith("/login")) {
    const body = Buffer.from(event.body!, "base64").toString("utf-8");
    if (!authenticateUser(body)) {
      const loginForm = createLoginForm(
        event.rawQueryString,
        "ユーザー名若しくは、パスワードが無効です"
      );
      // 認証に失敗した場合、Loginフォームを再表示
      return {
        statusCode: 200,
        headers: {
          "Content-Type": "text/html; charset=utf-8",
        },
        body: loginForm,
      };
    }

		

(2) SAML Response

認証が成功した場合、遂に、SAML Responseの生成に進みます。

ここでは、順次転送してきたクエリー文字列からsamlRequestrelayStateを紐解きます。

relayStateは、レスポンスにそのまま使用しますが、samlRequestは、パースが必要です。

			
			    // 認証に成功した場合の処理をここに追加
    const samlRequest = event.queryStringParameters?.SAMLRequest || "";
    const relayState = event.queryStringParameters?.RelayState || "";
    const saml = samlRequestParse(samlRequest);


		

実は、samlRequestのパースは、少し複雑で、下記の手順となっています。

  • URLデコード
  • base64デコード
  • zlibで解凍

lambda-saml-idp/cdk/lambda/saml.ts

			
			export function samlRequestParse(base64str: string): string {
  // URLデコード
  const decodedUrl = decodeURIComponent(base64str);

  // base64デコード
  const decodedBytes = Buffer.from(decodedUrl, "base64");

  // zlibで解凍(raw deflateとzlib両方を試す)
  let decompressedData;
  try {
    // 通常のzlib形式を試す
    decompressedData = zlib.inflateSync(decodedBytes);
  } catch (error) {
    // raw deflate形式を試す
    decompressedData = zlib.inflateRawSync(decodedBytes);
  }

  const requestSaml = decompressedData.toString();

  return requestSaml;
}


		

無事パースできたsamlは、事後の利用のために下記のオブジェクトに読み取っています。

			
			export interface AUTHN_REQUEST {
  id?: string;
  version?: string; 
  issueInstant?: string;
  destination?: string;
  assertionConsumerServiceURL?: string;
  issuer?: string;
  nameIdFormat?: string;
}

		

ここで、assertionConsumerServiceURLは、リクエストを送信したSPのACSとなっているのですが、これを予め設定した値と比較することで、想定外のSPからのリクエストを排除することができます。

			
			    const authnRequest = parseSamlAuthnRequest(saml);
    // cinfig.acsUrlが(空白ではない場合)設定されている場合、SPのリクエストからACSと比較するし、不一致の場合は受け付けない
    if (
      config.acsUrl !== "" &&
      config.acsUrl !== authnRequest.assertionConsumerServiceURL
    ) {
      return {
        statusCode: 404,
        headers: {
          "Content-Type": "text/html; charset=utf-8",
        },
        body: "Invalid ACS URL",
      };
    }


		

samlRequestが無事解釈できた時点で、SAMLレスポンスを生成します。

			
			// SAMLResponseを生成する
const samlResponse = createSAMLResponse(config.issuer, authnRequest);

		

ちょっとややこしくなってしまってますが、生成しているのは以下です。

lambda-saml-idp/cdk/lambda/saml.ts

			
			export function createSAMLResponse(
  entityId: string,
  authnRequest: AUTHN_REQUEST
): string {
  const timestampProvider = new SamlTimestampProvider();

  const id = uuidv4(); //Responseで生成
  const assertionId = uuidv4(); //Responseで生成
  const XMLS_SCHEMA = "http://www.w3.org/2001/XMLSchema";

  const inResponseTo = authnRequest.id; // Request のIDを指定
  const acsUrl = authnRequest.assertionConsumerServiceURL;
  const metadataUrl = authnRequest.issuer;

  let samlResponse = `
    <?xml version="1.0" encoding="UTF-8"?>
<saml2p:Response xmlns:saml2p="${SAML_NAMESPACE_2_0}:protocol" xmlns:saml2="${SAML_NAMESPACE_2_0}:assertion" ID="${id}" Version="2.0" IssueInstant="${timestampProvider.getIssuedAt()}" Destination="${acsUrl}" InResponseTo="${inResponseTo}">
  <saml2:Issuer>${entityId}</saml2:Issuer>
  <saml2p:Status>
    <saml2p:StatusCode Value="${SAML_NAMESPACE_2_0}:status:Success"/>
  </saml2p:Status>
  <saml2:Assertion xmlns:saml2="${SAML_NAMESPACE_2_0}:assertion" ID="${assertionId}" Version="2.0" IssueInstant="${timestampProvider.getIssuedAt()}">
    <saml2:Issuer>${entityId}</saml2:Issuer>
    <saml2:Subject>
      <saml2:NameID Format="${SAML_NAMESPACE_2_0}:nameid-format:emailAddress">admin@example.com</saml2:NameID>
      <saml2:SubjectConfirmation Method="${SAML_NAMESPACE_2_0}:cm:bearer">
        <saml2:SubjectConfirmationData NotOnOrAfter="${timestampProvider.getNotOnOrAfter()}" Recipient="${acsUrl}" InResponseTo="${inResponseTo}"/>
      </saml2:SubjectConfirmation>
    </saml2:Subject>
    <saml2:Conditions NotBefore="${timestampProvider.getIssuedAt()}" NotOnOrAfter="${timestampProvider.getNotOnOrAfter()}">
      <saml2:AudienceRestriction>
        <saml2:Audience>${metadataUrl}</saml2:Audience>
      </saml2:AudienceRestriction>
    </saml2:Conditions>
    <saml2:AuthnStatement AuthnInstant="${timestampProvider.getIssuedAt()}" SessionNotOnOrAfter="${timestampProvider.getSessionExpiry()}">
      <saml2:AuthnContext>
        <saml2:AuthnContextClassRef>${SAML_NAMESPACE_2_0}:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
      </saml2:AuthnContext>
    </saml2:AuthnStatement>
    <saml2:AttributeStatement>
      </saml2:Attribute>`;

  userAccountList.forEach((account) => {
    Object.entries(account).forEach(([key, value]) => {
      if (key === "user" || key === "password") return; // ユーザ名・パスワードは含めない
      samlResponse += `
      <saml2:Attribute Name="${key}" NameFormat="${SAML_NAMESPACE_2_0}:uri">
        <saml2:AttributeValue xsi:type="xs:string" xmlns:xsi="${XMLS_SCHEMA}-instance" xmlns:xs="${XMLS_SCHEMA}">${value}</saml2:AttributeValue>
      </saml2:Attribute>
      `;
    });
  });
  samlResponse += `</saml2:AttributeStatement>
  </saml2:Assertion>
</saml2p:Response>`;
  // Remove whitespace and newlines for compact XML
  return samlResponse.replace(/\s+/g, " ").replace(/>\s+</g, "><").trim();
}

		

生成したSAML Responseは、署名されます。

			
			//署名する
const signedXml = await signXML(samlResponse);

		

秘密鍵で作成したシグネチャーが、<Signature>タグで挿入されます。

lambda-saml-idp/cdk/lambda/saml.ts

			
			export async function signXML(xml: string): Promise<string> {
  const privateKey = await getSecret("PRIVATE_KEY");
  const publicCert = await getSecret("PUBLIC_CRT");
  if (!privateKey || !publicCert) {
    throw new Error("Failed to retrieve keys");
  }
  const sig = new SignedXml();

  // Response全体に署名を適用
  sig.addReference(
    "//*[local-name()='Response']",
    [
      "http://www.w3.org/2000/09/xmldsig#enveloped-signature",
      "http://www.w3.org/2001/10/xml-exc-c14n#",
    ],
    "http://www.w3.org/2001/04/xmlenc#sha256"
  );

  sig.signingKey = privateKey;
  sig.keyInfoProvider = {
    getKeyInfo: () => {
      const certFormatted = publicCert
        .replace(/-----BEGIN CERTIFICATE-----/, "")
        .replace(/-----END CERTIFICATE-----/, "")
        .replace(/\n/g, "");
      return `<X509Data><X509Certificate>${certFormatted}</X509Certificate></X509Data>`;
    },
    getKey: () => Buffer.from(privateKey),
  };

  sig.computeSignature(xml, {
    location: {
      reference: "//*[local-name()='Issuer']",
      action: "after",
    },
  });

  return sig.getSignedXml();
}

		

生成されたSAML Responseは、フォームとして展開されSPのエンドポイントへPOSTされます。

			
			const encodedResponse = Buffer.from(signedXml).toString("base64");
    const html = `
          <html>
            <head><title>Keep Calm and Single Sign-On!</title></head>
            <body>
              <form method="post" name="hiddenform" action="${config.acsUrl}">
                <input type="hidden" name="SAMLResponse" value="${encodedResponse}">
                ${
                  relayState
                    ? `<input type="hidden" name="RelayState" value="${relayState}">`
                    : ""
                }
                <noscript>
                  <p>JavaScript is disabled. Click Submit to continue.</p>
                  <input type="submit" value="Submit">
                </noscript>
              </form>
              <script language="javascript" type="text/javascript">
                window.setTimeout(function(){document.forms[0].submit();}, 0);
              </script>
            </body>
          </html>
        `;
    return {
      statusCode: 200,
      headers: {
        "Content-Type": "text/html; charset=utf-8",
      },
      body: html,
    };

		

5 最後に

今回は、Amazon Grafanaで軽易にSSO認証するための環境が準備できるようにと、Lambdaによる SAML idp を作成してみました。

metadataの生成と、受信した、SAML AuthRequestのパースあたりまでは、それほど複雑ではなかったのですが、SAML Responseの生成は、結構時間を要しました。
個人的には、非常に勉強になりました。

最初にも記載しましたが、本実装は、概念実証・学習目的として作成されたもので、本番環境での利用は想定されておりません。

最後に、作成したコードを本番運用に持って行く場合のセキュリティ上の改善点を Claude Codeに聞いてみました。

			
			  1. 認証情報の管理
    - パスワードが平文でハードコード(cdk/lambda/const.ts)
    - ブルートフォース攻撃対策なし
    - セッション管理の欠如
  2. SAML セキュリティ
    - 受信SAMLリクエストの署名検証なし
    - リプレイアタック対策不足(ID再利用チェック、タイムスタンプ検証なし)
    - InResponseTo検証が不完全
  3. 入力検証
    - XSS対策不足(ログインフォーム、RelayState)
    - XXE攻撃への対策が不明確
  4. CORS設定
    - 全オリジン許可(allowedOrigins: ["*"])
  5. エラーハンドリング
    - 詳細なエラー情報が露出する可能性

		

道のりは、まだ遠そうです。

6. 参考にさせて頂いたリンク

この記事をシェアする

FacebookHatena blogX

関連記事