外部APIでのmetatell利用者認証
プラグインから自前のバックエンドAPI(以下、プラグイン用API)を呼び出すとき、そのリクエストが本当にmetatellのログイン利用者から来たものかを、API側で確かめたいことがあります。 metatellが発行する短期トークンを検証すると、プラグイン用APIでログイン利用者を安全に識別できます。
おおまかな流れは次のとおりです。 まずプラグインがSDK経由でトークンを受け取り、それをプラグイン用APIへ送ります。 プラグイン用APIはそのトークンを確かめ、正しければ送信者をmetatellの利用者として信頼します。
このトークンは、特定のプラグイン用API宛てに発行された、短い有効期限を持つものです。 metatellのログインセッションそのものはプラグインへ渡らないため、万一トークンが漏れても影響を受ける範 囲を限定できます。
プラグイン側の実装
トークンを取得する
@urth/metatell-sdk/authのgetPluginApiToken()でトークンを取得します。
非同期関数なのでawaitで待ちます。
引数には、OAuth 2.0 のクライアントID(例: plugin-foo-api)を渡します。
これは、プラグイン用APIを表すものとしてmetatellのIdPに登録したクライアントのIDで、発行されるトークンの宛先(aud)になります。
登録は弊社側で行うため、プラグイン側でクライアントシークレットを扱う必要はありません。
入手方法は後述します。
import { getPluginApiToken } from "@urth/metatell-sdk/auth";
const token = await getPluginApiToken("plugin-foo-api");
トークンには有効期限があります。
リクエストのたびにgetPluginApiToken()を呼び、毎回新しいトークンを取得してください。
プラグイン用APIへ送る
取得したトークンをAuthorizationヘッダーにBearerとして付けて送信します。
import { getPluginApiToken } from "@urth/metatell-sdk/auth";
type ApiUserProfile = {
id: string;
displayName: string;
};
export async function fetchProfile(): Promise<ApiUserProfile> {
const token = await getPluginApiToken("plugin-foo-api");
const response = await fetch("https://foo.example.com/api/metatell/profile", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`Profile API failed: ${response.status}`);
}
return response.json() as Promise<ApiUserProfile>;
}
取得に失敗したときの処理
getPluginApiToken()は、失敗すると型付きのエラーでrejectします。
原因ごとにinstanceofで分岐できます。
いずれも@urth/metatell-sdk/authからエクスポートされています。
| エラー | 意味 |
|---|---|
InvalidClientIdError | clientId が空、もしくは文字列でない |
NotAuthenticatedError | metatellにログインしていない |
PluginApiClientNotFoundError | clientId が未登録(許可されていない) |
PluginTokenExchangeError | トークンの取得に失敗した(その他の失敗) |
import {
getPluginApiToken,
NotAuthenticatedError,
PluginApiClientNotFoundError,
} from "@urth/metatell-sdk/auth";
try {
const token = await getPluginApiToken("plugin-foo-api");
// token を使ってリクエストする
} catch (e) {
if (e instanceof NotAuthenticatedError) {
// 利用者にサインインを促す
} else if (e instanceof PluginApiClientNotFoundError) {
// clientId の設定ミス
} else {
// PluginTokenExchangeError / InvalidClientIdError
}
}
ブラウザー側でトークンの中身を読み取って得られる情報は、画面表示の用途にだけ使ってください。 署名を確かめていないトークンの中身は書き換えられている恐れがあるため、認証や権限判定の根拠にしてはいけません。 利用者の確認は、かならず次に説明するプラグイン用API側の検証で行います。
トークンの中身
検証を説明する前に、トークンの形式と中身を確認します。
getPluginApiToken()が返すのは、JWT(JSON Web Token)と呼ばれる、署名の付いた文字列です。
metatellの秘密鍵で署名されているため、第三者が中身を書き換えると署名の検証に失敗します。
そのためプラグイン用APIは、署名が正しいことを確かめたうえで、含まれる情報を信頼できます。
JWTの中身は「クレーム」と呼ばれる項目の集まりです。 検証で使うのは次の4つです。
iss(発行者):トークンを発行したmetatellを表す識別子(URL形式)。aud(宛先):このトークンの宛先。getPluginApiToken()に渡したOAuthクライアントID(プラグイン用APIを表す)が入る。sub(利用者ID):トークンの持ち主であるmetatell利用者を一意に表すID。exp(有効期限):このトークンが使える期限。
audを確かめることが、なぜ安全につながるのかを補足します。
audには、宛先となる特定のプラグイン用APIのIDだけが入ります。
これを照合すれば、別のAPI向けに発行されたトークンを自分のAPIへ使い回すことを防げます。
プラグイン用API側の検 証
検証には、署名を確かめるための公開鍵が必要です。
JWKSエンドポイントは、その公開鍵を配布しているmetatellのURLです。
署名方式は**RS256**(公開鍵暗号を用いる方式)に固定されています。
プラグイン用APIは、受け取ったトークンを信頼する前に次の項目をすべて検証してください。
| 項目 | 検証内容 |
|---|---|
| 署名 | JWKSエンドポイントの公開鍵で署名を確かめ、RS256 以外の方式は拒否する |
発行者(iss) | https://id.u-rth.com/realms/<organization_id> と一致する |
宛先(aud) | 自分のOAuthクライアントID(例: plugin-foo-api)と一致する |
有効期限(exp) | 期限切れのトークンを拒否する |
利用者ID(sub) | UUID v4形式(最大36文字)。利用者の一意なキーとして使える |
JWKSエンドポイントのURLは次の形式です。
https://id.u-rth.com/realms/<organization_id>/protocol/openid-connect/certs
この機能の利用は申請制です。 弊社までお申し込みいただくと、弊社側でOAuthクライアントを登録し、コードに設定する次の2つの値をお知らせします。
- 組織ID(
<organization_id>に入る値) - OAuthクライアントID(トークンの
audに入る値)
subは利用者ごとに一意なため、バックエンドのデータベースで利用者キーとして保存できます。
値はUUID v4形式(最大36文字)なので、36文字まで格納できる列を用意してください。
検証コードの例(Node.js)
jose を使った検証例です。
import { createRemoteJWKSet, jwtVerify } from "jose";
// 組織IDとクライアントIDに合わせて設定する
const ORGANIZATION_ID = "your-organization-id";
const ISSUER = `https://id.u-rth.com/realms/${ORGANIZATION_ID}`;
const AUDIENCE = "plugin-foo-api"; // 宛先となるOAuthクライアントID
const jwks = createRemoteJWKSet(
new URL(`${ISSUER}/protocol/openid-connect/certs`),
);
export async function verifyMetatellToken(authorizationHeader?: string) {
const token = authorizationHeader?.replace(/^Bearer\s+/i, "");
if (!token) {
throw new Error("missing bearer token");
}
const { payload } = await jwtVerify(token, jwks, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["RS256"],
});
// payload.sub が metatell 利用者の一意な ID
return payload;
}
プラグイン用APIのリクエストハンドラーから利用する例です。
export async function handleRequest(request: Request): Promise<Response> {
try {
const claims = await verifyMetatellToken(
request.headers.get("authorization") ?? undefined,
);
return Response.json({ userId: claims.sub });
} catch {
return new Response("unauthorized", { status: 401 });
}
}
セキュリティに関する注意
- シークレットをプラグインに埋め込まない:プラグインのコードは利用者環境でそのまま実行されるため、バックエンドのAPIキーや認証情報をコードに含めないでください。
- 通信はHTTPSで行う:トークンを送るリクエストは、かならずHTTPSで送信してください。
- ルームのCSPで接続先を許可する:プラグインから外部APIへ接続するには、プラグインを適用するルームの
connect-srcへ外部APIのオリジンを追加する必要があります。設定方法はContent Security Policy(CSP)の設定を参照してください。
関連ドキュメント
- 外部API認証の参考実装:プラグインから外部APIを呼び出し、プラグイン用API側でトークンを検証するサンプル実装
- クイックスタート — プラグインの作成・登録手順
- イベント連携(JS Call Events) — Spokeからのイベント受信