Nuxt 3で認証機能を実装したい場合に認証機能を提供するライブラリ/サービスには複数の選択肢がありますが本文書ではusername+password認証に対応したLuciaを利用して動作確認を行います。LuciaはSessionベースの認証ライブラリでデータベースにSessionとCookiesにSessionIdを保存して認証を管理します。データベースにはPrisma経由でSQLiteを利用します。

Luciaとは

LuciaはSessionベースの認証ライブラリで、Nuxt以外にもNext.js, SvelteKitなどを含めさまざまなフレームワークに対応しています。認証方式としてusername+password以外にOAuthにも対応しています。TypeScriptにも対応しているので認証機能の実装を型安全に行うことができます。データベースはAdapter方式を利用しておりデータベースに対するコードがそれぞれ用意されています。認証に関するコアのコードは共通しているので一つのフレームワークで理解ができれば他のフレームワークでもその知識を活用することができます。

認証の流れ

設定を行う前にLuciaでは認証管理をどのように行っているのか簡単に説明を行っておきます。事前にサインアップ画面からユーザの登録は完了していることを前提にしています。

  • ログイン画面に事前に登録したusename+passwordを入力して送信ボタンをクリック。
  • 送信ボタンをクリックするとAPI Route(/api/login)に入力したデータをPOSTリクエストで送信。送信されたusername+passwordに一致するユーザが存在するか確認。
  • ユーザの存在確認後、Sessionデータを新規作成しデータベースに保存、作成したSessionデータに含まれるSessionIdを持つCookiesを作成。ログイン処理完了(認証済)。
  • middlewareを利用してページを移動する度に認証済みかどうかの確認。認証の確認はAPI Route(/api/user)にGETリクエストを送信。CookiesからSessionIdを取り出しデータベースからSessionを取得。
  • 取得したSessionの有効期限をチェック。有効期限のチェックはactivePeriodとidlePeriodの2つで実施。activePeriodの期限が切れてもidlePeriod期限内であればSessionの有効期限の更新。デフォルトではactivePeriodが1日、idlePeriodが2週間+activePeriod有効期間。
  • idlePeriodの有効期限が切れたら認証が終了。Cookieの有効期限切れても認証が終了。Cookieの有効期限はデフォルトではidlePeriodと同じ。
  • 意図的に認証を終了したい場合はAPI Route(/api/logout)にPOSTリクエストを送信してSessionの無効化とCookieの削除を実施。Cookiesを手動で削除しても認証が終了。

設定について

サンプルなしで一から設定をするのであれば実装が難しいと思いますが、フレームワーク/データベース毎に設定に関するサンプルのドキュメントが提供されているので手順通りに進めれば比較的簡単に設定を行うことができます。各フレームワーク毎にemail-and-password, github-oauth, username-and-passwordの3つのサンプルが準備されています。サインアップ、ログインページの作成、APIエンドポイントの作成は一からすべて各自で行う必要があります。

プロジェクトの作成

Luciaの動作確認を行うためにNuxt 3のプロジェクトを”npx nuxi@latest init”コマンドで行います。プロジェクト名はnuxt3-lucia-authとしています。


 % npx nuxi@latest init nuxt3-lucia-auth

✔ Which package manager would you like to use?
npm
◐ Installing dependencies...                   

> postinstall
> nuxt prepare

✔ Types generated in .nuxt                           

added 733 packages, and audited 735 packages in 37s

121 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
✔ Installation completed.                     

✔ Initialize git repository?
No
                                                      
✨ Nuxt project has been created with the v3 template. Next steps:
 › cd nuxt3-lucia-auth                                
 › Start development server with npm run dev

プロジェクトを作成後、プロジェクトディレクトリに移動します。


% cd nuxt3-lucia-auth                                

Prismaの設定

本文書ではデータベースにはPrismaを利用して接続しますがLuciaがサポートしているデータベースには以下のものがあるので各自の環境にあったものをインストールしてください。

サポートされているデータベース
サポートされているデータベース

Adapterが提供されているのでデータベースのテーブル作成後の処理については大きな違いがありません。テーブルを作成する方法はそれぞれのデータベースによって異なりますがドキュメントに掲載されているのでドキュメント通りに設定することができます。

Prisma のインストール

npm コマンドを利用して Prisma のインストールを行います。


 % npm install prisma --save-dev
コマンド実行後にインストールされた Prisma のバージョンは 5.7.1 でした。

Prisma 用の設定ファイルを作成するために”npx prisma init”コマンドを実行します。実行するとプロジェクトディレクトリ下には prisma ディレクトリと.env ファイルが作成されます。prisma ディレクトリには Prisma の設定ファイルである schema.prisma ファイルが作成されています。.env ファイルはデータベースに接続するために必要となる環境変数を設定するために利用します。

SQLite データベースを利用するのでオプション–datasource-provider に sqlite を設定しています。もし指定しない場合には postgresql データベースが接続データベースとして設定された状態でファイルが作成されます。


 % npx prisma init --datasource-provider sqlite

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.

warn You already have a .gitignore file. Don't forget to add `.env` in it to not commit any private information.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Run prisma db pull to turn your database schema into a Prisma schema.
3. Run prisma generate to generate the Prisma Client. You can then start querying your database.

More information in our documentation:
https://pris.ly/d/getting-started

SQLiteデータベースではファイルを利用してデータを保存するため.envファイルにはデータベースファイルを保管するパスとファイル名が環境変数DATABASE_URLに設定されています。

モデルの設定

schema.prismaファイルでは–datasource-providerを指定して実行した場合はSQLiteデータベースに関する設定は完了しているのでスキーマの設定を行います。スキーマについてはドキュメントに掲載されているのでそのまま利用します。


// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id           String    @id @unique
  auth_session Session[]
  key          Key[]
}

model Session {
  id             String @id @unique
  user_id        String
  active_expires BigInt
  idle_expires   BigInt
  user           User   @relation(references: [id], fields: [user_id], onDelete: Cascade)

  @@index([user_id])
}

model Key {
  id              String  @id @unique
  hashed_password String?
  user_id         String
  user            User    @relation(references: [id], fields: [user_id], onDelete: Cascade)
  @@index([user_id])
}

Lusiaで利用するモデルはUser, Session, Keyから構成されています。

Userモデルについてはデフォルトではidのみとなっていますが属性を追加することができます。

Sessionモデルについてはuser_idを介してUserモデルと紐付きログインする度にログインしたユーザに対して新たなSessionデータが作成されます。

Keyモデルについては一人のユーザが複数認証方式で登録した場合に認証方式毎にUserデータを作成するのではなく1つのUserデータに複数のKeyデータを紐づける場合に利用することができます。本文書ではデフォルトのusernameをKeyとして登録するので複数のKeyの動作確認は行なっていません。

テーブルの作成

schema.prismaファイルでのスキーマのの設定が完了したらSQLiteデータベースにテーブルを作成するために”npx prisma db push”コマンドを実行します。


 % npx prisma db push
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"

