Google認証とOAuth2.0で、Googleサービスと連携する例のアレを作る
こんにちは。
この記事は、Acompanyのバレンタインアドカレ 12日目の記事です。
開発の業務の中で、Google 認証 + OAuth2.0 + ドライブアクセスができる機能を作る際になかなかベストな記事がなかったため、備忘録として書きました。
Webサービスなどでよく見るGoogle認証してカレンダー連携やDrive連携だったりを行う際に参考にしていただければと思います。
Google認証をしてGoogleサービスと連携する、例のアレを作りたい
今回作りたい機能は、Googleのサービスとアプリを連携したい時によく見るGoogleログインをして、アクセスリクエストを許可する例のアレです。
例えばNotionで、自分のアカウントのDriveにあるファイルを連携したい際に/GoogleDrive
で連携を開始すると下記のように認証と認可を行う画面が出てきます。
今回作りたい機能はまさにこれです。
Google認証とOAuth2.0
上記の機能を作る前に、そもそもAuth2.0は何か、機能としてどう利用されるのか改めて整理します。
OAuth2.0の説明は、より詳しい記事が世の中にたくさんあるので、詳細はこちらを読んでみてください。
上記の記事内にある「認可サーバー」がGoogle認証とアクセスリクエストの許可をユーザーに求める主体になり、「リソースサーバー」がGoogle DriveやGoogleカレンダーといったGoogleサービスになります。
OAuth2.0の仕組みを用いて、認証→アクセスの認可→アクセストークンの発行→リソースへのアクセスを行えるようにします。
ちなみに、「一番わかりやすいOAuthの説明」の記事で説明されている通り、OAuth2.0では、認可サーバーでのアクセストークンの発行とリソースサーバーでのアクセストークンの検証があります。
しかし、Googleのエコシステム上では私たち開発者が意識するべき箇所は、「ユーザーの認可サーバーへの誘導」「アクセストークンの取得」「リソースサーバーへのアクセストークン付きリクエスト」のみです。
本来であれば様々なセキュリティ上の考慮や攻撃モデルへの対処を行なわなければならないですが、Googleのおかげで開発者が考えるべきスコープがグッと小さくなっているのはありがたいことです。
作りながら学ぶGoogle OAuth2.0
今回は、Google Driveにアクセスし、ファイルのダウンロードが行えるサンプルアプリを用いてOAuth2.0の雰囲気を掴んでいこうと思います。
完成までのロードマップは以下です。
- GCPのセットアップ
- クライアントアプリのセットアップ
- 認可フローの構築
- リソースアクセスの構築
全てのコードはGitHubにあります。
1. GCPセットアップ
Google OAuth2.0を実装するためには、GCPにてプロジェクトのセットアップを行う必要があります。
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のクライアントを初期化
// クライアントを初期化する関数
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 認可サーバーへのリダイレクトを行います。
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の認証へリダイレクトを行うことができます。
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 認証から返される認証コードを受け取ります。
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の中にはアクセストークン以外にもリフレッシュトークンなども入っています。
4.リソースへのアクセスの構築
トークンの生成と利用ができるようになったので、いよいよGoogle Driveへのアクセスを行います。
googleapi
を使って、Driveにアクセスします。
Driveのファイルリストを表示するページを作成します。
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
ページで自アカウントのドライブの情報を一覧で表示できるよになります。
ついでに、ドライブのデータをアプリからダウンロードできる機能もつけてみましょう。
コード
// ... 省略
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アプリケーションレイヤーから、データ基盤の構築、エンプラ向けのセキュリティの強化など、ありとあらゆる領域で挑戦できる環境があります。
採用ページでも募集要項が出ていますが、ここ最近はカジュアル面談から採用が決まるケース、ポジションがないけど応募いただいて採用が決まるケースも増えていきています。
ぜひ、少しでも気になったら、まずは気軽にカジュアル面談から!
おまけ
Google認証→Callback後に任意のページに遷移させたい
認可フローの構築で、/callback
からアプリケーションに戻る際に任意のページにさせたいと思いました。
基本的な方針としては、/api/auth/google-oauth
へリクエストを送る際にクエリーパラメータに完了後に遷移させたいページへのパスを入れ込むことで対処しました。
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>
);
}
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);
}
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は、最初の承認のみ有効
認証されたアプリをGoogleアプリから削除して初期化するといいみたい。
シンプルにsetCredentials忘れていた説
const { tokens } = await oauth2Client.getToken(code);
oauth2Client.setCredentials(tokens);
invalid_requestエラー
{
error: 'invalid_request',
error_description: 'Missing parameter: redirect_uri'
}
OAuthクライアントにredirect_uriの設定を明示
google.auth.OAuth2(clientId, clientSecret, redirectUri);
参考
Discussion