Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 170 additions & 68 deletions src/lib/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,94 +1,173 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Session, Profile } from 'next-auth';
import type { JWT } from 'next-auth/jwt';
import type { NextAuthOptions } from 'next-auth';
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { Session, Profile } from "next-auth";
import type { JWT } from "next-auth/jwt";
import type { NextAuthOptions } from "next-auth";

describe('authOptions', () => {
describe("authOptions", () => {
let authOptions: NextAuthOptions;
const mockUser = { id: '1', email: 'test@example.com', emailVerified: null };
const mockUser = { id: "1", email: "test@example.com", emailVerified: null };

beforeEach(async () => {
vi.resetModules();
process.env.NEXTAUTH_SECRET = 'my_secret';
const authModule = await import('@/lib/auth');
process.env.NEXTAUTH_SECRET = "my_secret";
const authModule = await import("@/lib/auth");
authOptions = authModule.authOptions;
});

describe('callbacks', () => {
describe('jwt', () => {
it('account が提供された場合、token.accessToken に account.access_token を追加する', async () => {
const token = { name: 'Test', email: 'test@example.com' };
const account = { access_token: 'token123', provider: 'github', type: 'oauth' as const, providerAccountId: '123' };
const result = await authOptions.callbacks!.jwt!({ token, account, user: mockUser, profile: undefined, trigger: 'signIn', session: undefined, isNewUser: false });
expect((result as JWT)?.accessToken).toBe('token123');
describe("callbacks", () => {
describe("jwt", () => {
it("account が提供された場合、token.accessToken に account.access_token を追加する", async () => {
const token = { name: "Test", email: "test@example.com" };
const account = {
access_token: "token123",
provider: "github",
type: "oauth" as const,
providerAccountId: "123",
};
const result = await authOptions.callbacks!.jwt!({
token,
account,
user: mockUser,
profile: undefined,
trigger: "signIn",
session: undefined,
isNewUser: false,
});
expect((result as JWT)?.accessToken).toBe("token123");
});

it('account が提供されない場合は accessToken を追加しない', async () => {
const token = { name: 'Test', email: 'test@example.com' };
const result = await authOptions.callbacks!.jwt!({ token, account: null, user: mockUser, profile: undefined, trigger: 'signIn', session: undefined, isNewUser: false });
it("account が提供されない場合は accessToken を追加しない", async () => {
const token = { name: "Test", email: "test@example.com" };
const result = await authOptions.callbacks!.jwt!({
token,
account: null,
user: mockUser,
profile: undefined,
trigger: "signIn",
session: undefined,
isNewUser: false,
});
expect((result as JWT)?.accessToken).toBeUndefined();
});

it('profile が提供され、login が文字列の場合、token.login に profile.login を追加する', async () => {
const token = { name: 'Test', email: 'test@example.com' };
const profile = { login: 'testuser', id: 1 } as unknown as Profile;
const result = await authOptions.callbacks!.jwt!({ token, account: null, profile, user: mockUser, trigger: 'signIn', session: undefined, isNewUser: false });
expect((result as JWT)?.login).toBe('testuser');
it("profile が提供され、login が文字列の場合、token.login に profile.login を追加する", async () => {
const token = { name: "Test", email: "test@example.com" };
const profile = { login: "testuser", id: 1 } as unknown as Profile;
const result = await authOptions.callbacks!.jwt!({
token,
account: null,
profile,
user: mockUser,
trigger: "signIn",
session: undefined,
isNewUser: false,
});
expect((result as JWT)?.login).toBe("testuser");
});

it('profile が提供されない場合は login を追加しない', async () => {
const token = { name: 'Test', email: 'test@example.com' };
const result = await authOptions.callbacks!.jwt!({ token, account: null, user: mockUser, profile: undefined, trigger: 'signIn', session: undefined, isNewUser: false });
it("profile が提供されない場合は login を追加しない", async () => {
const token = { name: "Test", email: "test@example.com" };
const result = await authOptions.callbacks!.jwt!({
token,
account: null,
user: mockUser,
profile: undefined,
trigger: "signIn",
session: undefined,
isNewUser: false,
});
expect((result as JWT)?.login).toBeUndefined();
});

it('profile がオブジェクトではない場合は login を追加しない', async () => {
const token = { name: 'Test', email: 'test@example.com' };
it("profile がオブジェクトではない場合は login を追加しない", async () => {
const token = { name: "Test", email: "test@example.com" };
const profile = "not an object";
const result = await authOptions.callbacks!.jwt!({ token, account: null, profile: profile as unknown as Profile, user: mockUser, trigger: 'signIn', session: undefined, isNewUser: false });
const result = await authOptions.callbacks!.jwt!({
token,
account: null,
profile: profile as unknown as Profile,
user: mockUser,
trigger: "signIn",
session: undefined,
isNewUser: false,
});
expect((result as JWT)?.login).toBeUndefined();
});

it('profile.login が文字列ではない場合は login を undefined に設定する', async () => {
const token = { name: 'Test', email: 'test@example.com' };
it("profile.login が文字列ではない場合は login を undefined に設定する", async () => {
const token = { name: "Test", email: "test@example.com" };
const profile = { login: 12345, id: 1 } as unknown as Profile;
const result = await authOptions.callbacks!.jwt!({ token, account: null, profile, user: mockUser, trigger: 'signIn', session: undefined, isNewUser: false });
const result = await authOptions.callbacks!.jwt!({
token,
account: null,
profile,
user: mockUser,
trigger: "signIn",
session: undefined,
isNewUser: false,
});
expect((result as JWT)?.login).toBeUndefined();
});

it('profile に login キーがない場合は login を設定しない', async () => {
const token = { name: 'Test', email: 'test@example.com' };
it("profile に login キーがない場合は login を設定しない", async () => {
const token = { name: "Test", email: "test@example.com" };
const profile = { id: 1 } as unknown as Profile;
const result = await authOptions.callbacks!.jwt!({ token, account: null, profile, user: mockUser, trigger: 'signIn', session: undefined, isNewUser: false });
const result = await authOptions.callbacks!.jwt!({
token,
account: null,
profile,
user: mockUser,
trigger: "signIn",
session: undefined,
isNewUser: false,
});
expect((result as JWT)?.login).toBeUndefined();
});
});

describe('session', () => {
it('session.accessToken に token.accessToken を追加する', async () => {
const session = { expires: '123' };
const token = { accessToken: 'token123' };
const result = await authOptions.callbacks!.session!({ session, token, user: mockUser, newSession: undefined, trigger: 'update' });
expect((result as Session)?.accessToken).toBe('token123');
describe("session", () => {
it("session.accessToken に token.accessToken を追加する", async () => {
const session = { expires: "123" };
const token = { accessToken: "token123" };
const result = await authOptions.callbacks!.session!({
session,
token,
user: mockUser,
newSession: undefined,
trigger: "update",
});
expect((result as Session)?.accessToken).toBe("token123");
});

it('session.user が存在する場合、session.user.login に token.login を追加する', async () => {
const session = { expires: '123', user: { name: 'Test' } };
const token = { login: 'testuser' };
const result = await authOptions.callbacks!.session!({ session, token, user: mockUser, newSession: undefined, trigger: 'update' });
expect((result as Session)?.user?.login).toBe('testuser');
it("session.user が存在する場合、session.user.login に token.login を追加する", async () => {
const session = { expires: "123", user: { name: "Test" } };
const token = { login: "testuser" };
const result = await authOptions.callbacks!.session!({
session,
token,
user: mockUser,
newSession: undefined,
trigger: "update",
});
expect((result as Session)?.user?.login).toBe("testuser");
});

it('session.user が存在しない場合は login を追加しない', async () => {
const session = { expires: '123' };
const token = { login: 'testuser' };
const result = await authOptions.callbacks!.session!({ session, token, user: mockUser, newSession: undefined, trigger: 'update' });
it("session.user が存在しない場合は login を追加しない", async () => {
const session = { expires: "123" };
const token = { login: "testuser" };
const result = await authOptions.callbacks!.session!({
session,
token,
user: mockUser,
newSession: undefined,
trigger: "update",
});
expect((result as Session)?.user?.login).toBeUndefined();
});
});
});

describe('getSecret', () => {
describe("getSecret", () => {
const originalEnv = process.env;

beforeEach(() => {
Expand All @@ -100,35 +179,58 @@ describe('authOptions', () => {
process.env = originalEnv;
});

it('returns NEXTAUTH_SECRET if set', async () => {
process.env.NEXTAUTH_SECRET = 'my_secret';
const authModule = await import('@/lib/auth');
expect(authModule.authOptions.secret).toBe('my_secret');
it("returns NEXTAUTH_SECRET if set", async () => {
process.env.NEXTAUTH_SECRET = "my_secret";
const authModule = await import("@/lib/auth");
expect(authModule.authOptions.secret).toBe("my_secret");
});

it('returns fallback string in development environments', async () => {
it("returns dynamic random string in development environments", async () => {
delete process.env.NEXTAUTH_SECRET;
Object.defineProperty(process.env, 'NODE_ENV', { value: 'development', configurable: true });
const authModule = await import('@/lib/auth');
expect(authModule.authOptions.secret).toBe('fallback_secret_for_development_only');
Object.defineProperty(process.env, "NODE_ENV", {
value: "development",
configurable: true,
});
const authModule = await import("@/lib/auth");
expect(authModule.authOptions.secret).toBeDefined();
expect(typeof authModule.authOptions.secret).toBe("string");
expect(authModule.authOptions.secret?.length).toBeGreaterThan(10);
expect(authModule.authOptions.secret).not.toBe(
"fallback_secret_for_development_only",
);
});

it('throws error outside development and test environments if NEXTAUTH_SECRET is missing', async () => {
it("throws error outside development and test environments if NEXTAUTH_SECRET is missing", async () => {
delete process.env.NEXTAUTH_SECRET;
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', configurable: true });
await expect(() => import('@/lib/auth')).rejects.toThrow('NEXTAUTH_SECRET is not set. Please set it to a secure random value.');
Object.defineProperty(process.env, "NODE_ENV", {
value: "production",
configurable: true,
});
await expect(() => import("@/lib/auth")).rejects.toThrow(
"NEXTAUTH_SECRET is not set. Please set it to a secure random value.",
);
});

it('throws error in staging-like environments if NEXTAUTH_SECRET is missing', async () => {
it("throws error in staging-like environments if NEXTAUTH_SECRET is missing", async () => {
delete process.env.NEXTAUTH_SECRET;
Object.defineProperty(process.env, 'NODE_ENV', { value: 'staging', configurable: true });
await expect(() => import('@/lib/auth')).rejects.toThrow('NEXTAUTH_SECRET is not set. Please set it to a secure random value.');
Object.defineProperty(process.env, "NODE_ENV", {
value: "staging",
configurable: true,
});
await expect(() => import("@/lib/auth")).rejects.toThrow(
"NEXTAUTH_SECRET is not set. Please set it to a secure random value.",
);
});

it('throws error in test environment if NEXTAUTH_SECRET is missing', async () => {
it("throws error in test environment if NEXTAUTH_SECRET is missing", async () => {
delete process.env.NEXTAUTH_SECRET;
Object.defineProperty(process.env, 'NODE_ENV', { value: 'test', configurable: true });
await expect(() => import('@/lib/auth')).rejects.toThrow('NEXTAUTH_SECRET is not set. Please set it to a secure random value.');
Object.defineProperty(process.env, "NODE_ENV", {
value: "test",
configurable: true,
});
await expect(() => import("@/lib/auth")).rejects.toThrow(
"NEXTAUTH_SECRET is not set. Please set it to a secure random value.",
);
});
});
});
14 changes: 11 additions & 3 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,21 @@ declare module "next-auth/jwt" {
}
}

let devSecret: string | undefined;

const getSecret = (): string => {
if (process.env.NEXTAUTH_SECRET) {
return process.env.NEXTAUTH_SECRET;
}
if (process.env.NODE_ENV === "development") {
return "fallback_secret_for_development_only";
if (!devSecret) {
devSecret = crypto.randomUUID();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 開発用 Secret が再生成される

NODE_ENV=developmentNEXTAUTH_SECRET がない場合、authOptions.secret はこのモジュールが読み込まれるたびに新しい UUID になります。next dev のホットリロードや開発サーバーの再起動で既存の NextAuth セッション Cookie は古い secret で署名されたままになるため、保存や再読み込みの直後にログイン状態が失われます。

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/auth.ts
Line: 29

Comment:
**開発用 Secret が再生成される**

`NODE_ENV=development``NEXTAUTH_SECRET` がない場合、`authOptions.secret` はこのモジュールが読み込まれるたびに新しい UUID になります。`next dev` のホットリロードや開発サーバーの再起動で既存の NextAuth セッション Cookie は古い secret で署名されたままになるため、保存や再読み込みの直後にログイン状態が失われます。

How can I resolve this? If you propose a fix, please make it concise.

}
return devSecret;
}
throw new Error("NEXTAUTH_SECRET is not set. Please set it to a secure random value.");
throw new Error(
"NEXTAUTH_SECRET is not set. Please set it to a secure random value.",
);
};

export const authOptions: NextAuthOptions = {
Expand All @@ -46,7 +53,8 @@ export const authOptions: NextAuthOptions = {
token.accessToken = account.access_token;
}
if (profile && typeof profile === "object" && "login" in profile) {
token.login = typeof profile.login === "string" ? profile.login : undefined;
token.login =
typeof profile.login === "string" ? profile.login : undefined;
}
return token;
},
Expand Down
Loading