DRFのためにAuth.jsのAdapterを作った

カテゴリー:Auth.jsNextDRF投稿日:2024-01-02

明けましておめでとうございます。年が過ぎるのもあっという間な感じです。

最近はAuth.js(NextAuth)を使って個人開発をやっています。進捗が遅くてまだまだリリースとは行かなそうです。

今までも個人開発じみたことはやっていたんですけどいかんせんリリースまでこぎつけたことがありません。今回こそはリリースまで行きたいとものです。

ちょっと話がそれましたが、今回のアプリの認証処理はAuth.jsを使って作っています。選定理由は今まで使ったことがなかったからです。結構これで認証処理が簡単に実装できるよと聞いていたので楽勝と思っていたのですが、結構大変でした。多分Prismaを使っていたら一瞬だったと思うのですが、何せDRF (Django REST framework)+SSOなもんで結構時間がかかりました。

備忘録的にAuth.jsの使い方を書いていきたいと思います。


Option

Optionと書きながら実質本体です。

import { AuthOptions } from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import CredentialsProvider from 'next-auth/providers/credentials';
import { loginUser } from '@/lib/server/login';
import { path } from '@/utils/path';
import { MyAdapter } from './adapter';

const { NEXTAUTH_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, JWT_SECRET } = process.env;

if (!GOOGLE_CLIENT_ID) throw new Error('You must provide GOOGLE_ID env var.');
if (!GOOGLE_CLIENT_SECRET) throw new Error('You must provide GOOGLE_SECRET env var.');
if (!NEXTAUTH_SECRET) throw new Error('You must provide NEXTAUTH_SECRET env var.');
if (!JWT_SECRET) throw new Error('You must provide JWT_SECRET env var.');

const nextAuthOption: AuthOptions = {
  debug: true,
  adapter: MyAdapter(),
  session: {
    strategy: 'jwt',
    maxAge: 30 * 24 * 60 * 60
  },
  jwt: {
    secret: JWT_SECRET
  },
  providers: [
    GoogleProvider({
      clientId: GOOGLE_CLIENT_ID,
      clientSecret: GOOGLE_CLIENT_SECRET
    }),
    CredentialsProvider({
      id: 'credentials',
      name: 'credentials',
      type: 'credentials',
      credentials: {
        email: { label: 'Email', type: 'email', placeholder: 'sample@example.com' },
        password: { label: 'Password', type: 'password' }
      },
      authorize: async (credentials, req) => {
        const email = credentials?.email;
        const password = credentials?.password;
        if (!email || !password) return null;
        try {
          const { isError, response: user } = await loginUser(email, password);
          if (isError || !user) return null;
          return { id: user.id, name: user.username, email: user.email, image: user.image, plan: user.plan.plan_name };
        } catch (e) {
          console.log({ e });
          return null;
        }
      }
    })
  ],
  callbacks: {
    jwt: async ({ token, user, account, profile }) => {
      if (user) token.plan = user.plan;
      return { ...token };
    },
    session: async ({ session, user, token }) => {
      session.user.plan = token.plan;
      return { ...session, ...token };
    }
  },
  logger: {
    error: (code, metaData) => console.log({ code, error: metaData.message, stack: metaData.stack, ...metaData }),
    warn: (code) => console.log({ code }),
    debug: (code, metaData) => console.log({ code, metaData })
  },
  pages: {
    signIn: path.login,
    signOut: path.login
  },
  secret: NEXTAUTH_SECRET
};

export default nextAuthOption;

こんな感じです。おそらくこんな構成なものはQiitaやZenn、もちろん公式Docにも書いてあるのでざっくりは見たことがあるかと思います。以下は自分が詰まったことです

session

session: {
    strategy: 'jwt',
    maxAge: 30 * 24 * 60 * 60
  },

上記のsession部分です。ここのstrategyはjwtdatabaseのの2種類から選べます。adapterでも後述しますが、databaseを選ばないとdbを使ったsessionの作成・更新・削除は起きません。これに気づかずに1日ぐらい無駄にしました。jwtを選んでいるとcookieを使ったセッション管理になります。なのでDB側にセッション用のテーブルを作ったところで関係なくなります(そりゃそうだよね)

callbacks

callbacks: {
    jwt: async ({ token, user, account, profile }) => {
      if (user) token.plan = user.plan;
      return { ...token };
    },
    session: async ({ session, user, token }) => {
      session.user.plan = token.plan;
      return { ...session, ...token };
    }
  },

