「Googleでログイン」したアカウントをリアルタイムで守るRISC APIを試したみた【SSF】

Digital Identity技術勉強会 #iddance Advent Calendar 2024 の記事です。 Digital Identityに興味を持ち始めてからたくさんお世話になったAdvent Calendarです。 

https://qiita.com/advent-calendar/2024/iddance

# はじめに

本記事はタイトルにある通り、GoogleのRISC APIを試した記録とSSFの概要を解説の解説になります。

[OpenID TechNight vol21](https://openid.connpass.com/event/316748/)で [Tom Satoさん](https://www.linkedin.com/in/tomsato/?originalSubdomain=jp)の発表を聞いて以来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サイトの例を考えます。
1. ECサイトに「Googleでログイン」しクレジットカード情報を登録する
2. セッションが保持された状態でアカウントが乗っ取られる
3. 攻撃者はセッションを利用して登録済みのクレジットカードの利用が可能になる

認証した時点で正しかった状態が、セッション保持中に正しくなる可能性があるということです。
アプリケーションの認証に利用したGoogleアカウントに不審な動きがあった場合にリアルタイムで対策する手段は現在の「Googleでログイン」の仕組みでは存在しません。

上記のような課題を解決するため、OpenID FoundationによってSSFの仕様が策定されました。

# SSFとは
SSF（Shared Signal Framework）は上記のような認証後の課題を解決するため、継続的にセッションの状態やアカウントのセキュリティイベントをリアルタイムで共有するためのフレームワークです。 以下のOpenIDのWGによって策定されています。
https://openid.net/wg/sharedsignals/

SSFで共有する情報としては
- アカウントのリスクインシデント共有するため[RISC](https://openid.net/specs/openid-risc-profile-specification-1_0.html)
- 継続的にセッションを評価するための[CAEP](https://openid.net/specs/openid-caep-1_0-ID2.html)

が標準化されています。

また、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](https://openid.net/specs/openid-sharedsignals-framework-1_0.html#name-management-api-for-set-even)で策定されている。

# Googleの[RISC API](https://console.cloud.google.com/apis/library/risc.googleapis.com)

Googleでは「Googleでログイン」をしているアプリケーション向けにGoogleアカウントのSSFの仕組みを利用してRISCイベントを共有するための[RISC API](https://console.cloud.google.com/apis/library/risc.googleapis.com)が提供されています。
[RISC](https://openid.net/specs/openid-risc-profile-specification-1_0.html)で策定さえれている以下のような情報をリアルタイムに受け取り、アカウントを保護することができます。
- sessions-revoked
- tokens-revoked
- token-revoked
- account-disabled
- account-enabled	
- account-credential-change-required

公式ドキュメントでは以下のように、Cross Account Protectionと銘打って解説されています。

https://developers.google.com/identity/protocols/risc


本記事では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”を選択します。  
  
![google-risc-service-account.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/630070/435d1ce1-14ea-1ee2-68d2-bafb6bd60ade.png)

  
※ RISC APIを利用するためには、セキュリティイベントのaudienceとなるためのOAuth 2.0 Client IDが同じプロジェクト内に設定されている必要があります。[Google Cloudのcredentials管理画面](https://console.cloud.google.com/apis/credentials)より OAuth 2.0 の Clinet ID が存在する場合には表示されます。  
  
  
## RISC APIにアクセスするためのtokenの作成  
  
  
```typescript  
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の登録部分の実装を紹介します。  
  
  
```typescript  
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 エンドポイントにセキュリティイベントの配信が始まります。  
  
  
レスポンスには以下のような型で、作成されたストリームの情報が返ります。  
  
  
```typescript  
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が載せられています。
  
  
```text  
  POST /Events HTTP/1.1  
  Content-Type: application/secevent+jwt  
  
  eyJ0eXAiOiJzZWNldmVudCtqd3QiLCJhbGciOiJIUzI1NiJ9Cg  
  .  
  eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbS8iLCJqdGkiOiI3NTZFNjk  
  3MTc1NjUyMDY5NjQ2NTZFNzQ2OTY2Njk2NTcyIiwiaWF0IjoxNTA4MTg0ODQ1LC  
  JhdWQiOiI2MzZDNjk2NTZFNzQ1RjY5NjQiLCJldmVudHMiOnsiaHR0cHM6Ly9zY  
  2hlbWFzLm9wZW5pZC5uZXQvc2VjZXZlbnQvcmlzYy9ldmVudC10eXBlL2FjY291  
  bnQtZGlzYWJsZWQiOnsic3ViamVjdCI6eyJzdWJqZWN0X3R5cGUiOiJpc3Mtc3V  
  iIiwiaXNzIjoiaHR0cHM6Ly9pZHAuZXhhbXBsZS5jb20vIiwic3ViIjoiNzM3NT  
  YyNkE2NTYzNzQifSwicmVhc29uIjoiaGlqYWNraW5nIn19fQ  
  .  
  Y4rXxMD406P2edv00cr9Wf3_XwNtLjB9n-jTqN1_lLc  
```  
  
  
エンドポイントの実装部分。（handlerの前後はお使いのアプリケーションによって変化するかと思います）  
  
  
```typescript  
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の実装  
  
  
```typescript  
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以外で参考にしたサイトは以下になります。
- https://sharedsignals.guide/
- https://help.okta.com/oie/ja-jp/content/topics/itp/configure-shared-signal-provider.htm
- https://caep.dev/