🧼

Google認証とOAuth2.0で、Googleサービスと連携する例のアレを作る

2024/01/24に公開

こんにちは。

この記事は、Acompanyのバレンタインアドカレ 12日目の記事です。

https://recruit.acompany.tech/264dc776835849328cdbdb52b369c6f9

開発の業務の中で、Google 認証 + OAuth2.0 + ドライブアクセスができる機能を作る際になかなかベストな記事がなかったため、備忘録として書きました。

Webサービスなどでよく見るGoogle認証してカレンダー連携やDrive連携だったりを行う際に参考にしていただければと思います。

Google認証をしてGoogleサービスと連携する、例のアレを作りたい

今回作りたい機能は、Googleのサービスとアプリを連携したい時によく見るGoogleログインをして、アクセスリクエストを許可する例のアレです。

例えばNotionで、自分のアカウントのDriveにあるファイルを連携したい際に/GoogleDriveで連携を開始すると下記のように認証と認可を行う画面が出てきます。

Google OAuth2.0 Notioin

今回作りたい機能はまさにこれです。

Google認証とOAuth2.0

上記の機能を作る前に、そもそもAuth2.0は何か、機能としてどう利用されるのか改めて整理します。

OAuth2.0の説明は、より詳しい記事が世の中にたくさんあるので、詳細はこちらを読んでみてください。

https://qiita.com/TakahikoKawasaki/items/e37caf50776e00e733be

https://auth0.com/jp/intro-to-iam/what-is-oauth-2

上記の記事内にある「認可サーバー」がGoogle認証とアクセスリクエストの許可をユーザーに求める主体になり、「リソースサーバー」がGoogle DriveやGoogleカレンダーといったGoogleサービスになります。

OAuth2.0の仕組みを用いて、認証→アクセスの認可→アクセストークンの発行→リソースへのアクセスを行えるようにします。

ちなみに、「一番わかりやすいOAuthの説明」の記事で説明されている通り、OAuth2.0では、認可サーバーでのアクセストークンの発行とリソースサーバーでのアクセストークンの検証があります。

しかし、Googleのエコシステム上では私たち開発者が意識するべき箇所は、「ユーザーの認可サーバーへの誘導」「アクセストークンの取得」「リソースサーバーへのアクセストークン付きリクエスト」のみです。

本来であれば様々なセキュリティ上の考慮や攻撃モデルへの対処を行なわなければならないですが、Googleのおかげで開発者が考えるべきスコープがグッと小さくなっているのはありがたいことです。

作りながら学ぶGoogle OAuth2.0

今回は、Google Driveにアクセスし、ファイルのダウンロードが行えるサンプルアプリを用いてOAuth2.0の雰囲気を掴んでいこうと思います。

完成までのロードマップは以下です。

  1. GCPのセットアップ
  2. クライアントアプリのセットアップ
  3. 認可フローの構築
  4. リソースアクセスの構築

全てのコードはGitHubにあります。

https://github.com/Hiro-mackay/google-oauth-example

1. GCPセットアップ

Google OAuth2.0を実装するためには、GCPにてプロジェクトのセットアップを行う必要があります。

https://developers.google.com/workspace/guides/auth-overview?hl=ja

GCPのプロジェクトを通して、クライアントアプリの制御を行うためだとは思われますが、微妙に分かりづらいので、このGCPのセットアップを完了するのも少し苦労しました。

GCPでのセットアップ事項

  • APIの有効化
  • OAuth同意画面の設定
  • アクセス認証情報の作成

APIの有効化

一番最初に、今回利用するGoogle Drive APIを有効にしておく必要がります。

APIとサービス > 「APIとサービスの有効化」から、Google Drive APIを有効にしておきます。

OAuth同意画面の設定

Googleの公式の通り、OAuth同意画面の設定を行います。

OAuth 同意画面を設定し、スコープを選択する  |  Google Workspace  |  Google for Developers

GCPコンソールから、APIとサービス > OAuth同意画面から設定します。

User Type「外部」を選択して、必要な事項を入力していきます。

OAuthのスコープとして今回はGoogle Drive APIを登録します。

「スコープの追加または削除」から、drive.readonlyの権限を選択し、スコープに登録します。

テストユーザーとして、今回アプリで認証認可を行うユーザー(自分)のメールアドレスを登録します。

これで、OAuth 同意画面は完了です。

アクセス認証情報の作成

Googleの公式の通り、アクセス認証情報の作成を行います。

アクセス認証情報を作成する  |  Google Workspace  |  Google for Developers

APIとサービス > 認証情報 > 認証情報を作成 > OAuth クライアント IDから作成を開始します。

ウェブアプリケーションから、必要な情報を入力します。

承認済みの JavaScript 生成元には、アプリのURLを入力します。

承認済みのリダイレクト URIには、apiのコールバックを受け取るAPI URLを入力します。

