方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

[Mia] Implementation of Google Calendar API access utilizing Auth Code and Refresh Token (Go and Flutter)

authcode-refreshtoken-calendar
This article can be read in about 28 minutes.

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 the users 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 namedata typeDescription.
idUUID/BigIntUnique token identifier
user_idUUID/BigIntUser ID (foreign key in the users table)
refresh_tokenTEXTEncrypted refresh token
created_atTIMESTAMPrecord creation time
updated_atTIMESTAMPrecord 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

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.
Dart
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

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).
Go
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.

Go
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.

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

JSON
{
  "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).

Copied title and URL