Zenn
🦊

Google Calendar Integration 開発者向け Token 管理の注意点

2025/03/26に公開

Google Calendar Integration 開発者向け Token 管理の注意点

1. Google Token の基本

Google のサービスを利用する際、認証システムには主に二つの Token が関わっています:

  • Access Token: 短期間有効で、API へのアクセス権を提供します
  • Refresh Token: 長期間有効で、Access Token の再発行に使用されます

Token の形式と例

実際の Token は以下のような JSON 形式で返されます:

{
  "access_token": "ya29.a0AfB_byC-3n...",
  "expires_in": 3599,
  "refresh_token": "1//0eXy...",
  "scope": "https://www.googleapis.com/auth/calendar",
  "token_type": "Bearer"
}

2. Refresh Token の失効条件

Google の公式ドキュメントによると、以下の状況で Refresh Token が無効になることがあります:

  • ユーザーがアプリのアクセス権を取り消した
  • Refresh Token が 6 か月間使用されていない
  • ユーザーがパスワードを変更し、Refresh Token に Gmail スコープが含まれている
  • ユーザー アカウントが、付与された(有効な)Refresh Token の最大数を超えている
  • 管理者がアプリのスコープでリクエストされたサービスのいずれかを制限付きに設定した場合(エラーは admin_policy_enforced)
  • Google Cloud Platform API の場合 - 管理者が設定したセッション継続時間が超過している可能性がある

3. 未使用による失効の防止策

特に注意すべきは「Refresh Token が 6 か月間使用されていない」という条件です。これを防ぐために、cron ジョブなどの定期実行ツールを利用して、定期的に Refresh Token を使用して Access Token を取得するプロセスを自動化することが推奨されます。

Refresh Token を使ったアクセストークン取得の例

Node.js の例:

// Get new access token using refresh token
async function refreshAccessToken(refreshToken) {
  const { google } = require('googleapis');
  
  const oauth2Client = new google.auth.OAuth2(
    CLIENT_ID,
    CLIENT_SECRET,
    REDIRECT_URI
  );
  
  oauth2Client.setCredentials({
    refresh_token: refreshToken
  });
  
  try {
    // This will use the refresh token to get a new access token
    const { credentials } = await oauth2Client.refreshAccessToken();
    console.log('New access token acquired');
    return credentials;
  } catch (error) {
    console.error('Error refreshing access token:', error);
    throw error;
  }
}

定期実行のサービス例

Node.js の cron ジョブ例

// Setup a cron job to refresh tokens every month
const cron = require('node-cron');
const { google } = require('googleapis');
const User = require('./models/User'); // 例:Mongoose モデル

// Run at 00:00 on the 1st of every month
cron.schedule('0 0 1 * *', async () => {
  try {
    // Get all user refresh tokens from database
    const users = await User.find({ refreshToken: { $exists: true } });
    
    for (const user of users) {
      const newCredentials = await refreshAccessToken(user.refreshToken);
      
      // If we got a new refresh token, update it in the database
      if (newCredentials.refresh_token) {
        user.refreshToken = newCredentials.refresh_token;
      }
      
      // Update access token
      user.accessToken = newCredentials.access_token;
      await user.save();
    }
    
    console.log('All tokens refreshed successfully');
  } catch (error) {
    console.error('Token refresh cron job failed:', error);
  }
});

AWS クラウドサービスを使用した定期実行例

AWS Lambda と EventBridge (旧 CloudWatch Events) を使用して定期的なトークン更新を自動化できます:

// AWS Lambda function for refreshing tokens
// File: refreshTokensFunction.js
const { DynamoDB } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocument } = require('@aws-sdk/lib-dynamodb');
const { google } = require('googleapis');

const dynamoClient = new DynamoDB();
const docClient = DynamoDBDocument.from(dynamoClient);

exports.handler = async (event) => {
  try {
    // Get all users with refresh tokens from DynamoDB
    const { Items: users } = await docClient.scan({
      TableName: 'UsersTable',
      FilterExpression: 'attribute_exists(refreshToken)'
    });
    
    for (const user of users) {
      const newCredentials = await refreshAccessToken(user.refreshToken);
      
      // Update user record
      await docClient.update({
        TableName: 'UsersTable',
        Key: { userId: user.userId },
        UpdateExpression: 'SET accessToken = :accessToken, refreshToken = :refreshToken',
        ExpressionAttributeValues: {
          ':accessToken': newCredentials.access_token,
          ':refreshToken': newCredentials.refresh_token || user.refreshToken
        }
      });
    }
    
    return { statusCode: 200, body: 'All tokens refreshed successfully' };
  } catch (error) {
    console.error('Token refresh failed:', error);
    return { statusCode: 500, body: 'Token refresh failed' };
  }
};

// この Lambda 関数を AWS EventBridge で毎月実行するように設定:
// - EventBridge のルール: cron(0 0 1 * ? *)
// - ターゲット: 上記の Lambda 関数

Google Cloud サービスを使用した定期実行例

Google Cloud Functions と Cloud Scheduler を使用して定期的なトークン更新を自動化できます:

// Google Cloud Function for refreshing tokens
// File: index.js
const { Firestore } = require('@google-cloud/firestore');
const { google } = require('googleapis');

const firestore = new Firestore();

exports.refreshTokens = async (req, res) => {
  try {
    // Get all users with refresh tokens from Firestore
    const usersSnapshot = await firestore.collection('users')
      .where('refreshToken', '!=', null)
      .get();
      
    const promises = [];
    
    usersSnapshot.forEach(doc => {
      const user = doc.data();
      promises.push(
        (async () => {
          const newCredentials = await refreshAccessToken(user.refreshToken);
          
          // Update Firestore document
          await doc.ref.update({
            accessToken: newCredentials.access_token,
            refreshToken: newCredentials.refresh_token || user.refreshToken,
            lastRefreshed: new Date()
          });
        })()
      );
    });
    
    await Promise.all(promises);
    
    res.status(200).send('All tokens refreshed successfully');
  } catch (error) {
    console.error('Token refresh failed:', error);
    res.status(500).send('Token refresh failed');
  }
};

// この Cloud Function を Google Cloud Scheduler で毎月実行するように設定:
// - Cloud Scheduler のジョブ: 
//   - 頻度: 0 0 1 * *
//   - ターゲット: HTTP
//   - URL: Cloud Function のエンドポイント
//   - HTTP メソッド: GET

4. Token 数の上限に関する注意点

もう一つ重要な点は「ユーザー アカウントが付与された Refresh Token の最大数を超えている」という条件です。ユーザーがプライベートブラウジングモードを使用したり、デバイスを変更してログインしたりすると、新しい Refresh Token が発行される可能性があります。

Google のドキュメントによれば:

現在のところ、Google アカウントあたり、OAuth 2.0 クライアント ID あたりの更新トークンの上限は 100 個です。この上限値に達すると、新しい更新トークンが作成された際に自動的に一番古い更新トークンが警告なく無効化されます。この上限はサービス アカウントには適用されません。

したがって、アプリケーション開発時には新しい Refresh Token を受け取った場合、必ず最新の Token を保存するように実装することが重要です。

認証コールバックでの Token 処理例

// OAuth2 callback handler
app.get('/auth/google/callback', async (req, res) => {
  const { google } = require('googleapis');
  const code = req.query.code;
  
  try {
    const oauth2Client = new google.auth.OAuth2(
      CLIENT_ID,
      CLIENT_SECRET,
      REDIRECT_URI
    );
    
    // Exchange authorization code for tokens
    const { tokens } = await oauth2Client.getToken(code);
    
    // Get user info from Google
    oauth2Client.setCredentials(tokens);
    const oauth2 = google.oauth2({
      auth: oauth2Client,
      version: 'v2'
    });
    
    const { data } = await oauth2.userinfo.get();
    
    // Find user in database
    let user = await User.findOne({ googleId: data.id });
    
    if (user) {
      // Update existing user's tokens
      user.accessToken = tokens.access_token;
      
      // Always save new refresh token if provided
      if (tokens.refresh_token) {
        user.refreshToken = tokens.refresh_token;
      }
    } else {
      // Create new user
      user = new User({
        googleId: data.id,
        email: data.email,
        name: data.name,
        accessToken: tokens.access_token,
        refreshToken: tokens.refresh_token
      });
    }
    
    await user.save();
    
    // Set session and redirect
    req.session.userId = user._id;
    res.redirect('/dashboard');
  } catch (error) {
    console.error('Authentication error:', error);
    res.redirect('/auth/error');
  }
});

5. Token 保存のためのデータベース設計例

Google Calendar Token を安全かつ効率的に保存するためのデータベース設計が重要です。以下に異なるデータベースタイプでの設計例を示します。

リレーショナルデータベース(MySQL / PostgreSQL)

ユーザーテーブル設計例

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    google_id VARCHAR(255) UNIQUE NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(255),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE oauth_tokens (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    provider VARCHAR(50) NOT NULL DEFAULT 'google',
    access_token TEXT NOT NULL,
    refresh_token TEXT,
    token_type VARCHAR(50),
    expires_at TIMESTAMP,
    scopes TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_refreshed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    CONSTRAINT user_provider_unique UNIQUE (user_id, provider)
);

-- インデックスの作成
CREATE INDEX oauth_tokens_user_id_idx ON oauth_tokens (user_id);
CREATE INDEX oauth_tokens_expires_at_idx ON oauth_tokens (expires_at);

セキュリティのベストプラクティス

  1. Token は常に暗号化した状態で保存する
-- 暗号化関数を使用(データベースによって異なる)
UPDATE oauth_tokens 
SET access_token = ENCRYPT(access_token, 'secret_key'),
    refresh_token = ENCRYPT(refresh_token, 'secret_key');
  1. 必要に応じてデータベースレベルでの暗号化も検討する(Transparent Data Encryption など)

NoSQL データベース(MongoDB)

ユーザードキュメント設計例

