Closed31

Next.js App Router でAuth.jsを開始する

keitaknkeitakn

必要packageをインストール

以下を実行する。

npm install next-auth

Auth.jsという名前になったのは最近だと記憶しているがpackage名は next-auth のままらしい。

keitaknkeitakn

Next.js以外は @auth/core などのpackageを利用するようだ。

keitaknkeitakn

OpenID Providerのクライアント情報を作成(Google)

Googleログインを実装する為にクライアント情報を作成する、以下の手順に従う。

1. プロジェクトの作成

以下のURLからプロジェクトを作成する。

別に最初からあるプロジェクトを使っても良いが、プロジェクトを作ったほうが分かりやすい。

https://console.cloud.google.com/home/dashboard

以下から作成を進める。

create_google1

create_google2

今回プロジェクト名は next-auth-examples としましたが、これが会社名等になる場合もアリだと思います。

create_google3

2. 認証情報に必要なOAuth同意画面を作成

以下のように作成したプロジェクトに移動して「APIとサービス」→「認証情報」に遷移します。

create_google4

「認証情報を作成」→「OAuth クライアントID」を選択します。

すると同意画面の設定を求められるので「同意画面を設定」を押下して設定を開始します。

create_google5

User Typeは「外部」を選択して作成を押下します。

create_google7

ここから各項目を埋めていきます。

アプリ名

外部に公開される値ではないので、サービスの識別子になる文字列であれば何でも大丈夫です。

ユーザーサポートメール

ユーザーが同意画面から問い合わせる際に利用するメールアドレスを入力します。

アプリのロゴ

一旦はなしで大丈夫ですが、アプリを本番に公開する際は設定したほうが良いので 120 × 120のサービスロゴをリリースまでに用意しておきましょう。

create_google8

アプリのドメイン

アプリケーションのホームページには https://example.com のような対象サービスのトップページのURLを入れます。

以下の2つは現時点で実装されていなくても問題ありませんが実際に利用するURLを入れておきます。(アプリを本番公開する際は必須となります。)

  • 【アプリケーション プライバシーポリシー】リンク
  • 【アプリケーション 利用規約】リンク

承認済ドメイン

複数追加可能です。アプリケーションのホームページに入れたURLで利用されているドメイン example.com は登録しておきます。

Vercelを利用している場合、ブランチの作成毎に .vercel.app のサブドメインでプレビュー環境が生成されます。Vercelのプレビュー環境でGoogleログインを実行したい場合はこちらに事前に登録しておく必要があります。

デベロッパーの連絡先情報

メールアドレスを入力します。

ここまで出来たら「保存して次へ」を押下します。

スコープの追加

スコープの追加を行います。

ここで指定したスコープに応じてGoogleから取得出来るアクセストークンの権限が決定されます。

以下の例では openid, email, profile を指定しています。

これらを指定すると、ユーザー識別子、メールアドレス、プロフィール(名前等)をGoogleログイン時に得られるアクセストークンから取得可能です。

他にもスコープの設定は可能ですがあまりに多くのスコープを設定するとエンドユーザーから同意を得られずに離脱される可能性が高くなるので、実際のサービス開発では必要最低限のスコープを設定しプライバシーポリシーに利用範囲等を明記しておく事が大事になります。

create_google9

「保存して次へ」を押下して次へ進みます。

テストユーザーの追加

公開前のアプリはテストユーザーのみ利用可能なので、検証を行う際に利用するメールアドレスを追加します。

100名まで追加可能です。

create_google10

「保存して次へ」を押下すると同意画面の設定は完了です。

3. OAuth クライアント ID の作成

同意画面の設定が出来たので、OAuth クライアント IDの作成を実施します。

CreateOAuthClient1

CreateOAuthClient2

各項目を入力していきます。

アプリケーションの種類は「ウェブ アプリケーション」名前はプロダクト名などを入れます。

私が next-auth-examples(production) と入れている理由は本番用と別にステージングや開発用のクライアントIDが必要になった場合に備えてそうしています。

「承認済みのリダイレクト URI」にはGoogleからアプリケーションに戻ってくる際のURLを完全一致で入れておく必要があります。

最初はローカル環境で動作確認をするので http://localhost:3000/api/auth/callback/google を入れておきます。これはNext.jsでAuth.jsを使った場合に生成されるコールバックURLです。

この時点でステージング環境や本番環境のURLが決まっている場合は登録しておきます。

例えば以下のような形になります。ローカルホストと違ってプロトコルはHTTPSを利用する必要があります。

  • https://example.com/api/auth/callback/google
  • https://stg.example.com/api/auth/callback/google

CreateOAuthClient3

「作成」を押下すると作成完了です。

クライアント IDとクライアント シークレットをコピーしておきます。

keitaknkeitakn

ローカルでの動作確認

最初に .env.local を下記の内容で用意します。

NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=独自に生成した十分に長い文字列
GOOGLE_CLIENT_ID=作成したGoogleのクライアントIDを指定
GOOGLE_CLIENT_SECRET=作成したGoogleのクライアントシークレットを指定
APP_ACCESS_TOKEN_SECRET=独自に生成した十分に長い文字列(これに関しては後で説明します)

