いかそばの部屋へようこそ

当サイトはリンクフリーです!
Sorry. this site is written only Japanese.

MisskeyのOAuth2.0認可について・・・お話します・・・

今回はみすてむず いず みすきーしすてむず (4) Advent Calendar 2023の13日目への投稿となります。やったね。

MisskeyのOAuth2.0はIndieAuthを元に作られており、他の一般的な実装とはすこし異なります。

この記事ではそんなMisskeyのOAuth2.0について仕様を読みながら書いていきます。

普通のOAuth2.0と違うこと

  • client_id が URL でなければならない
  • クライアントの情報は microformats で記述しなければならない

以上の2つが一般的なものとは大きく異なるようです。

認可サーバーのメタデータ取得

misskeyには /.well-known/oauth-authorization-server というエンドポイントが実装されています。

これは、oauth2.0のエンドポイントや、スコープなどについての情報が得られるようです。

仕様はRFC8414で定義されているらしいです。

また、レスポンスは以下の形式で返されるようです。

{
  /**
   * 認可サーバーのURL
   */
  issuer: string;

  /**
   * 認可エンドポイント
   */
  authorization_endpoint: string;

  /**
   * トークンエンドポイント
   */
  token_endpoint: string;

  /**
   * サポートされてるスコープの一覧
   */
  scopes_supported: string[];

  /**
   * サポートされてる認可リクエストのレスポンスタイプの一覧
   *
   * 現時点では `code` のみ
   */
  response_types: string[];

  /**
   * サポートされてるトークンのgrantタイプの一覧
   *
   * 現時点では `authorization_code` のみ
   */
  grant_types_supported: string[];

  /**
   * ドキュメントへのリンク
   */
  service_documentation?: string;

  /**
   * PKCEのサポートされてる形式の一覧
   *
   * 現時点では `S256` のみ
   */
  code_challenge_methods_supported?: string[];
}

クライアント

登録の必要はなく、代わりにクライアントのwebページを作成する必要があります。

そのwebページはクライアントのIDとしても使用されます。

クライアントの情報はmicroformatsの仕様に則って記述する必要があり、最小限の例は以下のようになります。

<link rel="redirect_uri" href="./redirect" />
<div class="h-app">
  <a href="./" class="u-url p-name">
    クライアント名
  </a>
</div>

で、これどうやって登録するの??

その必要はありません!!!

しれっとクライアントのIDとしてこのページヘのURLを使いましょう。

PKCEについて・・・お話します・・・

「PKCE」っていうのはね・・・

たとえば、認可コードを横取りされると・・・

気持ちが、良くないとか・・・

そういったことの対策として・・・「PKCE」というものがあるんだ・・・