SQLite database dev.db created at file:./dev.db

🚀  Your database is now in sync with your Prisma schema. Done in 22ms

Running generate... (Use --skip-generate to skip the generators)

added 1 package, and audited 742 packages in 3s

121 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

✔ Generated Prisma Client (v5.7.1) to ./node_modules/@prisma/client in 165ms

コマンドを実行すると.env ファイルの DATABASE_URL で指定した場所に SQLite のデータベースファイル dev.db が作成されます

Prisma Studio からのデータベース接続

Prisma には Prisma Studio という Prisma 専用の GUI ツールを利用してデータベースにアクセスを行うことができます。Prisma Studio を起動するために npx prisma studio コマンドを実行します。


% npx prisma studio
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Prisma Studio is up on http://localhost:5555

設定したスキーマからモデルが作成させていることが確認できます。

作成したモデルの確認
作成したモデルの確認

Luciaの設定

データベースの作成が完了したのでLuciaのインストールを行い、ページの作成や各種設定を行なっていきます。

Luciaのインストール

luciaライブラリのインストールを行います。


 % npm i lucia

Prisma用のAdapterのインストールも行います。接続するデータベースによってインストールするAdapterは変わります。


 % npm i @lucia-auth/adapter-prisma

lucia.tsファイルの作成

luciaの設定を行うためにserverディレクトリにutilsディレクトリを作成してlucia.tsファイルを作成して以下のコードを記述します。utilsに保存することでcreateErrorのようにヘルパー関数として利用することができます。


import { lucia } from 'lucia';
import { h3 } from 'lucia/middleware';
import { prisma } from '@lucia-auth/adapter-prisma';
import { PrismaClient } from '@prisma/client';

const client = new PrismaClient();

export const auth = lucia({
  adapter: prisma(client),
  env: process.dev ? 'DEV' : 'PROD',
  middleware: h3(),
});

export type Auth = typeof auth;

このファイルの内容は接続するデータベースや利用するフレームワーク/ライブラリによって設定が異なります。adapterは利用するデータベース、middlewareは利用するフレームワーク/ライブラリによって変わります。本文書の環境ではadapterにはPrisma, middlewareにはNuxtのh3を設定します。

型定義ファイルの作成

Userモデルにid以外の属性を追加できるという話をしました。データベースから取得したユーザの情報やテーブルに属性を追加した場合に型の情報を正しく取得できるようにserverディレクトリにapp.d.tsファイルを作成して以下の情報を追加します。


/// 
declare namespace Lucia {
  type Auth = import('./utils/lucia').Auth;
  type DatabaseUserAttributes = {
    // username: string;
  };
  type DatabaseSessionAttributes = {};
}

Layoutsページの作成

layoutsディレクトリを作成してdefault.vueファイルを作成します。これからsignup.vue, login.vue, index.vue, dashboard/index.vueの4つのページを作成するのでLayoutファイルには4つのページを移動できるようにNuxtLinkでリンクを設定しておきます。


<template>
  <div>
    <nav>
      <ul>
        <li><NuxtLink to="/">Home</NuxtLink></li>
        <li><NuxtLink to="/login">Login</NuxtLink></li>
        <li><NuxtLink to="/signup">Signup</NuxtLink></li>
        <li><NuxtLink to="/dashboard">Dashboard</NuxtLink></li>
      </ul>
    </nav>
    <slot />
  </div>
</template>

ページの作成

app.vueファイルでは作成するページの内容が表示されるようにNuxtPageタグを設定し、作成したLayoutファイルを設定します。


<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

ページファイルを作成するためにpagesディレクトリを作成してその下にindex.vueファイルを作成して以下の内容を記述します。


<template>
  <h1>Home Page</h1>
</template>

pagesディレクトリの下にdashboardディレクトリを作成してindev.vueファイルを作成して以下の内容を記述します。


<template>
  <h1>Dashboard</h1>
</template>

“npm run dev”コマンドで開発サーバを起動します。ブラウザでアクセスすると以下の画面が表示されます。”/”と”/dashboard”はページが存在するので移動することができます。

ホームページの表示
ホームページの表示

signupページの作成

pageディレクトリの下にsignup.vueファイルを作成してユーザの登録を行うsignupページの作成を行います。


<script lang="ts" setup>
import { FetchError } from 'ofetch';

const handleSubmit = async (e: Event) => {
  if (!(e.target instanceof HTMLFormElement)) return;
  const formData = new FormData(e.target);
  try {
    await $fetch('/api/signup', {
      method: 'POST',
      body: {
        username: formData.get('username'),
        password: formData.get('password'),
      },
    });
    await navigateTo('/dashboard');
  } catch (e) {
    if (e instanceof FetchError) console.log(e.data);
    console.log(e);
  }
};
</script>

<template>
  <h1>Sign up</h1>
  <form @submit.prevent="handleSubmit">
    <div>
      <label for="username">Username</label>
      <input name="username" id="username" />
    </div>
    <div>
      <label for="password">Password</label>
      <input type="password" name="password" id="password" />
    </div>
    <input type="submit" />
  </form>
</template>

入力フォームからusernameとpasswordの入力を行い、POSTリクエストで入力した値を送信します。送信先はServer Endpointsである/api/signupです。ユーザの認証に成功したらdashboardにリダイレクトするように設定を行っています。

/api/signupエンドポイントの作成

POSTリクエストの送信先である/api/signupを作成するためにserver/apiディレクトリを作成してその下にsignup.post.tsファイルを作成します。


import { LuciaError } from 'lucia';

export default defineEventHandler(async (event) => {
  const { username, password } = await readBody<{
    username: unknown;
    password: unknown;
  }>(event);
  if (
    typeof username !== 'string' ||
    username.length < 4 ||
    username.length > 31
  ) {
    throw createError({
      message: 'Invalid username',
      statusCode: 400,
    });
  }
  if (
    typeof password !== 'string' ||
    password.length < 6 ||
    password.length > 255
  ) {
    throw createError({
      message: 'Invalid password',
      statusCode: 400,
    });
  }
  try {
    const user = await auth.createUser({
      key: {
        providerId: 'username',
        providerUserId: username.toLowerCase(),
        password,
      },
      attributes: {},
    });
    const session = await auth.createSession({
      userId: user.userId,
      attributes: {},
    });
    const authRequest = auth.handleRequest(event);
    authRequest.setSession(session);
    return setResponseStatus(event, 201);
  } catch (e) {
    if (e instanceof LuciaError && e.message === 'AUTH_DUPLICATE_KEY_ID') {
      throw createError({
        message: 'Username already taken',
        statusCode: 400,
      });
    }

    throw createError({
      message: 'An unknown error occurred',
      statusCode: 500,
    });
  }
});

