ksg

ksg@software_engineer

Portfolio Blog

Expo + Supabaseの匿名ログインに「Appleでサインイン」を後付けして、機種変更の引き継ぎとアカウント削除まで作った話

Expo + Supabaseの匿名ログインに「Appleでサインイン」を後付けして、機種変更の引き継ぎとアカウント削除まで作った話

はじめに

スマホを触らない時間(デジタルデトックス)でモンスターが育つ育成ゲーム「Hotamon」を、Expo(React Native)とSupabaseで個人開発しています。

このアプリは、起動した瞬間から遊べるように ログイン画面を出さず、裏でSupabaseの匿名ログインを行う 作りにしていました。手軽な反面、このままだと機種変更や再インストールでデータが消えてしまいます。

そこで今回、App Storeへの申請前に次の3つを入れました。

  1. 匿名アカウントに 「Appleでサインイン」を後から紐づけて、機種変更しても引き継げるようにする
  2. App Storeの審査で必須の アプリ内アカウント削除(Appleのトークン取り消しを含む)
  3. AI生成(名前・画像)を 1日あたりの回数で制限 して、費用が膨らまないようにする

実装の流れと、レビューで見つかった落とし穴をまとめます。

使ったもの

項目

内容

アプリ

Expo SDK 57 / Expo Router / TypeScript

認証・DB

Supabase(Auth・Postgres・Storage・Edge Functions)

Appleでサインイン

expo-apple-authentication / expo-crypto

サーバー

Supabase Edge Functions(Deno)、jose でJWT署名

全体の流れ

ボタンは「Appleでサインイン」の1つだけにして、状況によって動きを自動で分けました。ユーザーに「連携」と「引き継ぎ」のどちらなのかを考えさせないためです。

状況

動き

今の端末で初めて押した

今の匿名アカウントにAppleを紐づける(ユーザーIDは変わらない)

そのAppleアカウントが前の端末のユーザーに使われていた

確認ダイアログを出し、そのユーザーとしてサインインし直す

連携済み

ボタンを出さない

データはすべてSupabase側に user_id で保存しているので、同じユーザーIDに戻れれば引き継ぎは完了 します。端末側でデータを移す処理は要りません。

1. Appleのトークンを取る(nonce付き)

expo-apple-authentication でAppleのサインイン画面を出し、IDトークンを受け取ります。リプレイ攻撃を防ぐため、毎回ランダムなnonceを作り、Appleにはハッシュ化した値を、Supabaseには元の値を 渡します。

import * as AppleAuthentication from 'expo-apple-authentication';
import * as Crypto from 'expo-crypto';

export type AppleIdTokenResult = { status: 'ok'; token: string; nonce: string } | { status: 'canceled' };

export async function requestAppleIdToken(): Promise<AppleIdTokenResult> {
  const nonce = Crypto.randomUUID();
  const hashedNonce = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, nonce);
  try {
    const credential = await AppleAuthentication.signInAsync({ nonce: hashedNonce });
    if (!credential.identityToken) throw new Error('Apple did not return an identity token');
    return { status: 'ok', token: credential.identityToken, nonce };
  } catch (error) {
    if (isCanceled(error)) return { status: 'canceled' };
    throw error;
  }
}

// iOSは、閉じる操作を「不明なエラー」として返すことがあるので、それもキャンセル扱いにする
function isCanceled(error: unknown): boolean {
  const code = (error as { code?: string }).code;
  return code === 'ERR_REQUEST_CANCELED' || code === 'ERR_REQUEST_UNKNOWN';
}

氏名やメールアドレスは使わないので、requestedScopes は指定していません。これが後でSupabase側の設定に効いてきます(後述)。

2. 匿名アカウントに紐づける/前のアカウントに切り替える

supabase-js の linkIdentity は、IDトークンを渡すネイティブ向けの形にも対応しています。紐づけに成功すればユーザーIDはそのままです。

そのAppleアカウントが すでに別のユーザーに紐づいている(=前の端末のアカウント) 場合は、identity_already_exists というエラーコードが返ってきます。これを「切り替えの確認が必要」という合図にしました。

export type LinkResult = 'linked' | 'needs-switch' | 'canceled' | 'failed';

export async function linkApple(): Promise<LinkResult> {
  const client = supabase;
  if (!client) return 'failed';
  try {
    await signInAnonymously();
    // オフラインで匿名ログインできていないと必ず失敗するので、Appleの画面を出す前に断る
    const { data: { session } } = await client.auth.getSession();
    if (!session) return 'failed';

    const apple = await requestAppleIdToken();
    if (apple.status === 'canceled') return 'canceled';

    const { error } = await client.auth.linkIdentity({ provider: 'apple', token: apple.token, nonce: apple.nonce });
    if (!error) return 'linked';
    if (error.code === 'identity_already_exists') {
      // 連携済みの判定が通信できずに外れていただけなら、切り替えを確認しない
      return (await isAppleLinked()) ? 'linked' : 'needs-switch';
    }
    return 'failed';
  } catch {
    return 'failed';
  }
}

