Closed36

Next.js App Router & Auth.js (next-auth v5) に入門する

nbstshnbstsh

Next.js Pages Router & Next Auth の時代から諸々変わっていそうなので、再入門する。

nbstshnbstsh

Next.js setup

create-next-app する。

npx create-next-app@latest

準備完了 (Next.js v14.2.2)

nbstshnbstsh

Auth.js setup

公式 Doc に従って Auth.js setup する。

https://authjs.dev/getting-started/installation?framework=next.js

nbstshnbstsh

install

npm install next-auth@beta

Auth.js になったけど、Next.js 向けの package は依然として next-auth なのか。
他の framework 向けの Auth.js の package は @auth/* みたい。

  • SvelteKit: @auth/sveltkit
  • express: @auth/express
nbstshnbstsh

Setup Environment

AUTH_SECRET env var を設定する。
token encryption や hash で使う値らしい。なんでも OK だが、Auth.js がこの値を生成するためのコマンドを用意してくれているので利用する。

npx auth secret

こんな感じの ouput が出力されるので、そのまま .env.local に貼り付ける。

Secret generated. Copy it to your .env/.env.local file (depending on your framework):

AUTH_SECRET=GENERATETD_RANDOM_VALUE
.env.local
AUTH_SECRET=GENERATETD_RANDOM_VALUE
nbstshnbstsh

Configure

NextAuthConfig 用意

Auth.js 用の config を管理する file を用意する。file 名はなんでもOKとのことだが、公式 Doc に従って auth.ts とする。

src/auth.ts
import NextAuth from "next-auth"
 
export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [],
})

Route Handler setup

/app/api/auth/[...nextauth]/route.ts に NextAuth 用の route handler 用意。

src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers

(optional) middleware

任意で、session の有効期限を延長させる middleware を設定する。

src/middleware.ts
export { auth as middleware } from "@/auth"
nbstshnbstsh

Setup Authentication Methods

ここまでで基本的な設定は完了!簡単!
あとは認証 provider の設定を NextAuthConfig の providers に渡してあげれば良い。

nbstshnbstsh

Google OAuth を設定してみる

https://authjs.dev/getting-started/authentication/oauth

nbstshnbstsh

Google OAuth application 用意

OAuth Client ID 作成

GCP Console > APIs & Services > Credentials から新規 OAuth Client ID 作成。

作成したら、Client ID と Client secret を控えておく。

redirect uri 設定

とりあえず local で動けばいいので http://localhost:3000/api/auth/callback/google 設定

nbstshnbstsh

Setup Environment Variables

Google OAuth application の Client ID と Client secret を環境変数にセット。

.env.local
AUTH_GOOGLE_ID={CLIENT_ID}
AUTH_GOOGLE_SECRET={CLIENT_SECRET}
nbstshnbstsh

Setup Provider

NextAuthConfig.providers に Google 用の設定を行う。

やることは next-auth/providers/google を providers に渡すだけ。簡単!

src/auth.ts
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
 
export const { signIn, signOut, auth } = NextAuth({
  providers: [Google],
})
nbstshnbstsh

Add Signin Button

SignIn の導線を乗っける。

import { signIn } from '@/auth';

function SignIn() {
  return (
    <form
      action={async () => {
        'use server';
        await signIn('google');
      }}
    >
      <button
        type='submit'
        className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded'
      >
        Sign in with Google
      </button>
    </form>
  );
}

nbstshnbstsh

所感

Pages Router & NextAuth の時代より遥かに簡単になっていて導入するのはかなり楽。

nbstshnbstsh

Server Component で session を利用する

NextAuth() の戻り値の auth を使う。

auth.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [Google],
});

Server component 内で auth() を呼び出せば session を取得できる。

  const session = await auth()
sample code
import { auth, signIn, signOut } from '@/auth';

function SignIn() {
  return (
    <form
      action={async () => {
        'use server';
        await signIn('google');
      }}
    >
      <button
        type='submit'
        className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded'
      >
        Sign in with Google
      </button>
    </form>
  );
}

function SignOut() {
  return (
    <form
      action={async () => {
        'use server';
        await signOut();
      }}
    >
      <button
        type='submit'
        className='bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded'
      >
        Sign Out
      </button>
    </form>
  );
}

export default async function Home() {
  const session = await auth();

  return (
    <div>
      <div>
        {session ? (
          <div>
            <p>Signed in as {session.user?.email}</p>
            <pre>{JSON.stringify(session, null, 2)}</pre>
          </div>
        ) : (
          <p>Not signed in</p>
        )}
      </div>
      <SignIn />
      <SignOut />
    </div>
  );
}
nbstshnbstsh

Auth.js では session の利用は server side 前提なのかしら...?
Next Auth v4 の useSession 相当のものはないのか...?

nbstshnbstsh

Client component では next-auth/react を使う

https://authjs.dev/reference/next-auth/react

現状、Auth.js の公式 Docs での記載は上記 API reference しかなくわかりにくい...

型定義見た方がわかりやすいかも

next-auth/react 型定義
react.d.ts
/**
 *
 * NextAuth.js methods and components that work in [Client components](https://nextjs.org/docs/app/building-your-application/rendering/client-components) and the [Pages Router](https://nextjs.org/docs/pages).
 *
 * For use in [Server Actions](https://nextjs.org/docs/app/api-reference/functions/server-actions), check out [these methods](https://authjs.dev/guides/upgrade-to-v5#methods)
 *
 * @module react
 */
