Amazon Grafanaへのシングルサインオンのため、簡易な試験環境として、Lambda(関数URL)でSAML 2.0 Identity Provider (IdP) を作成してみました
1. はじめに
製造ビジネステクノロジー部の平内(SIN)です。
Amazon Grafanaでワークスペースのコンソールを開くには、下記のいずれかの認証が必要です。
- SAML 2.0 をサポートする IdP
- AWS SSO
ちょっと試してみたい時など、上記の認証環境が利用できれば問題ありませんが、簡単に準備できない場合は困ってしまいます。
今回は、そのような場面をイメージして、LambdaでSAML Idpを実装してみました。
Lambdaは、関数URLでホストされており、必要な証明書及び秘密鍵は、パラメータストアに配置しました。Lambdaは、CDKでデプロイできます。
※ 注意
今回の実装は、概念実証・学習目的として作成されたもので、本番環境での利用は想定されておりません。
本番では、企業向けIdPソリューション(Okta、Auth0等)をご利用下さい。
2. デプロイ
環境構築の手順は以下のとおりです。
(1) Git Clone
リポジトリをCloneします。
% git clone https://github.com/furuya02/lambda-saml-idp.git
% cd lambda-saml-idp
(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 | 秘密鍵 |
(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のエンドポイントが確認できます。
この関数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設定を行います。
「SAMLの設定」を開くと「サービスプロパイダの応答URL」が確認できます
この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」を付加したものを設定します。
アサーション属性のマッピングは、以下のとおりです。
設定項目 | 設定値 | 説明 |
---|---|---|
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 |
ログイン有効期間(分) |
3 動作確認
Grafana ワークスペースのURLを開きます
Sign in with SAMLをクリックすると、Lambdaから認証画面が返されます。
ユーザー情報は、cdk/lambda/const.tsのuserAccountListに設定されています。変更する場合は、ここを編集して、再デプロイして下さい。
ユーザ名及びパスワードを入力してLoginボタンを押すと、GrafanaのWorkspaceが表示されることを確認できます。
Profileを確認すると、認証したユーザの情報を確認できます。
今回の実装を利用する場合、ここまでの説明で終わりです。
以下は、実装に関して少し詳細に記載させて頂きました。もし、興味がございましたら読み進めて頂ければ幸いです。
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?の後に続くクエリー文字列には、認証のリクエスト情報(SampRequestとrelayState)が含まれますが、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の生成に進みます。
ここでは、順次転送してきたクエリー文字列からsamlRequestとrelayStateを紐解きます。
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. エラーハンドリング
- 詳細なエラー情報が露出する可能性
道のりは、まだ遠そうです。