この設定については、後述します。

保存で、GCP側の設定は完了です。

2.クライアントアプリのセットアップ

ユーザーに実際に触ってもらうアプリを構成します。

今回はNext.jsで、データベースも何も準備しない超絶なシンプルな構成にします。

Next.jsのセットアップ

npx create-next-app@latest

Google APIのインストール

npm install googleapis

googleapiのクライアントを初期化

/lib/google/oauth.ts
// クライアントを初期化する関数

export const REDIRECT_URI =
  process.env.NODE_ENV === "development"
    ? "http://localhost:3000/api/auth/google-oauth/callback"
    : "https://xxxxxxxx.vercel.app/api/auth/google-oauth/callback";

export const OPTIONS = {
  clientId: process.env.GOOGLE_API_CLIENT_ID || "",
  clientSecret: CLIENT_SECRET,
  redirectUri: REDIRECT_URI,
};

export function createOAuth2Client(options?: {
  clientId?: string;
  clientSecret?: string;
  redirectUri?: string;
}) {
  const { clientId, clientSecret, redirectUri } = {
    ...OPTIONS,
    ...options,
  };

  // OAuthクライアントの初期化
  return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
}

Client IDとSecretは、APIとサービス > 認証情報から作成したOAuth 2.0 クライアント IDから確認することができます。

その他もろもろもセットアップ

npx shadcn-ui@latest init
npx shadcn-ui@latest add button card

3.認可フローの構築

アプリケーションの準備が整ったので、実際に認可フローを構築していきます。

まずは、ユーザーにGoogleの認証を行ってもらうために、Google 認可サーバーへのリダイレクトを行います。

/api/auth/google-oauth/route.tsx
import { createOAuth2Client } from "@/lib/google/auth-client";
import { redirect } from "next/navigation";
import { type NextRequest } from "next/server";

// Google 認証後、ユーザーに許可を得る認可スコープ
// この場合は、DriveへのRead権限の認可
const scopes = ["https://www.googleapis.com/auth/drive.readonly"];

export async function GET(req: NextRequest) {

  // OAuthクライアントの初期化
  const oauth2Client = createOAuth2Client();
	
  // Google 認証へのリンク生成
  const url = oauth2Client.generateAuthUrl({
    access_type: "offline",
    scope: scopes,
  });
	
  // Google認証リンクへリダイレクト
  redirect(url);
}

このAPIにGETリクエストを送ると、Googleの認証へリダイレクトを行うことができます。

/app/page.tsx
import { DriveLogo } from "@/components/logo/Drive";
import { Button } from "@/components/ui/button";
import Link from "next/link";

export default function Home() {
  return (
    <main className="min-h-screen flex justify-center items-center p-10">
      <Link href="/api/auth/google-oauth">
        <Button>
          <DriveLogo className="mr-2 text-xl" />
          <p>Drive 連携</p>
        </Button>
      </Link>
    </main>
  );
}

/api/auth/google-oauthにリクエストを送ることで、Google認証へのリダイレクトを行います。

テスト中のアプリのため、警告が出ますが「続行」で認可を行います。

よく見る、アクセス許可のページに遷移します。

このまま続行すると、元にいたページに戻り、アクセスリクエストが承認されます。

上記の「続行」後、Google認証はリダイレクト先(元のアプリケーション)にアクセス許可されたトークンを生成するための認証コードを生成して返します。

アプリケーション側では、この認証コードを受け取り、アクセストークンを生成することでリソースへのアクセスが可能になります。

そのため、Google認証後のリダイレクト先で認証コードを受け取りアクセストークンを生成する処理を書きます。

ここで、先ほどGCPで設定した際に出てきた/api/auth/google-oauth/callbackの出番です。

/api/auth/google-oauth/callbackでGoogle 認証から返される認証コードを受け取ります。

/api/auth/google-oauth/callback/route.tsx
import { createOAuth2Client, setOAuthTokenCookie } from "@/lib/google/oauth";
import { redirect } from "next/navigation";
import { type NextRequest } from "next/server";
import { cookies } from "next/headers";

export async function GET(req: NextRequest) {
  // OAuth2.0のクライアント
  const oauth2Client = createOAuth2Client();

  // 認証コードの取得
  // ?code=xxxxxのURLパラメータをget
  const searchParams = req.nextUrl.searchParams;
  const code = searchParams.get("code");

  if (!code) {
    return new Response(`Missing query parameter`, {
      status: 400,
    });
  }

  // 認証コードからトークンの生成
  const { tokens } = await oauth2Client.getToken(code);

  cookies().set({
    name: "google-oauth2-tokens",
    value: JSON.stringify(tokens),
    maxAge: 60 * 60 * 24 * 30, // 一ヶ月
    path: "/",
    sameSite: "lax",
    secure: true,
  });

  redirect("/drive");
}