切り替えでは signInWithIdToken を使います。このとき Appleの画面をもう一度出して、新しいトークンを取り直す ようにしました。1回目のトークンを使い回すと、nonceの再利用として拒否される可能性があるためです。

export async function switchToAppleAccount(): Promise<'switched' | 'canceled' | 'failed'> {
  const apple = await requestAppleIdToken();
  if (apple.status === 'canceled') return 'canceled';
  const { error } = await supabase!.auth.signInWithIdToken({ provider: 'apple', token: apple.token, nonce: apple.nonce });
  return error ? 'failed' : 'switched';
}

なお、signInWithIdToken が失敗した場合、auth-js は既存のセッションを消しません。切り替えに失敗しても、今の匿名アカウントのまま遊び続けられます。

Supabase側で必要だった設定

ダッシュボードで次の3つが必要でした。特に2つ目は見落としやすいです。

設定

理由

Authentication → Providers → Apple の Client IDs にアプリのBundle IDを入れる

ネイティブのサインインは、トークンの宛先がBundle IDになるため。Secret Key(OAuth用)はWeb向けなので空でよい

Apple の Allow users without an email をオン

スコープを指定しないとトークンにメールアドレスが入らず、オフのままだと連携も引き継ぎも失敗する

Allow manual linking をオン

linkIdentity は「手動連携」の扱いで、オフだと manual_linking_disabled で失敗する

Expo側は app.json の ios.usesAppleSignIn: true と、プラグインに expo-apple-authentication を足すだけです。Expo Goではトークンの宛先がExpo Go自身になるのでSupabaseに拒否されます。開発ビルドで試す必要があります。

3. アプリ内アカウント削除(Appleのトークン取り消しまで)

App Store審査ガイドライン5.1.1(v)で、アカウントを作れるアプリにはアプリ内からの削除が求められます。さらにAppleでサインインを使っている場合は、削除時にAppleのトークンを取り消す ことが求められています。

削除はEdge Functionで行い、順番を次のようにしました。

  1. 送られてきたJWTから本人を確認する
  2. Apple連携済みなら、Appleのトークンを取り消す(失敗したら何も消さずにエラーを返す)
  3. 生成した画像をStorageから消す(失敗してもログに残して続ける)
  4. auth.admin.deleteUser でユーザーを消す(各テーブルは on delete cascade で一緒に消える)

取り消しを最初にしているのは、先に消してしまうと取り消しに失敗したときにやり直せなくなるためです。

クライアントシークレットを作ってトークンを取り消す

アプリから「削除の直前に」Appleの画面をもう一度出して認可コード(authorizationCode、有効期限5分)を受け取り、サーバーに送ります。サーバーは、Appleの秘密鍵(.p8)でES256のクライアントシークレットを作り、認可コード → リフレッシュトークン → 取り消し、と進めます。

import { importPKCS8, SignJWT } from 'https://esm.sh/jose@5.10.0'

async function createAppleClientSecret(clientId: string): Promise<string> {
  const privateKey = await importPKCS8(normalizePrivateKey(Deno.env.get('APPLE_PRIVATE_KEY') ?? ''), 'ES256')
  const now = Math.floor(Date.now() / 1000)
  return await new SignJWT({
    iss: Deno.env.get('APPLE_TEAM_ID') ?? '',
    iat: now,
    exp: now + 5 * 60, // 削除のたびに作るので短くてよい
    aud: 'https://appleid.apple.com',
    sub: clientId, // ネイティブアプリの認可コードなので Bundle ID
  })
    .setProtectedHeader({ alg: 'ES256', kid: Deno.env.get('APPLE_KEY_ID') ?? '' })
    .sign(privateKey)
}
const tokenRes = await fetch('https://appleid.apple.com/auth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code, grant_type: 'authorization_code' }),
})
const { refresh_token: refreshToken, id_token: idToken } = await tokenRes.json()

// 連携しているのと別のAppleアカウントで認可された場合は、取り消さずに止める
if (expectedSub && appleSubFromIdToken(idToken) !== expectedSub) throw new AppleAccountMismatchError()

await fetch('https://appleid.apple.com/auth/revoke', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, token: refreshToken, token_type_hint: 'refresh_token' }),
})

.p8 をSecretsに1行で登録すると改行が \n という文字のまま入ることがあるので、読み込む前に改行へ戻しています。

Denoに依存しない部分を切り出してjestでテストする

このリポジトリにはサーバー側のテストの仕組みがありません。そこで、画像URLからファイル名を取り出す処理やAppleに送るフォームの組み立てなど、Denoに依存しない処理を logic.ts に切り出し、アプリ側のjestから相対パスで読み込んでテストしました。Edge Function本体は ./logic.ts を、jestは拡張子なしで読み込むだけで、同じファイルを共有できます。