次に必要なpackageを追加します。

npm install next-auth
# これが必要な理由は後で説明します
npm install jose

ここからはポイントを絞って解説します。

以下のPRを見ると全体が分かるようになっているので、詳しく知りたい方は以下のPRを参照してください。

https://github.com/keitakn/next-auth-examples/pull/4

最初に src/constants/auth.ts というファイルを用意します。

このコードの説明に関しては以下のPRのコード内コメントを見たほうが早いのでリンクを貼っておきます。

https://github.com/keitakn/next-auth-examples/pull/4/files

import * as jose from 'jose';
import type { DefaultSession, NextAuthOptions, Session, User } from 'next-auth';
import type { DefaultJWT, JWT } from 'next-auth/jwt';
import GoogleProvider from 'next-auth/providers/google';

declare module 'next-auth' {
  interface Session extends DefaultSession {
    appAccessToken: string;
  }
}

declare module 'next-auth/jwt' {
  interface JWT extends DefaultJWT {
    iat: number;
    exp: number;
    jti: string;
    provider?: OidcProvider;
  }
}

type OidcProvider = 'google' | 'line';

const isOidcProvider = (value: unknown): value is OidcProvider => {
  if (typeof value !== 'string') {
    return false;
  }

  // Providerの種類が増えたらリファクタリングを検討
  // Providerの種類が増えたら https://next-auth.js.org/providers/ を参照
  return value === 'google' || value === 'line';
};

export const options: NextAuthOptions = {
  debug: true,
  providers: [
    GoogleProvider({
      clientId: String(process.env.GOOGLE_CLIENT_ID),
      clientSecret: String(process.env.GOOGLE_CLIENT_SECRET),
    }),
  ],
  callbacks: {
    session: async ({
      session,
      token,
    }: {
      session: Session;
      user: User;
      token: JWT;
    }) => {
      if (token.sub != null && token.provider != null) {
        const payload = {
          sub: token.sub,
          provider: String(token.provider),
        };

        const secret = new TextEncoder().encode(
          String(process.env.APP_ACCESS_TOKEN_SECRET),
        );

        const alg = 'HS256';

        session.appAccessToken = await new jose.SignJWT(payload)
          .setProtectedHeader({ alg })
          .setExpirationTime('30d')
          .setJti(String(token.jti))
          .sign(secret);
      }

      return session;
    },
    // eslint-disable-next-line @typescript-eslint/require-await
    jwt: async ({ token, account }) => {
      if (account) {
        if (isOidcProvider(account.provider)) {
          token.provider = account.provider;
        }
      }

      return token;
    },
  },
};

次に src/app/api/auth/[...nextauth]/route.ts というファイルを以下の内容で作成します。

import { options } from '@/constants/auth';
import NextAuth from 'next-auth/next';

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const handler = NextAuth(options);

export { handler as GET, handler as POST };

これだけで /api/auth/callback/google のようなOpenIDConnectのコールバックURLや /api/auth/session のようなログイン中のユーザー情報を取得するエンドポイントが実装されます。

export const runtime = 'edge' を追加して Edge Runtime で動作させたいところだが残念ながら動作しなかった。(next-auth 4.23.1 で確認)

以下を見る限り、次のバージョンではサポートされそうなので対応されたら置き換えようと思う。

https://github.com/nextauthjs/next-auth/discussions/5855

keitaknkeitakn

ローカルでの動作確認

以下のようにGoogleログインを実行後にGoogleからプロフィール情報を取得出来ている事を確認済み。

keitaknkeitakn

Vercel上での動作確認

Vercel上に環境変数の登録を忘れずに行う。

承認済ドメインにVercelのアプリのドメインを追加しておくのと、https://Vercelドメイン名/api/auth/callback/google をリダイレクトURIに設定しておく必要があります。

keitaknkeitakn

ログアウト機能の実装

以下のPRを参照。

https://github.com/keitakn/next-auth-examples/pull/8

特に難しくはない。

ログアウト後の挙動としては next-auth.session-token というCookieが削除されるのみ。

データベースにデータを永続化するとデータベースに保存されているsessionのレコードが削除されるのだと思う。

keitaknkeitakn

LINEログインの実装(事前準備)

LINE Business IDのアカウント作成

以下のページに遷移します。

自分はLINEアカウントでログインを用いてログインを実施しました。

https://account.line.biz/login

1. プロバイダーの作成

以下のページからプロバイダーの作成を実施します。

https://developers.line.biz/console/

これはサンプルなので実験用のアプリ名である next-auth-examples を入れていますが実際には組織名やサービス名等を入れるのが良いでしょう。

create_line_provider1

create_line_provider2

2. LINEログイン用のチャネルを作成する

LINEログイン用のチャネルを作成していきます。

channel1

以下のように結構入力情報が多いですが1つ1つ入力していきます。

チャネルの種類

これは「LINEログイン」でOKです。

プロバイダー

先程作成したプロバイダーが選択されていると思いますのでそのままでOKです。

サービスを提供する地域

サービス提供地域を選択します。