これで、アプリケーションで認可されたアクセスをリソースに対して行うことができるようになります。

トークンを使う際には、Cookieからgetして利用します。

ちなみにtokensの中にはアクセストークン以外にもリフレッシュトークンなども入っています。

https://github.com/googleapis/google-auth-library-nodejs/blob/058a5035e3e4df35663c6b3adef2dda617271849/src/auth/credentials.ts#L15-L40

4.リソースへのアクセスの構築

トークンの生成と利用ができるようになったので、いよいよGoogle Driveへのアクセスを行います。

googleapiを使って、Driveにアクセスします。

Driveのファイルリストを表示するページを作成します。

/app/drive/page.tsx
import { DriveLogo } from "@/components/logo/Drive";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { createOAuth2Client, getOAuthTokenCookie } from "@/lib/google/oauth";
import { google } from "googleapis";
import Link from "next/link";

async function getDriveFiles() {
	
  // Cookieの取り扱いをモジュール化
  // https://github.com/Hiro-mackay/google-oauth-example/blob/daed8b5669f28621f88520bc175814a6176d0056/src/lib/google/oauth.ts#L30-L47
  const credentials = getOAuthTokenCookie();

  if (!credentials) return undefined;

  // クライアントの初期化
  // Cookieから取得したcredentialsをクライアントにセット
  const auth = createOAuth2Client();
  auth.setCredentials(credentials);

  // Drive APIを呼び出し
  // 初期化したOAuthクライアントも一緒に渡す
  const files = await google.drive({ version: "v3", auth }).files.list({
    pageSize: 10,
    fields: "files(id, name)",
  });

  return files.data;
}

export default async function Home() {
  const data = await getDriveFiles();

  return (
    <Card className="max-w-[800px] w-full">
      <CardHeader>
        <div className="flex justify-between">
          <CardTitle className="flex gap-3 items-center">
            <DriveLogo className="text-3xl" />
            Google Drive
          </CardTitle>
        </div>
      </CardHeader>

      <CardContent className="flex flex-col divide-y">
        {data?.files?.length ? (
          data.files.map((file) => (
            <Link
	      href={`https://drive.google.com/file/d/${file.id}/view?usp=drive_link`}
              key={file.id}
              className="text-blue-500 p-5 hover:underline"
            >
              {file.name}
            </Link>
          ))
        ) : (
          <p className="w-full p-10 text-center">No data.</p>
        )}
      </CardContent>
    </Card>
  );
}

上記のコードがうまくいくと、/driveページで自アカウントのドライブの情報を一覧で表示できるよになります。

ついでに、ドライブのデータをアプリからダウンロードできる機能もつけてみましょう。

コード
/app/drive/page.tsx
// ... 省略

async function getDriveFiles() {
  // ... 省略
  const files = await google.drive({ version: "v3", auth }).files.list({
    pageSize: 10,
		// webContentLink のフィールドを追加
    fields: "files(id, name, webContentLink)",
  });

  // ... 省略
}

export default async function Home() {
  const data = await getDriveFiles();

  return (
     // ... 省略

        {data?.files?.length ? (
          data.files.map((file) => (
            <div
              key={file.id}
              className="flex justify-between items-center p-4"
            >
              <Link
                href={`https://drive.google.com/file/d/${file.id}/view?usp=drive_link`}
                className="text-blue-500 hover:underline"
              >
                {file.name}
              </Link>

              <Link href={file.webContentLink || ""} passHref>
                <Button
                  variant="ghost"
                  size="icon"
                  disabled={!file.webContentLink}
                >
                  <Download />
                </Button>
              </Link>
            </div>
          ))
        ) : (
          <p className="w-full p-10 text-center">No data.</p>
        )}

	// ... 省略
  );
}

ダウンロードボタンとともに、データのダウンロードが可能になります。

まとめ

SaaSのサービスを利用していると、よくGoogleサービスの連携のために認証を求められる機能がありますが、この記事を通してどのように実装されているのか触りの部分を紹介しました。

今回は、機能として動作させるにとどまっているため、トークン管理の機構やもっと多くのDrive APIを通して実利用可能な機能にしていく必要はあります。

ただ、そこはアプリケーションの特性や要件によって固有性が高い部分にはなるので、ぜひ、この記事を参考にしながらGoogleサービス連携の機能を作ってみてはいかがでしょうか?

また、自分でGoogle連携の機能を作ってみて思ったのは、Google OAuth2.0の機能はアプリ開発者に大きなセキュリティリスクを依存してしまうものになるとも感じました。

ご覧いただいてわかる通り、アプリケーションはユーザーのアカウントの権限を持ってして、データアクセスができるので、例えば、悪意を持ったアプリケーションの開発者がOAtuh2.0を通してユーザートークンを獲得し好き勝手できる、みたいなことも簡単にできてしまいます。