前半では送信されてきたusernameとpasswordのバリデーションを行っています。後半はauth.createUserでユーザの作成を行っています。この時Keyも作成されます。パスワードはLuciaによってハッシュ化されて保存されます。keyの作成で設定するproviderIdはusernameとなっていますがemailやgithubなど利用するProviderによって変更します。providerUserIdは各Providerで一意となる値を設定します。最終的にKeyテーブルに保存される際にはproviderIdで指定したusername:をつけproviderUserIdで設定した文字列の組み合わせとなります。例えばusernameのフォームでJohneDoeと設定したら”username:johndoe”で保存されます。Johndoe, johnDoe, JohnDoeで別々に保存できないようにtoLowerCaseで小文字にしています。

ユーザを作成後はユーザのidに紐づくSessionデータを作成してauth.createSessionで作成し、authRequest.setSessionでSessionを元にCookiesを作成しています。

ユーザを作成するとデータベースには下記の情報が保存されます。

ユーザIDはランダムな文字列でSessionとKeyが紐づいています。

Userテーブル
Userテーブル

Keyテーブルにはusername:と入力フォームで入力した文字列の小文字と一緒に保存されます。

Keyテーブル
Keyテーブル

idは40文字のSessionIdでactive_expiresとidle_expiresには有効期限が保存されます。

Sessionテーブル
Sessionテーブル

ブラウザのデベロッパーツールのApplicationのCookiesからCookiesを確認することができます。

Cookiesの確認
Cookiesの確認

signup.post.ts中の処理の詳細

よりLuciaの理解を深めるためにユーザ、Session、Cookieの作成がどのように行われているのかソースコードを見ながら確認していきます。ソースコートを行ったり来たりするのでわかりにくいとは思いますが理解できれば勉強になると思うので興味のある人だけ読み進めてください。興味のない人は次の”Loginページの作成”に進んでください。

ユーザの作成時にauth.createUserとauthが突然でてきますがこれはserver/utils/lucia.tsで設定したヘルパー関数のauthです。

バリデーションにパスするとcreateUserでユーザの作成を行い、createSessionでSessionデータの作成を行っています。authはserver/utilsに作成したlucia.tsからexportしたヘルパー関数でAuthクラスをインスタンス化したものです。


import { lucia } from 'lucia';
import { h3 } from 'lucia/middleware';
import { prisma } from '@lucia-auth/adapter-prisma';
import { PrismaClient } from '@prisma/client';

const client = new PrismaClient();

export const auth = lucia({
  adapter: prisma(client),
  env: process.dev ? 'DEV' : 'PROD',
  middleware: h3(),
});

export type Auth = typeof auth;

importしているluciaの処理はnode_modeles/lucia/dist/auth/index.jsファイルに記述されており、実行するとAuthクラスがインスタンス化されて戻されます。configの引数にはlucia.tsで設定した値が入ります。


export const lucia = (config) => {
    return new Auth(config);
};

Authクラスでは引数でもらったconfigを元にadapter、Sessionの有効期限であるactivePeriod, idlePeriodの設定を行っています。activePeriodは1日、idlePeriodは14日に設定されています。有効期限の変更はlucia.tsファイルで行うことができます。


