Firebase AuthenticationでのSign in with Appleの削除要件に対するCloud FunctionsとFlutterの実装

こんにちは。[virapture株式会社](https://virapture.com)でCEOしながら[ラグナロク株式会社](https://ragna-rock.com)でもCKOとして働いている[もぐめっと](https://mogmet.com)です。

![IMB_I6Cq1q.GIF](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/87b7c585-c4c4-1e04-42b5-337089d9e511.gif)
趣味はスノボです。まだまだ初心者ですが最近ルックバックかっこインディーという新しい技ができました。あまりにも嬉しかったのでつい動画にしてしまいました。

本日は**Sign in with Apple**を用いているときに、アカウント削除機能を実装しないとレビューが通らなくなるようになってしまったので、Firebaseを使ったプロジェクトでどう実装すればいいかの共有をします。

https://developer.apple.com/jp/news/?id=12m75xbj

## 概要

まずはじめにガイドラインは目を通しておくといいでしょう。もしかするといい感じにアナウンスすれば削除を手動で運用するというやり方で逃げれる可能性もあります。

https://developer.apple.com/jp/support/offering-account-deletion-in-your-app/

実装の概要については、下記記事が参考になるので丸投げしますｗ

https://qiita.com/kahi0223/items/673e7f90474b706df285

簡単に言うとsigninしたときの認証コードとjwtを使ってrefresh tokenを発行して、AppleのAPIにそれを投げてアカウント削除しましょうという感じです。

## 実装詳細

クライアント側とサーバ側で実装をそれぞれ紹介します。



### クライアント実装

今回はflutterで実装していたのでflutterベースで紹介します。
まずはsignInするときにrefreshTokenを取得するようにしましょう。

flutterでのsign in with appleの実装については下記を参照してください。（今回省略してますが本来はnonceの設定とかしたほうが良い）

https://pub.dev/packages/sign_in_with_apple/example

```auth_service.dart
class AuthService {
  AuthService(TokenService tokenService) : _tokenService = tokenService; // TokenServiceは後述

  final TokenService _tokenService;

  /// SignInWithAppleでSignInする。
  Future<User> signInWithApple({bool isReAuth = false}) async {
    final appleCredential = await SignInWithApple.getAppleIDCredential(
      scopes: [
        AppleIDAuthorizationScopes.email,
      ],
    );
    final credential = OAuthProvider('apple.com').credential(
      idToken: appleCredential.identityToken,
      accessToken: appleCredential.authorizationCode,
    );
    final userCredential = await FirebaseAuth.instance.signInWithCredential(
      credential,
    );
    final user = userCredential.user;
    if (user == null) {
      throw Exception('invalid user');
    }
    // 認証するときに用いたauthorizationCodeがほしい！！！
    await _tokenService.setAppleRefreshToken(appleCredential.authorizationCode);
    return user;
  }
}
```

アプリの仕様として削除前にsigninを求めるのは問題ないので、削除前に再認証させるようにするといいでしょう。

次に、sign inするときに使ったauthorizationCodeを使ってCloudFunctionsからrefresh tokenを取得して保管しておきます。

```token_service.dart
class TokenService {
  String? refreshToken;
  
  Future<void> setAppleRefreshToken(String authorizationCode) async {
    final callable = FirebaseFunctions.instanceFor(region: 'asia-northeast1')
        .httpsCallable('onCallAppleRefreshToken');
    final result = await callable.call<Map<String, dynamic>>({
      'authorizationCode': authorizationCode,
    });
    // String, dynamic -> String, Stringに変換しないと扱えない
    final data = Map<String, String>.from(result.data);
    refreshToken = data['refreshToken'];
  }
}
```

ユーザを削除する処理をするときは上記のrefreshTokenを用いてCloudFunctionsを叩いてユーザを削除するようにします。

```user_manager.dart
class UserManager {
  UserManager(TokenService tokenService): _tokenService = tokenService;
  final TokenService _tokenService;

  Future<void> deleteUser() async {
    final callable = FirebaseFunctions.instanceFor(region: 'asia-northeast1')
        .httpsCallable('onCallUserDelete');
     await callable.call<Map<String, dynamic>>({
      'refreshToken': _tokenService.refreshToken,
    });
  }
}
```

これでクライアント側は準備完了です！

### CloudFunctions実装

firebaseのSign in with Appleの削除対応に関しては、現在下記で話されております。
今後公式に実装される可能性があるのでもしかすると今回紹介する方法は腐るかもしれません。

https://github.com/firebase/firebase-ios-sdk/issues/9906

上記issueに、ソースコードのサンプルがあったのでいい感じに実装します。

https://github.com/jooyoungho/apple-token-revoke-in-firebase

まずはappleと通信するためのrepositoryを準備します。

```appleIdRepository.ts
import * as jwt from 'jsonwebtoken'
import axios from 'axios'
import qs from 'qs'

export interface IAppleIdRepository {
  fetchRefreshToken(authorizationCode: string): Promise<string>
  revokeToken(refreshToken: string): Promise<void>
}
export class AppleIdRepository implements IAppleIdRepository {
  constructor(
    readonly teamId: string,
    readonly clientId: string,
    readonly keyId: string,
    readonly privateKey: string
) {}

  /// refreshTokenを生成
  async fetchRefreshToken(authorizationCode: string): Promise<string> {
    const client_secret = this.makeJWT()
    let data = {
      code: authorizationCode,
      client_id: this.clientId,
      client_secret: client_secret,
      grant_type: 'authorization_code',
    }
    const result = await axios.post(`https://appleid.apple.com/auth/token`, qs.stringify(data), {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    })
    const refreshToken = result.data.refresh_token
    return refreshToken
  }

  /// refreshTokenを使ってAppleアカウントを削除する
  async revokeToken(refreshToken: string): Promise<void> {
    const clientSecret = this.makeJWT()
    const data = {
      token: refreshToken,
      client_id: this.clientId,
      client_secret: clientSecret,
      token_type_hint: 'refresh_token',
    }
    await axios.post(`https://appleid.apple.com/auth/revoke`, qs.stringify(data), {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    })
  }

  // JWTの生成。コピペ実装。
  private makeJWT(): string {
    // Path to download key file from developer.apple.com/account/resources/authkeys/list
    const privateKeyString = this.privateKey
    const privateKey = Buffer.alloc(privateKeyString.length)
    privateKey.write(privateKeyString)
    //Sign with your team ID and key ID information.
    const token = jwt.sign(
      {
        iss: this.teamId,
        iat: Math.floor(Date.now() / 1000),
        exp: Math.floor(Date.now() / 1000) + 120,
        aud: 'https://appleid.apple.com',
        sub: this.clientId,
      },
      privateKey,
      {
        algorithm: 'ES256',
        header: {
          alg: 'ES256',
          kid: this.keyId,
        },
      }
    )
    return token
  }
}