import type { BuiltInProviderType, RedirectableProviderType } from "@auth/core/providers";
import type { Session } from "@auth/core/types";
import * as React from "react";
import type { AuthClientConfig, ClientSafeProvider, LiteralUnion, SessionProviderProps, SignInAuthorizationParams, SignInOptions, SignInResponse, SignOutParams, SignOutResponse, UseSessionOptions } from "./lib/client.js";
export { SessionProviderProps };
export type { LiteralUnion, SignInAuthorizationParams, SignInOptions, SignInResponse, SignOutParams };
export declare const __NEXTAUTH: AuthClientConfig;
/** @todo Document */
export type UpdateSession = (data?: any) => Promise<Session | null>;
/**
 * useSession() returns an object containing three things: a method called {@link UpdateSession|update}, `data` and `status`.
 */
export type SessionContextValue<R extends boolean = false> = R extends true ? {
    update: UpdateSession;
    data: Session;
    status: "authenticated";
} | {
    update: UpdateSession;
    data: null;
    status: "loading";
} : {
    update: UpdateSession;
    data: Session;
    status: "authenticated";
} | {
    update: UpdateSession;
    data: null;
    status: "unauthenticated" | "loading";
};
export declare const SessionContext: React.Context<{
    update: UpdateSession;
    data: Session;
    status: "authenticated";
} | {
    update: UpdateSession;
    data: null;
    status: "unauthenticated" | "loading";
} | undefined>;
/**
 * React Hook that gives you access to the logged in user's session data and lets you modify it.
 *
 * :::info
 * You will likely not need `useSession` if you are using the [Next.js App Router (`app/`)](https://nextjs.org/blog/next-13-4#nextjs-app-router).
 * :::
 */
export declare function useSession<R extends boolean>(options?: UseSessionOptions<R>): SessionContextValue<R>;
export interface GetSessionParams {
    event?: "storage" | "timer" | "hidden" | string;
    triggerEvent?: boolean;
    broadcast?: boolean;
}
export declare function getSession(params?: GetSessionParams): Promise<Session | null>;
/**
 * Returns the current Cross-Site Request Forgery Token (CSRF Token)
 * required to make requests that changes state. (e.g. signing in or out, or updating the session).
 *
 * [CSRF Prevention: Double Submit Cookie](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie)
 */
export declare function getCsrfToken(): Promise<string>;
type ProvidersType = Record<LiteralUnion<BuiltInProviderType>, ClientSafeProvider>;
/**
 * Returns a client-safe configuration object of the currently
 * available providers.
 */