// MongoDB Schema (Mongoose)
const UserSchema = new mongoose.Schema({
  googleId: {
    type: String,
    required: true,
    unique: true,
  },
  email: {
    type: String,
    required: true,
    unique: true,
  },
  name: String,
  oauthTokens: [{
    provider: {
      type: String,
      default: 'google',
    },
    accessToken: {
      type: String,
      required: true,
    },
    refreshToken: String,
    tokenType: String,
    expiresAt: Date,
    scopes: [String],
    lastRefreshed: {
      type: Date,
      default: Date.now,
    }
  }],
  createdAt: {
    type: Date,
    default: Date.now,
  },
  updatedAt: {
    type: Date,
    default: Date.now,
  }
});

// インデックスの設定
UserSchema.index({ 'oauthTokens.expiresAt': 1 });
UserSchema.index({ googleId: 1 });
UserSchema.index({ email: 1 });

const User = mongoose.model('User', UserSchema);

DynamoDB (AWS) の設計例

// DynamoDB テーブル設計
const UserTable = {
  TableName: 'Users',
  KeySchema: [
    { AttributeName: 'userId', KeyType: 'HASH' }, // パーティションキー
  ],
  AttributeDefinitions: [
    { AttributeName: 'userId', AttributeType: 'S' },
    { AttributeName: 'googleId', AttributeType: 'S' },
    { AttributeName: 'email', AttributeType: 'S' },
  ],
  GlobalSecondaryIndexes: [
    {
      IndexName: 'GoogleIdIndex',
      KeySchema: [
        { AttributeName: 'googleId', KeyType: 'HASH' },
      ],
      Projection: { ProjectionType: 'ALL' },
      ProvisionedThroughput: {
        ReadCapacityUnits: 5,
        WriteCapacityUnits: 5,
      },
    },
    {
      IndexName: 'EmailIndex',
      KeySchema: [
        { AttributeName: 'email', KeyType: 'HASH' },
      ],
      Projection: { ProjectionType: 'ALL' },
      ProvisionedThroughput: {
        ReadCapacityUnits: 5,
        WriteCapacityUnits: 5,
      },
    },
  ],
  ProvisionedThroughput: {
    ReadCapacityUnits: 10,
    WriteCapacityUnits: 10,
  },
};

// ユーザーアイテム例
const userItem = {
  userId: 'user123',
  googleId: '123456789',
  email: 'user@example.com',
  name: 'Example User',
  googleOauth: {
    accessToken: 'ya29.a0AfB_byC-3n...',
    refreshToken: '1//0eXy...',
    tokenType: 'Bearer',
    expiresAt: 1672531199000, // Unix timestamp in milliseconds
    scopes: ['https://www.googleapis.com/auth/calendar'],
    lastRefreshed: 1672444799000,
  },
  createdAt: 1672358399000,
  updatedAt: 1672444799000,
};

Firestore (Google Cloud) の設計例

// Firestore コレクション構造
// Collection: 'users'
// Document ID: ユーザーのユニークID

// ユーザードキュメント例
const userDocument = {
  googleId: '123456789',
  email: 'user@example.com',
  name: 'Example User',
  oauth: {
    google: {
      accessToken: 'ya29.a0AfB_byC-3n...',
      refreshToken: '1//0eXy...',
      tokenType: 'Bearer',
      expiresAt: new Date('2023-01-01T00:00:00Z'),
      scopes: ['https://www.googleapis.com/auth/calendar'],
      lastRefreshed: new Date('2022-12-31T00:00:00Z'),
    }
  },
  createdAt: new Date('2022-12-30T00:00:00Z'),
  updatedAt: new Date('2022-12-31T00:00:00Z'),
};

// Index設定 (Firestore コンソールから設定)
// - Collection: users, Field: googleId, Order: Ascending
// - Collection: users, Field: email, Order: Ascending

セキュリティに関する一般的な推奨事項

  1. 暗号化: すべての Token を保存する前に適切な暗号化を行う
  2. アクセス制限: データベースへのアクセス権限を最小限に保つ
  3. 監査ログ: Token のアクセスと更新操作をログに記録する
  4. バックアップ: 定期的なバックアップを実施して Token データの損失を防ぐ
  5. データ分離: 可能であれば認証情報を他のユーザーデータと別のテーブル/コレクションに保存する

6. まとめ

Google Calendar Integration を実装する際には、Token 管理に細心の注意を払う必要があります:

  1. Refresh Token の失効条件を理解する
  2. 定期的に Token を使用して 6 ヶ月間の未使用による失効を防ぐ
  3. 新しい Refresh Token を受け取ったら常に保存する
  4. ユーザー体験を向上させるため、Token 失効時の適切な再認証フローを提供する
  5. 適切なデータベース設計で Token を安全に保存する

これらの注意点を守ることで、より信頼性の高い Google Calendar Integration を構築することができます。

最後に

Jicooではエンジニアを全方位的に募集しておりますので、ご興味ありましたらカジュアル面談も可能ですので応募いただけると嬉しいです!
https://www.wantedly.com/companies/jicoo/projects
ここまで読んでいただいて、ありがとうございました。

この記事は Cursor(with Claude 3.7 sonet)と一緒に作成しました。

ここまで読んでいただいて、ありがとうございました。

Discussion

ログインするとコメントできます