最近はユーザー目線でも簡単にサービス感でのインテグレーションができて便利な反面、実はそのクリックがとんでもないセキュリティリスクの責任を負っているとも言えます。

便利な機能なため、使う側も作る側もリスク意識を持っていきたいと改めて思わされました。

もし、この記事が皆さんの開発にお役に立てれれば、ぜひ、この記事へのイイネお願いします!

Happy Hacking😎

Acompanyでは、「パーソナルデータクラウドプラットフォーム」を一緒に作っていただける仲間をのどから手が出るほど募集しています。

今回のようなWebアプリケーションレイヤーから、データ基盤の構築、エンプラ向けのセキュリティの強化など、ありとあらゆる領域で挑戦できる環境があります。

採用ページでも募集要項が出ていますが、ここ最近はカジュアル面談から採用が決まるケース、ポジションがないけど応募いただいて採用が決まるケースも増えていきています。

ぜひ、少しでも気になったら、まずは気軽にカジュアル面談から!

https://recruit.acompany.tech/

おまけ

Google認証→Callback後に任意のページに遷移させたい

認可フローの構築で、/callbackからアプリケーションに戻る際に任意のページにさせたいと思いました。

基本的な方針としては、/api/auth/google-oauthへリクエストを送る際にクエリーパラメータに完了後に遷移させたいページへのパスを入れ込むことで対処しました。

/app/route.tsx
import { DriveLogo } from "@/components/logo/Drive";
import { Button } from "@/components/ui/button";
import Link from "next/link";

export default function Home() {
  return (
    // originalUrlクエリーパラメータに任意のパスを指定
    <Link href="/api/auth/google-oauth/?originalUrl=/drive">
      <Button>
        <DriveLogo className="mr-2 text-xl" />
        <p>Drive 連携</p>
      </Button>
    </Link>
  );
}
/api/auth/google-oauth
import { createOAuth2Client } from "@/lib/google/oauth";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { type NextRequest } from "next/server";

const scopes = ["https://www.googleapis.com/auth/drive.readonly"];

export async function GET(req: NextRequest) {
  // リダイレクト先のURLをクエリーから取得してCookieに保存
  const searchParams = req.nextUrl.searchParams;
  const originalUrl = searchParams.get("originalUrl") || "/";
  cookies().set("oauth2-redirect-original-url", originalUrl);

  const oauth2Client = createOAuth2Client();

  const url = oauth2Client.generateAuthUrl({
    access_type: "offline",
    scope: scopes,
  });

  redirect(url);
}
/api/auth/google-oauth/callback/route.ts
import { createOAuth2Client, setOAuthTokenCookie } from "@/lib/google/oauth";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { type NextRequest } from "next/server";

export async function GET(req: NextRequest) {
  const oauth2Client = createOAuth2Client();

  const searchParams = req.nextUrl.searchParams;
  const code = searchParams.get("code");

  if (!code) {
    return new Response(`Missing query parameter`, {
      status: 400,
    });
  }

  cookies().delete("oauth2-redirect-original-url");

  const { tokens } = await oauth2Client.getToken(code);

  setOAuthTokenCookie(tokens);

  // リダイレクト先のURLを取得
  const originalUrl =
    cookies().get("oauth2-redirect-original-url")?.value || "/";
    
  // 指定されたURLにリダイレクト
  // decodeURIComponent: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent
  redirect(decodeURIComponent(originalUrl));
}

これで、「Drive連携」を押下後、/driveに自動で遷移できるようになります。

デバッグヒストリー

invalid_grantエラー

{ error: 'invalid_grant', error_description: 'Bad Request' }

refresh tokenは、最初の承認のみ有効
https://developers.google.com/identity/protocols/oauth2/web-server?hl=ja#creatingclient:~:text=重要な注意事項%3A refresh_token は最初の承認でのみ返されます

認証されたアプリをGoogleアプリから削除して初期化するといいみたい。
https://github.com/googleapis/google-api-nodejs-client/issues/750#issuecomment-304521450

シンプルにsetCredentials忘れていた説

const { tokens } = await oauth2Client.getToken(code);
oauth2Client.setCredentials(tokens);

https://developers.google.com/identity/protocols/oauth2/web-server?hl=ja#creatingclient

invalid_requestエラー

{
      error: 'invalid_request',
      error_description: 'Missing parameter: redirect_uri'
}

OAuthクライアントにredirect_uriの設定を明示

google.auth.OAuth2(clientId, clientSecret, redirectUri);

参考

https://zenn.dev/mackay/scraps/16b5ab2339ad58
https://developers.google.com/workspace/guides/auth-overview?hl=ja
https://developers.google.com/drive/api/quickstart/js?hl=ja

Acompany

Discussion