export declare function getProviders(): Promise<ProvidersType | null>;
/**
 * Initiate a signin flow or send the user to the signin page listing all possible providers.
 * Handles CSRF protection.
 */
export declare function signIn<P extends RedirectableProviderType | undefined = undefined>(provider?: LiteralUnion<P extends RedirectableProviderType ? P | BuiltInProviderType : BuiltInProviderType>, options?: SignInOptions, authorizationParams?: SignInAuthorizationParams): Promise<P extends RedirectableProviderType ? SignInResponse | undefined : undefined>;
/**
 * Initiate a signout, by destroying the current session.
 * Handles CSRF protection.
 */
export declare function signOut<R extends boolean = true>(options?: SignOutParams<R>): Promise<R extends true ? undefined : SignOutResponse>;
/**
 * [React Context](https://react.dev/learn/passing-data-deeply-with-context) provider to wrap the app (`pages/`) to make session data available anywhere.
 *
 * When used, the session state is automatically synchronized across all open tabs/windows and they are all updated whenever they gain or lose focus
 * or the state changes (e.g. a user signs in or out) when {@link SessionProviderProps.refetchOnWindowFocus} is `true`.
 *
 * :::info
 * You will likely not need `SessionProvider` if you are using the [Next.js App Router (`app/`)](https://nextjs.org/blog/next-13-4#nextjs-app-router).
 * :::
 */
export declare function SessionProvider(props: SessionProviderProps): JSX.Element;
//# sourceMappingURL=react.d.ts.map

ざっとみた感じ v4 の Client API 変わってないか...?

https://next-auth.js.org/getting-started/client

nbstshnbstsh

Middleware で session を利用する

これまた NextAuth() の戻り値の auth を使う。

auth.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [Google],
});

シンプルに session の有効期限を延長するだけの middleware が欲しいのなら auth をそのまま middleware として export すればOK。

middleware.ts
export { auth as middleware } from "@/auth"

自前の middleware に噛ませたい場合は、auth() で wrap してあげると、req.auth が生えて session が取得可能になる。

middleware.ts
import { auth } from "./auth"
 
export default auth((req) => {
  if (!req.auth) {
    return NextResponse.redirect("/login")
  }
})

req.auth は、Session | null

export interface DefaultSession {
  user?: User
  expires: ISODateString
}

/** The active session of the logged in user. */
export interface Session extends DefaultSession {}

なぜ req.auth という命名にしたんだろ... req.session の方がわかりやすくないか...

nbstshnbstsh

API Routes で session を取得する

これまた NextAuth() の戻り値の auth を使う。

auth.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [Google],
});

route handler を auth() で wrap してあげると req.auth が生えて session が取得可能になる。middleware と全く同じ。

import { auth } from "@/auth"
import { NextResponse } from "next/server"
 
export const GET = auth(function GET(req) {
  if (req.auth) return NextResponse.json(req.auth)
  return NextResponse.json({ message: "Not authenticated" }, { status: 401 })
})
nbstshnbstsh

auth() について雑感

  • session の取得
  • NextRequest handler の NextRequest に session を付与

この2つの役割を同じ auth() という api が担っているわけだけど、これ分けた方がわかりやすくないか...?

"session を使いたい" 点においては共通してるけど、session そのものの取得と session を request object に付与するのでは役割若干違う気もする...

nbstshnbstsh

裏側で何が起こっているのかざっくりメモ

  • provider ごとの認証処理
  • 認証情報を encrypt
    • jwt: jwt を encrypt
    • db: db に保存された session の Id を encrypt
  • encrypted な認証情報を cookie に set
  • cookie から encrypted な認証情報取得
  • 認証情報 decrypt しユーザ情報管理
    • jwt: jwt の情報そのまま利用
    • db: sessionId に紐づく session データを db から取得
nbstshnbstsh

Session の拡張

Auth.js libraries only expose a subset of the user’s information by default in a session to not accidentally expose sensitive user information. This is name, email, and image.

Auth.js は余計なデータを session に漏洩せないために、デフォで name, email, image のみを session に含ませる。

他の情報を session に載せたい場合のやり方をメモしていく。公式 Doc はこちら↓