削除後に「新しい匿名ユーザー」として始め直す

削除が終わったら、端末のセッションを消して新しい匿名アカウントで始め直します。ここで1つハマりました。

匿名ログインは、起動時とデータアクセス時に同時に呼ばれても1回で済むよう、Promiseを共有して使い回す 作りにしていました。そのため、signOut しただけでは解決済みのPromiseが使い回され、匿名ログインし直さないのです。

export async function resetSession(): Promise<void> {
  const client = supabase;
  if (!client) return;
  try {
    await client.auth.signOut({ scope: 'local' });
  } catch (error) {
    console.warn('Local sign-out failed:', error);
  }
  signInPromise = null; // 共有Promiseを捨てないと匿名ログインし直さない
  await signInAnonymously();
}

scope: 'local' にしているのは、サーバー側ではユーザーがもう消えていて、サーバーへのサインアウト要求は失敗するためです。

4. AI生成の回数を1日あたりで制限する

モンスターの名前(OpenAI)と画像(fal.ai)の生成はEdge Function経由ですが、匿名アカウントは誰でもすぐ作れます。アプリを通さずに直接呼ばれ続けると、費用がかかり続けてしまいます。

普通に遊ぶ限り、生成は「タマゴがかえるとき1回」と「進化のたびに1回」しか起きません。そこで、普通の人は絶対に届かず、悪用だけを止める 値として、名前は1日1回、画像は1日3回にしました。

回数は、判定と加算を1文のupsertで行うSQL関数で数えます。同時に呼ばれても数え漏れしません。

create or replace function try_use_generation(p_user_id uuid, p_kind text, p_limit integer)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
  v_today date := (now() at time zone 'Asia/Tokyo')::date;
  v_count integer;
begin
  insert into generation_usage as usage (user_id, kind, used_on, count)
  values (p_user_id, p_kind, v_today, 1)
  on conflict (user_id, kind, used_on)
  do update set count = usage.count + 1
  where usage.count < p_limit
  returning usage.count into v_count;

  return v_count is not null and v_count <= p_limit;
end;
$$;

revoke all on function try_use_generation(uuid, text, integer) from public, anon, authenticated;
grant execute on function try_use_generation(uuid, text, integer) to service_role;

上限に達していると on conflict ... where の条件で更新されず、returning が何も返さないので false になります。Edge Function側はAIを呼ぶ前にこの関数を呼び、false なら 429 を返します。数えるのに失敗したとき(DBの不調など)も、費用を守るために断るようにしました。

レビューで見つかった落とし穴

実装後、別のAIエージェントにブランチ全体をレビューしてもらい、次のような問題が見つかりました。どれもテストを先に書いて再現してから直しています。

問題

何が起きるか

対策

useState の処理中フラグで連打を防いでいた

Appleのボタンはネイティブから押下が届くので、再描画前に2回押されると処理が二重に走る

処理中は次の呼び出しを無視する小さな関数(single-flight)を作った

タマゴの孵化中に引き継ぎができた

引き継ぎ後のアカウントにモンスターが2匹でき、前のモンスターが表示されなくなる

孵化中は連携ボタンを、連携中は孵化ボタンを押せなくした

削除は終わったのに応答が届かない

端末が消えたユーザーのまま固まり、再試行も孵化もできない

失敗時に getUser() を呼び、user_not_found なら削除済みとして始め直す

画像の image_url はアプリが書き込む値

URLを細工すると他人の画像を消させられる。壊れたURLが1件あると二度と削除できない

画像をユーザーごとのフォルダに置き、本人のフォルダのものだけを消す。読めないURLは飛ばす

別のAppleアカウントの認可コード

連携していたAppleアカウントの連携が取り消されないまま削除される

トークン交換で返る id_token の sub を、連携中のものと照合する

特に「応答が届かないケース」と「アプリが書き込んだ値を信じてサービスロールで消すケース」は、自分では気づけていませんでした。削除のような取り返しのつかない処理ほど、別の目で見てもらう価値があると感じました。

まとめ

  • 匿名ログインから始めるアプリでも、Supabaseの linkIdentity と signInWithIdToken を使えば、Appleでサインインを後付けして引き継ぎを作れる
  • identity_already_exists を「前の端末のアカウント」の合図にすると、ボタン1つで連携と引き継ぎを両立できる
  • Supabase側は Client IDs・Allow users without an email・Allow manual linking の3つを忘れずに
  • アカウント削除は「Appleの取り消し → 画像 → ユーザー」の順にし、取り消しに失敗したら何も消さない
  • AI生成のように費用がかかる処理は、匿名アカウントでも呼べる前提で回数を制限しておく

同じように Expo + Supabase で個人開発している方の参考になればうれしいです。

おすすめの記事