🔥

【ミーア】Auth CodeとRefresh Tokenを活用したGoogle Calendar APIアクセスの実装 (Go言語とFlut

2024/11/25に公開

はじめに

様々な方言を話す。おしゃべりに小型ロボット「ミーア」を開発中。

https://mia-cat.com/

現在、「カレンダー連携で予定を音声通知する機能」を開発中で、前回はFlutterアプリからGoogleサインインを利用し、Googleカレンダーの認証を行い、ユーザーのカレンダー情報を取得するところまでを記載した。

https://kazulog.fun/dev/flutter-google-calendar-integration/

この時は、アプリでgoogleカレンダーの認証後にアクセストークンを取得してアクセストークンをもとにgoogleカレンダーの予定表示を行っていた。

ただ、今回は、Google Calendar APIへのアクセスをより安全かつ効率的に管理するために、アプリ側でアクセストークンを直接使用せず、認証コード(Auth Code)をバックエンドに送信してリフレッシュトークンを生成・保存し、そのリフレッシュトークンを使用してGoogle Calendar APIにアクセスする仕組みに変更することにした。

本記事では、以下のポイントについて記載

  • Flutterアプリ側:認証コードとユーザーIDをバックエンドに送信する仕組み。
  • バックエンド(Go言語):認証コードをリフレッシュトークンに変換し、データベースに保存するロジック。
  • 全体のフロー:Google OAuth 2.0フローに基づく処理の流れと実装の工夫。

システム全体の概要

Flutterアプリ側

  • Googleサインインで認証コード(Auth Code)を取得。
  • 認証コードとユーザーIDをバックエンドに送信。

バックエンド

  • 認証コードをGoogleのトークンエンドポイントに送信し、認証コードからリフレッシュトークンを取得。
  • 取得したリフレッシュトークンをデータベースに保存。
  • 保存されたリフレッシュトークンを使ってGoogle Calendar APIにアクセス。

トークンの保存と管理における課題

GoogleカレンダーAPIを利用する際、アクセストークンは有効期限が1時間と短く、定期的に新しいトークンを発行する必要がある。このため、長期的な認証を維持するには、リフレッシュトークンを保存し、これを用いてアクセストークンを再生成する仕組みが必要。

しかし、リフレッシュトークンには以下の課題がある。

セキュリティリスク

  • リフレッシュトークンが流出すると、攻撃者がユーザーのGoogleカレンダー情報にアクセスできる可能性がある。
  • 必要な権限以上に多くのサービスがトークンにアクセスできるのは望ましくない。

データベース設計の柔軟性

  • トークンをusersテーブルに直接保存すると、アクセス権限を細かく管理するのが難しくなる。

専用テーブルによるリフレッシュトークンの分離

これらの課題を解決するために、リフレッシュトークンを専用のテーブルに分離して保存することにする。

専用テーブルを作成

  • リフレッシュトークンはgoogle_calendar_tokensテーブルに保存し、usersテーブルとは外部キーで紐付ける。

暗号化して保存

  • リフレッシュトークンはデータベースに保存する前に暗号化し、復号化は必要な時だけ行う。

アクセス制御

  • トークン用のテーブルに対するアクセス権限を、バックエンドの一部のモジュールやサービスアカウントに限定する。

google_calendar_tokensテーブルの設計例

Flutterアプリの実装

Flutterアプリでは、Googleサインインを使用して認証コードを取得し、バックエンドに送信する。以下は、主要な実装例。

CalendarIntegrationScreen:Googleカレンダー連携画面

Googleカレンダー連携画面を構成し、ユーザーがボタンを押すことでGoogleサインインを実行し、認証コードを取得する処理を提供。

serverClientId を指定すると、サインイン後にバックエンドに送信可能な認証コード(serverAuthCode)が返される。

serverClientId を指定せずにclientIdのみを指定した場合、サインイン後、アクセストークン が直接アプリ側に返される。アクセストークンは短時間(通常1時間)しか有効でないため、アプリ側でトークンの有効期限を管理し、期限切れの際に再度サインインを行う必要がある。

今回は、バックエンド側にリフレッシュトークンを保存したいので、そのためには認証コードの取得が必要なのでserverClientId を指定する。

lib/screens/home/calendar_integration_screen.dart

import 'package:clocky_app/api/api_client_provider.dart';
import 'package:clocky_app/api/user_notifier.dart';
import 'package:clocky_app/firebase_options.dart';
import 'package:clocky_app/services/google_calendar_auth_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_sign_in/google_sign_in.dart';

class CalendarIntegrationScreen extends ConsumerWidget {
  const CalendarIntegrationScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('カレンダー連携'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () async {
            await _fetchTodayEvents(context, ref);
          },
          child: const Text('Googleカレンダーと連携する'),
        ),
      ),
    );
  }

  Future<void> _fetchTodayEvents(BuildContext context, WidgetRef ref) async {
    try {
      // UserNotifier からユーザー情報を取得
      final user = ref.watch(userProvider);
      if (user == null) {
        throw Exception('ユーザー情報がロードされていません');
      }

      final internalUserId = user.id;
      if (internalUserId == null) {
        throw Exception('内部ユーザーIDが存在しません');
      }
      debugPrint('取得した内部ユーザーID: $internalUserId');

      // Google Sign-Inの初期化
      final GoogleSignIn googleSignIn = GoogleSignIn(
        scopes: [
          'https://www.googleapis.com/auth/calendar.readonly',
          'https://www.googleapis.com/auth/calendar',
        ],
        clientId: DefaultFirebaseOptions.ios.iosClientId,
        serverClientId:
            'XXXXXX.apps.googleusercontent.com', // WebクライアントID
      );

      // Googleサインインを実行
      final account = await googleSignIn.signIn();
      if (account == null) {
        debugPrint('Googleサインインがキャンセルされました');
        return;
      }

      // 認証コードの取得
      final authCode = account.serverAuthCode;
      if (authCode == null) {
        throw Exception('認証コードが取得できませんでした');
      }
      debugPrint('取得した認証コード: $authCode');

      // サーバーに認証コードを送信してリフレッシュトークンを取得・保存
      final googleCalendarService =
          GoogleCalendarService(apiClient: ref.read(apiClientProvider));
      await googleCalendarService.sendAuthCode(
          internalUserId.toString(), authCode);
      debugPrint('リフレッシュトークンの取得に成功しました');
    } catch (e) {
      debugPrint('エラーが発生しました: $e');
    }
  }
}

GoogleCalendarService: Google Calendar関連の処理を担当するサービスクラス

lib/services/google_calendar_auth_service.dart

  • アプリ内で認証コードをバックエンドに送信するためのラッパーメソッド。
  • ApiClient を使用して、バックエンドエンドポイントを呼び出す。
import 'package:clocky_app/api/api_client.dart';

class GoogleCalendarService {
  final ApiClient apiClient;
  GoogleCalendarService({required this.apiClient});

  // 認可コードをバックエンドに送信
  Future<void> sendAuthCode(String userId, String authCode) async {
    await apiClient.sendAuthCode(userId, authCode);
  }
}

ApiClient:アプリのバックエンド通信を統一的に管理するクラス

続きは、こちらで記載しています。
https://kazulog.fun/dev/authcode-refreshtoken-calendar/

Discussion