Google OAuth2.0認証時に取得したアクセストークンの有効期限が切れてしまい、Google APIにアクセスできなくなったときの対処法
remix-auth
とremix-auth-google
を使ってRemixアプリケーションでGoogleアカウントでのログインを実装するとaccessTokenとrefreshTokenを取得することができる。
今まではaccessTokenのみにしか注目しておらず、refreshToken
については全く触らずに開発を行なってきた。
しかし、そのaccessToken
を使用してGoogle APIを操作しようとする際に、アクセストークンの取得から1時間以上経過したことによりGoogle APIを操作することができなくなってしまった。
この問題を調べていたときに、Remixでアクセストークンの更新処理を説明している記事をほとんど見かけなかったので、今回はTypeScriptを使ってその処理の実装方法について説明する。
リフレッシュトークンとは
リフレッシュトークンは有効期限が切れたaccessTokenを再発行するときに使用するトークンのことです。
リフレッシュトークンを利用するとユーザーに再ログインをしてもらわなくても新しいトークンを取得できます。 ユーザーに再ログインを促すことなく、アクセストークンの有効期限以上の長期間にわたってアクセストークンを利用したい場合などに活用できると思います。
リフレッシュトークンを取得する際の注意点
リフレッシュトークンは初回の認証時にしか受け取ることができない。
The refresh_token is only provided on the first authorization from the user. Subsequent authorizations, such as the kind you make while testing an OAuth2 integration, will not return the refresh_token again.
gdata - Not receiving Google OAuth refresh token - Stack Overflow
remix-auth-google
を使ってストラテジーを登録する際には、accessType
をoffline
に設定しないとリフレッシュトークンを取得することができない。そして、初回の認証以降もrefreshTokenが必要な場合はprompt
をconsent
に設定しないといけない。
online accessの場合はrefresh tokenは発行されない。
authenticatorに対してストラテジーを登録する処理は次のようになる。
// app/auth.server.ts import { Authenticator } from "remix-auth"; import { GoogleStrategy } from "remix-auth-google"; import { sessionStorage } from "./session.server"; export type User = { ... accessToken: string; refreshToken: string; }; export let authenticator = new Authenticator<User>(sessionStorage); const googleClientId = process.env.GOOGLE_CLIENT_ID ?? ""; const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET ?? ""; const googleCallbackURL = process.env.GOOGLE_CALLBACK_URL ?? ""; if (!googleClientId || !googleClientSecret || !googleCallbackURL) { throw new Error( "GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET and GOOGLE_CALLBACK_URL must be provided" ); } const scope = ["OAuth2.0スコープ"].join(" "); authenticator.use( new GoogleStrategy( { clientID: googleClientId, clientSecret: googleClientSecret, callbackURL: googleCallbackURL, scope, includeGrantedScopes: true, accessType: "offline", prompt: "consent", }, async ({ accessToken, refreshToken, extraParams, profile }) => { // 処理を記述 return { ... "accessToken": accessToken, "refreshToken": refreshToken ?? "", }; }), "google" );
このとき、コールバック関数の返り値のフィールドに必ずaccessTokenとrefreshTokenを返すようにして、セッションに保存するようにしてください。
後でアクセストークンを再発行する処理を実装する際にUserオブジェクトからrefreshTokenを取得する必要があるからです。
sessionStorage
はcreateCookieSessionStorage
を使用して各自で実装することをおすすめします。
リフレッシュトークンを再発行するための関数を実装
アクセストークンを再発行する際に使用するリクエストエンドポイントはhttps://www.googleapis.com/oauth2/v4/token
になります。
こちらのエンドポイントに対してPOSTメソッドでリクエストを送信します。
実装は次のようになります。
// app/utils/google/retrieveAccessTokenFromRefreshToken.ts export async function retrieveAccessTokenFromRefreshToken( refreshToken: string ): Promise< | { access_token: string; expires_in: number; scope: string; token_type: string; id_token: string; } | { error: string; error_description: string; } > { const bodyObj: { [key: string]: string } = { refresh_token: refreshToken, client_id: process.env.GOOGLE_CLIENT_ID ?? "", client_secret: process.env.GOOGLE_CLIENT_SECRET ?? "", redirect_uri: process.env.GOOGLE_CALLBACK_URL ?? "", grant_type: "refresh_token", access_type: "offline", }; const body = Object.keys(bodyObj) .map((key) => key + "=" + encodeURIComponent(bodyObj[key])) .join("&"); const response = await fetch( `https://www.googleapis.com/oauth2/v4/token`, { method: "post", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body, } ); const data = await response.json(); return data; }
body
に渡す内容として、認証に使用するOAuth2.0 クライアントIDのクライアントID
とクライアントシークレット
、そしてリダイレクトURI
を指定してください。
それ以外には、gran_typeとしてrefresh_token
を、access_typeはoffline
を指定します。
この関数を呼び出す際に、先ほどセッションに保存したrefreshTokenを取り出して呼び出してください。
const authUser = await authenticator.isAuthenticated(request); if (!authUser || !authUser.refreshToken) { // 認証できなかったときの処理 } const res = await retrieveAccessTokenFromRefreshToken( authUser.refreshToken );
まとめ
remix-auth-google
のREADME.mdにaccessToken
やrefreshToken
などの使い方や取得方法が書いていなかったのと、オンライン上で探してもJavaやPHP、Pythonなどでの実装方法しか見かけることがなかった。
なので、もしTypeScriptやRemixを利用していて、同じような問題にぶつかった人の一助になればと思う。