Introduction.
I am developing a small cat-shaped robot “Mia”, that speaks various funny phrases in dialects
We are currently developing a “voice notification function for appointments with calendar integration,” and in the last issue, we described the process of using Google sign-in from the Flutter application, authenticating Google Calendar, and retrieving the user’s calendar information.
At that time, after the application authenticated the google calendar, it obtained an access token and used the access token as the basis for displaying the google calendar appointments.
However, in order to manage access to the Google Calendar API more securely and efficiently, we decided to change to a mechanism in which the application does not directly use an access token, but instead sends an authentication code (Auth Code) to the back end, generates and stores a refresh token, and uses that refresh token The backend will send an authentication code (Auth Code) to generate and store a refresh token, and use the refresh token to access the Google Calendar API.
This article describes the following points
- On the Flutter app side: A mechanism to send the authentication code and user ID to the backend.
- Backend (Go language): Logic to convert authentication codes into refresh tokens and store them in the database.
- Overall flow: Processing flow based on Google OAuth 2.0 flow and implementation innovations.
Overall System Overview
Flutter app side
- Obtain an authentication code (Auth Code) through Google Sign-In.
- The authentication code and user ID are sent to the backend.
back-end
- The authentication code is sent to Google’s token endpoint and a refresh token is obtained from the authentication code.
- Store the obtained refresh token in the database.
- Access Google Calendar API with the saved refresh token.
Challenges in token storage and management
When using the Google Calendar API, access tokens have a short expiration time of one hour, and new tokens must be issued periodically. Therefore, to maintain long-term authentication, a mechanism is needed to store refresh tokens and use them to regenerate access tokens.
However, refresh tokens have the following issues
Security Risks
- If the refresh token is leaked, an attacker could gain access to the user’s Google Calendar information.
- It is undesirable for more services to have access to the token than the required permissions.
Flexibility in database design
- Storing tokens directly in the
users
table makes it difficult to control access privileges in detail.
Separation of refresh tokens by dedicated table
To solve these issues, refresh tokens should be separated and stored in a dedicated table.
Create dedicated table
- Refresh tokens are stored in the
google_calendar_tokens
table and are associated with theusers
table by a foreign key.
Encrypted storage
- Refresh tokens are encrypted before storing them in the database, and decryption is performed only when necessary.
access control
- Limit access privileges to tables for tokens to some back-end modules and service accounts.
Example design of google_calendar_tokens
table
column name | data type | Description. |
id | UUID/BigInt | Unique token identifier |
user_id | UUID/BigInt | User ID (foreign key in the users table) |
refresh_token | TEXT | Encrypted refresh token |
created_at | TIMESTAMP | record creation time |
updated_at | TIMESTAMP | record modification date |
Flutter app implementation
The Flutter app uses Google sign-in to obtain the authentication code and send it to the backend. Below is an example of a key implementation.
CalendarIntegrationScreen: Google Calendar Integration Screen
It provides a process to configure the Google Calendar integration screen, where users press a button to perform Google sign-in and obtain an authentication code.
If serverClientId
is specified, the authentication code ( serverAuthCode)
that can be sent to the backend after sign-in is returned.
If only clientId is specified without serverClientId
, an access token is returned directly to the application side after sign-in. Since the access token is only valid for a short period of time (usually one hour), it is necessary for the application to manage the token expiration date and sign in again when it expires.
This time, we want to store the refresh token on the backend side, so we specify serverClientId
because we need to obtain an authentication code for this purpose.
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 _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: Service class responsible for Google Calendar-related processing
lib/services/google_calendar_auth_service.dart
- Wrapper method for sending authentication codes to the backend in the app.
- Use
ApiClient
to invoke the backend point.
import 'package:clocky_app/api/api_client.dart';
class GoogleCalendarService {
final ApiClient apiClient;
GoogleCalendarService({required this.apiClient});
// 認可コードをバックエンドに送信
Future sendAuthCode(String userId, String authCode) async {
await apiClient.sendAuthCode(userId, authCode);
}
}
ApiClient: Class that manages application backend communication in a unified manner
lib/api/api_client.dart
extension GoogleCalendarApi on ApiClient {
Future sendAuthCode(String userId, String authCode) async {
final url = Uri.parse('$apiUrl/app/google-calendar/tokens');
final headers = await apiHeaders();
final body = {
'userId': int.parse(userId),
'authCode': authCode,
};
final response = await http.post(
url,
headers: headers,
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw Exception('Failed to send auth code: ${response.body}');
}
}
}
Go (backend) implementation
On the back end, the authentication code is converted into a refresh token and stored in the database.
GoogleCalendarTokenHandler: Handles HTTP endpoints for applications
- Processes the HTTP endpoint of the application, receives the authentication code, and obtains the token using
GoggleCalendarTokenValidator
. - Provides high-level business logic for storing tokens in the database, using the
GoggleCalendarTokenValidator
as the injector (dependency injection).
package http
import (
"context"
"net/http"
"time"
"log/slog"
"github.com/EarEEG-dev/clocky_be/mia"
"github.com/EarEEG-dev/clocky_be/mia/google"
"github.com/labstack/echo/v4"
)
type GoogleCalendarTokenRequest struct {
UserID uint64 `json:"userId" validate:"required"`
AuthCode string `json:"authCode" validate:"required"`
}
type GoogleCalendarTokenService interface {
SaveToken(ctx context.Context, token mia.GoogleCalendarToken) error
GetTokenByUserID(ctx context.Context, userID uint64) (mia.GoogleCalendarToken, error)
}
type GoogleCalendarTokenHandler struct {
TokenValidator *google.GoogleCalendarTokenValidator
TokenService GoogleCalendarTokenService
}
func NewGoogleCalendarTokenHandler(
tokenValidator *google.GoogleCalendarTokenValidator,
tokenService GoogleCalendarTokenService,
) *GoogleCalendarTokenHandler {
return &GoogleCalendarTokenHandler{
TokenValidator: tokenValidator,
TokenService: tokenService,
}
}
// 認証コードをリフレッシュトークンに交換しデータベースに保存
func (h *GoogleCalendarTokenHandler) HandleExchangeAuthCode(c echo.Context) error {
var req GoogleCalendarTokenRequest
if err := c.Bind(&req); err != nil {
slog.Error("Failed to bind request", slog.Any("error", err))
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request payload"})
}
// リクエスト内容のデバッグログ
slog.Info("Received request", "userID", req.UserID, "authCode", req.AuthCode)
// リクエストのバリデーション
if req.UserID == 0 || req.AuthCode == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "userId and authCode are required"})
}
ctx := context.Background()
// AuthCodeを使用してアクセストークンとリフレッシュトークンを取得
token, err := h.TokenValidator.ExchangeAuthCode(ctx, req.AuthCode)
if err != nil {
slog.Error("Failed to exchange auth code", slog.Any("error", err))
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to exchange auth code"})
}
// トークン内容のデバッグログ
slog.Info("Token exchanged successfully", "accessToken", token.AccessToken, "refreshToken", token.RefreshToken, "expiry", token.Expiry)
// トークンデータを構築
googleToken := mia.GoogleCalendarToken{
UserID: req.UserID,
RefreshToken: token.RefreshToken,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// トークンをデータベースに保存
if err := h.TokenService.SaveToken(ctx, googleToken); err != nil {
slog.Error("Failed to save Google Calendar token", slog.Any("error", err))
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save token"})
}
return c.JSON(http.StatusOK, map[string]string{"message": "Token saved successfully"})
}
GooggleCalendarTokenValidator: change the authentication code to a refresh token
GoogleCalendarTokenValidator
is a component that provides the logic to exchange an authentication code for a refresh token based on the OAuth 2.0 authentication flow.
Since Google Service Account cannot obtain user-specific access permissions to Google Calendar, OAuth 2.0 must be used to access user-specific Google Calendar in this case.
package google
import (
"context"
"errors"
"fmt"
"os"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
)
// GoogleCalendarTokenValidator は Google Calendar API のトークン操作を行う構造体
type GoogleCalendarTokenValidator struct {
Service *calendar.Service
PackageName string
Config *oauth2.Config
}
// NewGoogleCalendarTokenValidator は Google Calendar API 用の Token Validator を初期化する
func NewGoogleCalendarTokenValidator() (*GoogleCalendarTokenValidator, error) {
// 環境変数からOAuth 2.0クライアント情報を取得
clientID := os.Getenv("GOOGLE_CLIENT_ID")
clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
redirectURI := os.Getenv("GOOGLE_REDIRECT_URI")
// 必須情報が不足している場合はエラー
if clientID == "" || clientSecret == "" || redirectURI == "" {
return nil, errors.New("missing required environment variables: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI")
}
// OAuth2 Config の初期化
config := &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURI,
Scopes: []string{calendar.CalendarScope},
Endpoint: google.Endpoint,
}
// Google Calendar Service の初期化
ctx := context.Background()
service, err := calendar.NewService(ctx, option.WithHTTPClient(config.Client(ctx, nil)))
if err != nil {
return nil, fmt.Errorf("failed to initialize Google Calendar service: %w", err)
}
// Validator の初期化
return &GoogleCalendarTokenValidator{
Service: service,
Config: config,
}, nil
}
// ExchangeAuthCode は認証コードをアクセストークンおよびリフレッシュトークンに交換します
func (v *GoogleCalendarTokenValidator) ExchangeAuthCode(ctx context.Context, authCode string) (*oauth2.Token, error) {
if v.Config == nil {
return nil, errors.New("OAuth2 config is not initialized")
}
// 認証コードをアクセストークンに交換
token, err := v.Config.Exchange(ctx, authCode)
if err != nil {
return nil, fmt.Errorf("failed to exchange auth code: %w", err)
}
return token, nil
}
Create OAuth 2.0 client ID
Google Cloud Console → APIs and Services → Authentication Information → OAuth 2.0 Client ID → Web Applications
If you download the authentication information in JSON format, you will get the following JSON.
{
"web": {
"client_id": "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com",
"project_id": "your-project-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_secret": "YOUR_CLIENT_SECRET",
"redirect_uris": ["https://your-app-url/callback"]
}
}
Google OAuth 2.0 requires the following client information for applications to access Google APIs
client_id
: ID to identify the application.client_secret
: Application secret information, used to exchange authentication codes for tokens.redirect_uri
: URL to redirect the user to after the authentication flow is complete (endpoint that processes tokens in the backend).
If successful, access_token
and refresh_token
are returned.
Response Example
{
"access_token": "ya29.a0AfH6SMC...",
"expires_in": 3599,
"refresh_token": "1//0g5fDh...",
"scope": "https://www.googleapis.com/auth/calendar",
"token_type": "Bearer"
}
operation check
After pressing the calendar integration button from the Flutter app and executing the google sign-in, I was able to confirm that the refreshtoken was saved on the server side as shown below after granting access to the calendar.
The next step is to develop the part that uses this refresh_token to retrieve calendar appointments, convert the text to speech, and send it to the main body of the meer (ESP32).