https://authjs.dev/guides/extending-the-session

nbstshnbstsh

JWT を利用している場合

session データを JWT に載せている (= DB に保存していない) ケース。

以下は、userId を session に載せる例。
やっていることとしては、

  1. jwt callback で Provider から受け取った user 情報から userId を token に載せる
  2. session callback で token に載せた userId を session に載せる
  providers,
  callbacks: {
    jwt({ token, user }) {
      if (user) { // User is available during sign-in
        token.id = user.id
      }
      return token
    },
    session({ session, token }) {
      session.user.id = token.id
      return session
    },
  },
}

おそらく jwt callback は cookie にセットされる jwt を作成する際に、生成される jwt をカスタマイズするための callback だと思われるが、詳細な説明が同ページにはない...

nbstshnbstsh

Callbacks とは...?

API reference 見る。

https://authjs.dev/reference/core#callbacks

Callbacks are asynchronous functions you can use to control what happens when an action is performed. Callbacks are extremely powerful, especially in scenarios involving JSON Web Tokens as they allow you to implement access controls without a database and to integrate with external databases or APIs.

便利だよ〜としか書いてないな...
具体的に under the hood で何が起きているのか知りたい...

nbstshnbstsh

jwt callback とは...?

API reference 見る。

https://authjs.dev/reference/core#jwt

This callback is called whenever a JSON Web Token is created (i.e. at sign in) or updated (i.e whenever a session is accessed in the client). Anything you return here will be saved in the JWT and forwarded to the session callback. There you can control what should be returned to the client. Anything else will be kept from your frontend. The JWT is encrypted by default via your AUTH_SECRET environment variable.

jwt callback で return された値が JWT payload に保存される、とのこと。あってた。

nbstshnbstsh

試しに、foo: 'bar' を jwt callback で追加してみる。

    jwt: ({ user, token, account }) => {
      return {
        ...token,
+        foo: 'bar',
      };
    },

chorme dev tool から cookie 取得する。対象の cookie は "authjs.session-token"

nbstshnbstsh

decode (decrypt)

Auth.js の JWT は encrypted されてるので decrypt する方法を調べる。

この↓decode を内部で利用しているのでこいつを引っ張ってくれば良さそう。

https://github.com/nextauthjs/next-auth/blob/main/packages/core/src/jwt.ts#L75-L108

"next-auth/jwt" から import できる。

import { decode } from 'next-auth/jwt';

decode の引数

export interface JWTDecodeParams {
  /** Used in combination with `secret`, to derive the encryption secret for JWTs. */
  salt: string
  /**
   * Used in combination with `salt`, to derive the encryption secret for JWTs.
   *
   * @note
   * You can also pass an array of secrets, in which case the first secret that successfully
   * decrypts the JWT will be used. This is useful for rotating secrets without invalidating existing sessions.
   * The newer secret should be added to the start of the array, which will be used for all new sessions.
   */
  secret: string | string[]
  /** The Auth.js issued JWT to be decoded */
  token?: string
}

secret は AUTH_SECRET。
salt は何を使ってるのかわからん...コード見るか

nbstshnbstsh

decode するコード

scripts/main.mts
import { decode } from 'next-auth/jwt';

const SESSION_NAME = 'authjs.session-token';

const TOKEN = 'eyJ......';

const decoded = await decode({
  salt: SESSION_NAME,
  secret: process.env.AUTH_SECRET!,
  token: TOKEN,
});

console.log('decoded', decoded);

実行する。

bun run scripts/main.mts

