[{"data":1,"prerenderedAt":86},["ShallowReactive",2],{"article-5nf10o2ud0j":3,"prev-article-5nf10o2ud0j":27,"next-article-5nf10o2ud0j":68},{"contents":4,"totalCount":25,"offset":26,"limit":25},[5],{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":8,"revisedAt":8,"title":9,"content":10,"tags":11,"is_no_index":24},"5nf10o2ud0j","2026-04-02T14:00:30.562Z","2026-04-02T14:01:54.127Z","ねこちゃんをお迎えしました2","\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>以前から検討していたのですが、新しく2匹目のねこをお迎えすることにしました。\u003Cbr>今回お迎えしたのは、ノルウェージャンフォレストキャットの女の子(7ヶ月齢)です。名前は「ラテ」にしました。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/3204c913ca2a4c6b81fcf3660d7af26b/beauty_1774772499640.jpeg\" alt=\"\" width=\"1650\" height=\"928\">\u003C/figure>\u003Cp>一般的にはもっと幼い時期にお迎えするケースが多いかと思いますが、あえてこの月齢の子を選びました。\u003C/p>\u003Cp>7ヶ月ともなると、性格もかなり安定していますし、体調面での不安も少ないです。ノルウェージャンらしい立派な飾り毛や毛吹きも既に出始めており、成猫になった時の完成形がイメージしやすかったのも決め手の一つでした。\u003C/p>\u003Cp>先住猫のモカちゃんは、お迎え当日に添い寝をしてくれるほどの驚異的な適応能力を持っていました。対して今回のラテちゃんは、初日こそ少し慎重でしたが、数時間もすれば部屋の探索を始めるなど、なかなかの図太さを見せてくれています。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/bb54aa22e13446e79786cc85f973296d/beauty_1774942744631.jpeg\" alt=\"\" width=\"1650\" height=\"928\">\u003C/figure>\u003Cp>先住猫のモカちゃんは、ご縁があって譲っていただいた子だったので、実質的な生体価格というものは発生していませんでした。\u003Cbr>しかし、今回は順当な？ルートでお迎えしたため、総額で25万円近くかかりました。\u003C/p>\u003Cp>猫を購入する経験がなかった身からすると、この金額には正直かなりの衝撃を受けています。支払う瞬間に一瞬手が止まるくらいの重みはありましたが、まあ、お迎えしてしまったものは仕方がありません。\u003Cbr>これからこの2匹が仲良く並んで寝てくれる日を目標に、しっかり面倒を見ていこうと思います。\u003C/p>\u003Cfigure style=\"text-align: left;\">\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/106405f266634defae61fc31b18da359/beauty_1774881852261.jpeg\" alt=\"\" width=\"1650\" height=\"928\">\u003C/figure>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/a5212af8ab2548c6b4fd0abe6d7476d0/beauty_1774869951812.jpeg\" alt=\"\" width=\"3840\" height=\"2160\">\u003C/figure>",[12,18],{"id":13,"createdAt":14,"updatedAt":15,"publishedAt":14,"revisedAt":15,"slug":16,"name":17},"4oy2jc8mdg","2025-04-28T07:14:01.179Z","2025-12-03T16:08:24.495Z","diary","日記",{"id":19,"createdAt":20,"updatedAt":21,"publishedAt":20,"revisedAt":21,"slug":22,"name":23},"zvbbh2k4o","2025-07-09T17:50:30.110Z","2025-12-03T16:06:32.163Z","cat-life","ネコのいる暮らし",false,1,0,{"contents":28,"totalCount":67,"offset":26,"limit":25},[29],{"id":30,"createdAt":31,"updatedAt":32,"publishedAt":32,"revisedAt":32,"title":33,"content":34,"tags":35,"is_no_index":24,"summary":66},"u6nouw78z6k","2026-03-27T09:42:30.473Z","2026-03-27T10:05:58.634Z","Mewkの認証のおはなし","\u003Ch1 style=\"text-align: start\" id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp style=\"text-align: start\">どうも、わたしです。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://mq1.dev/entry/ql0n4uqo0fo\">前回の記事\u003C/a>では、Mewkのアーキテクチャやインフラ構成、OGP画像生成、モデレーションまわりの話を書きました。「まあ自分の記録として残しておければいいか」くらいの温度感で書いたもので、読んでくれる人がいるだけで御の字だと思っていましたが、思っていたより反応をもらえました。個人開発者が書く技術記事なんて、よほどのことがない限り誰にも読まれないのが常ですし、多くはなかったですが、それでも自分の想定を超えていたのは素直に嬉しかったです。\u003C/p>\u003Cp style=\"text-align: start\">その中で、認証まわりの話をもう少し詳しく聞きたいという声を何人かからいただきました。前回の記事でMiAuthトークンの暗号化やNuxt側へのロジック集約について軽く触れていたのが引っかかった方がいたようで、続きを書いてほしいというリクエストをもらいました。書くきっかけをもらえたので、今回はその認証基盤に絞って書くことにします。\u003C/p>\u003Cp style=\"text-align: start\">認証まわりは地味です。ユーザから見えるものではないし、うまく動いていても誰も褒めてくれません。しかし、サービスを真っ当に運営するためには、ある程度は考えなければいけない部分です。\u003C/p>\u003Cp style=\"text-align: start\">書いてみると、思いのほか細かい判断が積み重なっていて、自分でも整理になりました。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h6f17e1addd\">MiAuthトークンを直接扱いたくなかった\u003C/h1>\u003Cp style=\"text-align: start\">MewkはMiAuthでユーザを認証します。詳細は省きますが、MiAuthでは一連の認証フローを完了すると、Misskeyからアクセストークンを取得することができます。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://misskey-hub.net/ja/docs/for-developers/api/token/miauth/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://misskey-hub.net/ja/docs/for-developers/api/token/miauth/\u003C/a>\u003C/p>\u003Cp style=\"text-align: start\">ここで最初に決めたのが、このアクセストークンをクライアントに一切渡さないという方針です。\u003C/p>\u003Cp style=\"text-align: start\">Misskey向けのアプリケーションでよく見かける実装として、MiAuthで取得したアクセストークンをそのままSPAに持たせ、APIリクエストのたびにバックエンド側で検証するというパターンがあります。実装としては確かにシンプルです。しかしながら、この設計には問題があると私は考えています。\u003C/p>\u003Cp style=\"text-align: start\">Misskeyのアクセストークンを「サービス側が発行・管理しているもの」として扱っていない点です。サービス側でこのトークンを失効させる手段がなく、ユーザが自らMisskeyの設定画面から認可を取り消す以外に無効化できません。もし何らかの経緯でトークンが漏洩した場合、サービスとしては当然何もできません。サービス側でのRevoke手段を持たない認証設計は、失効対応が必要な場面で致命的になると考えられます。\u003C/p>\u003Cp style=\"text-align: start\">加えて、多くのユーザはアクセストークンが何であるかを理解していないという現実もあります。MewkがMiAuthで要求する権限スコープは\u003Ccode>read:account\u003C/code>, \u003Ccode>write:notes\u003C/code>, \u003Ccode>write:notifications\u003C/code>, \u003Ccode>read:drive\u003C/code>, \u003Ccode>write:drive\u003C/code>の5つで、これだけあればノートの投稿やドライブへのアクセスといった、Mewkが必要とする範囲の操作は全てできます。そして当然ながら、それ以外にもかなり色々なことができてしまいます。そういうトークンをクライアントに持たせ、ユーザに気づかれないまま扱う設計は、とても設計として筋が悪い。\u003C/p>\u003Cp style=\"text-align: start\">そこで、MiAuthフローが完了した時点でバックエンド側のみでトークンを受け取り、Mewkが独自に発行したJWTアクセストークンとリフレッシュトークンをクライアントに返すという構成にしました。Misskeyトークンはバックエンド内に閉じ込め、クライアントはMewkのJWTだけを使う。リフレッシュトークンをDBで管理することで、サービス側からいつでも全セッションを無効化できます。sidebaseのLocal実装をそのまま使いつつ、肝心な部分は全てバックエンド側で握る感じです。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/api/v1/auth/miauth/callback.post.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">\n// MiAuthフロー完了時点でMisskeyからトークンを受け取る\nconst response = await $fetch&lt;{ ok: boolean, token?: string, user?: MisskeyUser }&gt;(checkUrl, {\n  method: &apos;POST&apos;,\n});\n\n// Misskeyトークンは暗号化してDBに保存\nconst encryptedAccessToken = encryptMiAuthToken(response.token);\nconst user = await prisma.users.upsert({ ... });\n\n// Mewk独自のJWTとリフレッシュトークンを発行してクライアントへ\nconst jwt = await signJWT({ userId: user.id });\nconst refreshToken = await generateRefreshToken(user.id);\n\nsetSessionCookies(event, { accessToken: jwt, refreshToken });\n\nreturn { token: jwt, refreshToken, ... };\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">JWTはHS256アルゴリズム、有効期限1時間の短寿命トークンです。\u003Ccode>jose\u003C/code>ライブラリを使ってサーバ側で署名・検証しており、issuer/audienceのクレームも設定しています。\u003C/p>\u003Cp style=\"text-align: start\">勘の良い方ならここで一つ疑問が生まれるかもしれません。\u003C/p>\u003Cp style=\"text-align: start\">JWTはステートレスであるという前提なのに、「サービス側からいつでも全セッションを無効化できる」と言えるのはなぜか、という話です。\u003C/p>\u003Cp style=\"text-align: start\">基本的に、JWTの検証はDBを必要としません。署名が正しく、有効期限内であれば、それだけで有効なトークンとして扱われます。つまり、一度発行したJWTをサーバ側から即座に無効化する方法は、仕様上原則として存在しません。ブロックリストをDBやKVに持たせてJWT検証のたびにチェックするという実装も可能ですが、そうするとリクエストごとにストア参照が発生し、ステートレスであるJWT本来の旨味が半減してしまいます。\u003C/p>\u003Cp style=\"text-align: start\">Mewkでは、この問題をJWTの有効期限を短く保つことで許容しています。アクセストークンの有効期限は1時間です。ユーザのログアウトや全セッション無効化(モデレーションに基づく利用制限、Misskeyトークン失効検知など)の操作は、JWTではなくリフレッシュトークンをDB上でrevokeすることで実現します。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/refreshToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">// ログアウト\nexport async function revokeRefreshToken(token: string): Promise&lt;void&gt; {\n  const tokenHash = hashToken(token);\n  await prisma.refreshToken.updateMany({\n    where: { tokenHash, revokedAt: null },\n    data: { revokedAt: new Date(0) },\n  });\n}\n\n// 全セッション無効化\nexport async function revokeAllUserRefreshTokens(userId: string): Promise&lt;void&gt; {\n  await prisma.refreshToken.updateMany({\n    where: { userId, revokedAt: null },\n    data: { revokedAt: new Date(0) },\n  });\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">リフレッシュトークンを失効させれば、次のトークン更新のタイミングでセッションが復元できなくなり、実質的にログアウトが完了します。言い換えると、即時の無効化ではなく無操作時で最長1時間(実質10-30分程度)の猶予ウィンドウを持つ無効化という設計です。\u003C/p>\u003Cp style=\"text-align: start\">1時間の猶予はトレードオフの結果です。ブロックリスト方式にすれば即時無効化が可能ですが、前述の通り、全APIリクエストにDB/KVアクセスが加わります。Cloudflare Workers上でエッジのレイテンシを活かしたいという方針と、通常のユーザ体験において最長1時間の猶予が実害になるケースは(おそらく)ほぼないという判断から、今の設計に落ち着いています。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h8594e013d3\">httpOnly Cookie\u003C/h1>\u003Cp style=\"text-align: start\">当初はトークンの管理をhttpOnly属性付きのCookieで統一しようとしていました。ただ、これについては少し補足が必要です。\u003C/p>\u003Cp style=\"text-align: start\">httpOnly Cookieに対してよく言われる「JavaScriptから読めないのでXSSに強い」というのは、正確ではあるけれど文脈を省きすぎた主張だとわたしは思っています。XSSが成立した時点で、攻撃者は\u003Ccode>credentials: &apos;include&apos;\u003C/code>を付けたfetchリクエストを送るだけで、httpOnly Cookieをそのまま乗せた状態で同一オリジンに任意のAPIリクエストを投げられます。「JavaScriptからCookieの値が読めない」と「Cookieが悪用できない」は全く別の話で、前者が達成されていても後者は保証されません。XSSが実現した時点でできることはいくらでもありますし、少なくとも私は悪いことを思いついてしまいます。\u003C/p>\u003Cp style=\"text-align: start\">正しい理解は、セキュリティは多層防御の文脈に依存するものであり、httpOnly Cookieはその内の一層に過ぎないということです。localStorageにトークンを保管するよりはhttpOnly Cookieの方が攻撃面が狭い、という程度の話であって、httpOnly Cookieさえ使えば安全というわけではありません。XSSが刺さった時点で無意味になるのはどちらも同じで、根本的な対策はXSSを作り込まないことです。\u003C/p>\u003Cp style=\"text-align: start\">こういう誤解を招きやすい主張が広まっているせいで、httpOnly Cookieを使えば安全という誤った安心感を持つ開発者が少なくありません。まあ、それはさておき。\u003C/p>\u003Cp style=\"text-align: start\">いずれにせよ、全てのトークンをhttpOnly Cookieで管理するという方針はsidebaseのLocal provider実装との相性問題で断念することになりました。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://github.com/sidebase/nuxt-auth/blob/33873aa356abdd2d52ab1b6b60930fa09005ef72/src/runtime/composables/local/useAuthState.ts#L30-L114\">https://github.com/sidebase/nuxt-auth/blob/33873aa356abdd2d52ab1b6b60930fa09005ef72/src/runtime/composables/local/useAuthState.ts#L30-L114\u003C/a>\u003C/p>\u003Cp style=\"text-align: start\">sidebaseのLocal providerはアクセストークンとリフレッシュトークンをそれぞれ\u003Ccode>useCookie()\u003C/code>で読み書きしており、\u003Ccode>useAuth().refreshToken\u003C/code>のようにComposable経由でJavaScriptから値にアクセスできる設計になっています。httpOnly属性を付けてしまうと\u003Ccode>useCookie()\u003C/code>でCookieの値が取得できなくなるため、リフレッシュトークンのローテーションが機能しなくなってしまいます。\u003C/p>\u003Cp style=\"text-align: start\">微妙だとは思うのですが、ここでは許容することとしています。\u003C/p>\u003Cp style=\"text-align: start\">また、現在の構成では、サーバ側のCookie操作(\u003Ccode>setSessionCookies\u003C/code>)でsidebaseのコンフィグ(Cookie名・maxAge・secure属性等)をそのまま引き継ぎ、サーバとクライアントで同じCookie設定が使われることを保証しています。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/auth/sessionCookies.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">function getLocalProviderConfig(): LocalProviderConfig {\n  const provider = useRuntimeConfig().public.auth.provider as LocalProviderConfig;\n  if (provider.type !== &apos;local&apos;) throw new Error(&apos;Local auth provider is required&apos;);\n  return provider;\n}\n\nfunction buildSidebaseCookieOptions(config: LocalCookieConfig): CookieSerializeOptions {\n  return {\n    path: &apos;/&apos;,\n    maxAge: config.maxAgeInSeconds,\n    sameSite: config.sameSiteAttribute,\n    secure: config.secureCookieAttribute,\n    domain: normalizeDomain(config.cookieDomain),\n    httpOnly: config.httpOnlyCookieAttribute,\n  };\n}\n\nexport function setSessionCookies(event: H3Event, session: { accessToken: string; refreshToken?: string | null }): void {\n  const provider = getLocalProviderConfig();\n  setCookie(event, provider.token.cookieName, session.accessToken, buildSidebaseCookieOptions(provider.token));\n  // ...\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">Cookieの属性は一元管理しているため、バックエンド側でCookieを書く際も同じ設定が自動的に適用されます。httpOnly属性で全てを閉じる設計にはできませんでしたが、冒頭に書いた通りそれが全ての解決策にはならないことも事実で、最終的な妥協点としては許容できる範囲であると考えます。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"ha9d0cf4895\">MiAuthトークン\u003C/h1>\u003Cp style=\"text-align: start\">前回の記事でも少しだけ言及しましたが、MisskeyのアクセストークンをそのままDBに平文で保存するのは論外です。万が一DBの内容が流出した場合、全ユーザのMisskeyアカウントに対して任意の操作が可能になってしまいます。\u003C/p>\u003Cp style=\"text-align: start\">そこで、MiAuthで取得したトークンはAES-256-GCMで暗号化してDBに保存しています。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/bba3194de09d4fbba244eb6664d20264/image.png\" alt=\"\" width=\"953\" height=\"84\">\u003C/figure>\u003Cdiv data-filename=\"./packages/app/server/utils/miauthToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">const ALGORITHM = &apos;aes-256-gcm&apos;;\nconst IV_LENGTH = 12;\nconst TAG_LENGTH = 16;\nconst VERSION_PREFIX = &apos;mewk-miauth:v1&apos;;\n\nexport function encryptMiAuthToken(token: string): string {\n  const key = getEncryptionKey();\n  const iv = randomBytes(IV_LENGTH);\n  const cipher = createCipheriv(ALGORITHM, key, iv);\n  const encrypted = Buffer.concat([cipher.update(token, &apos;utf8&apos;), cipher.final()]);\n  const tag = cipher.getAuthTag();\n\n  return [VERSION_PREFIX, toBase64Url(iv), toBase64Url(tag), toBase64Url(encrypted)].join(&apos;:&apos;);\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">暗号化されたトークンは\u003Ccode>mewk-miauth:v1:&lt;iv&gt;:&lt;tag&gt;:&lt;ciphertext&gt;\u003C/code>という形式で保存されます。GCMモードを採用しているのは認証付き暗号(AEAD)であるためで、改ざん検知が組み込まれています。ivはリクエストごとに\u003Ccode>randomBytes\u003C/code>で生成するため、同じトークンを暗号化しても毎回異なる暗号文になります。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ccode>mewk-miauth:v1\u003C/code>というPrefixを付けているのは後方互換性のためです。将来的にアルゴリズムや鍵長を変える必要が生じた場合、Prefixのバージョンで判別して適切な復号ロジックに分岐させることを想定しています。AES-256-GCMのまま運用し続けても問題はないですが、暗号プリミティブも長い目で見れば更新が必要になるでしょうし、バージョンを明示する習慣をつけておくだけで、後の改修がだいぶ楽になるはずです。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/miauthToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export function isEncryptedMiAuthToken(value: string): boolean {\n  return value.startsWith(`${VERSION_PREFIX}:`);\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">復号は\u003Ccode>resolveStoredMiAuthToken()\u003C/code>という関数にまとめており、DBから取得したトークンが暗号化済みかどうかをPrefixで判別してから復号します。暗号化済みでない場合はその旨を記録して処理を継続します。将来的にバージョンを上げる際も、このPrefix判定を拡張するだけで移行ロジックを組めるはずです。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h84ff7cb54a\">リフレッシュの重複\u003C/h1>\u003Cp style=\"text-align: start\">アクセストークンの有効期限は1時間です。これを自動で延長するために、sidebaseのセッションリフレッシュ機能を使っています。\u003C/p>\u003Cp style=\"text-align: start\">ここで地味に厄介なのが、複数タブで同時にリフレッシュが走る可能性です。\u003C/p>\u003Cp style=\"text-align: start\">ユーザが同じアカウントで複数タブを開いている状態でリロードしたり、アクセストークンが期限切れになると、各タブが独立してリフレッシュリクエストを飛ばします。リフレッシュのたびにリフレッシュトークンをローテーションしているため、最初のリクエストが成功して旧トークンが無効化された直後に、別タブの遅延リクエストが同じ旧トークンで来ると弾かれてしまい、該当するタブに引き摺られるようにログアウトさせられることになります。\u003C/p>\u003Cp style=\"text-align: start\">この問題に対して、二段構えで対応しています。\u003C/p>\u003Ch2 style=\"text-align: start\" id=\"h3336b53f2d\">クライアント\u003C/h2>\u003Cdiv data-filename=\"./packages/app/app/utils/deduplicatedAuthRefresh.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">let inflightRefresh: Promise&lt;unknown&gt; | null = null;\n\nexport function runDeduplicatedAuthRefresh(refresh: () =&gt; Promise&lt;unknown&gt;): Promise&lt;unknown&gt; {\n  if (inflightRefresh) return inflightRefresh;\n\n  // 同時に走るrefreshを1本にまとめる\n  inflightRefresh = refresh().finally(() =&gt; {\n    inflightRefresh = null;\n  });\n\n  return inflightRefresh;\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">モジュールスコープの変数でin-flightなPromiseを保持し、すでにリフレッシュが走っているなら同じPromiseを返します。同じタブ内で複数のリフレッシュトリガーが走っても(ウィンドウフォーカス復帰・定期実行・ミドルウェア等)、リクエストは1本しか飛びません。\u003C/p>\u003Cp style=\"text-align: start\">これをsidebaseのカスタムリフレッシュハンドラとして差し込んでいます。\u003C/p>\u003Cdiv data-filename=\"./packages/app/app/utils/DeduplicatedRefreshHandler.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">class DeduplicatedRefreshHandler implements RefreshHandler {\n  init(): void {\n    this.auth = useAuth();\n    document.addEventListener(&apos;visibilitychange&apos;, this.boundVisibilityHandler, false);\n\n    // ページロード時にリフレッシュトークンがあればセッション復元\n    if (this.auth.refreshToken.value &amp;&amp; !this.auth.data.value) {\n      runDeduplicatedAuthRefresh(this.auth.refresh);\n    }\n\n    // 55分ごとに定期リフレッシュ(アクセストークン期限1時間の直前)\n    this.refetchIntervalTimer = setInterval(() =&gt; {\n      const auth = this.getRefreshableAuth();\n      if (auth) runDeduplicatedAuthRefresh(auth.refresh);\n    }, intervalTime);\n  }\n\n  visibilityHandler(): void {\n    // タブが前面に戻ってきた時もリフレッシュを試みる\n    if (document.visibilityState !== &apos;visible&apos;) return;\n    const auth = this.getRefreshableAuth();\n    if (auth) runDeduplicatedAuthRefresh(auth.refresh);\n  }\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">\u003Ccode>visibilitychange\u003C/code>イベントを購読しているのは、ブラウザのサスペンドや別タブへの切替から復帰した際にセッションが失効している可能性が想定されるためです。ブラウザ/PWAを長時間バックグラウンドに置いていた場合などでも、タブに戻ってきた瞬間にリフレッシュが走ります。\u003C/p>\u003Ch2 style=\"text-align: start\" id=\"ha2cc149486\">バックエンド\u003C/h2>\u003Cp style=\"text-align: start\">クライアント側の重複排除だけでは不十分で、異なるタブ(=別のモジュールスコープ)からのリクエストは防げません。そこでサーバ側でも対策しています。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/refreshToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export async function verifyRefreshToken(token: string): Promise&lt;{ userId: string } | null&gt; {\n  const tokenHash = hashToken(token);\n  const refreshToken = await prisma.refreshToken.findUnique({ where: { tokenHash } });\n\n  if (!refreshToken) return null;\n\n  // 無効化済みトークンは拒否\n  if (refreshToken.revokedAt) {\n    const GRACE_PERIOD_MS = 30_000;\n    if (Date.now() - refreshToken.revokedAt.getTime() &gt; GRACE_PERIOD_MS) {\n      return null;\n    }\n  }\n\n  if (refreshToken.expiresAt &lt; new Date()) return null;\n\n  return { userId: refreshToken.userId };\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">リフレッシュトークンを無効化(\u003Ccode>revokedAt\u003C/code>を記録)してから30秒以内であれば、同じトークンでのリクエストを許容します。これにより、複数タブが同じ旧トークンでほぼ同時にリフレッシュを要求してきた場合でも、全てのタブが新しいアクセストークンを受け取れます。\u003C/p>\u003Cp style=\"text-align: start\">ただし、意図的なセッション失効(ログアウトや不正なトークン使用への対応)ではグレースピリオドをバイパスする必要があります。その場合は\u003Ccode>revokedAt\u003C/code>に\u003Ccode>new Date(0)\u003C/code>を設定することで、前述の30秒を許容する条件を満たせないようにしています。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/refreshToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">// ログアウト等の強制無効化\nawait prisma.refreshToken.updateMany({\n  where: { tokenHash, revokedAt: null },\n  data: { revokedAt: new Date(0) },\n});\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">また、リフレッシュ処理の実装では新トークンの発行を先に行い、旧トークンの無効化を後に行っています。順序が逆だとDB障害のタイミングによっては旧トークンが無効化されたが新トークンが発行されていない状態になり、ユーザが強制ログアウトされる可能性が考えられます。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/refreshToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export async function rotateRefreshToken(oldToken: string, userId: string): Promise&lt;string&gt; {\n  // 新トークンを先に発行\n  // NOTE: DB障害でログアウトされないよう順序を保証\n  const newToken = await generateRefreshToken(userId);\n\n  // 旧トークンを無効化\n  await prisma.refreshToken.updateMany({\n    where: { tokenHash: oldTokenHash, revokedAt: null },\n    data: { revokedAt: new Date() },\n  });\n\n  return newToken;\n}\u003C/code>\u003C/pre>\u003C/div>\u003Ch1 style=\"text-align: start\" id=\"h2f06bf172a\">\u003Cstrong>middlewareで割り込みトークン更新\u003C/strong>\u003C/h1>\u003Cp style=\"text-align: start\">認証が必要なページへのアクセス時に、トークンの状態に応じてリフレッシュや認証ページへのリダイレクトを行うのがNuxtのルートミドルウェアです。\u003C/p>\u003Cdiv data-filename=\"./packages/app/app/middleware/auth.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export default defineNuxtRouteMiddleware(async (to) =&gt; {\n  if (import.meta.server) {\n    // SSR時はセッションAPIを直接叩いて検証\n    const { data, error } = await useFetch(&apos;/api/v1/auth/session&apos;);\n    if (error.value || !data.value?.user) {\n      return redirectToSignIn();\n    }\n    return;\n  }\n\n  const { status } = useAuth();\n  const { waitForAuthReady, restoreSessionWithRetry, withSessionRestoreOverlay } = useDeduplicatedRefresh();\n\n  if (status.value === &apos;authenticated&apos;) return;\n\n  return withSessionRestoreOverlay(async () =&gt; {\n    await waitForAuthReady();\n    if (status.value === &apos;authenticated&apos;) return;\n\n    // 明示的ログアウト後はリフレッシュをスキップ\n    if (sessionStorage.getItem(&apos;mewk:explicit-logout&apos;)) {\n      return redirectToSignIn();\n    }\n\n    const restored = await restoreSessionWithRetry();\n    if (restored) return;\n\n    return redirectToSignIn();\n  });\n});\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">いくつか細かい工夫があります。\u003C/p>\u003Cp style=\"text-align: start\">まず\u003Ccode>waitForAuthReady()\u003C/code>で、sidebaseの初期ロード中(\u003Ccode>status === &apos;loading&apos;\u003C/code>)が完了するのを最大5秒待ちます。ページロード直後はsidebaseがセッションを確認している途中であることが多く、\u003Ccode>loading\u003C/code>状態を無視してリダイレクトしてしまうとちらつきが発生します。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ccode>waitForAuthReady()\u003C/code>が完了しても\u003Ccode>authenticated\u003C/code>でなかった場合、リフレッシュを試みます。ここでも\u003Ccode>restoreSessionWithRetry()\u003C/code>を挟んでおり、最大3回, 500ms間隔でリトライします。モバイル環境等のネットワークが不安定な場合など、一発でリフレッシュが成功しないことがあるためです。\u003C/p>\u003Cdiv data-filename=\"./packages/app/app/composables/useDeduplicatedRefresh.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">async function restoreSessionWithRetry(options: RestoreSessionOptions = {}): Promise&lt;boolean&gt; {\n  const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; // 3\n\n  for (let attempt = 0; attempt &lt;= maxRetries; attempt++) {\n    try {\n      await deduplicatedRefresh();\n    } catch { }\n\n    if (status.value !== &apos;authenticated&apos;) {\n      await Promise.race([\n        until(status).toBe(&apos;authenticated&apos;),\n        sleep(authenticatedWaitMs), // 最大1秒待機\n      ]);\n    }\n\n    if (status.value === &apos;authenticated&apos;) return true;\n    if (attempt &lt; maxRetries) await sleep(retryDelayMs);\n  }\n\n  return false;\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">\u003Ccode>withSessionRestoreOverlay()\u003C/code>はUIのちらつき防止のためのラッパーで、セッション復元中はオーバーレイカウントをインクリメントしてローディング状態を表現しています。\u003C/p>\u003Cp style=\"text-align: start\">なお、\u003Ccode>sessionStorage.getItem(&apos;mewk:explicit-logout&apos;)\u003C/code>のチェックは、ユーザが自分でログアウトした後にリフレッシュトークンが残っていても再ログインしてしまう問題を防ぐためです。明示的なログアウト操作時にはsessionStorageにフラグを立て、ミドルウェアがこれを検知した場合はリフレッシュをスキップして\u003Ccode>/auth/signin\u003C/code>にリダイレクトします。ちなみに、sessionStorageはタブを閉じると消えるため、以降のセッションでは正常にリフレッシュが走ります。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h6181bfeeb6\">SSR時のセッション処理\u003C/h1>\u003Cp style=\"text-align: start\">SSRが絡むと少し複雑になります。サーバサイドレンダリング時には\u003Ccode>useAuth()\u003C/code>が使えないため、セッションAPIを直接叩いてセッションを確認します。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ccode>/api/v1/auth/session\u003C/code>のエンドポイントでは、アクセストークンがCookieに存在する場合はそれで認証し、存在しない場合はリフレッシュトークンで1回だけセッション復元を試みます。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/api/v1/auth/session.get.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">let payload: { userId: string };\n\ntry {\n  payload = await requireAuthWithoutBanCheck(event);\n} catch (error) {\n  // アクセストークンが無ければリフレッシュトークンで復元\n  const refreshToken = getRefreshTokenFromCookies(event);\n  if (!refreshToken) throw error;\n\n  const restoredSession = await restoreSessionFromRefreshToken(event);\n  payload = { userId: restoredSession.userId };\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">\u003Ccode>restoreSessionFromRefreshToken()\u003C/code>はセッション復元と同時に新しいアクセストークンとリフレッシュトークンを発行し、Cookieをセットします。つまりSSR時にもトークンのローテーションが透過的に行われるため、ユーザは意識することなく認証状態が維持されます。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hbdedd2bd61\">Misskeyトークン失効の検知\u003C/h1>\u003Cp style=\"text-align: start\">Misskeyのアクセストークンは、ユーザが連携アプリの認可を取り消したり、アカウントが凍結された場合に無効になります。この場合、ユーザには継続して有効なMewkの発行するJWTが手元にありますが、バックエンドが実際にMisskey APIを叩こうとすると失敗します。\u003C/p>\u003Cp style=\"text-align: start\">この不整合を検知するため、セッション確認のたびにMisskeyの\u003Ccode>/api/i\u003C/code>へのアクセス確認を行っています。ただし毎回叩くとレイテンシが悪化する可能性があるため、KVを使って5分間キャッシュしています。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/auth.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export async function checkMisskeyTokenValidity(\n  event: H3Event,\n  userId: string,\n  domain: string,\n  accessToken: string,\n  options: MisskeyTokenCheckOptions,\n): Promise&lt;void&gt; {\n  const kv = getKV(event);\n  const cacheKey = `misskey-valid:${userId}`;\n  const cached = await kv.get(cacheKey);\n  if (cached) return;\n\n  // タイムアウト付きでMisskeyに問い合わせ\n  const result = await Promise.race([fetchPromise, timeoutPromise]);\n\n  if (status === 401 || status === 403) {\n    // トークン無効 全セッションを強制破棄\n    await revokeAllUserRefreshTokens(userId);\n    throw createError({ statusCode: 401, statusMessage: &apos;MISSKEY_SESSION_EXPIRED&apos;, ... });\n  } else if (status &gt;= 200 &amp;&amp; status &lt; 300) {\n    await kv.put(cacheKey, &apos;1&apos;, { expirationTtl: 300 });\n  }\n  // 5xx\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">タイムアウトは1500msに設定していて、Misskeyのレスポンスが遅い場合は検証をスキップします。不整合が発生したからといって、外部サービスの応答待ちでユーザのリクエストを阻害するのは設計として微妙すぎるためです。\u003C/p>\u003Cp style=\"text-align: start\">Misskeyがダウンしているだけで全ユーザがセッションを失うような事態を避けるため、5xx系のレスポンスやタイムアウトは正常扱いしてスルーしています。明確に401/403のレスポンスが返った場合のみ、ユーザの全リフレッシュトークンを無効化し、ログアウト理由をフロントに伝えるために非httpOnlyのCookie(\u003Ccode>mewk_logout_reason\u003C/code>)を短命で立てています。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp style=\"text-align: start\">振り返ると、認証まわりで一番頭を使ったのはrefreshの競合問題です。「複数タブで同時に開いてたら」「ブラウザのサスペンドから復帰したら」「ネットワークが不安定だったら」といった微妙なエッジケースを一つひとつ潰していくのは、手を抜けない部分でした。\u003C/p>\u003Cp style=\"text-align: start\">セキュリティまわりの設計については、「ライブラリを使っているから大丈夫」という発想を極力持たないようにしました。sidebaseを使っていますが、MiAuthトークンをクライアントに渡さない・DB保存時に暗号化する・revoke手段をサービス側で持つ、といった判断はライブラリに任せられるものではありません。ライブラリはあくまで実装の補助であって、設計上の責任まで肩代わりしてくれるわけではありません。ここを混同すると、ライブラリのデフォルト挙動に乗っかったまま後から気づけない穴を作ることになります。\u003C/p>\u003Cp style=\"text-align: start\">書いていて改めて思いましたが、認証の設計というのはこうすれば完璧という答えがなく、トレードオフの連続でした。JWTの即時revoke問題もhttpOnly Cookieの限界も、どこかで折り合いをつけなければいけない。大事なのは、そのトレードオフが何なのかを理解した上で判断することで、何も考えずにデフォルトに従うことではないと考えます。\u003C/p>\u003Cp style=\"text-align: start\">今後も機能追加や仕様変更の中で認証まわりはじわじわ変わっていくと思いますが、基本的な設計の方針は変えるつもりはありませんし、Misskeyのアクセストークンをバックエンドに閉じ込め管理するという構造は、使い続けていてもそれほど不便を感じていませんし、むしろ正解だったと思っています。\u003C/p>\u003Cp style=\"text-align: start\">それでは。\u003C/p>",[36,42,48,54,60],{"id":37,"createdAt":38,"updatedAt":39,"publishedAt":38,"revisedAt":39,"slug":40,"name":41},"vmhb23sq4","2025-07-29T12:56:07.884Z","2025-12-03T16:06:19.140Z","misskey","Misskey",{"id":43,"createdAt":44,"updatedAt":45,"publishedAt":44,"revisedAt":45,"slug":46,"name":47},"8q8qyy8sz3","2025-04-29T08:44:23.406Z","2025-12-03T16:07:24.495Z","programming","プログラミング",{"id":49,"createdAt":50,"updatedAt":51,"publishedAt":50,"revisedAt":51,"slug":52,"name":53},"qfey3yw0z1","2025-04-29T08:44:41.687Z","2025-12-03T16:07:14.622Z","technology","テクノロジー",{"id":55,"createdAt":56,"updatedAt":57,"publishedAt":56,"revisedAt":57,"slug":58,"name":59},"fwj_nwhj1-s","2025-04-30T04:55:28.953Z","2025-12-03T16:07:03.591Z","server","サーバー",{"id":61,"createdAt":62,"updatedAt":63,"publishedAt":62,"revisedAt":63,"slug":64,"name":65},"lte0t59xm8sb","2025-05-02T16:12:38.708Z","2025-12-03T16:06:52.753Z","network","ネットワーク","はじめにどうも、わたしです。前回の記事では、Mewkのアーキテクチャやインフラ構成、OGP画像生成、モデレーションまわりの話を書きました。「まあ自分の記録として残しておければいいか」くらいの温度感で書いたもので、読んでくれる人がいるだけで御の字だと思っていましたが、思っていたより反応をもらえました。",58,{"contents":69,"totalCount":85,"offset":26,"limit":25},[70],{"id":71,"createdAt":72,"updatedAt":73,"publishedAt":73,"revisedAt":73,"title":74,"content":75,"tags":76,"is_no_index":24,"summary":84},"jucf8gnoagb4","2026-04-09T04:58:35.980Z","2026-04-09T05:42:14.678Z","LLMがどれだけ優れていても、つまらない人間はつまらない","\u003Cp>最近、タイムラインを眺めていると、「LLMを使いこなせるのは一部の賢い連中だ」のような言説をよく目にします。\u003Cbr>確かにそれは一面の真実なのでしょうが、実際のところそこに存在しているのは、中身が空っぽな人間ほど、LLMによってガワから見た能力が派手に底上げされるという、極めて残酷なまでの非対称性でしょう。世間では知性の民主化などという美辞麗句が踊っていますが、その実態は単なる能力の底上げなどではなく、論理的な思考回路を持たない人間が高度な知性を出力として模倣し、あたかも自分の実力であるかのように偽装できてしまうという、歪な構造的欠陥に他ならないと感じています。\u003C/p>\u003Cp>たとえば、専門知識も論理的訓練も受けていない人間が、洗練された論理構築が可能な大規模言語モデルを使用したとしましょう。その間に生じる知的な解像度の乖離は、もはや対話すら成立しない絶望的な断絶です。認知の深さが一定以上異なれば、前提とする論理のレイヤすら共有できないのが常ですが、その圧倒的な差がある知性をあたかも自分の手足のように操っていると錯覚した人間がどうなるかは、想像に難くありません。本人は、LLMが吐き出したその高度で緻密な内容を、論理的に咀嚼し、真に理解することすら土台不可能なのです。内容を検証する力がない以上、彼らにとってLLMの出力は疑う余地のない神託へと昇格し、それを引き出した自分までもが全知全能の存在にでもなったかのような致命的な自己肥大に陥るわけです。\u003C/p>\u003Cp>一方で、もともと高度な専門性を備えた人間が高度なLLMを使ったところで、そこにある能力の差はそれほど大きくはありません。彼らにとってLLMは24/365で文句ひとつ言わずに稼働し、要求に対して及第点の成果を出す便利な手下程度の認識に収まるでしょう。彼らは出力される情報の裏にある限界や、統計的なもっともらしさの脆さを理解しており、自分の知性と照らし合わせながらその境界線を慎重に引くことができます。\u003C/p>\u003Cp>しかし、思考の基盤を持たない層にとって、LLMの出力は検証不可能な神託そのものとして機能してしまいます。LLMの出力を論理的に検証するだけの批判的思考力も、背景にある膨大な知識体系も欠如しているため、出力された内容を本質的に理解することも、その正当性を疑うことも、ましてや学術的な反証を試みることもできません。結果として彼らは、自分が突然、森羅万象を司る全知全能の存在にでも昇華されたかのような、致命的な自己肥大に陥るわけです。\u003C/p>\u003Cp>これは将棋のルールすら怪しい初心者が、将棋ソフトの最善手と言っている提案をただ無批判に盤上へ再現し、それでプロに勝利して「自分の才能がようやく世界に追いついた」と本気で悦に入っているような、極めて滑稽で厚顔無恥な喜劇です。こうしたLLMによって底上げされた無能たちは、いまやLLMとの対話で得た真理(のようなもの)という名のゴミを誇らしげに掲げ、あらゆる専門分野へ土足で自信満々に侵入を開始しています。彼らの発言は驚くほど定型化されており、正直見ていて反吐が出ます。\u003C/p>\u003Cp>彼らの常套句はこうです。\u003Cbr>「俺はついに、世界を根底から覆す画期的な新理論を発見してしまった。AIがこれは100%本物だと言っている」だとか、「これは複数の最高峰モデルをn時間以上も激論させ、n万円分ものコストを費やしてようやく抽出に成功した究極原理だ」といった具合です。さらに性質の悪いことに、「この理論は常識に縛られた凡人には理解しにくいかもしれないが、AIはこの独創的かつ鋭い着眼点を称賛していた」などと宣い、あたかも自分だけがLLMという高次元の知性と精神的に共鳴できる、選ばれし預言者であるかのように振る舞うのです。\u003C/p>\u003Cp>彼らは「天才たちが最後まで言語化できずにいた核心を、自分だけがついに最も明晰な形で取り出した」と語りますが、その中身を解剖すれば、そこにあるのはLLMが確率論に基づいて繋ぎ合わせた、耳当たりの良い単語のパッチワークに過ぎません。ここ数年、こうしたLLM製のプロパガンダを武器に各所のコミュニティを荒らし回る事例が散見されますが、それは知性に対するこの上ない侮辱であり、文明的な対話の破壊活動に等しいものです。自分の脳内で再構築もできず、論理的な因果関係を自らの言葉で説明すらできないのであれば、その借り物の羽で他者を威圧する行為がどれほど恥べきことか、いい加減自覚すべきでしょう。\u003C/p>\u003Cp>これからのLLM全盛時代において、真に求められる能力とは、出力結果を鵜呑みにして全知全能感に浸ることではありません。分からないことを、分からないままの状態として脳内に保留できる力、自分の理解が及ばない領域を正確に定義するメタ認知そのものです。LLMがどれほど尤もらしい真理を提示したところで、それが自分の論理として肉体化されていないのであれば、それは単なる無意味な記号の羅列に過ぎません。\u003C/p>\u003Cp>結局のところ、彼らは自分たちが楽をすることしか考えておらず、その思考停止のツケを未来の誰かが払わされることを全く想像できていない想像力の欠如こそが、彼らが無能であることの最大の証明ではないでしょうか。全知全能(笑)に酔いしれるのは勝手ですが、その酔いが覚めたときに鏡の前に立っているのが、言葉の重みすら計ることのできない空虚な自分自身であるという事実に、彼らはいつ直面することになるのでしょうか。\u003C/p>\u003Cp>まあ、温室の中で空虚ささえも心地よい万能感として消費し続けるのが、彼らにとってのしあわせなのかもしれませんね。非常に気持ちが悪いので消えてほしいですが。\u003C/p>\u003Cp>それでは。\u003C/p>",[77,83],{"id":78,"createdAt":79,"updatedAt":80,"publishedAt":79,"revisedAt":80,"slug":81,"name":82},"wia4slp55q","2025-04-28T07:14:14.541Z","2025-12-03T16:08:13.798Z","thoughts","独り言",{"id":49,"createdAt":50,"updatedAt":51,"publishedAt":50,"revisedAt":51,"slug":52,"name":53},"最近、タイムラインを眺めていると、「LLMを使いこなせるのは一部の賢い連中だ」のような言説をよく目にします。確かにそれは一面の真実なのでしょうが、実際のところそこに存在しているのは、中身が空っぽな人間ほど、LLMによってガワから見た能力が派手に底上げされるという、極めて残酷なまでの非対称性でしょう。",10,1783975274074]