今回自分は日本だけで困らないので詳しくは調べてないのですが、複数選択は出来ないので複数の地域にサービスを提供する場合はそれぞれの地域毎にチャネルを作成する必要があるかもです。

会社・事業者の所在国・地域

選択します。

チャネルアイコン

こちらは必須ではないですが、同意画面に出てくる画像になるので可能な限り設定しておいたほうが良いです。

チャネル名

チャネル名を設定します。これはLINEログイン用のチャネルなので大体はサービス名を入れる事になるかと思います。

チャネル説明

サービスの説明等を記載します。サンプルなので適当に入力しています。

channel2

続き

アプリタイプ

こちらはウェブアプリを選択します。

2要素認証の必須化

これを必須化するとログイン時にLINEアプリに送信された6桁の数字の入力を求められるようになる。

メールアドレス

受信可能なメールアドレスを入れる。

プライバシーポリシーURL

任意だが可能な限り設定しておいたほうが良い。

サービス利用規約URL

任意だが可能な限り設定しておいたほうが良い。

channel3

3. LINEからメールアドレスを取得出来るようにする

デフォルトだとLINEからはプロフィールとユーザーの識別子しか取得出来ない、LINEログイン時にメールアドレスを取得出来るように申請を行う。

email1

「申請」を押下するとメールアドレスの利用範囲を示したスクリーンショットを要求されるので、プライバシーポリシーの該当箇所などをスクショしてアップロードすれば良い。

成功すると以下のように権限の箇所に OC_EMAIL が追加されている。

email2

4. コールバックURLを追加する

Googleの時と同じようにURLを登録する、以下のように /api/auth/callback/line を指定する。

改行する事で複数件のURLを登録可能なので現時点で利用すると決まっているURLは全て登録しておく。

http://localhost:3000/api/auth/callback/line
https://example.com/api/auth/callback/line

以上で事前準備は完了。

keitaknkeitakn

LINEログインの実装

以下のように .env.local にLINEの情報を追加する。

LINE_CLIENT_ID=チャネルIDを指定
LINE_CLIENT_SECRET=チャネルシークレットを指定

詳しい実装内容は以下のPRを参照。

https://github.com/keitakn/next-auth-examples/pull/9

Googleログインを実装した時とほぼ同じだが LineProvider の設定に authorization.params の指定が明示的に必要だった。これがないとLINEアカウントからメールアドレスの取得が出来なかった。

    LineProvider({
      clientId: String(process.env.LINE_CLIENT_ID),
      clientSecret: String(process.env.LINE_CLIENT_SECRET),
      authorization: {
        params: { scope: 'openid profile email' },
      },
    }),

公式の下記の説明を見る限り申請だけすればメールアドレスは取得出来るように見えたが実際には scope を明示的に指定する必要があった。

https://next-auth.js.org/providers/line

keitaknkeitakn

ちなみに同意画面は以下のように表示される。

authorization

これは初回だけしか表示されないので、再度見たい時は手元のスマートフォンのLINEアプリから「設定(歯車アイコン)」→「アカウント」→「連動アプリ」から作成したチャネルを探して連携解除すれば再度同意画面を確認出来る。

keitaknkeitakn

Auth.jsのデータをDBに永続化する

次は認証データの永続化を行っていく。

Drizzle というORMが評判がよくパフォーマンスも Prisma より良いらしいので、以下の記事を参考に試してみる。

以下の記事ではadapterを自作しているがどうやら https://authjs.dev/reference/adapter/drizzle を見る限りすでに公式のadapterが出ているようだ。

https://dev.to/miljancode/drizzle-orm-next-auth-and-planetscale-2jbl

DBはPlanetScaleを使う。PlanetScaleの始め方は以下にまとめている。

https://zenn.dev/keitakn/scraps/561179d821b973

keitaknkeitakn

最初に drizzle-kit push:mysql --config=drizzle.config.ts を使ったスキーマを反映しようとしたが、以下のエラーが発生した。

Error: Transform failed with 4 errors:
/Users/keita-koga/gitrepos/next-auth-examples/src/db/schema.ts:10:7: ERROR: Transforming const to the configured target environment ("es5") is not supported yet
/Users/keita-koga/gitrepos/next-auth-examples/src/db/schema.ts:21:7: ERROR: Transforming const to the configured target environment ("es5") is not supported yet
/Users/keita-koga/gitrepos/next-auth-examples/src/db/schema.ts:45:7: ERROR: Transforming const to the configured target environment ("es5") is not supported yet
/Users/keita-koga/gitrepos/next-auth-examples/src/db/schema.ts:53:7: ERROR: Transforming const to the configured target environment ("es5") is not supported yet
    at failureErrorWithLog (/Users/keita-koga/gitrepos/next-auth-examples/node_modules/esbuild/lib/main.js:1649:15)
    at /Users/keita-koga/gitrepos/next-auth-examples/node_modules/esbuild/lib/main.js:847:29
    at responseCallbacks.<computed> (/Users/keita-koga/gitrepos/next-auth-examples/node_modules/esbuild/lib/main.js:703:9)
    at handleIncomingPacket (/Users/keita-koga/gitrepos/next-auth-examples/node_modules/esbuild/lib/main.js:762:9)
    at Socket.readFromStdout (/Users/keita-koga/gitrepos/next-auth-examples/node_modules/esbuild/lib/main.js:679:7)
    at Socket.emit (node:events:513:28)
    at addChunk (node:internal/streams/readable:324:12)
    at readableAddChunk (node:internal/streams/readable:297:9)
    at Readable.push (node:internal/streams/readable:234:10)
    at Pipe.onStreamRead (node:internal/stream_base_commons:190:23) {
  errors: [
    {
      detail: undefined,
      id: '',
      location: [Object],
      notes: [],
      pluginName: '',
      text: 'Transforming const to the configured target environment ("es5") is not supported yet'
    },
    {
      detail: undefined,
      id: '',
      location: [Object],
      notes: [],
      pluginName: '',
      text: 'Transforming const to the configured target environment ("es5") is not supported yet'
    },
    {
      detail: undefined,
      id: '',
      location: [Object],
      notes: [],
      pluginName: '',
      text: 'Transforming const to the configured target environment ("es5") is not supported yet'
    },
    {
      detail: undefined,
      id: '',
      location: [Object],
      notes: [],
      pluginName: '',
      text: 'Transforming const to the configured target environment ("es5") is not supported yet'
    }
  ],
  warnings: []
}

