@aiquants/auth-react-router 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Tests for the auth route handlers (login/logout/callback).
3
+ * 認証ルートハンドラ (login/logout/callback) のテスト。
4
+ */
5
+ import type { MyGoogleProfile } from "@aiquants/auth-core"
6
+ import type { Authenticator } from "remix-auth"
7
+ import { describe, expect, it } from "vitest"
8
+ import { createAuthHandlers } from "./handlers"
9
+ import type { AuthServerConfig, LoggerPort } from "./ports"
10
+ import { createAuthSessionStorage } from "./session"
11
+
12
+ const silentLogger: LoggerPort = { info: () => {}, warn: () => {}, error: () => {} }
13
+
14
+ const config: AuthServerConfig = {
15
+ google: { clientId: "cid", clientSecret: "csec", redirectURI: "http://localhost/cb" },
16
+ session: { secrets: ["test-secret"] },
17
+ isEmailAllowed: () => true,
18
+ logger: silentLogger,
19
+ }
20
+
21
+ const googleProfile: MyGoogleProfile = {
22
+ id: "107791905418691074770",
23
+ displayName: "宏治",
24
+ name: { familyName: "F", givenName: "G" },
25
+ emails: [{ value: "u@example.com" }],
26
+ photo: "https://example.com/p.png",
27
+ accessToken: "at",
28
+ refreshToken: "rt",
29
+ expirationDateMs: 1750000000000,
30
+ provider: "google",
31
+ }
32
+
33
+ const fakeAuthenticator = (result: MyGoogleProfile | Error): Authenticator<MyGoogleProfile> =>
34
+ ({
35
+ authenticate: async () => {
36
+ if (result instanceof Error) throw result
37
+ return result
38
+ },
39
+ }) as unknown as Authenticator<MyGoogleProfile>
40
+
41
+ const setup = (authResult: MyGoogleProfile | Error = googleProfile) => {
42
+ const storage = createAuthSessionStorage(config.session)
43
+ const handlers = createAuthHandlers(config, storage, fakeAuthenticator(authResult), silentLogger)
44
+ return { storage, handlers }
45
+ }
46
+
47
+ describe("loginLoader", () => {
48
+ it("returns the error query param for unauthenticated users", async () => {
49
+ const { handlers } = setup()
50
+ const data = await handlers.loginLoader({ request: new Request("http://localhost/auth/login?error=oops") })
51
+ expect(data).toEqual({ error: "oops" })
52
+ })
53
+
54
+ it("redirects authenticated users to the post-login path", async () => {
55
+ const { handlers, storage } = setup()
56
+ const cookie = await storage.saveSession(new Request("http://localhost/"), googleProfile)
57
+ try {
58
+ await handlers.loginLoader({ request: new Request("http://localhost/auth/login", { headers: { Cookie: cookie } }) })
59
+ expect.unreachable("should have thrown a redirect")
60
+ } catch (error) {
61
+ expect((error as Response).headers.get("Location")).toBe("/")
62
+ }
63
+ })
64
+ })
65
+
66
+ describe("loginAction", () => {
67
+ it("authenticates on the Google form action", async () => {
68
+ const { handlers } = setup()
69
+ const form = new FormData()
70
+ form.set("_action", "Sign In with Google")
71
+ const user = await handlers.loginAction({ request: new Request("http://localhost/auth/login", { method: "POST", body: form }) })
72
+ expect(user).toEqual(googleProfile)
73
+ })
74
+
75
+ it("returns null for unknown actions", async () => {
76
+ const { handlers } = setup()
77
+ const form = new FormData()
78
+ form.set("_action", "Something Else")
79
+ const user = await handlers.loginAction({ request: new Request("http://localhost/auth/login", { method: "POST", body: form }) })
80
+ expect(user).toBeNull()
81
+ })
82
+ })
83
+
84
+ describe("logoutLoader", () => {
85
+ it("destroys the session and redirects with Set-Cookie", async () => {
86
+ const { handlers, storage } = setup()
87
+ const cookie = await storage.saveSession(new Request("http://localhost/"), googleProfile)
88
+ try {
89
+ await handlers.logoutLoader({ request: new Request("http://localhost/auth/logout", { headers: { Cookie: cookie } }) })
90
+ expect.unreachable("should have thrown a redirect")
91
+ } catch (error) {
92
+ const res = error as Response
93
+ expect(res.headers.get("Location")).toBe("/")
94
+ expect(res.headers.get("Set-Cookie")).toContain("__session=")
95
+ }
96
+ })
97
+ })
98
+
99
+ describe("googleCallbackLoader", () => {
100
+ it("saves a byte-compatible SessionProfile (no photo) and redirects home", async () => {
101
+ const { handlers, storage } = setup()
102
+ try {
103
+ await handlers.googleCallbackLoader({ request: new Request("http://localhost/auth/api/google/callback?code=x") })
104
+ expect.unreachable("should have thrown a redirect")
105
+ } catch (error) {
106
+ const res = error as Response
107
+ expect(res.headers.get("Location")).toBe("/")
108
+ const setCookie = res.headers.get("Set-Cookie")
109
+ expect(setCookie).toContain("__session=")
110
+
111
+ // 保存されたセッションを読み戻し、photo を含まない byte 互換形状を検証
112
+ const user = await storage.getSessionUser(new Request("http://localhost/", { headers: { Cookie: setCookie ?? "" } }))
113
+ expect(user).toEqual({
114
+ id: googleProfile.id,
115
+ displayName: googleProfile.displayName,
116
+ name: googleProfile.name,
117
+ emails: googleProfile.emails,
118
+ accessToken: "at",
119
+ refreshToken: "rt",
120
+ expirationDateMs: 1750000000000,
121
+ provider: "google",
122
+ role: undefined,
123
+ })
124
+ expect(user && "photo" in user).toBe(false)
125
+ }
126
+ })
127
+
128
+ it("redirects to the login page on authenticator errors", async () => {
129
+ const { handlers } = setup(new Error("access_denied"))
130
+ try {
131
+ await handlers.googleCallbackLoader({ request: new Request("http://localhost/auth/api/google/callback") })
132
+ expect.unreachable("should have thrown a redirect")
133
+ } catch (error) {
134
+ expect((error as Response).headers.get("Location")).toBe("/auth/login")
135
+ }
136
+ })
137
+
138
+ it("re-throws Response errors from the authenticator as-is", async () => {
139
+ const redirectResponse = new Response(null, { status: 302, headers: { Location: "/auth/login?error=x" } })
140
+ const storage = createAuthSessionStorage(config.session)
141
+ const authenticator = {
142
+ authenticate: async () => {
143
+ throw redirectResponse
144
+ },
145
+ } as unknown as Authenticator<MyGoogleProfile>
146
+ const handlers = createAuthHandlers(config, storage, authenticator, silentLogger)
147
+ try {
148
+ await handlers.googleCallbackLoader({ request: new Request("http://localhost/auth/api/google/callback") })
149
+ expect.unreachable("should have thrown the response")
150
+ } catch (error) {
151
+ expect(error).toBe(redirectResponse)
152
+ }
153
+ })
154
+ })
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @module handlers
3
+ * @description Route handler factories for login / logout / Google callback.
4
+ * login / logout / Google コールバックのルートハンドラ工場。
5
+ *
6
+ * 原本 (quants-react-router routes/_auth+ の 4 ルート + features/logoutAndRedirect.ts) の
7
+ * サーバー側ロジックを挙動保存で移植。UI (JSX) はアプリ側に残す。
8
+ * コールバックのセッション保存形状 (SessionProfile、photo/_json/photos を含まない) は
9
+ * cookie byte 互換のため厳密に保存する。
10
+ */
11
+ import type { AuthenticationPayload, MyGoogleProfile, SessionProfile } from "@aiquants/auth-core"
12
+ import { redirect } from "react-router"
13
+ import type { Authenticator } from "remix-auth"
14
+ import type { AuthServerConfig, LoggerPort } from "./ports"
15
+ import type { AuthSessionStorage } from "./session"
16
+
17
+ /** createAuthHandlers が返すハンドラ束。 */
18
+ export type AuthHandlers = ReturnType<typeof createAuthHandlers>
19
+
20
+ /**
21
+ * Builds loader/action handlers for the auth routes.
22
+ * 認証ルート用の loader/action ハンドラ群を生成する。
23
+ */
24
+ export const createAuthHandlers = (config: AuthServerConfig, storage: AuthSessionStorage, authenticator: Authenticator<MyGoogleProfile>, logger: LoggerPort) => {
25
+ const loginPath = config.loginPath ?? "/auth/login"
26
+ const postLoginRedirect = config.postLoginRedirect ?? "/"
27
+ const postLogoutRedirect = config.postLogoutRedirect ?? "/"
28
+
29
+ /**
30
+ * Login-page loader: redirects authenticated users, passes the error message through.
31
+ * ログインページ loader。認証済みならリダイレクトし、エラーメッセージを data として返す。
32
+ */
33
+ const loginLoader = async ({ request }: { request: Request }): Promise<{ error: string | null }> => {
34
+ const user = await storage.getSessionUser(request)
35
+ if (user) {
36
+ // 認証済みならトップページにリダイレクト
37
+ throw redirect(postLoginRedirect)
38
+ }
39
+ const url = new URL(request.url)
40
+ const error = url.searchParams.get("error")
41
+ return { error } // エラーメッセージを loader data として渡す
42
+ }
43
+
44
+ /**
45
+ * Login action: starts the Google OAuth flow for the "Sign In with Google" form action.
46
+ * ログイン action。"Sign In with Google" フォーム action で Google OAuth フローを開始する。
47
+ */
48
+ const loginAction = async ({ request }: { request: Request }): Promise<MyGoogleProfile | null> => {
49
+ const formData = await request.clone().formData()
50
+ const action = String(formData.get("_action"))
51
+
52
+ let user: MyGoogleProfile | null = null
53
+ switch (action) {
54
+ case "Sign In with Google":
55
+ user = await authenticator.authenticate("google", request)
56
+ break
57
+ default:
58
+ user = null
59
+ }
60
+ return user
61
+ }
62
+
63
+ /**
64
+ * Destroys the session and returns the Set-Cookie value (logout primitive).
65
+ * セッションを破棄して Set-Cookie 値を返す (ログアウトの基本操作)。
66
+ */
67
+ const logout = async (request: Request) => {
68
+ return await storage.logout(request)
69
+ }
70
+
71
+ /**
72
+ * Logs out and redirects with the destroyed-session cookie attached.
73
+ * ログアウトし、破棄済みセッション Cookie を付与してリダイレクトする。
74
+ */
75
+ const logoutAndRedirect = async (request: Request) => {
76
+ const cookie = await logout(request)
77
+ const headers = new Headers()
78
+ headers.append("Set-Cookie", cookie)
79
+ return redirect(postLogoutRedirect, { headers })
80
+ }
81
+
82
+ /**
83
+ * Logout-route loader: performs logout and throws the redirect.
84
+ * ログアウトルート loader。ログアウトを実行してリダイレクトを throw する。
85
+ */
86
+ const logoutLoader = async ({ request }: { request: Request }): Promise<never> => {
87
+ throw await logoutAndRedirect(request)
88
+ }
89
+
90
+ /**
91
+ * Google OAuth callback loader: authenticates, saves the session and redirects home.
92
+ * Google OAuth コールバック loader。認証してセッションを保存し、ホームへリダイレクトする。
93
+ */
94
+ const googleCallbackLoader = async ({ request }: { request: Request }): Promise<never> => {
95
+ try {
96
+ const googleProfile = await authenticator.authenticate("google", request)
97
+
98
+ // 認証ペイロードの構築
99
+ const authenticationPayload: AuthenticationPayload = {
100
+ accessToken: googleProfile.accessToken,
101
+ refreshToken: googleProfile.refreshToken,
102
+ expirationDateMs: googleProfile.expirationDateMs,
103
+ provider: googleProfile.provider,
104
+ }
105
+
106
+ // セッション内のユーザ情報 (photo/_json/photos を含まない byte 互換形状)
107
+ const user: SessionProfile = {
108
+ id: googleProfile.id,
109
+ displayName: googleProfile.displayName,
110
+ name: googleProfile.name,
111
+ emails: googleProfile.emails,
112
+ ...authenticationPayload,
113
+ role: googleProfile.role,
114
+ }
115
+
116
+ // 認証後のユーザ情報をセッションに保存
117
+ const cookie = await storage.saveSession(request, user)
118
+
119
+ // ホームへリダイレクト
120
+ throw redirect(postLoginRedirect, { headers: { "Set-Cookie": cookie } })
121
+ } catch (error) {
122
+ // authenticator.authenticate は認証失敗時に Response か Error を throw する。
123
+ // access_denied のような OAuth エラーでは Error の可能性があるため、
124
+ // どちらの場合でもログインページへのリダイレクトが安全 (Response はそのまま再 throw)。
125
+ if (error instanceof Response) {
126
+ throw error
127
+ }
128
+ logger.error("Authentication error:", error)
129
+ throw redirect(loginPath)
130
+ }
131
+ }
132
+
133
+ return { loginLoader, loginAction, logoutLoader, logout, logoutAndRedirect, googleCallbackLoader }
134
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @module logger
3
+ * @description Default console-backed logger used when no logger port is injected.
4
+ * ロガーポート未注入時に使う console ベースの既定ロガー。
5
+ */
6
+ import type { LoggerPort } from "./ports"
7
+
8
+ /** console ベースの既定ロガー (ライブラリ既定のフォールバックとして意図的に console を使用)。 */
9
+ export const defaultLogger: LoggerPort = {
10
+ // biome-ignore lint/suspicious/noConsole: ロガー未注入時の意図的な console フォールバック
11
+ info: (...args: unknown[]) => console.info("[Auth]", ...args),
12
+ warn: (...args: unknown[]) => console.warn("[Auth]", ...args),
13
+ error: (...args: unknown[]) => console.error("[Auth]", ...args),
14
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @module ports
3
+ * @description DI port types for the React Router auth adapter.
4
+ * React Router 認証アダプタの DI ポート型定義。
5
+ *
6
+ * アプリ固有の結合 (env・backend HTTP・DB 由来の写真解決・DB ウォームアップ・テストバックドア) は
7
+ * すべてここで定義するポートとして注入する。パッケージ内には汎用ロジックだけを置く。
8
+ */
9
+ import type { AuthMessages, SessionProfile } from "@aiquants/auth-core"
10
+
11
+ /** OAuth Google の資格情報とオプション (scopes はアプリ注入 — sanledge が拡張)。 */
12
+ export type GoogleOAuthConfig = {
13
+ clientId: string
14
+ clientSecret: string
15
+ redirectURI: string
16
+ /** 追加スコープ (既定は openid profile email)。 */
17
+ scopes?: string[]
18
+ accessType?: "online" | "offline"
19
+ includeGrantedScopes?: boolean
20
+ prompt?: "none" | "consent" | "select_account"
21
+ }
22
+
23
+ /** セッション cookie 設定 (既定は __session/lax/30 日)。secrets のみ必須。 */
24
+ export type SessionConfig = {
25
+ secrets: string[]
26
+ /** cookie 名。既定 "__session" (変更すると全ライブセッションが無効化されるため既定を推奨)。 */
27
+ cookieName?: string
28
+ /** 有効期間 (秒)。既定 60*60*24*30。 */
29
+ maxAge?: number
30
+ /** Secure 属性。既定は NODE_ENV === "production"。 */
31
+ secure?: boolean
32
+ /** セッション内のユーザーキー。既定 "user"。 */
33
+ sessionKey?: string
34
+ }
35
+
36
+ /** バックエンド (FastAPI) 認証ポート。verify → allowlist → signup の順で呼ばれる。 */
37
+ export type BackendAuthPort = {
38
+ /** GET /openid/verify 相当。ok なら true。 */
39
+ verify: (provider: string, accessToken: string) => Promise<boolean>
40
+ /** POST /openid/signup?overwrite_existing=true 相当 (毎ログイン実行)。 */
41
+ signup: (provider: string, accessToken: string) => Promise<unknown>
42
+ }
43
+
44
+ /** ユーザー写真解決ポート (drizzle TDOpenid 等はアプリ側)。キャッシュ + dedup はパッケージ内。 */
45
+ export type UserPhotoPort = (openid: string) => Promise<string>
46
+
47
+ /** 毎リクエストの DB 接続ウォームアップ (quants は 2 プール、sanledge は 1 プール)。 */
48
+ export type WarmupPort = () => Promise<void>
49
+
50
+ /** テストバックドア (quants は mock_user_id パース + 名前マップ、sanledge は固定 id)。 */
51
+ export type MockUserPort = {
52
+ /** このリクエストでバックドアを使うか (NODE_ENV/cookie の判定はアプリ側)。 */
53
+ enabled: (request: Request) => boolean
54
+ /** バックドア時に返すセッションプロファイル。 */
55
+ resolve: (request: Request) => SessionProfile
56
+ }
57
+
58
+ /** ロガーポート (既定は console)。 */
59
+ export type LoggerPort = {
60
+ info: (...args: unknown[]) => void
61
+ warn: (...args: unknown[]) => void
62
+ error: (...args: unknown[]) => void
63
+ }
64
+
65
+ /** 認証サーバーの集約設定。 */
66
+ export type AuthServerConfig = {
67
+ google: GoogleOAuthConfig
68
+ session: SessionConfig
69
+ /** allowlist 判定 (auth-core の emailDomainAllowlist を推奨)。 */
70
+ isEmailAllowed: (email: string | null | undefined) => boolean
71
+ /** バックエンド verify/signup。省略時はスキップ (フロント単独運用)。 */
72
+ backendAuth?: BackendAuthPort
73
+ /** ユーザー写真解決。省略時は常に空文字。 */
74
+ getUserPhoto?: UserPhotoPort
75
+ /** 毎リクエストのウォームアップ。省略時は何もしない。 */
76
+ warmup?: WarmupPort
77
+ /** テストバックドア。省略時は無効 (本番安全側)。 */
78
+ mockUser?: MockUserPort
79
+ /** ログインページのパス。既定 "/auth/login"。 */
80
+ loginPath?: string
81
+ /** ログイン成功後のリダイレクト先。既定 "/"。 */
82
+ postLoginRedirect?: string
83
+ /** ログアウト後のリダイレクト先。既定 "/"。 */
84
+ postLogoutRedirect?: string
85
+ /** エラーメッセージ上書き (既定は日本語の原本文言)。 */
86
+ messages?: Partial<AuthMessages>
87
+ /** ロガー (既定 console)。 */
88
+ logger?: LoggerPort
89
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Tests for the session storage factory (cookie shape, backdoor port, guards).
3
+ * セッションストレージファクトリのテスト (cookie 形状・バックドアポート・ガード)。
4
+ */
5
+ import type { SessionProfile } from "@aiquants/auth-core"
6
+ import { describe, expect, it } from "vitest"
7
+ import type { MockUserPort } from "./ports"
8
+ import { createAuthSessionStorage } from "./session"
9
+
10
+ const SECRET = "test-secret"
11
+
12
+ const profile: SessionProfile = {
13
+ id: "107791905418691074770",
14
+ displayName: "宏治",
15
+ name: { familyName: "Mock", givenName: "User" },
16
+ emails: [{ value: "mock@example.com" }],
17
+ accessToken: "at",
18
+ refreshToken: "rt",
19
+ expirationDateMs: 1750000000000,
20
+ provider: "google",
21
+ }
22
+
23
+ const request = (cookie?: string) => new Request("http://localhost/", cookie ? { headers: { Cookie: cookie } } : undefined)
24
+
25
+ describe("createAuthSessionStorage", () => {
26
+ it("throws when secrets are missing (fail-closed)", () => {
27
+ expect(() => createAuthSessionStorage({ secrets: [] })).toThrow("REMIX_SESSION_SECRET must be set")
28
+ expect(() => createAuthSessionStorage({ secrets: [""] })).toThrow("REMIX_SESSION_SECRET must be set")
29
+ })
30
+
31
+ it("commits a byte-compatible __session cookie (httpOnly/lax/30d)", async () => {
32
+ const storage = createAuthSessionStorage({ secrets: [SECRET] })
33
+ const cookie = await storage.saveSession(request(), profile)
34
+
35
+ expect(cookie.startsWith("__session=")).toBe(true)
36
+ expect(cookie).toContain("HttpOnly")
37
+ expect(cookie).toContain("SameSite=Lax")
38
+ expect(cookie).toContain(`Max-Age=${60 * 60 * 24 * 30}`)
39
+ expect(cookie).toContain("Path=/")
40
+ })
41
+
42
+ it("roundtrips the session user under the 'user' key without extra fields", async () => {
43
+ const storage = createAuthSessionStorage({ secrets: [SECRET] })
44
+ const cookie = await storage.saveSession(request(), profile)
45
+ const user = await storage.getSessionUser(request(cookie))
46
+
47
+ expect(user).toEqual(profile)
48
+ // photo/_json/photos を含まない byte 互換形状
49
+ expect(user && "photo" in user).toBe(false)
50
+ })
51
+
52
+ it("returns undefined without a session cookie", async () => {
53
+ const storage = createAuthSessionStorage({ secrets: [SECRET] })
54
+ expect(await storage.getSessionUser(request())).toBeUndefined()
55
+ })
56
+
57
+ it("uses the injected mockUser port when enabled", async () => {
58
+ const mockUser: MockUserPort = {
59
+ enabled: (req) => req.headers.get("Cookie")?.includes("mock_user=true") ?? false,
60
+ resolve: () => ({ ...profile, displayName: "太郎" }),
61
+ }
62
+ const storage = createAuthSessionStorage({ secrets: [SECRET] }, { mockUser })
63
+
64
+ const mocked = await storage.getSessionUser(request("mock_user=true"))
65
+ expect(mocked?.displayName).toBe("太郎")
66
+ // バックドア条件を満たさないリクエストは通常経路 (cookie 無し → undefined)
67
+ expect(await storage.getSessionUser(request())).toBeUndefined()
68
+ })
69
+
70
+ it("is backdoor-disabled by default (no port injected)", async () => {
71
+ const storage = createAuthSessionStorage({ secrets: [SECRET] })
72
+ expect(await storage.getSessionUser(request("mock_user=true; mock_user_id=8"))).toBeUndefined()
73
+ })
74
+
75
+ it("requireUser redirects to the login path when unauthenticated", async () => {
76
+ const storage = createAuthSessionStorage({ secrets: [SECRET] })
77
+ try {
78
+ await storage.requireUser(request())
79
+ expect.unreachable("should have thrown a redirect")
80
+ } catch (error) {
81
+ expect(error).toBeInstanceOf(Response)
82
+ expect((error as Response).headers.get("Location")).toBe("/auth/login")
83
+ }
84
+ })
85
+
86
+ it("requireAdminUser redirects non-admin users to the root", async () => {
87
+ const storage = createAuthSessionStorage({ secrets: [SECRET] })
88
+ const cookie = await storage.saveSession(request(), profile)
89
+ try {
90
+ await storage.requireAdminUser(request(cookie))
91
+ expect.unreachable("should have thrown a redirect")
92
+ } catch (error) {
93
+ expect(error).toBeInstanceOf(Response)
94
+ expect((error as Response).headers.get("Location")).toBe("/")
95
+ }
96
+ })
97
+
98
+ it("requireAdminUser passes admin users through", async () => {
99
+ const storage = createAuthSessionStorage({ secrets: [SECRET] })
100
+ const cookie = await storage.saveSession(request(), { ...profile, role: "admin" })
101
+ const user = await storage.requireAdminUser(request(cookie))
102
+ expect(user.role).toBe("admin")
103
+ })
104
+
105
+ it("logout destroys the session cookie", async () => {
106
+ const storage = createAuthSessionStorage({ secrets: [SECRET] })
107
+ const cookie = await storage.saveSession(request(), profile)
108
+ const destroyed = await storage.logout(request(cookie))
109
+ // 破棄 cookie は即時失効する
110
+ expect(destroyed).toContain("__session=")
111
+ expect(destroyed).toContain("Expires=Thu, 01 Jan 1970")
112
+ })
113
+ })
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @module session
3
+ * @description Cookie session storage factory with a DI test backdoor.
4
+ * DI テストバックドア付きの cookie セッションストレージファクトリ。
5
+ *
6
+ * 原本 (quants-react-router session.server.ts) を挙動保存で移植。相違点は
7
+ * (1) module-level の env throw をファクトリ引数検証へ移した、
8
+ * (2) テストバックドア (mock_user cookie) をハードコードから `mockUser` ポート注入に変えた、
9
+ * の 2 点のみ。cookie 形状 (`__session`/httpOnly/lax/30d、キー "user") は byte 互換。
10
+ */
11
+ import type { SessionProfile } from "@aiquants/auth-core"
12
+ import { createCookieSessionStorage, redirect } from "react-router"
13
+ import type { MockUserPort, SessionConfig } from "./ports"
14
+
15
+ /** createAuthSessionStorage が返すセッション操作束。 */
16
+ export type AuthSessionStorage = ReturnType<typeof createAuthSessionStorage>
17
+
18
+ /**
19
+ * Builds the cookie session storage and session helpers.
20
+ * cookie セッションストレージとセッションヘルパー群を生成する。
21
+ *
22
+ * @throws secrets が空の場合 (fail-closed の設定ガード)。
23
+ */
24
+ export const createAuthSessionStorage = (config: SessionConfig, options?: { mockUser?: MockUserPort; loginPath?: string; postLoginRedirect?: string }) => {
25
+ // ファクトリ実行時検証 (import 時副作用の排除)
26
+ if (!config.secrets || config.secrets.length === 0 || config.secrets.some((s) => !s)) {
27
+ throw new Error("REMIX_SESSION_SECRET must be set")
28
+ }
29
+
30
+ const sessionKey = config.sessionKey ?? "user"
31
+ const maxAge = config.maxAge ?? 60 * 60 * 24 * 30
32
+ const loginPath = options?.loginPath ?? "/auth/login"
33
+ const postLoginRedirect = options?.postLoginRedirect ?? "/"
34
+ const mockUser = options?.mockUser
35
+
36
+ const sessionStorage = createCookieSessionStorage({
37
+ cookie: {
38
+ name: config.cookieName ?? "__session",
39
+ httpOnly: true, // for security reasons, make this cookie http only
40
+ path: "/", // remember to add this so the cookie will work in all routes
41
+ sameSite: "lax", // this helps with CSRF
42
+ secrets: config.secrets,
43
+ secure: config.secure ?? process.env.NODE_ENV === "production",
44
+ maxAge,
45
+ },
46
+ })
47
+
48
+ /**
49
+ * Reads the session from the request cookie.
50
+ * リクエストの Cookie からセッションを取得する。
51
+ */
52
+ const getSession = async (request: Request) => {
53
+ return await sessionStorage.getSession(request.headers.get("Cookie"))
54
+ }
55
+
56
+ /**
57
+ * Reads the session user, honoring the injected test backdoor when enabled.
58
+ * セッションからユーザー情報を取得する (注入されたテストバックドアが有効ならそれを返す)。
59
+ */
60
+ const getSessionUser = async (request: Request): Promise<SessionProfile | undefined> => {
61
+ // テストバックドア (ポート注入時のみ有効。判定条件はアプリ側の enabled に委譲)
62
+ if (mockUser?.enabled(request)) {
63
+ return mockUser.resolve(request)
64
+ }
65
+
66
+ const session = await getSession(request)
67
+ const user = session.get(sessionKey) as SessionProfile | undefined
68
+ return user
69
+ }
70
+
71
+ /**
72
+ * Saves the user into the session and returns the Set-Cookie value.
73
+ * セッションへユーザー情報を保存し、Set-Cookie 値を返す。
74
+ */
75
+ const saveSession = async (request: Request, user: SessionProfile) => {
76
+ const session = await getSession(request)
77
+ session.set(sessionKey, user)
78
+ return await sessionStorage.commitSession(session)
79
+ }
80
+
81
+ /**
82
+ * Authenticated-user guard: redirects to the login page when absent.
83
+ * 認証ユーザーガード。未認証ならログインページへリダイレクトする。
84
+ */
85
+ const requireUser = async (request: Request) => {
86
+ const sessionUser = await getSessionUser(request)
87
+ if (sessionUser === undefined) {
88
+ throw redirect(loginPath)
89
+ }
90
+ return sessionUser as SessionProfile
91
+ }
92
+
93
+ /**
94
+ * Admin-user guard: redirects to the app root when the role is not "admin".
95
+ * 管理者ユーザーガード。role が "admin" でなければルートへリダイレクトする。
96
+ */
97
+ const requireAdminUser = async (request: Request) => {
98
+ const sessionUser = await requireUser(request)
99
+ if (sessionUser?.role !== "admin") {
100
+ throw redirect(postLoginRedirect)
101
+ }
102
+ return sessionUser
103
+ }
104
+
105
+ /**
106
+ * Destroys the session and returns the Set-Cookie value (logout primitive).
107
+ * セッションを破棄して Set-Cookie 値を返す (ログアウトの基本操作)。
108
+ */
109
+ const logout = async (request: Request) => {
110
+ const session = await getSession(request)
111
+ const cookie = await sessionStorage.destroySession(session)
112
+ return cookie
113
+ }
114
+
115
+ return {
116
+ sessionStorage,
117
+ sessionKey,
118
+ getSession,
119
+ getSessionUser,
120
+ saveSession,
121
+ requireUser,
122
+ requireAdminUser,
123
+ logout,
124
+ commitSession: sessionStorage.commitSession,
125
+ destroySession: sessionStorage.destroySession,
126
+ }
127
+ }
package/src/server.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @aiquants/auth-react-router/server — server-side auth adapter barrel.
3
+ * サーバーサイド認証アダプタのバレル。
4
+ */
5
+
6
+ export type { AuthServer } from "./server/auth-server"
7
+ export { createAuthServer } from "./server/auth-server"
8
+ export { createAuthenticate, GoogleInvalidGrantError, TokenRefreshError } from "./server/authenticate"
9
+ export { createAuthenticateInLoader } from "./server/authenticate-in-loader"
10
+ export { createGoogleAuthenticator } from "./server/google-strategy"
11
+ export { createAuthHandlers } from "./server/handlers"
12
+ export type { AuthServerConfig, BackendAuthPort, GoogleOAuthConfig, LoggerPort, MockUserPort, SessionConfig, UserPhotoPort, WarmupPort } from "./server/ports"
13
+ export type { AuthSessionStorage } from "./server/session"
14
+ export { createAuthSessionStorage } from "./server/session"