【ミーア】Auth CodeとRefresh Tokenを活用したGoogle Calendar APIアクセスの実装 (Go言語とFlut
はじめに
様々な方言を話す。おしゃべりに小型ロボット「ミーア」を開発中。
現在、「カレンダー連携で予定を音声通知する機能」を開発中で、前回はFlutterアプリからGoogleサインインを利用し、Googleカレンダーの認証を行い、ユーザーのカレンダー情報を取得するところまでを記載した。
この時は、アプリで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
:アプリのバックエンド通信を統一的に管理するクラス
続きは、こちらで記載しています。
Discussion