いつ役立つのか

  • URI経由で認可レスポンスを受け取りたい場合(my-app://みたいな)
  • クライアントがコンフィデンシャルでない場合

などに役立ちます。

また、スマホに限った話ではなく、WindowsやLinuxでもURI経由でソフトを呼び出せるためネイティブアプリなどで気をつける必要があるようです。

横取りの方法

認可レスポンスがサーバーから返される時に、不正なアプリケーションがredirect_uriで使用されているスキームに登録されていた場合に起こります。

当然、不正なアプリケーションに認可コードが含まれる認可レスポンスが渡ってしまうので横取りサれてしまうのです。

PKCEピクシー3分クッキング

misskeyではS256のみ対応してるのでS256の場合で説明します。

PKCEピクシー3分クッキング ~

👩👵「「みなさんこんにちわ」」

👩「今回は、PKCEのcode_verifiercode_challengeを生成していきます。」

👩「まず、code_verifier用のランダムな文字列を生成します。」

👵「これは毎度生成したほうが良いですね。」

👩「これをsha-256へハッシュドポテトにしてbase64uriの形式にエンコードしていきます。」

👵「だいたい、500Wで30秒くらいが目安ですね。」

\ チーン /

👩「この出来上がったものがcode_challengeで使うものになります。」

👩「これらは認可サーバーとクライアントのみが知る秘密の文字列なので、悪意あるアプリケーションは認可コードを横取りをできたとしてもcode_verifierを検証する際に弾かれると言うことですね。」

👵「それではごきげんよう。」

PKCEで認可リクエスト

認可リクエスト時にはcode_challengeと、そのcode_challengeがどのようにして生成されたのか認可サーバーに知らせるためのcode_challenge_methodをパラメーターと一緒に渡します。

今回の例ではS256の場合として説明しているのでcode_challenge_methodの内容はS256としてください。

次に、認可コードをトークンに替えてもらう時の工程を説明します。

PKCEでトークンリクエスト

code_challengeを検証するためcode_verifierをトークンリクエストに含んで、認可サーバーへ送信します。

ここでようやく、PKCEの効果が発揮されます。

悪意あるアプリケーションはcode_verifierがなんなのかわからず弾かれるという感じになります。

認可リクエスト

GET <authorization_endpoint>?<params>

認可リクエストエンドポイントは以下のクエリパラメーターを受け付けてるようです。 また、リクエストメソッドはGETを受け付けています。

アクセスするべき場所はメタデータのauthorization_endpointに書いてあるようです。

名前 説明 任意
response_type codeのみ いいえ
client_id クライアントのURL いいえ
redirect_uri リダイレクトURI いいえ
state CSRF攻撃を防ぐためのパラメーター はい
code_challenge PKCEコードチャレンジ いいえ
code_challenge_method PKCEコードチャレンジの形式 いいえ
scope 要求するスコープ いいえ

PKCEについては任意ではなく必ず使用しないといけないっぽいです。

認可レスポンスについては通常のOAuth2.0と違いはなさそうです。

成功レスポンス

レスポンスは認可リクエスト後、ユーザーがredirect_uriへリダイレクトされる形で返されます。

内容はクエリパラメータを介して渡されます。

名前 説明
code 生成された認可コード
state リクエスト時に渡された state と同一
iss 認可コードの発行者

認可コードは、トークンリクエストでトークンへ替える必要があります。

失敗レスポンス

名前 説明
error エラーコード
state リクエスト時に渡された state と同一

error に渡されるエラーコードについて(一部)

  • invalid_request

    リクエストに必須パラメーターなどが含まれてなかったりする場合などに返されます。

    HTTPの400 Bad Requestのようなものです。

  • unsupported_response_type

    認可サーバーがサポートしていないresponse_typeを選択したときなどに返されます。

  • invalid_scope

    スコープが変な時に返されます。

    これが返されたときには、スコープの名称に誤字脱字がないかチェックしてみましょう。

  • server_error

    HTTPの500 Internal Server Errorのようなもので、認可サーバー側でなんらかのエラーが起きた場合などに返されます。

他にもいろいろあるので、暇なときなどに仕様を読んでみましょう。

https://openid-foundation-japan.github.io/rfc6749.ja.html#rfc.section.4.1.2.1

トークンリクエスト

POST <token_endpoint>
Content-Type: application/x-www-form-urlencoded

アクセスするべき場所はメタデータのtoken_endpointに書いてあるようです。

トークンリクエストエンドポイントは以下のパラメーターを受け付けているようです。

また、リクエストメソッドはPOSTで受け付けており、パラメーターはapplication/x-www-form-urlencodedとして送信する必要があります。

名前 説明
grant_type authorization_codeのみ
code 成功レスポンスで受け取った認可コード
client_id クライアントのID、ここで用意したページのURLを渡してあげましょう
redirect_uri 認可リクエストで使用したredirect_uriと同一である必要がある
code_verifier 認可リクエストで使用したcode_challengeと対になるcode_verifier

成功レスポンス

以下の型に当てはまるJSONが返ってくるようです。

{
  // アクセストークン
  access_token: string;
  // スコープ
  scope: string;
  // 有効期限
  expires_in: number;
  // リフレッシュトークン
  refresh_token?: string;
}

失敗レスポンス

認可リクエストのものと同様です。

実際にリクエストを送信してみる

コードの全体

* JSランタイムにはdenoを使用する

base64uriのエンコーダーを適当に作る

import { nanoid } from "https://esm.sh/nanoid/";
import { encodeBase64 } from "https://deno.land/std/encoding/base64.ts";

const base64uri = (data: ArrayBuffer) =>
  encodeBase64(data)
    .replaceAll("+", "-")
    .replaceAll("/", "_")
    .replaceAll("=", "");

client_idなどを設定

const clientId = "https://ikasoba.github.io/misskey-oauth2-client-example";
const redirectUri =
  "https://ikasoba.github.io/misskey-oauth2-client-example/redirect";

code_challenge生成

const codeChallengeMethod = "S256";
const codeVerifier = nanoid();
const codeChallenge = base64uri(
  await crypto.subtle.digest("sha-256", new TextEncoder().encode(codeVerifier))
);

好きなインスタンスを設定して、メタデータを取る

const host = new URL("https://misskey.systems");

const meta = await fetch(
  new URL("/.well-known/oauth-authorization-server", host)
).then((res) => res.json());

認可リクエスト用のリンクを作る

const authRequest = new URL(meta.authorization_endpoint, host);
authRequest.searchParams.set("response_type", "code");
authRequest.searchParams.set("client_id", clientId);
authRequest.searchParams.set("redirect_uri", redirectUri);
authRequest.searchParams.set("code_challenge", codeChallenge);
authRequest.searchParams.set("code_challenge_method", codeChallengeMethod);
authRequest.searchParams.set("scope", "write:notes");

ブラウザでやってみる

console.log("ブラウザでリクエスト", authRequest.toString());

取得したcodeを標準入力から取る

const code = prompt("認可コードを入力")!;

トークンをリクエスト

const tokenResponse = await fetch(new URL(meta.token_endpoint, host), {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
  },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code: code,
    client_id: clientId,
    redirect_uri: redirectUri,
    code_verifier: codeVerifier,
  }).toString(),
}).then((res) => res.json());

const token = tokenResponse.access_token;

投稿してみる

await fetch(new URL("/api/notes/create", host), {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    text: "OAuth2.0 テスト",
  }),
});

おわりに

こんな感じでmisskeyのoauth2.0認可について、indieoauthの仕様を調べながら紹介してみました。

以上、いかそばの記事でした。

12/14はゆんぽんさんの記事です。楽しみですね。