これは単純にNext.jsの tsconfig.jsontargetes6 とかにすれば解決する。

push は何度も行う物ではないので、一時的に tsconfig.json を修正してから push する事にした。

keitaknkeitakn

初回にログインを実行した際に Data too long for column 'id_token' at row 1 (errno 1406) (sqlstate 22001) というエラーが発生した。

これは id_token の長さが 255 を超えてしまった為に起こるエラーだが一応公式の定義しているスキーマをそのまま利用したのにエラーになってしまったので少々不安になった。

ちなみにPlanetScal は仕様上外部キー制約を使えないので .references(() => users.id, { onDelete: "cascade" }) 等の記述は削除する必要がある。

https://authjs.dev/reference/adapter/drizzle

keitaknkeitakn

https://github.com/nextauthjs/next-auth/issues/8629 の回答はないが @auth/prisma-adapter で定義されている構造を参考にすると問題なく動作した。

しかし問題が1つ発生した。

account テーブルに created_atupdated_at をつけて drizzle-kit push:mysql --config=drizzle.config.ts を実行すると以下のエラーが発生した。

Error: target: next_auth_examples.-.primary: vttablet: rpc error: code = InvalidArgument desc = Invalid default value for 'created_at' (errno 1067) (sqlstate 42000) (CallerID: 1ufs1omww9tx8is7xtzl): Sql: "alter table account add INDEX idx_account_01 (userId)", BindVars: {REDACTED}
    at PromiseConnection.query (/Users/keita-koga/gitrepos/next-auth-examples/node_modules/drizzle-kit/index.cjs:35481:26)
    at Command.<anonymous> (/Users/keita-koga/gitrepos/next-auth-examples/node_modules/drizzle-kit/index.cjs:53292:33)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5) {
  code: 'ER_INVALID_DEFAULT',
  errno: 1067,
  sql: 'CREATE INDEX `idx_account_01` ON `account` (`userId`);',
  sqlState: '42000',
  sqlMessage: `target: next_auth_examples.-.primary: vttablet: rpc error: code = InvalidArgument desc = Invalid default value for 'created_at' (errno 1067) (sqlstate 42000) (CallerID: 1ufs1omww9tx8is7xtzl): Sql: "alter table account add INDEX idx_account_01 (userId)", BindVars: {REDACTED}`
}

メッセージ的に ER_INVALID_DEFAULT とあるが実際には CREATE INDEX の実行で失敗しているようだった。

PlanetScaleの問題なのか drizzle-kit の問題なのかはよく分からないが、仮にこれを何とか解決したとしてもタイムスタンプがミリ秒まで記録されていなかったりとテーブル設定の細かい部分までは制御が難しいので、無理に drizzle-kit push:mysql --config=drizzle.config.ts で作ったテーブル構造を利用せずに自分でテーブル定義をする事にした。

