# はじめに
どうも、わたしです。
前回の記事では、Mewkのアーキテクチャやインフラ構成、OGP画像生成、モデレーションまわりの話を書きました。「まあ自分の記録として残しておければいいか」くらいの温度感で書いたもので、読んでくれる人がいるだけで御の字だと思っていましたが、思っていたより反応をもらえました。個人開発者が書く技術記事なんて、よほどのことがない限り誰にも読まれないのが常ですし、多くはなかったですが、それでも自分の想定を超えていたのは素直に嬉しかったです。
その中で、認証まわりの話をもう少し詳しく聞きたいという声を何人かからいただきました。前回の記事でMiAuthトークンの暗号化やNuxt側へのロジック集約について軽く触れていたのが引っかかった方がいたようで、続きを書いてほしいというリクエストをもらいました。書くきっかけをもらえたので、今回はその認証基盤に絞って書くことにします。
認証まわりは地味です。ユーザから見えるものではないし、うまく動いていても誰も褒めてくれません。しかし、サービスを真っ当に運営するためには、ある程度は考えなければいけない部分です。
書いてみると、思いのほか細かい判断が積み重なっていて、自分でも整理になりました。
# MiAuthトークンを直接扱いたくなかった
MewkはMiAuthでユーザを認証します。詳細は省きますが、MiAuthでは一連の認証フローを完了すると、Misskeyからアクセストークンを取得することができます。
ここで最初に決めたのが、このアクセストークンをクライアントに一切渡さないという方針です。
Misskey向けのアプリケーションでよく見かける実装として、MiAuthで取得したアクセストークンをそのままSPAに持たせ、APIリクエストのたびにバックエンド側で検証するというパターンがあります。実装としては確かにシンプルです。しかしながら、この設計には問題があると私は考えています。
Misskeyのアクセストークンを「サービス側が発行・管理しているもの」として扱っていない点です。サービス側でこのトークンを失効させる手段がなく、ユーザが自らMisskeyの設定画面から認可を取り消す以外に無効化できません。もし何らかの経緯でトークンが漏洩した場合、サービスとしては当然何もできません。サービス側でのRevoke手段を持たない認証設計は、失効対応が必要な場面で致命的になると考えられます。
加えて、多くのユーザはアクセストークンが何であるかを理解していないという現実もあります。MewkがMiAuthで要求する権限スコープはread:account, write:notes, write:notifications, read:drive, write:driveの5つで、これだけあればノートの投稿やドライブへのアクセスといった、Mewkが必要とする範囲の操作は全てできます。そして当然ながら、それ以外にもかなり色々なことができてしまいます。そういうトークンをクライアントに持たせ、ユーザに気づかれないまま扱う設計は、とても設計として筋が悪い。
そこで、MiAuthフローが完了した時点でバックエンド側のみでトークンを受け取り、Mewkが独自に発行したJWTアクセストークンとリフレッシュトークンをクライアントに返すという構成にしました。Misskeyトークンはバックエンド内に閉じ込め、クライアントはMewkのJWTだけを使う。リフレッシュトークンをDBで管理することで、サービス側からいつでも全セッションを無効化できます。sidebaseのLocal実装をそのまま使いつつ、肝心な部分は全てバックエンド側で握る感じです。
// MiAuthフロー完了時点でMisskeyからトークンを受け取る
const response = await $fetch<{ ok: boolean, token?: string, user?: MisskeyUser }>(checkUrl, {
method: 'POST',
});
// Misskeyトークンは暗号化してDBに保存
const encryptedAccessToken = encryptMiAuthToken(response.token);
const user = await prisma.users.upsert({ ... });
// Mewk独自のJWTとリフレッシュトークンを発行してクライアントへ
const jwt = await signJWT({ userId: user.id });
const refreshToken = await generateRefreshToken(user.id);
setSessionCookies(event, { accessToken: jwt, refreshToken });
return { token: jwt, refreshToken, ... };JWTはHS256アルゴリズム、有効期限1時間の短寿命トークンです。joseライブラリを使ってサーバ側で署名・検証しており、issuer/audienceのクレームも設定しています。
勘の良い方ならここで一つ疑問が生まれるかもしれません。
JWTはステートレスであるという前提なのに、「サービス側からいつでも全セッションを無効化できる」と言えるのはなぜか、という話です。
基本的に、JWTの検証はDBを必要としません。署名が正しく、有効期限内であれば、それだけで有効なトークンとして扱われます。つまり、一度発行したJWTをサーバ側から即座に無効化する方法は、仕様上原則として存在しません。ブロックリストをDBやKVに持たせてJWT検証のたびにチェックするという実装も可能ですが、そうするとリクエストごとにストア参照が発生し、ステートレスであるJWT本来の旨味が半減してしまいます。
Mewkでは、この問題をJWTの有効期限を短く保つことで許容しています。アクセストークンの有効期限は1時間です。ユーザのログアウトや全セッション無効化(モデレーションに基づく利用制限、Misskeyトークン失効検知など)の操作は、JWTではなくリフレッシュトークンをDB上でrevokeすることで実現します。
// ログアウト
export async function revokeRefreshToken(token: string): Promise<void> {
const tokenHash = hashToken(token);
await prisma.refreshToken.updateMany({
where: { tokenHash, revokedAt: null },
data: { revokedAt: new Date(0) },
});
}
// 全セッション無効化
export async function revokeAllUserRefreshTokens(userId: string): Promise<void> {
await prisma.refreshToken.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date(0) },
});
}リフレッシュトークンを失効させれば、次のトークン更新のタイミングでセッションが復元できなくなり、実質的にログアウトが完了します。言い換えると、即時の無効化ではなく無操作時で最長1時間(実質10-30分程度)の猶予ウィンドウを持つ無効化という設計です。
1時間の猶予はトレードオフの結果です。ブロックリスト方式にすれば即時無効化が可能ですが、前述の通り、全APIリクエストにDB/KVアクセスが加わります。Cloudflare Workers上でエッジのレイテンシを活かしたいという方針と、通常のユーザ体験において最長1時間の猶予が実害になるケースは(おそらく)ほぼないという判断から、今の設計に落ち着いています。
# httpOnly Cookie
当初はトークンの管理をhttpOnly属性付きのCookieで統一しようとしていました。ただ、これについては少し補足が必要です。
httpOnly Cookieに対してよく言われる「JavaScriptから読めないのでXSSに強い」というのは、正確ではあるけれど文脈を省きすぎた主張だとわたしは思っています。XSSが成立した時点で、攻撃者はcredentials: 'include'を付けたfetchリクエストを送るだけで、httpOnly Cookieをそのまま乗せた状態で同一オリジンに任意のAPIリクエストを投げられます。「JavaScriptからCookieの値が読めない」と「Cookieが悪用できない」は全く別の話で、前者が達成されていても後者は保証されません。XSSが実現した時点でできることはいくらでもありますし、少なくとも私は悪いことを思いついてしまいます。
正しい理解は、セキュリティは多層防御の文脈に依存するものであり、httpOnly Cookieはその内の一層に過ぎないということです。localStorageにトークンを保管するよりはhttpOnly Cookieの方が攻撃面が狭い、という程度の話であって、httpOnly Cookieさえ使えば安全というわけではありません。XSSが刺さった時点で無意味になるのはどちらも同じで、根本的な対策はXSSを作り込まないことです。
こういう誤解を招きやすい主張が広まっているせいで、httpOnly Cookieを使えば安全という誤った安心感を持つ開発者が少なくありません。まあ、それはさておき。
いずれにせよ、全てのトークンをhttpOnly Cookieで管理するという方針はsidebaseのLocal provider実装との相性問題で断念することになりました。
export function useAuthState(): UseAuthStateReturn {
const config = useTypedBackendConfig(useRuntimeConfig(), 'local')
const commonAuthState = makeCommonAuthState<SessionData>()
const instance = getCurrentInstance()
// Re-construct state from cookie, also setup a cross-component sync via a useState hack, see https://github.com/nuxt/nuxt/issues/13020#issuecomment-1397282717
const _rawTokenCookie = useCookie<string | null>(config.token.cookieName, {
default: () => null,
domain: config.token.cookieDomain,
maxAge: config.token.maxAgeInSeconds,
sameSite: config.token.sameSiteAttribute,
secure: config.token.secureCookieAttribute,
httpOnly: config.token.httpOnlyCookieAttribute
})
const rawToken = useState('auth:raw-token', () => _rawTokenCookie.value)
watch(rawToken, () => {
_rawTokenCookie.value = rawToken.value
})
const token = computed(() => formatToken(rawToken.value, config))
function setToken(newToken: string | null) {
rawToken.value = newToken
}
function clearToken() {
setToken(null)
}
// When the page is cached on a server, set the token on the client
if (instance) {
onMounted(() => {
if (_rawTokenCookie.value && !rawToken.value) {
setToken(_rawTokenCookie.value)
}
})
}
// Handle refresh token, for when refresh logic is enabled
const rawRefreshToken = useState<string | null>('auth:raw-refresh-token', () => null)
if (config.refresh.isEnabled) {
const _rawRefreshTokenCookie = useCookie<string | null>(config.refresh.token.cookieName, {
default: () => null,
domain: config.refresh.token.cookieDomain,
maxAge: config.refresh.token.maxAgeInSeconds,
sameSite: config.refresh.token.sameSiteAttribute,
secure: config.refresh.token.secureCookieAttribute,
httpOnly: config.refresh.token.httpOnlyCookieAttribute
})
// Set default value if `useState` returned `null`
// https://github.com/sidebase/nuxt-auth/issues/896
if (rawRefreshToken.value === null) {
rawRefreshToken.value = _rawRefreshTokenCookie.value
}
watch(rawRefreshToken, () => {
_rawRefreshTokenCookie.value = rawRefreshToken.value
})
// When the page is cached on a server, set the refresh token on the client
if (instance) {
onMounted(() => {
if (_rawRefreshTokenCookie.value && !rawRefreshToken.value) {
rawRefreshToken.value = _rawRefreshTokenCookie.value
}
})
}
}
const refreshToken = computed(() => rawRefreshToken.value)
return {
...commonAuthState,
token,
rawToken,
refreshToken,
rawRefreshToken,
setToken,
clearToken,
_internal: {
rawTokenCookie: _rawTokenCookie
}
}
}
export default useAuthStatesidebaseのLocal providerはアクセストークンとリフレッシュトークンをそれぞれuseCookie()で読み書きしており、useAuth().refreshTokenのようにComposable経由でJavaScriptから値にアクセスできる設計になっています。httpOnly属性を付けてしまうとuseCookie()でCookieの値が取得できなくなるため、リフレッシュトークンのローテーションが機能しなくなってしまいます。
微妙だとは思うのですが、ここでは許容することとしています。
また、現在の構成では、サーバ側のCookie操作(setSessionCookies)でsidebaseのコンフィグ(Cookie名・maxAge・secure属性等)をそのまま引き継ぎ、サーバとクライアントで同じCookie設定が使われることを保証しています。
function getLocalProviderConfig(): LocalProviderConfig {
const provider = useRuntimeConfig().public.auth.provider as LocalProviderConfig;
if (provider.type !== 'local') throw new Error('Local auth provider is required');
return provider;
}
function buildSidebaseCookieOptions(config: LocalCookieConfig): CookieSerializeOptions {
return {
path: '/',
maxAge: config.maxAgeInSeconds,
sameSite: config.sameSiteAttribute,
secure: config.secureCookieAttribute,
domain: normalizeDomain(config.cookieDomain),
httpOnly: config.httpOnlyCookieAttribute,
};
}
export function setSessionCookies(event: H3Event, session: { accessToken: string; refreshToken?: string | null }): void {
const provider = getLocalProviderConfig();
setCookie(event, provider.token.cookieName, session.accessToken, buildSidebaseCookieOptions(provider.token));
// ...
}Cookieの属性は一元管理しているため、バックエンド側でCookieを書く際も同じ設定が自動的に適用されます。httpOnly属性で全てを閉じる設計にはできませんでしたが、冒頭に書いた通りそれが全ての解決策にはならないことも事実で、最終的な妥協点としては許容できる範囲であると考えます。
# MiAuthトークン
前回の記事でも少しだけ言及しましたが、MisskeyのアクセストークンをそのままDBに平文で保存するのは論外です。万が一DBの内容が流出した場合、全ユーザのMisskeyアカウントに対して任意の操作が可能になってしまいます。
そこで、MiAuthで取得したトークンはAES-256-GCMで暗号化してDBに保存しています。

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const VERSION_PREFIX = 'mewk-miauth:v1';
export function encryptMiAuthToken(token: string): string {
const key = getEncryptionKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [VERSION_PREFIX, toBase64Url(iv), toBase64Url(tag), toBase64Url(encrypted)].join(':');
}暗号化されたトークンはmewk-miauth:v1:<iv>:<tag>:<ciphertext>という形式で保存されます。GCMモードを採用しているのは認証付き暗号(AEAD)であるためで、改ざん検知が組み込まれています。ivはリクエストごとにrandomBytesで生成するため、同じトークンを暗号化しても毎回異なる暗号文になります。
mewk-miauth:v1というPrefixを付けているのは後方互換性のためです。将来的にアルゴリズムや鍵長を変える必要が生じた場合、Prefixのバージョンで判別して適切な復号ロジックに分岐させることを想定しています。AES-256-GCMのまま運用し続けても問題はないですが、暗号プリミティブも長い目で見れば更新が必要になるでしょうし、バージョンを明示する習慣をつけておくだけで、後の改修がだいぶ楽になるはずです。
export function isEncryptedMiAuthToken(value: string): boolean {
return value.startsWith(`${VERSION_PREFIX}:`);
}復号はresolveStoredMiAuthToken()という関数にまとめており、DBから取得したトークンが暗号化済みかどうかをPrefixで判別してから復号します。暗号化済みでない場合はその旨を記録して処理を継続します。将来的にバージョンを上げる際も、このPrefix判定を拡張するだけで移行ロジックを組めるはずです。
# リフレッシュの重複
アクセストークンの有効期限は1時間です。これを自動で延長するために、sidebaseのセッションリフレッシュ機能を使っています。
ここで地味に厄介なのが、複数タブで同時にリフレッシュが走る可能性です。
ユーザが同じアカウントで複数タブを開いている状態でリロードしたり、アクセストークンが期限切れになると、各タブが独立してリフレッシュリクエストを飛ばします。リフレッシュのたびにリフレッシュトークンをローテーションしているため、最初のリクエストが成功して旧トークンが無効化された直後に、別タブの遅延リクエストが同じ旧トークンで来ると弾かれてしまい、該当するタブに引き摺られるようにログアウトさせられることになります。
この問題に対して、二段構えで対応しています。
## クライアント
let inflightRefresh: Promise<unknown> | null = null;
export function runDeduplicatedAuthRefresh(refresh: () => Promise<unknown>): Promise<unknown> {
if (inflightRefresh) return inflightRefresh;
// 同時に走るrefreshを1本にまとめる
inflightRefresh = refresh().finally(() => {
inflightRefresh = null;
});
return inflightRefresh;
}
モジュールスコープの変数でin-flightなPromiseを保持し、すでにリフレッシュが走っているなら同じPromiseを返します。同じタブ内で複数のリフレッシュトリガーが走っても(ウィンドウフォーカス復帰・定期実行・ミドルウェア等)、リクエストは1本しか飛びません。
これをsidebaseのカスタムリフレッシュハンドラとして差し込んでいます。
class DeduplicatedRefreshHandler implements RefreshHandler {
init(): void {
this.auth = useAuth();
document.addEventListener('visibilitychange', this.boundVisibilityHandler, false);
// ページロード時にリフレッシュトークンがあればセッション復元
if (this.auth.refreshToken.value && !this.auth.data.value) {
runDeduplicatedAuthRefresh(this.auth.refresh);
}
// 55分ごとに定期リフレッシュ(アクセストークン期限1時間の直前)
this.refetchIntervalTimer = setInterval(() => {
const auth = this.getRefreshableAuth();
if (auth) runDeduplicatedAuthRefresh(auth.refresh);
}, intervalTime);
}
visibilityHandler(): void {
// タブが前面に戻ってきた時もリフレッシュを試みる
if (document.visibilityState !== 'visible') return;
const auth = this.getRefreshableAuth();
if (auth) runDeduplicatedAuthRefresh(auth.refresh);
}
}
visibilitychangeイベントを購読しているのは、ブラウザのサスペンドや別タブへの切替から復帰した際にセッションが失効している可能性が想定されるためです。ブラウザ/PWAを長時間バックグラウンドに置いていた場合などでも、タブに戻ってきた瞬間にリフレッシュが走ります。
## バックエンド
クライアント側の重複排除だけでは不十分で、異なるタブ(=別のモジュールスコープ)からのリクエストは防げません。そこでサーバ側でも対策しています。
export async function verifyRefreshToken(token: string): Promise<{ userId: string } | null> {
const tokenHash = hashToken(token);
const refreshToken = await prisma.refreshToken.findUnique({ where: { tokenHash } });
if (!refreshToken) return null;
// 無効化済みトークンは拒否
if (refreshToken.revokedAt) {
const GRACE_PERIOD_MS = 30_000;
if (Date.now() - refreshToken.revokedAt.getTime() > GRACE_PERIOD_MS) {
return null;
}
}
if (refreshToken.expiresAt < new Date()) return null;
return { userId: refreshToken.userId };
}
リフレッシュトークンを無効化(revokedAtを記録)してから30秒以内であれば、同じトークンでのリクエストを許容します。これにより、複数タブが同じ旧トークンでほぼ同時にリフレッシュを要求してきた場合でも、全てのタブが新しいアクセストークンを受け取れます。
ただし、意図的なセッション失効(ログアウトや不正なトークン使用への対応)ではグレースピリオドをバイパスする必要があります。その場合はrevokedAtにnew Date(0)を設定することで、前述の30秒を許容する条件を満たせないようにしています。
// ログアウト等の強制無効化
await prisma.refreshToken.updateMany({
where: { tokenHash, revokedAt: null },
data: { revokedAt: new Date(0) },
});また、リフレッシュ処理の実装では新トークンの発行を先に行い、旧トークンの無効化を後に行っています。順序が逆だとDB障害のタイミングによっては旧トークンが無効化されたが新トークンが発行されていない状態になり、ユーザが強制ログアウトされる可能性が考えられます。
export async function rotateRefreshToken(oldToken: string, userId: string): Promise<string> {
// 新トークンを先に発行
// NOTE: DB障害でログアウトされないよう順序を保証
const newToken = await generateRefreshToken(userId);
// 旧トークンを無効化
await prisma.refreshToken.updateMany({
where: { tokenHash: oldTokenHash, revokedAt: null },
data: { revokedAt: new Date() },
});
return newToken;
}# middlewareで割り込みトークン更新
認証が必要なページへのアクセス時に、トークンの状態に応じてリフレッシュや認証ページへのリダイレクトを行うのがNuxtのルートミドルウェアです。
export default defineNuxtRouteMiddleware(async (to) => {
if (import.meta.server) {
// SSR時はセッションAPIを直接叩いて検証
const { data, error } = await useFetch('/api/v1/auth/session');
if (error.value || !data.value?.user) {
return redirectToSignIn();
}
return;
}
const { status } = useAuth();
const { waitForAuthReady, restoreSessionWithRetry, withSessionRestoreOverlay } = useDeduplicatedRefresh();
if (status.value === 'authenticated') return;
return withSessionRestoreOverlay(async () => {
await waitForAuthReady();
if (status.value === 'authenticated') return;
// 明示的ログアウト後はリフレッシュをスキップ
if (sessionStorage.getItem('mewk:explicit-logout')) {
return redirectToSignIn();
}
const restored = await restoreSessionWithRetry();
if (restored) return;
return redirectToSignIn();
});
});
いくつか細かい工夫があります。
まずwaitForAuthReady()で、sidebaseの初期ロード中(status === 'loading')が完了するのを最大5秒待ちます。ページロード直後はsidebaseがセッションを確認している途中であることが多く、loading状態を無視してリダイレクトしてしまうとちらつきが発生します。
waitForAuthReady()が完了してもauthenticatedでなかった場合、リフレッシュを試みます。ここでもrestoreSessionWithRetry()を挟んでおり、最大3回, 500ms間隔でリトライします。モバイル環境等のネットワークが不安定な場合など、一発でリフレッシュが成功しないことがあるためです。
async function restoreSessionWithRetry(options: RestoreSessionOptions = {}): Promise<boolean> {
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; // 3
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await deduplicatedRefresh();
} catch { }
if (status.value !== 'authenticated') {
await Promise.race([
until(status).toBe('authenticated'),
sleep(authenticatedWaitMs), // 最大1秒待機
]);
}
if (status.value === 'authenticated') return true;
if (attempt < maxRetries) await sleep(retryDelayMs);
}
return false;
}
withSessionRestoreOverlay()はUIのちらつき防止のためのラッパーで、セッション復元中はオーバーレイカウントをインクリメントしてローディング状態を表現しています。
なお、sessionStorage.getItem('mewk:explicit-logout')のチェックは、ユーザが自分でログアウトした後にリフレッシュトークンが残っていても再ログインしてしまう問題を防ぐためです。明示的なログアウト操作時にはsessionStorageにフラグを立て、ミドルウェアがこれを検知した場合はリフレッシュをスキップして/auth/signinにリダイレクトします。ちなみに、sessionStorageはタブを閉じると消えるため、以降のセッションでは正常にリフレッシュが走ります。
# SSR時のセッション処理
SSRが絡むと少し複雑になります。サーバサイドレンダリング時にはuseAuth()が使えないため、セッションAPIを直接叩いてセッションを確認します。
/api/v1/auth/sessionのエンドポイントでは、アクセストークンがCookieに存在する場合はそれで認証し、存在しない場合はリフレッシュトークンで1回だけセッション復元を試みます。
let payload: { userId: string };
try {
payload = await requireAuthWithoutBanCheck(event);
} catch (error) {
// アクセストークンが無ければリフレッシュトークンで復元
const refreshToken = getRefreshTokenFromCookies(event);
if (!refreshToken) throw error;
const restoredSession = await restoreSessionFromRefreshToken(event);
payload = { userId: restoredSession.userId };
}
restoreSessionFromRefreshToken()はセッション復元と同時に新しいアクセストークンとリフレッシュトークンを発行し、Cookieをセットします。つまりSSR時にもトークンのローテーションが透過的に行われるため、ユーザは意識することなく認証状態が維持されます。
# Misskeyトークン失効の検知
Misskeyのアクセストークンは、ユーザが連携アプリの認可を取り消したり、アカウントが凍結された場合に無効になります。この場合、ユーザには継続して有効なMewkの発行するJWTが手元にありますが、バックエンドが実際にMisskey APIを叩こうとすると失敗します。
この不整合を検知するため、セッション確認のたびにMisskeyの/api/iへのアクセス確認を行っています。ただし毎回叩くとレイテンシが悪化する可能性があるため、KVを使って5分間キャッシュしています。
export async function checkMisskeyTokenValidity(
event: H3Event,
userId: string,
domain: string,
accessToken: string,
options: MisskeyTokenCheckOptions,
): Promise<void> {
const kv = getKV(event);
const cacheKey = `misskey-valid:${userId}`;
const cached = await kv.get(cacheKey);
if (cached) return;
// タイムアウト付きでMisskeyに問い合わせ
const result = await Promise.race([fetchPromise, timeoutPromise]);
if (status === 401 || status === 403) {
// トークン無効 全セッションを強制破棄
await revokeAllUserRefreshTokens(userId);
throw createError({ statusCode: 401, statusMessage: 'MISSKEY_SESSION_EXPIRED', ... });
} else if (status >= 200 && status < 300) {
await kv.put(cacheKey, '1', { expirationTtl: 300 });
}
// 5xx
}タイムアウトは1500msに設定していて、Misskeyのレスポンスが遅い場合は検証をスキップします。不整合が発生したからといって、外部サービスの応答待ちでユーザのリクエストを阻害するのは設計として微妙すぎるためです。
Misskeyがダウンしているだけで全ユーザがセッションを失うような事態を避けるため、5xx系のレスポンスやタイムアウトは正常扱いしてスルーしています。明確に401/403のレスポンスが返った場合のみ、ユーザの全リフレッシュトークンを無効化し、ログアウト理由をフロントに伝えるために非httpOnlyのCookie(mewk_logout_reason)を短命で立てています。
# さいごに
振り返ると、認証まわりで一番頭を使ったのはrefreshの競合問題です。「複数タブで同時に開いてたら」「ブラウザのサスペンドから復帰したら」「ネットワークが不安定だったら」といった微妙なエッジケースを一つひとつ潰していくのは、手を抜けない部分でした。
セキュリティまわりの設計については、「ライブラリを使っているから大丈夫」という発想を極力持たないようにしました。sidebaseを使っていますが、MiAuthトークンをクライアントに渡さない・DB保存時に暗号化する・revoke手段をサービス側で持つ、といった判断はライブラリに任せられるものではありません。ライブラリはあくまで実装の補助であって、設計上の責任まで肩代わりしてくれるわけではありません。ここを混同すると、ライブラリのデフォルト挙動に乗っかったまま後から気づけない穴を作ることになります。
書いていて改めて思いましたが、認証の設計というのはこうすれば完璧という答えがなく、トレードオフの連続でした。JWTの即時revoke問題もhttpOnly Cookieの限界も、どこかで折り合いをつけなければいけない。大事なのは、そのトレードオフが何なのかを理解した上で判断することで、何も考えずにデフォルトに従うことではないと考えます。
今後も機能追加や仕様変更の中で認証まわりはじわじわ変わっていくと思いますが、基本的な設計の方針は変えるつもりはありませんし、Misskeyのアクセストークンをバックエンドに閉じ込め管理するという構造は、使い続けていてもそれほど不便を感じていませんし、むしろ正解だったと思っています。
それでは。