sessionとjwtのコールバックです。Auth.jsにおけるuserはデフォルトではid,name,email,emailVerified,imageの5つの要素しかありませんが、肩を拡張することでいくらでも増やすことができます。今回は新たにplanを追加したのですが、その元となるuserがundefinedになってしまいました。原因は全くわかっていません。signinを実行した1度目のcallbacksの実行時にはuserは正常なのですが、2回目以降だと必ずundefinedになってしまい要素が取れませんでした。なのでtoken経由でsessionにplanの情報を伝えるようにしました。userがundefinedになってしまう理由がわかる方がいたら教えてくれると嬉しいです。


Adapter

drf用に作ったadapterです。

export const MyAdapter = (): Adapter => {
  return {
    async getUser(id) {
      const { isError, user } = await getUserById(id);
      if (!user || isError) return null;
      return {
        id: user.id,
        name: user.username,
        email: user.email,
        emailVerified: new Date(),
        image: user.image,
        plan: user.plan.plan_name
      };
    },

全体を載せると大きくなり過ぎるので一部抜粋です。根本としてadapterを実際に作った記事がとても少なく感じました。

adapterを実装する上で最低限必要なのは以下の通り

  • createUser
  • getuserByEmail
  • getUserByAccount
  • updateUser
  • deleteUser
  • linkAccount
  • unlinkAccount
  • createSession
  • getSessionAndUser
  • updateSession
  • deleteSession

しかし先ほどoptionでも書いた通りsessionのstrategyがjwtであれば後ろ4つのsession系は(多分)実行されません。現状わかっているのは↓のような動きをしているということです。

自分が観測した範囲ではこのような動きだと認識しています。間違っていたらごめんなさい。updateUserとかまだ動きを観測できていないものもあります。ここら辺は分かり次第別の記事で上げようかなと思います。

さてここで困ったことと勘違いを起こしました。まず勘違いとは、adapterで実装したcreateUserを特定のエンドポイントから実行できると思っていました。要はユーザーの登録フォームを作った時に登録の処理はこのcreateUserを呼べばいいと思っていましたが、どうやらこいつはSSO or EmailProviderでしか動かないようでした。なのでユーザー登録は別に自分で作る必要がありました。

次に困ったことはimageについてです。SSOを使うとimageというユーザーのプロフィール画像のURLが取得できます。しかし今回のDRF側ではURLで送られることを想定していませんでした。


DRF

DRFのモデルでは以下のような定義をしていました。

image = models.ImageField(verbose_name="プロフィール画像", blank=True, null=True)

このようにFE側からはファイルオブジェクトをBEに向けて送り、BE側で画像を保存するような想定でした。しかし、SSOの場合、画像自体はそれぞれのプラットフォーム?にすでに保存されておりURLのみが使われるようになっています。DRFのImageFieldに画像のURLを送ってもSerializerのバリデーションで弾かれてしまいます。そのため、モデル側を以下のように変更しました。

image = models.ImageField(verbose_name="プロフィール画像", blank=True, null=True)
image_url = models.URLField(verbose_name="プロフィール画像URL", blank=True, null=True)

別途URL用の項目を追加しました。これで登録自体はできるようにはなりましたが、APIのレスポンスにimageとimage_urlが付属されるようになったりして扱いがめんどくさくなってしまいました。そこでSerializerをReadとWriteに分けることで対応しました。

class UserWriteSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = "__all__"

    def create(self, validated_data):
        user = UserEX.create_user(validated_data)
        return user

    def update(self, instance, validated_data):
        user_id = instance.id
        user = UserEX.update_user(user_id, validated_data)
        return user


class UserReadSerializer(serializers.ModelSerializer):
    image = serializers.SerializerMethodField()
    plan = PlanReadSerializer()

    class Meta:
        model = User
        fields = ("id", "username", "email", "email_verified", "image", "plan")

    def get_image(self, obj):
        return obj.get_image_url()

このようにすることでimage,image_urlのどちらに画像の情報が入っていても、FE側はそんなこと考えずimageから画像のURLを取得できる形にしました。DRFのアーキテクチャはこのZennを参考にしています。すごく助かっています。

とりあえずこんな形の実装にすることで今はどうにかやっています。多分まだ使えてない機能だったり不都合が起きたりすると思うんですけども、それはまた別で記事にできたらなと思っています。もしこの記事が誰かの助けになっていれば幸いです。