//略
export class Auth {
    adapter;
    sessionCookieConfig;
    sessionExpiresIn;
    csrfProtection;
    env;
    passwordHash = {
        generate: generateScryptHash,
        validate: validateScryptHash
    };
    middleware = defaultMiddleware();
    experimental;
    constructor(config) {
        console.log('index.js config',config)
        validateConfiguration(config);
        this.adapter = createAdapter(config.adapter);
        this.env = config.env;
        this.sessionExpiresIn = {
            activePeriod: config.sessionExpiresIn?.activePeriod ?? 1000 * 60 * 60 * 24,
            idlePeriod: config.sessionExpiresIn?.idlePeriod ?? 1000 * 60 * 60 * 24 * 14
        };
//略

createUserの確認

Authクラスは複数のメソッドを持っているのでユーザ作成のcreateUserメソッドを確認します。


createUser = async (options) => {
    const userId = options.userId ?? generateRandomString(15);
    const userAttributes = options.attributes ?? {};
    const databaseUser = {
        ...userAttributes,
        id: userId
    };
    if (options.key === null) {
        await this.adapter.setUser(databaseUser, null);
        return this.transformDatabaseUser(databaseUser);
    }
    const keyId = createKeyId(options.key.providerId, options.key.providerUserId);
    const password = options.key.password;
    const hashedPassword = password === null ? null : await this.passwordHash.generate(password);
    await this.adapter.setUser(databaseUser, {
        id: keyId,
        user_id: userId,
        hashed_password: hashedPassword
    });
    return this.transformDatabaseUser(databaseUser);
};

createUserメソッドの中身を見るとuserIdはランダムな15桁の文字列であることがわかります。またkeyのIdはcreateUserメソッドの引数で渡したproviderIdとproviderUserIdを利用して作成しています。

createKeyId関数はnode_modules/lucia/dist/auth/database.jsファイルに記述されておりシンプルに”${providerId}:${providerUserId}”が設定されているだけです。


export const createKeyId = (providerId, providerUserId) => {
    if (providerId.includes(":")) {
        throw new TypeError("Provider id must not include any colons (:)");
    }
    return `${providerId}:${providerUserId}`;
};

パスワードはハッシュ化され、adapterのsetUserが実行されています。データベースによってデータを作成するコードが異なるのでadapterを利用しています。


await this.adapter.setUser(databaseUser, {
    id: keyId,
    user_id: userId,
    hashed_password: hashedPassword
});

adapterを利用してsetUserメソッドはインストールしたnode_modules/@lucia-auth/adapter-prisma/dist/prisma.jsファイルで確認できます。setUserメソッドに渡した引数でUserとKeyを作成しています。


setUser: async (user, key) => {
    if (!key) {
        await User.create({
            data: user
        });
        return;
    }
    try {
        await client.$transaction([
            User.create({
                data: user
            }),
            Key.create({
                data: key
            })
        ]);
    }
    catch (e) {
        const error = e;
        if (error.code === "P2002" && error.message?.includes("`id`"))
            throw new LuciaError("AUTH_DUPLICATE_KEY_ID");
        throw error;
    }
},

createSessionの確認

AuthのcreateSessionメソッドでは40文字のランダムな文字列を作成してsessionIdとしてactive_expires, idle_expiresにSessionの有効期限を設定してデータベースのSessionに登録しています。


createSession = async (options) => {
    const { activePeriodExpiresAt, idlePeriodExpiresAt } = this.getNewSessionExpiration();
    const userId = options.userId;
    const sessionId = options?.sessionId ?? generateRandomString(40);
    const attributes = options.attributes;
    const databaseSession = {
        ...attributes,
        id: sessionId,
        user_id: userId,
        active_expires: activePeriodExpiresAt.getTime(),
        idle_expires: idlePeriodExpiresAt.getTime()
    };
    const [user] = await Promise.all([
        this.getUser(userId),
        this.adapter.setSession(databaseSession)
    ]);
    return this.transformDatabaseSession(databaseSession, {
        user,
        fresh: false
    });
};

adapterのsetSessionを確認すると引数で受け取ったsession情報を利用してSessionを作成しています。


setSession: async (session) => {
    if (!Session) {
        throw new Error("Session table not defined");
    }
    try {
        await Session.create({
            data: session
        });
    }
    catch (e) {
        const error = e;
        if (error.code === "P2003") {
            throw new LuciaError("AUTH_INVALID_USER_ID");
        }
        throw error;
    }
},

handleRequestの確認

auth.handleRequestメソッドは頻繁に出てくる処理でauth/index.jsファイルの中でAuthRequestクラスをインスタンス化しています。主にCookiesの処理で利用します。


handleRequest = (
// cant reference middleware type with Lucia.Auth
...args) => {
    const middleware = this.middleware;
    const sessionCookieName = this.sessionCookieConfig.name ?? DEFAULT_SESSION_COOKIE_NAME;
    return new AuthRequest(this, {
        csrfProtection: this.csrfProtection,
        requestContext: transformRequestContext(middleware({
            args,
            env: this.env,
            sessionCookieName: sessionCookieName
        }))
    });
};

AuthRequestクラスの引数にrequestContextプロパティが含まれていますがこの値を設定するためにmiddleware関数が実行されています。middleware関数はserver/lib/lucia.tsファイルの中で設定したh3関数です。


import { lucia } from 'lucia';
import { h3 } from 'lucia/middleware';
//略
export const auth = lucia({
  adapter: prisma(client),
  env: process.dev ? 'DEV' : 'PROD',
  middleware: h3(),
});

export type Auth = typeof auth;

h3関数はlucia.tsファイル内でlucia/middlewareからimportされているのでlucia/dist/auth/middleware.jsファイルを確認します。


export const h3 = () => {
    const nodeMiddleware = node();
    return ({ args, sessionCookieName, env }) => {
        const [context] = args;
        return nodeMiddleware({
            args: [context.node.req, context.node.res],
            sessionCookieName,
            env
        });
    };
};

h3関数はnode関数が実行されているので同じファイルに記述されているnode関数を確認します。戻されるrequestContextにはrequestのHeaderを保存したrequestオブジェクトとsetCookie関数が含まれています。h3関数を実行するとCookiesに関するオブジェクトで戻されることがわかります。


export const node = () => {
    return ({ args }) => {
        const [incomingMessage, outgoingMessage] = args;
        const requestContext = {
            request: {
                method: incomingMessage.method ?? "",
                headers: createHeadersFromObject(incomingMessage.headers)
            },
            setCookie: (cookie) => {
                let parsedSetCookieHeaderValues = [];
                const setCookieHeaderValue = outgoingMessage.getHeader("Set-Cookie");
                if (typeof setCookieHeaderValue === "string") {
                    parsedSetCookieHeaderValues = [setCookieHeaderValue];
                }
                else if (Array.isArray(setCookieHeaderValue)) {
                    parsedSetCookieHeaderValues = setCookieHeaderValue;
                }
                outgoingMessage.setHeader("Set-Cookie", [
                    cookie.serialize(),
                    ...parsedSetCookieHeaderValues
                ]);
            }
        };
        return requestContext;
    };
};

AuthRequestクラスの確認

AuthReuqestクラスの引数のrequestContextの中身が確認できたのでAuthRequestクラスの中身を確認します。ファイルはnode_modules/lucia/dist/auth/request.jsです。requestContextはconfigの中に含まれています。AuthRequestクラスもAuthクラスと同様にsetSessionなど複数のメソッドも持っています。


//略
export class AuthRequest {
    auth;
    requestContext;
    constructor(auth, config) {
        debug.request.init(config.requestContext.request.method, config.requestContext.request.url ?? "(url unknown)");
        this.auth = auth;
        this.requestContext = config.requestContext;

        const csrfProtectionConfig = typeof config.csrfProtection === "object" ? config.csrfProtection : {};
        const csrfProtectionEnabled = config.csrfProtection !== false;
        if (!csrfProtectionEnabled ||
            this.isValidRequestOrigin(csrfProtectionConfig)) {
            this.storedSessionId =
                this.requestContext.sessionCookie ??
                auth.readSessionCookie(this.requestContext.request.headers.get("Cookie"));
            console.log('request constructor', this.storedSessionId)
        }
        else {
            this.storedSessionId = null;
        }
        this.bearerToken = auth.readBearerToken(this.requestContext.request.headers.get("Authorization"));
    }
//略

AuthReuqestの初期化の中では最初にcsrfProtectionのチェックを行っています。csrfProtectionでは同じドメインからのリクエストかどうかの確認行い、異なる場合はallowedSubdomainsの設定が必要となります。csrfProtection、allowedSubdomains設定はserver/utils/lucia.tsで行うことができます。

次にrequestContextが利用されHeaderにCookieが存在するかチェックしています。存在する場合はCookiesの値をstoreSessionIdに保存し、存在しない場合はnullとしています。bearTokenは利用しませんがHeaderからbearTokenの取得も行っています。

authRequest.setSessionの確認

signup.post.tsファイルでauth.handleRequestでAuthRequestクラスを初期化した後、authRequest.setSession(session)を実行しています。AuthRequestクラスのではcreateSessionではstoredSessionId(Cookiesに存在すればCookiesから取得したSessionId)をチェックしてsessionに含まれるidと異なる場合はsetSessionCookieで作成します。


//略
setSession = (session) => {
    const sessionId = session?.sessionId ?? null;
    if (this.storedSessionId === sessionId)
        return;
    this.validatePromise = null;
    this.setSessionCookie(session);
};
//略
setSessionCookie = (session) => {
    const sessionId = session?.sessionId ?? null;
    if (this.storedSessionId === sessionId)
        return;
    this.storedSessionId = sessionId;
    this.requestContext.setCookie(this.auth.createSessionCookie(session));
    if (session) {
        debug.request.notice("Session cookie stored", session.sessionId);
    }
    else {
        debug.request.notice("Session cookie deleted");
    }
};

Cookiesの設定はrequestContextに含まれているsetCookie関数を利用しています。これでCookiesにSessionIdが保存されます。

Loginページの作成

pagesディレクトリの下にlogin.vueファイルを作成します。signup.vueファイルと内容はほとんど変わりませんがusernameとpassword入力後は/api/signupではなく/api/loginに対してPOSTリクエストが送信されます。/api/loginに送信したデータで認証が完了したら/dashboardにリダイレクトされます。


<script lang="ts" setup>
import { FetchError } from 'ofetch';

const handleSubmit = async (e: Event) => {
  if (!(e.target instanceof HTMLFormElement)) return;
  const formData = new FormData(e.target);
  try {
    await $fetch('/api/login', {
      method: 'POST',
      body: {
        username: formData.get('username'),
        password: formData.get('password'),
      },
    });
    await navigateTo('/dashboard');
  } catch (e) {
    if (e instanceof FetchError) console.log(e.data);
    console.log(e);
  }
};
</script>

<template>
  <h1>Sign in</h1>
  <form @submit.prevent="handleSubmit">
    <div>
      <label for="username">Username</label>
      <input name="username" id="username" />
    </div>
    <div>
      <label for="password">Password</label>
      <input type="password" name="password" id="password" />
    </div>
    <input type="submit" />
  </form>
  <NuxtLink to="/signup">Create an account</NuxtLink>
</template>

/api/loginのAPIエンドポイントの作成を行います。前半ではリクエストで送信されてきたデータのバリデーションを行っています。auth.useKeyメソッドを利用して入力したusernameとpasswordを持つユーザが存在するかの確認と同時に保存されたkeyを取得しています。keyに含まれるuseIdを利用してauth.createSessionでsessionデータを新規作成してsetSessionでCookieに作成しています。


import { LuciaError } from 'lucia';

export default defineEventHandler(async (event) => {
  const { username, password } = await readBody<{
    username: unknown;
    password: unknown;
  }>(event);
  // basic check
  if (
    typeof username !== 'string' ||
    username.length < 1 ||
    username.length > 31
  ) {
    throw createError({
      message: 'Invalid username',
      statusCode: 400,
    });
  }
  if (
    typeof password !== 'string' ||
    password.length < 1 ||
    password.length > 255
  ) {
    throw createError({
      message: 'Invalid password',
      statusCode: 400,
    });
  }
  try {
    const key = await auth.useKey('username', username.toLowerCase(), password);
    const session = await auth.createSession({
      userId: key.userId,
      attributes: {},
    });
    const authRequest = auth.handleRequest(event);
    authRequest.setSession(session);
    return setResponseStatus(event, 201);
  } catch (e) {
    if (
      e instanceof LuciaError &&
      (e.message === 'AUTH_INVALID_KEY_ID' ||
        e.message === 'AUTH_INVALID_PASSWORD')
    ) {
      // user does not exist
      // or invalid password
      throw createError({
        message: 'Incorrect username or password',
        statusCode: 400,
      });
    }
    throw createError({
      message: 'An unknown error occurred',
      statusCode: 500,
    });
  }
});

login.posts.ts中の処理の詳細

login.posts.tsファイルの中で実行されていたauth.createSession, auth.hadleRequest, authRequest.setSessionはsignup.posts.tsファイルで確認ですが唯一異なるauth.useKeyを確認します。


useKey = async (providerId, providerUserId, password) => {
    const keyId = createKeyId(providerId, providerUserId);
    const databaseKey = await this.adapter.getKey(keyId);
    if (!databaseKey) {
        debug.key.fail("Key not found", keyId);
        throw new LuciaError("AUTH_INVALID_KEY_ID");
    }
    const hashedPassword = databaseKey.hashed_password;
    if (hashedPassword !== null) {
        debug.key.info("Key includes password");
        if (!password) {
            debug.key.fail("Key password not provided", keyId);
            throw new LuciaError("AUTH_INVALID_PASSWORD");
        }
        const validPassword = await this.passwordHash.validate(password, hashedPassword);
        if (!validPassword) {
            debug.key.fail("Incorrect key password", password);
            throw new LuciaError("AUTH_INVALID_PASSWORD");
        }
        debug.key.notice("Validated key password");
    }
    else {
        if (password !== null) {
            debug.key.fail("Incorrect key password", password);
            throw new LuciaError("AUTH_INVALID_PASSWORD");
        }
        debug.key.info("No password included in key");
    }
    debug.key.success("Validated key", keyId);
    return this.transformDatabaseKey(databaseKey);
};

createKeyId関数の確認

createKeyId関数でkeyIdを作成してkeyIdを引数にthis.adapterのgetKeyメソッドを実行しています。createKeyId関数は復習になりますがnode_modules/lucia/dist/auth/database.jsファイルに記述されておりシンプルに”${providerId}:${providerUserId}”が設定されているだけです。


export const createKeyId = (providerId, providerUserId) => {
    if (providerId.includes(":")) {
        throw new TypeError("Provider id must not include any colons (:)");
    }
    return `${providerId}:${providerUserId}`;
};

adapterを利用してgetメソッドはインストールしたnode_modules/@lucia-auth/adapter-prisma/dist/prisma.jsファイルで確認できます。getKeyはKeyテーブルからkeyIdを利用してKey情報を取得しています。KeyはuniqueキーなのでfindUniqueメソッドを利用しています。


getKey: async (keyId) => {
    return await Key.findUnique({
        where: {
            id: keyId
        }
    });
},

keyデータにはhashed_passwordも含まれているので正しいパスワードかどうかpasswordHashのvalidateメソッドで検証が行われて問題がなければkeyデータが戻され、次のcreateSessionで利用されています。

/api/userエンドポイントの作成

ここまでの設定でユーザログイン後にSession、Cookieの作成を行うことができるようになりましたが認証によるアクセス制限を行っていないため、ログインしているかどうかにも関わらずどのページにもアクセスすることができます。

アクセスの制限を行うためにはユーザが認証済みなのかどうかをチェックする必要があります。チェックする場所として新たにAPIエンドポイント/api/userの作成を行います。

server/apiディレクトリの下にuser.get.tsファイルを作成して以下のコードを記述します。GETリクエストを送信するとauth.validateを実行してSession情報を取得します。認証に成功した場合にはsessionにuser情報が含まれてるのでそのまま戻します。認証されていない場合にはnullを戻します。


export default defineEventHandler(async (event) => {
  const authRequest = auth.handleRequest(event);
  const session = await authRequest.validate();
  return {
    user: session?.user ?? null,
  };
});

user.get.tsファイル中の処理の詳細

auth.handleRequestメソッドについてはsignup.post.tsファイルの中で動作を確認済みの処理でAuthRequestクラスをインスタンス化する処理(auth/index.js)を行っています。auth.handleRequestメソッドの実行後に実行されるauthRequest.validateメソッドをこれから確認しますがコードの確認の前に行われている処理の内容に説明をしておきます。

validateメソッドではCookieに保存されているSessionIdを利用してデータベースに保存されているSessionを取得し、idle_expiresの有効期限を確認します。有効期限が切れていなければユーザ情報をデータベースから取得します。その後、active_expiresの有効期限も確認して有効期限が切れている場合は有効期限の更新を行い最後にユーザ情報と一緒にSession情報が戻されます。

authRequest.validateの確認

authRequestのvalidateメソッドはnode_modules/lucia/dist/auth/request.jsファイルの中に記述されています。


validate = async () => {
    if (this.validatePromise) {
        debug.request.info("Using cached result for session validation");
        return this.validatePromise;
    }
    this.validatePromise = new Promise(async (resolve, reject) => {
        if (!this.storedSessionId) {
            return resolve(null);                
        }
        try {
            const session = await this.auth.validateSession(this.storedSessionId);
            if (session.fresh) {
                this.maybeSetSession(session);
            }
            return resolve(session);
        }
        catch (e) {
            if (e instanceof LuciaError &&
                e.message === "AUTH_INVALID_SESSION_ID") {
                this.maybeSetSession(null);
                return resolve(null);
            }
            return reject(e);
        }
    });
    return await this.validatePromise;
};

Cookiesが存在する場合にはauth.handleRequestメソッドによるAuthRuquestの初期化の中でHeaderからCookiesを取得してstoredSessionIdに保存しています。storedSessionIdがnullの場合はCookiesが存在しないつまり認証済みではないのでそのままnullを戻しています。

StoreSessionIdが存在する場合はauthのvalidateSessionメソッドが実行されています。validateSessionメソッドの中ではSessionの検証が行われます。


validateSession = async (sessionId) => {
    this.validateSessionIdArgument(sessionId);
    const [databaseSession, databaseUser] = await this.getDatabaseSessionAndUser(sessionId);
    const user = this.transformDatabaseUser(databaseUser);
    const session = this.transformDatabaseSession(databaseSession, {
        user,
        fresh: false
    });
    if (session.state === "active") {
        debug.session.success("Validated session", session.sessionId);
        return session;
    }
    const { activePeriodExpiresAt, idlePeriodExpiresAt } = this.getNewSessionExpiration();
    await this.adapter.updateSession(session.sessionId, {
        active_expires: activePeriodExpiresAt.getTime(),
        idle_expires: idlePeriodExpiresAt.getTime()
    });
    const renewedDatabaseSession = {
        ...session,
        idlePeriodExpiresAt,
        activePeriodExpiresAt,
        fresh: true
    };
    return renewedDatabaseSession;
};

validateSessionIdArgument関数でsessionIdが存在するかチェックしています。


validateSessionIdArgument = (sessionId) => {
    if (!sessionId) {
        debug.session.fail("Empty session id");
        throw new LuciaError("AUTH_INVALID_SESSION_ID");
    }
};

getDatabaseSessionAndUser関数ではAdapterを利用してデータベースからSession,User情報を取得しています。


getDatabaseSessionAndUser = async (sessionId) => {
    if (this.adapter.getSessionAndUser) {
        const [databaseSession, databaseUser] = await this.adapter.getSessionAndUser(sessionId);
        if (!databaseSession) {
            debug.session.fail("Session not found", sessionId);
            throw new LuciaError("AUTH_INVALID_SESSION_ID");
        }
        if (!isValidDatabaseSession(databaseSession)) {
            debug.session.fail(`Session expired at ${new Date(Number(databaseSession.idle_expires))}`, sessionId);
            throw new LuciaError("AUTH_INVALID_SESSION_ID");
        }
        return [databaseSession, databaseUser];
    }
    const databaseSession = await this.getDatabaseSession(sessionId);
    const databaseUser = await this.getDatabaseUser(databaseSession.user_id);
    return [databaseSession, databaseUser];
};

getDatabaseSessionAndUser関数内のgetSessionAndUserメソッドがPrismaのAdapterのprisma.jsファイルには存在しないのでgetDatabaseSession関数を確認します。


getDatabaseSession = async (sessionId) => {
    const databaseSession = await this.adapter.getSession(sessionId);
    if (!databaseSession) {
        debug.session.fail("Session not found", sessionId);
        throw new LuciaError("AUTH_INVALID_SESSION_ID");
    }
    if (!isValidDatabaseSession(databaseSession)) {
        debug.session.fail(`Session expired at ${new Date(Number(databaseSession.idle_expires))}`, sessionId);
        throw new LuciaError("AUTH_INVALID_SESSION_ID");
    }
    return databaseSession;
};

getDatabaseSession関数ではadapterの持つgetSessionメソッドでSessionテーブルからsessionIdを利用してSessionデータを取得しています。


getSession: async (sessionId) => {
    if (!Session) {
        throw new Error("Session table not defined");
    }
    const result = await Session.findUnique({
        where: {
            id: sessionId
        }
    });
    if (!result)
        return null;
    return transformPrismaSession(result);
},

getSessionメソッドで戻されたSessionデータはdatabaseSessionに保存されるので存在する場合はisValidDatabaseSession関数が実行されます。isValidDatabaseSession関数はauth/index.jsファイルでsession.jsファイルからimportされている関数です。


import { isValidDatabaseSession } from "./session.js";

session.jsファイルでidle_expiresの有効期限が切れているかのチェックを行っています。


import { isWithinExpiration } from "../utils/date.js";
export const isValidDatabaseSession = (databaseSession) => {
    return isWithinExpiration(databaseSession.idle_expires);
};
//../utils/date.js
export const isWithinExpiration = (expiresInMs) => {
    const currentTime = Date.now();
    if (currentTime > expiresInMs)
        return false;
    return true;
};

有効期限が切れている場合はエラーがthrowされます。


    if (!isValidDatabaseSession(databaseSession)) {
        debug.session.fail(`Session expired at ${new Date(Number(databaseSession.idle_expires))}`, sessionId);
        throw new LuciaError("AUTH_INVALID_SESSION_ID");
    }

idle_expiresの有効期限が切れていない場合はgetDatabaseUseメソッドでデータベースからユーザ情報を取得します。


getDatabaseUser = async (userId) => {
    const databaseUser = await this.adapter.getUser(userId);
    if (!databaseUser) {
        throw new LuciaError("AUTH_INVALID_USER_ID");
    }
    return databaseUser;
};

これでgetDatabaseSessionAndUserの処理後、databaseSessionとdatabaseUserが取得できます。

databaseSessionとdatabaseUserが取得できたらvalidateSession関数内での次の処理であるtransformDatabaseSession関数を利用してactive_expiresの有効期限が切れていないかチェックを行い(isWithinExpiration関数)、Sessionの形を変換します。その際にSessionにstateなどの状態も追加されます。有効期限内であればactive、切れていればidleとなります。


transformDatabaseSession = (databaseSession, context) => {
    const attributes = this.getSessionAttributes(databaseSession);
    const active = isWithinExpiration(databaseSession.active_expires);
    return {
        ...attributes,
        user: context.user,
        sessionId: databaseSession.id,
        activePeriodExpiresAt: new Date(Number(databaseSession.active_expires)),
        idlePeriodExpiresAt: new Date(Number(databaseSession.idle_expires)),
        state: active ? "active" : "idle",
        fresh: context.fresh
    };
};

sessionのstateがactiveの場合はそのまま変換したSessionが戻されます。


if (session.state === "active") {
    debug.session.success("Validated session", session.sessionId);
    return session;
}

activeではない場合はsessionの有効期限の更新が行われ、更新したデータが戻されます。


const { activePeriodExpiresAt, idlePeriodExpiresAt } = this.getNewSessionExpiration();
await this.adapter.updateSession(session.sessionId, {
    active_expires: activePeriodExpiresAt.getTime(),
    idle_expires: idlePeriodExpiresAt.getTime()
});
const renewedDatabaseSession = {
    ...session,
    idlePeriodExpiresAt,
    activePeriodExpiresAt,
    fresh: true
};
return renewedDatabaseSession

有効期限の更新を行うgetNewSessionExpiration関数ではactiveExpiresだけではなくidleExpiresの有効期限も更新されます。下記のコードからわかる通りidlePeriodExpiresの時間はactivePeriodExpiresAt+idlePeriodの時間になっています。


getNewSessionExpiration = (sessionExpiresIn) => {
    const activePeriodExpiresAt = new Date(new Date().getTime() +
        (sessionExpiresIn?.activePeriod ?? this.sessionExpiresIn.activePeriod));
    const idlePeriodExpiresAt = new Date(activePeriodExpiresAt.getTime() +
        (sessionExpiresIn?.idlePeriod ?? this.sessionExpiresIn.idlePeriod));
    return { activePeriodExpiresAt, idlePeriodExpiresAt };
};

middlewareの設定(global)

ユーザの認証のチェックを行う場所を作成することができましたがいつそチェックを行うかを設定する必要があります。チェックはmiddlewareで行います。

middlewareを設定することでページにアクセス前に追加処理を行うことができるので認証に活用することができます。ページを移動する度にmiddlewareの中でユーザ認証のエンドポイントにアクセスを行いユーザが認証されているかのチェックを行いその結果を保存します。

middlewareディレクトリを作成してauth.global.tsファイルを作成します。globalを設定することでアプリケーション全体での設定となります。すべてのページ移動で毎回実行されることになります。


import type { User } from 'lucia';

interface UserResponse {
  user: User | null;
}

export default defineNuxtRouteMiddleware(async () => {
  const user = useUser();
  const { data, error } = await useFetch<UserResponse>('/api/user');
  if (error.value) throw createError('Failed to fetch data');
  user.value = data.value?.user ?? null;
});

auth.global.tsで利用しているuseUserはComposablesとして作成します。

Composablesの作成

composabelsディレクトリを作成してauth.tsファイルを作成します。useUser関数は state management(状態管理) として利用できるcomposableでuseStateを利用することでアプリケーション内でデータを共有することができます。useUserには/api/userから取得したユーザデータを保存します。どのコンポーネントからもアクセスが可能となりるので、globalのmiddlewareで保存したユーザデータを別のmiddleware、ページコンポーネントでuseUserを介して取得します。


import type { User } from 'lucia';

export const useUser = () => {
  const user = useState('user', () => null);
  return user;
};

middlewareの設定(protected)

先ほどmiddlewareのglobalで行いましたが認証によるアクセス制限を行いたいページのみに設定を行うmiddlewareを追加します。

auth.globals.tsによって認証の確認が完了しているのでuseUser composableから保存されている値にアクセスを行い、認証されていない場合はloginページにリダイレクトさせます。


export default defineNuxtRouteMiddleware(async () => {
  const user = useUser();
  if (!user.value) return navigateTo("/login");
});

dashboardページは認証が完了したユーザのみアクセスができるのでmiddlewareのprotectedを設定します。middlewareの個別ページの設定はdefinePageMetaで行います。


<script lang="ts" setup>
definePageMeta({
  middleware: ['protected'],
});
</script>

<template>
  <h1>Dashboard</h1>
</template>

設定後、正常に動作するか確認するためにCookiesが存在する場合はブラウザのデベロッパーツールのApplicationからCookiesの削除を行います。(ログアウト機能は後ほど実装します)

他のページから/dashboardにアクセスして/loginにリダイレクトされることを確認してください。

リダイレクトが確認できたらログインを行い、/dashboardにアクセスできることを確認してください。ログイン後はどのページにもアクセスできるようになります。

login, signupページの設定

login, signupのページはログインが完了するとアクセスする必要がなくなるためuseUser composableを利用してリダイレクト処理を行います。この処理をmiddlewareとして作成することもできます。


<script lang="ts" setup>
import { FetchError } from 'ofetch';

const user = useUser();
if (user.value) {
  await navigateTo('/dashboard'); 
}
//略

ログイン中であれば/login, /signupにアクセスすると/dashboardにリダイレクトされます。

ユーザ情報の表示

認証済みのユーザのみアクセスすることができるdashboardページでユーザ情報を表示できるようにuseUser composableを利用します。


<script lang="ts" setup>
definePageMeta({
  middleware: ['protected'],
});
const user = await useUser();
</script>

<template>
  <h1>Dashboard</h1>
  <p>User id: {{ user?.userId }}</p>
</template>

ログイン中の場合は/dashboardにアクセスするとブラウザ上にuserIdが表示されます。

userIdの表示
userIdの表示

ログアウト機能の追加

最後にログアウト機能の追加を行います。dashboardページからログアウトできるようにログアウトボタンの設定を行います。/api/logoutのAPIエンドポイントにPOSTリクエストを送信するように設定を行います。


<script lang="ts" setup>
definePageMeta({
  middleware: ['protected'],
});
const user = await useUser();

const handleSubmit = async (e: Event) => {
  if (!(e.target instanceof HTMLFormElement)) return;
  await $fetch('/api/logout', {
    method: 'POST',
  });
  await navigateTo('/login');
};
</script>

<template>
  <h1>Dashboard</h1>
  <p>User id: {{ user?.userId }}</p>
  <form @submit.prevent="handleSubmit">
    <input type="submit" value="Sign out" />
  </form>
</template>

logoutエンドポイントの作成

ログアウトに利用するlogoutエンドポイントを作成するためにserver/apiディレクトリにlogout.post.tsファイルを作成して以下のコードを記述します。


export default defineEventHandler(async (event) => {
  const authRequest = auth.handleRequest(event);

  const session = await authRequest.validate();
  if (!session) {
    throw createError({
      message: 'Unauthorized',
      statusCode: 401,
    });
  }

  await auth.invalidateSession(session.sessionId);
  authRequest.setSession(null);
  return setResponseStatus(event, 200);
});

authReqeust.validateメソッドは処理はuser.get.tsファイルで確認済みの処理で実行すると有効期限のチェックが完了したSessionが戻されます。認証済みではない場合はエラーがthrowされます。

auth.invalidateSessionメソッドではSessionIdを利用してSessionテーブルからSessionデータを削除します。最後にauthRequest.setSessionメソッドでCookieの値をnullにしてCookieの削除を行っています。


setSession = (session) => {
    const sessionId = session?.sessionId ?? null;
    if (this.storedSessionId === sessionId)
        return;
    this.validatePromise = null;
    this.setSessionCookie(session);
};

Dashboadページに表示された”logout”ボタンをクリックするとloginページにリダイレクトされます。ブラウザのデベロッパツールのApplicationを確認するとCookieも消えていることが確認できます。

auth.invalidateSessionの詳細確認

auth.invalidateSessionメソッドの動作確認は未確認の処理なのでどのような処理を行なっているからソースコードから確認します。


invalidateSession = async (sessionId) => {
    this.validateSessionIdArgument(sessionId);
    await this.adapter.deleteSession(sessionId);
    debug.session.notice("Invalidated session", sessionId);
};

validateSessionIdArgument関数はauthRequest.validateメソッドでも利用する関数でsessionIdが値を持つかチェックを行います。


validateSessionIdArgument = (sessionId) => {
    if (!sessionId) {
        debug.session.fail("Empty session id");
        throw new LuciaError("AUTH_INVALID_SESSION_ID");
    }
};

次にAdapterを利用してdeleteSessionメソッドを実行します。sessionIdはSessionテーブルでUniqueな値なのでdeleteメソッドを利用して削除を行っています。


deleteSession: async (sessionId) => {
    if (!Session) {
        throw new Error("Session table not defined");
    }
    try {
        await Session.delete({
            where: {
                id: sessionId
            }
        });
    }
    catch (e) {
        const error = e;
        if (error.code === "P2025") {
            // session does not exist
            return;
        }
        throw e;
    }
},

ここまででNuxt 3で利用するために必要なLuciaの設定とソースコードを利用して実際にどのような処理が行われているか確認することができました。

その他

usename属性の追加

属性を追加する前にPrisma Studioを利用してUserテーブルに保存したデータを削除しておきます。

Userモデルではidしか設定が行われていませんでしたが、属性を追加することができます。最初にusernameを追加します。


model User {
  id           String    @id @unique
  username     String
  auth_session Session[]
  key          Key[]
}

モデルの更新を行ったので”npx prisma db push”コマンドを実行してモデルの変更をデータベースに反映させます。


 % npx prisma db push
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"

🚀  Your database is now in sync with your Prisma schema. Done in 21ms

✔ Generated Prisma Client (v5.7.1) to ./node_modules/@prisma/client in 165ms

server/utils/lucia.tsファイルに追加した属性の情報を追加する必要があります。


import { lucia } from 'lucia';
import { h3 } from 'lucia/middleware';
import { prisma } from '@lucia-auth/adapter-prisma';
import { PrismaClient } from '@prisma/client';

const client = new PrismaClient();

export const auth = lucia({
  adapter: prisma(client),
  env: process.dev ? 'DEV' : 'PROD',
  middleware: h3(),
  getUserAttributes: (data) => {
    return {
      username: data.username,
    };
  },
});

export type Auth = typeof auth;

型定義ファイルの更新も忘れずに行います。ファイルはserver/app.d.tsです。


/// 
declare namespace Lucia {
  type Auth = import('./utils/lucia').Auth;
  type DatabaseUserAttributes = {
    username: string;
  };
  type DatabaseSessionAttributes = {};
}

signup.post.tsファイルのcreateUserメソッドの引数にusernameを追加します。


const user = await auth.createUser({
  key: {
    providerId: 'username',
    providerUserId: username.toLowerCase(),
    password,
  },
  attributes: {
    username,
  },
});

Prisma StudioからUserテーブルに追加されたusernameを確認しておきます。

Userテーブルに追加したusername列を確認
Userテーブルに追加したusername列を確認

設定後はSignupページからユーザの登録を行います。登録後、Userテーブルを再度確認するとusernameにフォームで入力した名前が登録されていることが確認できます。

usernameに登録された名前の確認
usernameに登録された名前の確認

Dashboardページでも追加したusernameを表示させることができます。


<script lang="ts" setup>
definePageMeta({
  middleware: ['protected'],
});
const user = await useUser();

const handleSubmit = async (e: Event) => {
  if (!(e.target instanceof HTMLFormElement)) return;
  await $fetch('/api/logout', {
    method: 'POST',
  });
  await navigateTo('/login');
};
</script>

<template>
  <h1>Dashboard</h1>
  <p>User id: {{ user?.userId }}</p>
  <p>User name: {{ user?.username }}</p>
  <form @submit.prevent="handleSubmit">
    <input type="submit" value="Sign out" />
  </form>
</template>

ログインした状態でDashboardページにアクセスするとusernameが表示されます。

Dashboardページにユーザ名を表示
Dashboardページにユーザ名を表示

Sessionの有効期限の変更

Sessionの有効期限をデフォルト値から変更したい場合にはlucia.tsファイルで行うことができます。activePeriod, idlePeriodの有効期限を15秒に設定しています。実際にはidlePeriodの有効期限は30秒に設定されます。


export const auth = lucia({
  adapter: prisma(client),
  env: process.dev ? 'DEV' : 'PROD',
  middleware: h3(),
  getUserAttributes: (data) => {
    return {
      username: data.username,
    };
  },
  sessionExpiresIn: {
    activePeriod: 1000 * 15,
    idlePeriod: 1000 * 15,
  },
});

有効期限を設定するgetNewSessionExpiration関数は下記の通りになっています。


getNewSessionExpiration = (sessionExpiresIn) => {
    const activePeriodExpiresAt = new Date(new Date().getTime() +
        (sessionExpiresIn?.activePeriod ?? this.sessionExpiresIn.activePeriod));
    const idlePeriodExpiresAt = new Date(activePeriodExpiresAt.getTime() +
        (sessionExpiresIn?.idlePeriod ?? this.sessionExpiresIn.idlePeriod));
    return { activePeriodExpiresAt, idlePeriodExpiresAt };
};

Cookiesの有効期限もidlePeriodの設定に合わせて設定されます。

Debugモード

debugモードを設定したい場もlucia.tsファイルで行います。experimentalでdebugModeをtrueに設定することでdebugモードを利用することができます。


export const auth = lucia({
  adapter: prisma(client),
  env: process.dev ? 'DEV' : 'PROD',
  middleware: h3(),
  getUserAttributes: (data) => {
    return {
      username: data.username,
    };
  },
  sessionExpiresIn: {
    activePeriod: 1000 * 15,
    idlePeriod: 1000 * 15,
  },
  experimental: {
    debugMode: true,
  },
});

Cookiesの有効期限

Cookiesの有効期限はidlePeriodと同じ設定が行われますがSessionの有効期限が更新されてもCookiesの有効期限が更新されるわけではありません。lucia.tsファイルでsessionCookieのexpiresをfalseにすることでidlePeriodとは異なる有効期限に変更することができます。


export const auth = lucia({
  adapter: prisma(client),
  env: process.dev ? 'DEV' : 'PROD',
  middleware: h3(),
  getUserAttributes: (data) => {
    return {
      username: data.username,
    };
  },
  sessionExpiresIn: {
    activePeriod: 1000 * 15,
    idlePeriod: 1000 * 15,
  },
  sessionCookie: {
    expires: false,
  },
  experimental: {
    debugMode: true,
  },
});

設定するとCookieの有効期限が1年後に設定されました。