```

上記のrepositoryを使ってCloudFunctionsのエンドポイントを定義します。
また、各変数について説明しておきます。
- teamId: https://developer.apple.com/account/#!/membership からteamIdを参照してください
- clientId: https://developer.apple.com/account/resources/identifiers/list にあるアプリのApp ID (IDENTIFIER)です
- keyId: https://developer.apple.com/account/resources/authkeys/list sign in with appleで使ってるサービスのKey IDです
- privateKey: 上記サービスのpemファイルの中身になります。

```index.ts
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { AppleIdRepository } from './appleIdRepository'
admin.initializeApp()

const teamId = '0AB12CDEFG'
const clientId = 'com.example'
const keyId = '123ABCDFG4'
const privateKey = '-----BEGIN PRIVATE KEY-----\nMIG...'
// refresh tokenの取得
exports.onCallAppleRefreshToken = functions.region('asia-northeast1').https.onCall(async (data, _context) => {
  try {
    const refreshToken = await new AppleIdRepository(teamId, clientId, keyId, privateKey).fetchRefreshToken(data.authorizationCode)
    return { refreshToken }
  } catch (error) {
    console.error(error)
    throw error
  }
})
// ユーザ削除処理
exports.onCallUserDelete = functions.region('asia-northeast1').https.onCall(async (data, _context) => {
  try {
    await deleteUser() // なんかしらサービス側のユーザの削除を行う.ここはサービスごとで実装する
    await new AppleIdRepository(teamId, clientId, keyId, privateKey).revokeToken(data.refreshToken)
    return 'delete complete!'
  } catch (error) {
    console.error(error)
    throw error
  }
})

```

ちなみにprivateKeyなどはハードコーディングせず、環境変数やシークレットマネージャなどから取得するようにするのをおすすめします。

https://qiita.com/mogmet/items/6797f9a140ec62314c01

## まとめ

sign in with appleとFirebaseを使っている場合の削除要件の対応方法について紹介しました。
refreshTokenを用いることで削除できるようになりましたね。


最後に、[ワンナイト人狼オンライン](https://online.1nite-jinro.com/link/blog)というゲームを作ってます！よかったら遊んでね！

https://online.1nite-jinro.com/link/blog

他にも[Camecon](https://camecon.me/)や[Offcha](https://offcha.com/)、問い合わせ対応が簡単にできる[CSmart](https://csmart.jp/lp)といったサービスも作ってるのでよかったら使ってね！

https://camecon.me/

https://offcha.com/

https://csmart.jp/lp

また、チームビルディングや技術顧問、Firebaseの設計やアドバイスといったお話も受け付けてますので御用の方は弊社までお問い合わせください。

https://virapture.com/

ラグナロクでもエンジニアやデザイナーのメンバーを募集しています！！楽しくぶち上げたい人はぜひお話ししましょう！！

https://ragna-rock.com