Digital Identity技術勉強会 #iddance Advent Calendar 2024 の記事です。 Digital Identityに興味を持ち始めてからたくさんお世話になったAdvent Calendarです。
はじめに
本記事はタイトルにある通り、GoogleのRISC APIを試した記録とSSFの概要を解説の解説になります。
OpenID TechNight vol21で Tom Satoさんの発表を聞いて以来SSFに興味を持ち本記事のテーマの選定に至りました。
以下がTom Satoさんのスライドになります。SSFの概要把握の手助けになるかと思いますので事前お勧めします。
https://speakerdeck.com/oidfj/20240516-openid-technight-vol-dot-21-oidfsieadosigunaruhuremuwaku-id2-woli-yong-siteriarutaimudesekiyuriteisigunaruwogong-you-surutamenozui-xin-qing-bao
認証後の課題について
「Googleでログイン」しているアプリケーションはログイン時のみGoogleアカウントによる認証が行われ、ログイン後は継続してモニタリングが行われていないという課題があります。具体例としてあるECサイトの例を考えます。
- ECサイトに「Googleでログイン」しクレジットカード情報を登録する
- セッションが保持された状態でアカウントが乗っ取られる
- 攻撃者はセッションを利用して登録済みのクレジットカードの利用が可能になる
認証した時点で正しかった状態が、セッション保持中に正しくなる可能性があるということです。
アプリケーションの認証に利用したGoogleアカウントに不審な動きがあった場合にリアルタイムで対策する手段は現在の「Googleでログイン」の仕組みでは存在しません。
上記のような課題を解決するため、OpenID FoundationによってSSFの仕様が策定されました。
SSFとは
SSF(Shared Signal Framework)は上記のような認証後の課題を解決するため、継続的にセッションの状態やアカウントのセキュリティイベントをリアルタイムで共有するためのフレームワークです。 以下のOpenIDのWGによって策定されています。
https://openid.net/wg/sharedsignals/
SSFで共有する情報としては
が標準化されています。
また、SSFは異なるドメインやシステム間でユーザー情報やセキュリティ情報をリアルタイムで共有する標準仕様という側面があるため、アカウント連携による認証後のアカウントの監視以外にも、複数のサービスが統合されている環境における一貫したセキュリティ管理を実施する際の基幹システムになりうるのではと感じています。
SSFの文脈でよく見る用語
- SET: Security Event Tokenの略。SSFではSETの形式を用いてセキュリティイベントをコミュニケーションする。以下でわかりやすく解説してくれています。https://qiita.com/ritou/items/ca07761fc3a36039be54
- Transmitter: セキュリティイベントを共有するシステム
- Receiver: セキュリティイベントを受け取るシステム
- Subject: セキュリティイベントの対象。セキュリティイベントに紐づくユーザーIDなど。Subjectの形式は以下でわかりやすく解説されています。https://zenn.dev/ritou/articles/0b5ca8740a3beb
- Stream: TransmitterとReceiverの通信を抽象化した概念。SSFは1つのTransmitterに対して複数のReciverが想定されており、streamごとに受け取りたいセキュリティイベントの管理等が行える。詳細はManagement API for SET Event Streamsで策定されている。
GoogleのRISC API
Googleでは「Googleでログイン」をしているアプリケーション向けにGoogleアカウントのSSFの仕組みを利用してRISCイベントを共有するためのRISC APIが提供されています。
RISCで策定さえれている以下のような情報をリアルタイムに受け取り、アカウントを保護することができます。
- sessions-revoked
- tokens-revoked
- token-revoked
- account-disabled
- account-enabled
- account-credential-change-required
公式ドキュメントでは以下のように、Cross Account Protectionと銘打って解説されています。
本記事ではNode.jsアプリケーションで Google RISC APIを利用したみたのでその一部を共有します。(Java, Pythonでの実装例は上記のサイトで共有されています!)
Node.jsでGoogleのRISC APIを試したみた
環境
- node: 20.x
- jsonwebtoken: 9.0.2
- jwks-rsa: 3.1.0
サービスアカウントの作成
RISC APIを利用するためには、専用のロールを持ったサービスアカウントを作成する必要があります。
具体的にはサービスアカウント作成のロール選択にて”RISC Configuration Admin”を選択します。
※ RISC APIを利用するためには、セキュリティイベントのaudienceとなるためのOAuth 2.0 Client IDが同じプロジェクト内に設定されている必要があります。Google Cloudのcredentials管理画面より OAuth 2.0 の Clinet ID が存在する場合には表示されます。
RISC APIにアクセスするためのtokenの作成
import jwt from "jsonwebtoken";
export async function makeBearerToken() {
// 先ほど登録したサービスアカウントの認証情報を読み込む
const privateKey = process.env.RISC_API_PRIVATE_KEY?.replace(/\\n/g, "\n");
const clientEmail = process.env.RISC_API_CLIENT_EMAIL;
const keyId = process.env.RISC_API_KEY_ID;
if (!privateKey || !clientEmail || !keyId)
throw Error("Service Account Credentials Info couldn't be loaded");
// トークンの発行と有効期限を設定(1時間)
const issuedAt = Math.floor(Date.now() / 1000);
const expiresAt = issuedAt + 3600;
// JWTを作成し署名する
const token = jwt.sign(
{
iss: clientEmail,
sub: clientEmail,
aud: "https://risc.googleapis.com/google.identity.risc.v1beta.RiscManagementService",
iat: issuedAt,
exp: expiresAt,
},
privateKey,
{
algorithm: "RS256",
keyid: keyId,
}
);
return token;
}
こちらtokenをリクエストの際にAuthorizationヘッダーにbearerトークンとして付与することで、リクエストがサービスアカウントからのものであることが識別されます
receiverの登録
receiverを後で作成するとして、先にreceiverの登録部分の実装を紹介します。
export const SUPPORTED_EVENT_TYPES = [
"https://schemas.openid.net/secevent/risc/event-type/sessions-revoked",
"https://schemas.openid.net/secevent/oauth/event-type/tokens-revoked",
"https://schemas.openid.net/secevent/oauth/event-type/token-revoked",
"https://schemas.openid.net/secevent/risc/event-type/account-disabled",
"https://schemas.openid.net/secevent/risc/event-type/account-enabled",
"https://schemas.openid.net/secevent/risc/event-type/account-credential-change-required",
"https://schemas.openid.net/secevent/risc/event-type/verification", // verificationはRSICイベントではなく、動作確認用のイベント
] as const;
export async function configureEventStream(receiverEndpoint: string) {
try {
const bearerToken = await makeBearerToken();
// イベントストリーム設定のJSONを構築
const streamConfig = {
delivery: {
delivery_method:
"https://schemas.openid.net/secevent/risc/delivery-method/push",
url: receiverEndpoint,
},
events_requested: SUPPORTED_EVENT_TYPES,
};
const response = await fetch(
"https://risc.googleapis.com/v1beta/stream:update",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify(streamConfig),
}
);
const responseData = await response.json();
return {
success: true,
responseData,
};
} catch (error) {
console.log("configureEventStream error", error);
return { success: false };
}
}
こちらリクエストが成功すると、指定したreceiver エンドポイントにセキュリティイベントの配信が始まります。
レスポンスには以下のような型で、作成されたストリームの情報が返ります。
type StreamConfiguration = {
iss: "https://accounts.google.com";
aud: string[];
delivery: {
delivery_method: "https://schemas.openid.net/secevent/risc/delivery-method/push";
url: string;
};
events_supported: string[];
events_requested: string[];
events_delivered: string[];
min_verification_interval: number;
};
aud
には プロジェクト内に存在する OAuth 2.0 の Clinet ID が入っているかと思います。
receiver エンドポイントの作成
最も肝心なreceiver エンドポイントの部分の実装になります。
前提としてイベントは Content-Type: application/secevent+jwt
のリクエストで送られます。body部分にはSETが載せられています。
POST /Events HTTP/1.1
Content-Type: application/secevent+jwt
eyJ0eXAiOiJzZWNldmVudCtqd3QiLCJhbGciOiJIUzI1NiJ9Cg
.
eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbS8iLCJqdGkiOiI3NTZFNjk
3MTc1NjUyMDY5NjQ2NTZFNzQ2OTY2Njk2NTcyIiwiaWF0IjoxNTA4MTg0ODQ1LC
JhdWQiOiI2MzZDNjk2NTZFNzQ1RjY5NjQiLCJldmVudHMiOnsiaHR0cHM6Ly9zY
2hlbWFzLm9wZW5pZC5uZXQvc2VjZXZlbnQvcmlzYy9ldmVudC10eXBlL2FjY291
bnQtZGlzYWJsZWQiOnsic3ViamVjdCI6eyJzdWJqZWN0X3R5cGUiOiJpc3Mtc3V
iIiwiaXNzIjoiaHR0cHM6Ly9pZHAuZXhhbXBsZS5jb20vIiwic3ViIjoiNzM3NT
YyNkE2NTYzNzQifSwicmVhc29uIjoiaGlqYWNraW5nIn19fQ
.
Y4rXxMD406P2edv00cr9Wf3_XwNtLjB9n-jTqN1_lLc
エンドポイントの実装部分。(handlerの前後はお使いのアプリケーションによって変化するかと思います)
export async function POST(req: Request) {
try {
const jwtToken = await req.text();
const setPayload = (await validateSecurityEventToken(jwtToken))
console.log("received events", setPayload.events);
// ここで events に応じたアプリケーション側の対応を行う
console.log("handling events here");
return new Response("ack", {
status: 202,
});
} catch (error) {
console.log("set receiver handling error", error);
// セキュリティイベントを捌けなかった場合はRFC8935にしたがって400でレスポンスしておく
return new Response(
JSON.stringify({
err: "some error",
description: "some description",
}),
{
status: 400,
headers: {
"Content-Type": "application/json",
},
}
);
}
}
validateSecurityEventTokenの実装
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
async function validateSecurityEventToken(
token: string
): Promise<jwt.JwtPayload | string> {
const issuer = "https://accounts.google.com";
const jwksUri = "https://www.googleapis.com/oauth2/v3/certs";
const client = jwksClient({
jwksUri: jwksUri,
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 10,
});
const unverifiedJwt = jwt.decode(token, { complete: true });
if (!unverifiedJwt || !unverifiedJwt.header)
throw new Error("kid does not exist in jwt");
const kid = unverifiedJwt.header.kid;
const key = await client.getSigningKey(kid);
// JWTトークンを検証
return jwt.verify(token, key.getPublicKey(), {
issuer: issuer,
audience: [process.env.GOOGLE_CLIENT_ID!!],
algorithms: ["RS256"],
});
}
以上でnode.jsアプリケーションでもセキュリティイベントを受信することができるようになると思います。
最後に
SSFの概要と、Googleが提供するRISC APIをnode.jsアプリケーションで試す例を解説しました。個人開発レベル利用することは少ないとは思いますが、Googleが検知したアカウントの不審な動きをリアルタイムで受け取れる良い仕組みだと思います。
誤りや改善点がありましたら、ぜひコメントやご指摘をいただけると幸いです。皆様のフィードバックを元に、より良い記事に更新していきたいと考えています。
仕様, 公式Doc以外で参考にしたサイトは以下になります。