CREATE TABLE `user` (
  `id` varchar(255) NOT NULL COMMENT 'ユーザーの識別子、JWTトークンではsubに設定される値',
  `name` varchar(255) DEFAULT NULL COMMENT 'ユーザー名、最初に認証に利用したProviderから取得した値が入る',
  `email` varchar(255) NOT NULL COMMENT 'ユーザーのメールアドレス、最初に認証に利用したProviderから取得した値が入る',
  `emailVerified` DATETIME(6) COMMENT 'メールアドレスが認証済かどうかを示す、認証した日時が記録される',
  `image` varchar(255) DEFAULT NULL COMMENT 'エンドユーザーのプロフィールイメージが表示される',
  `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
  PRIMARY KEY (`id`),
  UNIQUE KEY `ui_user_01` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='Auth.jsでユーザーの識別子を管理するテーブル';

CREATE TABLE `account` (
  `userId` varchar(255) NOT NULL COMMENT 'userテーブルのid、ユーザーの識別子',
  `type` varchar(255) NOT NULL COMMENT 'oauth等の値が入る',
  `provider` varchar(255) NOT NULL COMMENT 'google, line等の認証Providerの名称が入る',
  `providerAccountId` varchar(255) NOT NULL COMMENT 'google, line等の認証Providerのユーザー識別子が入る',
  `refresh_token` text COMMENT '認証Providerから取得したリフレッシュトークンが記録される',
  `access_token` text COMMENT '認証Providerから取得したアクセストークンが記録される',
  `expires_at` int DEFAULT NULL COMMENT '認証Providerから取得したアクセストークンの有効期限',
  `token_type` varchar(255) DEFAULT NULL COMMENT 'Bearer等のトークン種別が記録される',
  `scope` varchar(255) DEFAULT NULL COMMENT 'リクエスト時に認証Providerに要求したscopeが記録される',
  `id_token` text COMMENT '認証Providerから取得したIDトークンの有効期限',
  `session_state` varchar(255) DEFAULT NULL,
  `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
  PRIMARY KEY (`provider`,`providerAccountId`),
  KEY `idx_account_01` (`userId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='Auth.jsでユーザーの認証情報を管理するテーブル';

CREATE TABLE `session` (
  `sessionToken` varchar(255) NOT NULL COMMENT 'セッションの識別子が格納される',
  `userId` varchar(255) NOT NULL COMMENT 'userテーブルのid、ユーザーの識別子',
  `expires` timestamp NOT NULL COMMENT 'セッションの有効期限が格納される',
  `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
  PRIMARY KEY (`sessionToken`),
  KEY `idx_session_01` (`userId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='Auth.jsでユーザーのセッションを管理するテーブル session.strategyがdatabaseの時だけ利用される';

CREATE TABLE `verificationToken` (
  `identifier` varchar(255) NOT NULL,
  `token` varchar(255) NOT NULL,
  `expires` timestamp NOT NULL,
  PRIMARY KEY (`identifier`,`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='Auth.jsでメールアドレスを使ったパスワードレスログインを利用する時に利用される';

これでも DrizzleAdapter を利用してAuth.jsのデータを永続化する事が出来るので問題はなさそう。

元々PlanetScaleは Safe migrations 機能の利用を推奨しており、ORMに付属しているマイグレーションツールでの管理を推奨していない。

今後Auth.jsがアップグレードされて、万が一テーブル構造の変更を余儀なくされても、Safe migrationsが有効なPlanetScaleであれば安全にテーブル構造の変更が可能なので問題なしと判断した。

keitaknkeitakn

データベース連携を実施する場合、公式ドキュメントのModels に記載されているテーブル構造にする必要があるがテーブル名に大文字が使われていたり、スネークケースとキャメルケースのカラム名が混在していたりする。

これはAuth.js側の方針のようでOAuth関連のカラム名にはスネークケースが使われているが、それ以外のパラメータはキャメルケースで表現されていたりする。

公式の https://authjs.dev/reference/adapter/prisma を見ると一応↓のような事が書いてあり、どちらかに統一は可能なようだが、設定が複雑になるのでオススメ出来ない。

If mixed snake_case and camelCase column names is an issue for you and/or your underlying database system, we recommend using Prisma's @map()(see the documentation here) feature to change the field names. This won't affect Auth.js, but will allow you to customize the column names to whichever naming convention you wish.

私も命名規則が統一されていないと気持ち悪いタイプなので、統一したい気持ちがあるがカラム名を変えてしまうとAuth.jsのアップグレードを実施する際の足かせになる可能性もあるので、これはこういう物だと割り切る事にした。

keitaknkeitakn

アカウント連携

既に認証済のユーザーが別の認証Provider のIDを連携する時の方法について解説する。

以下の例を元に説明する。

ユーザーは keita@example.com というGoogleアカウントを持っている。

同じく keita@example.com というアドレスで登録したLINEのアカウントを持っている。

初回にGoogleアカウントでログインを行う、その後ログアウトを実施して今度はLINEアカウントでログインを実施すると以下のようなエラーが発生する。

identity_error

これは user テーブルに既に同様のメールアドレスが登録されてしまっているので、登録を行う事が出来ないと判断されてしまう為である。

しかしログイン中であれば話は別。

keita@example.com というGoogleアカウントでログインを実施する。

LINE_Connect

LINEアカウント連携ボタンを押下してLINEログインを実施する。(LINEアカウント連携ボタンの裏側ではAuth.jsの signIn 関数が呼ばれている。)

すると今後は正常に処理が終了する。

user テーブルと account テーブルを見ると、最初にGoogleログインをした際に作成された、ユーザーID 039f7dbb-3f03-4e84-b07e-2b6b9ad52cd3 に対してGoogleアカウントとLINEアカウントが関連付けされた状態になっている事が分かる。

id_connect

このようにAuth.jsをデータベースありで使うとID連携を比較的簡単に実装出来る。

ちなみに今回の例では1人のユーザーがGoogleとLINEそれぞれのアカウントを1つずつ持っているという状態を作ったが、1人のユーザーが複数のGoogleアカウントを持つ事も出来る。

やり方は全く同じでログイン中のユーザーに対してAuth.jsの signIn 関数を使えば良い。

account テーブルを確認すると 039f7dbb-3f03-4e84-b07e-2b6b9ad52cd3 に対して複数のGoogleアカウントが紐づいている事が確認出来る。

id_connect2

これでどのGoogleアカウントでログインを実施しても同一人物(039f7dbb-3f03-4e84-b07e-2b6b9ad52cd3) として扱われる。

このようにAuth.jsをデータベースありで利用する事でアカウント連携機能も比較的簡単に実現出来る。

keitaknkeitakn

アカウント連携解除の方法について

ちょっと調べた感じだと連携解除の方法は見当たらなかった。

しかし account テーブルのデータを削除してしまえば連携解除は可能だったので、それで問題ないと思われる。

keitaknkeitakn

エラー画面のオーバーライド

先程、初回にGoogleアカウントでログインを行う、その後ログアウトを実施して今度はLINEアカウントでログインを実施すると以下のようなエラーが発生する事が分かったと思う。

identity_error1

このエラー画面はAuth.jsが自動で生成している物なので、自分たちのサービスのデザインに合わせてカスタマイズしたいと思うケースが大半だと思う。

その為のやり方をここに書いておく。

NextAuthOptionspages を追加して signIn ページを上書きする。(エラーが出ているのは signIn ページなので)

import type { NextAuthOptions } from 'next-auth';

export const options: NextAuthOptions = {
  // 省略
  // 必要に応じて https://next-auth.js.org/configuration/pages を参考に各ページをオーバーライドする
  pages: {
    signIn: '/login',
  },
  // 省略
};

次に src/app/login/page.tsx を作成する。

import { GoogleLoginButton, LineLoginButton } from '@/app/_components';
import type { JSX } from 'react';

// エラーメッセージの一覧は https://next-auth.js.org/configuration/pages#sign-in-page に記載されているので必要な物を定義する
type AuthErrorMessage =
  | 'OAuthSignin'
  | 'OAuthCallback'
  | 'OAuthCreateAccount'
  | 'EmailCreateAccount'
  | 'Callback'
  | 'OAuthAccountNotLinked'
  | 'EmailSignin'
  | 'CredentialsSignin'
  | 'SessionRequired';

type Props = {
  params: unknown;
  searchParams: {
    // TODO 型ガード用の関数とちゃんとした型を用意したほうが良い
    callbackUrl?: string;
    error?: AuthErrorMessage;
  };
};

function getErrorMessage(errorMessage?: AuthErrorMessage): string {
  if (errorMessage === 'OAuthAccountNotLinked') {
    return 'お手数ですが前回と同じアカウントでログインをお願いします。';
  }

  return '予期せぬエラーが発生しました。申し訳ありませんがしばらく時間が経ってからお試しください。';
}

export default function AuthErrorPage({ searchParams }: Props): JSX.Element {
  const errorMessage = getErrorMessage(searchParams.error);

  // TODO サンプルなので手抜き実装だが実際にはURLをバリデーションして自身のアプリケーションのURLかどうかを検証したほうが良い
  const callbackUrl =
    typeof searchParams.callbackUrl === 'string'
      ? searchParams.callbackUrl
      : process.env.NEXTAUTH_URL;

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div
        className="relative rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700"
        role="alert"
      >
        <strong className="font-bold">
          ログイン中にエラーが発生しました。
        </strong>
        <span className="block sm:inline">{errorMessage}</span>
      </div>
      <span className="isolate inline-flex rounded-md shadow-sm">
        <GoogleLoginButton callbackUrl={callbackUrl} />
        <LineLoginButton callbackUrl={callbackUrl} />
      </span>
    </main>
  );
}

サンプルなので雑な実装になってしまっているが、一応自分で設定したログインページ(エラーページ)が表示されてる事が確認出来る。

custom_login_page

以下の差分を見ると分かりやすいと思うので貼っておく。

https://github.com/keitakn/next-auth-examples/pull/10/commits/270d7450421252b1e5cb5f7d12827478e3881996

この方法に関しては公式ドキュメントの以下のページに載っている。

https://next-auth.js.org/configuration/pages

keitaknkeitakn

Auth.jsは データベースなしで使う、データベースありで使う、どちらが良いか?

これはどちらも良し悪しがある。

ログイン成功時に得られるJWTトークンのpayloadは下記の通り。

当然だがデータベースなしのほうはそのまま認証Providerから取得した値が入る。

データベースなしのJWTトークンの中身

{
  name: 'keita',
  email: 'keita@example.com',
  picture: 'https://example.com/your-image-path',
  // ここが認証Providerから取得したユーザー識別子になる
  sub: '111111111111111111111',
  provider: 'google',
  iat: 1695190516,
  exp: 1697782516,
  jti: 'UUID形式の値'
}

データベースありのJWTトークンの中身

データベースありの場合はAuth.jsが管理するデータベースuserテーブルのidの値になる。

{
  name: 'keita',
  email: 'keita@example.com',
  picture: 'https://example.com/your-image-path',
  // ここはAuth.jsが管理するデータベースuserテーブルのidの値になる
  sub: 'e770eafa-809b-484b-a253-c594b3a08994',
  provider: 'google',
  iat: 1695190728,
  exp: 1697782728,
  jti: 'UUID形式の値'
}

以下にPros & Consをまとめてみた。

データベースなし

Pros

  • 自由度が高い、アカウント連携の仕様等を自由に設計する事が可能

Cons

データベースあり

Pros

  • 初期の実装コストを減らす事が出来る

Cons

  • データベースの管理コストが発生する
  • 基本的にはAuth.jsの仕様に従う必要がある
    • データベースありの場合、同じメールアドレスを持つ別の認証Providerでアクセスを行った場合、エラーになってしまう等の制約がある
    • 一応↑の件に関しては アダプターをカスタマイズする する事で回避可能だが、このようなカスタマイズをするとAuth.jsのバージョンアップ時に動作しなくなってしまい、対応工数がかかってしまう可能性がある
    • アカウント連携に関しても1つの認証Providerで利用出来るのは1アカウントまでとか、連携アカウントの数を制限したい等の場合に対応出来ない
keitaknkeitakn

個人的にはAuth.jsの標準仕様で要件を満たせるなら、データベースありで使うのが簡単だと思う。

仕様を柔軟に変更したい場合や既存サービスに組み込む場合はデータベースなしで利用するのが無難だと思う。

後から変える事は変更コストがとても大きいので、最初にしっかりと考えておく事をオススメする。

keitaknkeitakn

データベースあり
Cons
データベースありの場合、同じメールアドレスを持つ別の認証Providerでアクセスを行った場合、エラーになってしまう等の制約がある
一応↑の件に関しては アダプターをカスタマイズする する事で回避可能だが、このようなカスタマイズをするとAuth.jsのバージョンアップ時に動作しなくなってしまい、対応工数がかかってしまう可能性がある

この問題に関しては設定で回避出来る事が分かったので、データベース利用のデメリットは性能面とデータベースの管理コストだけとなった。

↓に調査結果のコメントを貼っておく。

https://zenn.dev/link/comments/513d2952d24a70

keitaknkeitakn

データベースありでアカウントの名寄せと、メールアドレスなしのユーザーが登録出来るかを試す

LINEなどの場合、メールアドレスの登録が存在しないユーザーがいるので、そのようなユーザーでも登録が可能かどうかを試してみる。

また同じメールアドレスで別の認証Providerでログインを実行した場合、同一のメールアドレスを持つアカウントに自動的にリンクさせられないかも試してみる。

https://zenn.dev/link/comments/75c0c1f76f1c14 に書いた通り通常このケースはエラーになってしまうが 公式ドキュメントallowDangerousEmailAccountLinking を有効にする事でやりたい事が実現可能かもしれない。

ユーザーは keita@example.com というGoogleアカウントを持っている。
同じく keita@example.com というアドレスで登録したLINEのアカウントを持っている。

keitaknkeitakn

メールアドレスなしのユーザー

意外と簡単に達成出来た。

やる事は単純で user テーブルの email からNOT NULL制約を外せば良い。
私は何故かユニーク制約だから NOT NULL 付けないと駄目でしょ?と思い込んでいたが(実際にデータベースの設計としてはそっちのほうが良いと思う)PlanetScaleはMySQL互換でユニーク制約があってもNULLを複数のレコードで格納する事は可能だった事を思い出した。

という訳でテーブル構造を以下のように変更する。(userテーブル以外は前と全く同じ)

CREATE TABLE `user` (
  `id` varchar(255) NOT NULL COMMENT 'ユーザーの識別子、JWTトークンではsubに設定される値',
  `name` varchar(255) DEFAULT NULL COMMENT 'ユーザー名、最初に認証に利用したProviderから取得した値が入る',
  `email` varchar(255) DEFAULT NULL COMMENT 'ユーザーのメールアドレス、最初に認証に利用したProviderから取得した値が入る',
  `emailVerified` DATETIME(6) DEFAULT NULL COMMENT 'メールアドレスが認証済かどうかを示す、認証した日時が記録される',
  `image` varchar(255) DEFAULT NULL COMMENT 'エンドユーザーのプロフィールイメージが表示される',
  `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
  PRIMARY KEY (`id`),
  UNIQUE KEY `ui_user_01` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='Auth.jsでユーザーの識別子を管理するテーブル';

CREATE TABLE `account` (
  `userId` varchar(255) NOT NULL COMMENT 'userテーブルのid、ユーザーの識別子',
  `type` varchar(255) NOT NULL COMMENT 'oauth等の値が入る',
  `provider` varchar(255) NOT NULL COMMENT 'google, line等の認証Providerの名称が入る',
  `providerAccountId` varchar(255) NOT NULL COMMENT 'google, line等の認証Providerのユーザー識別子が入る',
  `refresh_token` text COMMENT '認証Providerから取得したリフレッシュトークンが記録される',
  `access_token` text COMMENT '認証Providerから取得したアクセストークンが記録される',
  `expires_at` int DEFAULT NULL COMMENT '認証Providerから取得したアクセストークンの有効期限',
  `token_type` varchar(255) DEFAULT NULL COMMENT 'Bearer等のトークン種別が記録される',
  `scope` varchar(255) DEFAULT NULL COMMENT 'リクエスト時に認証Providerに要求したscopeが記録される',
  `id_token` text COMMENT '認証Providerから取得したIDトークンの有効期限',
  `session_state` varchar(255) DEFAULT NULL,
  `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
  PRIMARY KEY (`provider`,`providerAccountId`),
  KEY `idx_account_01` (`userId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='Auth.jsでユーザーの認証情報を管理するテーブル';

CREATE TABLE `session` (
  `sessionToken` varchar(255) NOT NULL COMMENT 'セッションの識別子が格納される',
  `userId` varchar(255) NOT NULL COMMENT 'userテーブルのid、ユーザーの識別子',
  `expires` timestamp NOT NULL COMMENT 'セッションの有効期限が格納される',
  `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
  PRIMARY KEY (`sessionToken`),
  KEY `idx_session_01` (`userId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='Auth.jsでユーザーのセッションを管理するテーブル session.strategyがdatabaseの時だけ利用される';

CREATE TABLE `verificationToken` (
  `identifier` varchar(255) NOT NULL,
  `token` varchar(255) NOT NULL,
  `expires` timestamp NOT NULL,
  PRIMARY KEY (`identifier`,`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC COMMENT='Auth.jsでメールアドレスを使ったパスワードレスログインを利用する時に利用される';

LINEログインを実行して認可フォーム内でメールアドレスのチェックを意図的に外す。

ちなみに認可フォームは一度許可すると二回目以降は出現しなくなってしまうがスマホアプリのLINEで「歯車マークのアイコン」→「アカウント」→「連動アプリ」の一覧からテスト用のアプリを削除してしまえばもう一度認可画面を出す事が出来る。

こうすればLINEからメールアドレスを取得出来ないので、擬似的にメールアドレスを持たないユーザーを作る事が出来る。

authorization-form

私は検証用のスマホをもう1台持っているので、検証用スマホのLINEアカウントからも同じ手順でログインしてみた。

データベースを確認するとちゃんと、メールアドレスなしのユーザーが2件作られている事が分かる。

empty-email-users

という訳でこの問題はクリア出来た。

よく見ると TypeORMAdapter 等では以下のようにemailカラムをNULL許可で利用しているので、これは公式でも認められた方法なので無理なく実現出来ていると言えるだろう。

https://authjs.dev/reference/adapter/typeorm

@Column({ type: "varchar", nullable: true, unique: true })
email!: string | null

以下のページでも

Email address is optional, but if one is specified for a User, then it must be unique.

と記載されているので、emailのNULL許可は問題ないと考えて良さそうだ。

https://authjs.dev/reference/adapters

keitaknkeitakn

ちなみにもう一度メールアドレスの取得を認可フォームで拒否しているので、一度ログアウトしてもう一度ログインする時に再度認可フォームが出現する。

この時に次はメールアドレスの取得を許可しても user テーブルのemailはNULLのままだった。

どうもメールアドレスが user テーブルに記録されるタイミングは最初の1回だけのようだ。

keitaknkeitakn

同じメールアドレスで別の認証Providerでログインを実行した場合、同一人物と見なす(アカウントの名寄せ)

結論 allowDangerousEmailAccountLinking を利用する事で可能だった。

以下のように allowDangerousEmailAccountLinking を明示的に指定する。

export const options: NextAuthOptions = {
  // 省略
  providers: [
    GoogleProvider({
      clientId: String(process.env.GOOGLE_CLIENT_ID),
      clientSecret: String(process.env.GOOGLE_CLIENT_SECRET),
      allowDangerousEmailAccountLinking: true,
    }),
    LineProvider({
      clientId: String(process.env.LINE_CLIENT_ID),
      clientSecret: String(process.env.LINE_CLIENT_SECRET),
      authorization: {
        params: { scope: 'openid profile email' },
      },
      allowDangerousEmailAccountLinking: true,
    }),
  ],
  // 省略
}

この状態で以下の条件を満たすアカウントでGoogleログイン → ログアウト → LINEログインを実行した。

ユーザーは keita@example.com というGoogleアカウントを持っている。
同じく keita@example.com というアドレスで登録したLINEのアカウントを持っている。

すると期待通り user テーブルは1件、Google、LINEのアカウントが対象のメールアドレスを持つユーザーに連携されている事が確認出来た。

これでSupabaseと同じ仕様にする事が出来た。

AccountLink

ちなみにデフォルトだと無効になっている理由は以下の通りらしい。(公式から抜粋)

Normally, when you sign in with an OAuth provider and another account with the same email address already exists, the accounts are not linked automatically. Automatic account linking on sign in is not secure between arbitrary providers and is disabled by default (see our Security FAQ). However, it may be desirable to allow automatic account linking if you trust that the provider involved has securely verified the email address associated with the account. Just set allowDangerousEmailAccountLinking: true in your provider configuration to enable automatic account linking.

GoogleもLINEもメールアドレスの認証をしっかりと行っていると思われるので、基本的に信用して問題なしという判断をした。

Dangerous という名前が付いているので危険な印象を受けるが理解して利用する分には問題ないと思われる。

このスクラップは2023/09/27にクローズされました