ちゃんと `foo: "bar" が追加されてる↓

decoded {
  sub: "xxxxxxxxxxxxxx",
  uid: "xxxxxxxxxxxxxx",
  iat: 1715269183,
  exp: 1717861183,
  jti: "ec4e8678-6e12-4f62-9487-5a55aee6b4a4",
  foo: "bar"
}
nbstshnbstsh

jwt callback の pararms.user とは...?

Either the result of the OAuthConfig.profile or the CredentialsConfig.authorize callback.

https://authjs.dev/reference/core#jwt

OAuth provider の場合、OAuthConfig.profile callback で return したものが入る。
CredentialsProvider の場合、 authorize callback で return したものが入る。

nbstshnbstsh

session callback とは...?

API reference 見る。

https://authjs.dev/reference/core#session

This callback is called whenever a session is checked. (i.e. when invoking the /api/session endpoint, using useSession or getSession). The return value will be exposed to the client, so be careful what you return here! If you want to make anything available to the client which you’ve added to the token through the JWT callback, you have to explicitly return it here as well.

session として保存している値のうち、何を client に後悔するかの責務を担う。

以下は JWT に乗っけた accessToken を client に公開する例↓

callbacks: {
  async session({ session, token, user }) {
    // Send properties to the client, like an access_token from a provider.
    session.accessToken = token.accessToken
 
    return session
  }
}
nbstshnbstsh

Database session strategy の場合

session callback だけでOK。
sesseion callback の params.user に DB に保存された user データが入る。

  providers,
  callbacks: {
    session({ session, user }) {
      session.user.id = user.id
      return session
    },
  }
}
nbstshnbstsh

Session 拡張時の型について

Session 拡張時の型は、Module Augmentation で設定する。

以下は、session に address を追加する例↓

import NextAuth, { type DefaultSession } from "next-auth"
 
declare module "next-auth" {
  /**
   * Returned by `auth`, `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
   */
  interface Session {
    user: {
      /** The user's postal address. */
      address: string
      /**
       * By default, TypeScript merges new interface properties and overwrites existing ones.
       * In this case, the default session user properties will be overwritten,
       * with the new ones defined above. To keep the default session user properties,
       * you need to add them back into the newly declared interface.
       */
    } & DefaultSession["user"]
  }
}

https://authjs.dev/getting-started/typescript#module-augmentation

nbstshnbstsh

拡張可能な型

  • User
  • Account
  • Session
  • JWT
declare module "next-auth" {
  /**
   * The shape of the user object returned in the OAuth providers' `profile` callback,
   * or the second parameter of the `session` callback, when using a database.
   */
  interface User {}
  /**
   * The shape of the account object returned in the OAuth providers' `account` callback,
   * Usually contains information about the provider being used, like OAuth tokens (`access_token`, etc).
   */
  interface Account {}
 
  /**
   * Returned by `useSession`, `auth`, contains information about the active session.
   */
  interface Session {}
}
 
// The `JWT` interface can be found in the `next-auth/jwt` submodule
import { JWT } from "next-auth/jwt"
 
declare module "next-auth/jwt" {
  /** Returned by the `jwt` callback and `auth`, when using JWT sessions */
  interface JWT {
    /** OpenID ID Token */
    idToken?: string
  }
}
nbstshnbstsh

CredentialsProvider の authorize で return する object を拡張するケース

'next-auth' module の User interface を拡張すればOK。

import NextAuth from "next-auth"
 
declare module 'next-auth' {
  /**
   * The shape of the user object returned in the OAuth providers' `profile` callback,
   * or the second parameter of the `session` callback, when using a database.
   */
  interface User {
    foo: string;
  }
}

export const { handlers, signIn, signOut, auth } = NextAuth({
  //...
  providers: [
    //...
    Credentials({
      credentials: {},
      authorize: async () => {
        return {
          foo: 'bar',
        };
      },
    }),
  ],
  callbacks: {
    jwt({ token, user }) {
      const value = user.foo;

      //...
    },
    //...
  },
})

authorize の型↓ User を return していることがわかる。

  authorize: (
    /**
     * The available keys are determined by {@link CredentialInput}.
     *
     * @note The existence/correctness of a field cannot be guaranteed at compile time,
     * so you should always validate the input before using it.
     *
     * You can add basic validation depending on your use case,
     * or you can use a popular library like [Zod](https://zod.dev) for example.
     */
    credentials: Partial<Record<keyof CredentialsInputs, unknown>>,
    /** The original request. */
    request: Request
  ) => Awaitable<User | null>
このスクラップは1ヶ月前にクローズされました