@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,148 @@
1
+ /**
2
+ * @module google-strategy-impl
3
+ * @description Self-contained Google OAuth2 strategy (ported from @coji/remix-auth-google).
4
+ * 自己完結型の Google OAuth2 ストラテジー (@coji/remix-auth-google から移植)。
5
+ *
6
+ * @coji/remix-auth-google@4.2.0 の実装 (68 行) を挙動保存で移植し、外部依存を除去する。
7
+ * OAuth2 の本体機構 (PKCE / state / トークン交換) は継承元の remix-auth-oauth2 が担うため
8
+ * 移植対象ではない。ここに置くのは Google 固有の設定 (エンドポイント URL・既定スコープ)、
9
+ * 認可パラメータの上書き (access_type / include_granted_scopes / prompt / hd / login_hint)、
10
+ * および userinfo (v3) からプロファイルを取得する静的メソッドのみ。
11
+ *
12
+ * 既定スコープ・URL・プロファイル整形 (sub→id、picture→photos、_json=raw) は原本と byte 一致。
13
+ */
14
+ import { OAuth2Strategy } from "remix-auth-oauth2"
15
+
16
+ /** Google の既定スコープ (原本と同一。フル URL 形式)。 */
17
+ export const GoogleStrategyDefaultScopes = ["openid", "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"]
18
+
19
+ /** ストラテジー名 (remix-auth の Authenticator 登録キー)。 */
20
+ export const GoogleStrategyDefaultName = "google"
21
+
22
+ // userinfo エンドポイント (v3)。プロファイル取得のみに用いる
23
+ const userInfoURL = "https://www.googleapis.com/oauth2/v3/userinfo"
24
+
25
+ /** @see https://developers.google.com/identity/protocols/oauth2/scopes */
26
+ export type GoogleScope = string
27
+
28
+ /** GoogleStrategy のコンストラクタオプション (原本 @coji と同一)。 */
29
+ export type GoogleStrategyOptions = {
30
+ clientId: string
31
+ clientSecret: string
32
+ redirectURI: string
33
+ /** @default "openid profile email" 相当 (GoogleStrategyDefaultScopes)。 */
34
+ scopes?: GoogleScope[]
35
+ accessType?: "online" | "offline"
36
+ includeGrantedScopes?: boolean
37
+ prompt?: "none" | "consent" | "select_account"
38
+ hd?: string
39
+ loginHint?: string
40
+ }
41
+
42
+ /** userinfo から取得・整形した Google プロファイル (原本 GoogleProfile と同一形状)。 */
43
+ export type GoogleProfile = {
44
+ id: string
45
+ displayName: string
46
+ name: {
47
+ familyName: string
48
+ givenName: string
49
+ }
50
+ emails: [{ value: string }]
51
+ photos: [{ value: string }]
52
+ _json: {
53
+ sub: string
54
+ name: string
55
+ given_name: string
56
+ family_name: string
57
+ picture: string
58
+ locale: string
59
+ email: string
60
+ email_verified: boolean
61
+ hd: string
62
+ }
63
+ }
64
+
65
+ // userProfile が必要とする最小のトークン形状 (arctic の OAuth2Tokens の部分集合)。
66
+ // 直接 arctic へ依存しないための構造的型。実際に呼ぶのは accessToken() のみ。
67
+ type OAuth2AccessTokenSource = { accessToken(): string }
68
+
69
+ /**
70
+ * Google OAuth2 strategy for remix-auth (extends remix-auth-oauth2's OAuth2Strategy).
71
+ * remix-auth 用の Google OAuth2 ストラテジー (remix-auth-oauth2 の OAuth2Strategy を継承)。
72
+ */
73
+ export class GoogleStrategy<User> extends OAuth2Strategy<User> {
74
+ name = GoogleStrategyDefaultName
75
+ private readonly accessType: "online" | "offline"
76
+ private readonly prompt?: "none" | "consent" | "select_account"
77
+ private readonly includeGrantedScopes: boolean
78
+ private readonly hd?: string
79
+ private readonly loginHint?: string
80
+
81
+ constructor({ clientId, clientSecret, redirectURI, scopes, accessType, includeGrantedScopes, prompt, hd, loginHint }: GoogleStrategyOptions, verify: OAuth2Strategy<User>["verify"]) {
82
+ super(
83
+ {
84
+ clientId,
85
+ clientSecret,
86
+ redirectURI,
87
+ authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
88
+ tokenEndpoint: "https://oauth2.googleapis.com/token",
89
+ scopes: scopes ?? GoogleStrategyDefaultScopes,
90
+ },
91
+ verify,
92
+ )
93
+ this.accessType = accessType ?? "online"
94
+ this.includeGrantedScopes = includeGrantedScopes ?? false
95
+ this.prompt = prompt
96
+ this.hd = hd
97
+ this.loginHint = loginHint
98
+ }
99
+
100
+ /**
101
+ * Adds Google-specific authorization parameters to the redirect URL.
102
+ * 認可リダイレクト URL へ Google 固有パラメータを付与する。
103
+ */
104
+ protected authorizationParams(params: URLSearchParams, _request?: Request): URLSearchParams {
105
+ params.set("access_type", this.accessType)
106
+ params.set("include_granted_scopes", String(this.includeGrantedScopes))
107
+ if (this.prompt) {
108
+ params.set("prompt", this.prompt)
109
+ }
110
+ if (this.hd) {
111
+ params.set("hd", this.hd)
112
+ }
113
+ if (this.loginHint) {
114
+ params.set("login_hint", this.loginHint)
115
+ }
116
+ return params
117
+ }
118
+
119
+ /**
120
+ * Fetches the Google user profile from the userinfo (v3) endpoint.
121
+ * userinfo (v3) エンドポイントから Google ユーザープロファイルを取得する。
122
+ *
123
+ * @throws プロファイル取得に失敗した場合 (レスポンスが ok でない)。
124
+ */
125
+ static async userProfile(tokens: OAuth2AccessTokenSource): Promise<GoogleProfile> {
126
+ const response = await fetch(userInfoURL, {
127
+ headers: {
128
+ Authorization: `Bearer ${tokens.accessToken()}`,
129
+ },
130
+ })
131
+ if (!response.ok) {
132
+ throw new Error(`Failed to fetch user profile: ${response.statusText}`)
133
+ }
134
+ const raw = await response.json()
135
+ const profile: GoogleProfile = {
136
+ id: raw.sub,
137
+ displayName: raw.name,
138
+ name: {
139
+ familyName: raw.family_name,
140
+ givenName: raw.given_name,
141
+ },
142
+ emails: [{ value: raw.email }],
143
+ photos: [{ value: raw.picture }],
144
+ _json: raw,
145
+ }
146
+ return profile
147
+ }
148
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Tests for the Google verify callback (order, error mapping, profile shape).
3
+ * Google verify コールバックのテスト (検証順序・エラー写像・プロファイル形状)。
4
+ *
5
+ * GoogleStrategy をコンストラクタ捕捉でフェイク化し、verify コールバックを直接起動する。
6
+ */
7
+ import type { MyGoogleProfile } from "@aiquants/auth-core"
8
+ import { defaultAuthMessages } from "@aiquants/auth-core"
9
+ import { beforeEach, describe, expect, it, vi } from "vitest"
10
+ import type { AuthServerConfig, LoggerPort } from "./ports"
11
+
12
+ type VerifyFn = (args: { request: Request; tokens: { data: Record<string, unknown> } }) => Promise<MyGoogleProfile>
13
+
14
+ const captured: { options?: Record<string, unknown>; verify?: VerifyFn } = {}
15
+ const userProfileMock = vi.hoisted(() => vi.fn())
16
+
17
+ vi.mock("./google-strategy-impl", () => {
18
+ /** コンストラクタ引数を捕捉するフェイク GoogleStrategy。 */
19
+ class FakeGoogleStrategy {
20
+ static userProfile = userProfileMock
21
+ name = "google"
22
+ constructor(options: Record<string, unknown>, verify: VerifyFn) {
23
+ captured.options = options
24
+ captured.verify = verify
25
+ }
26
+ }
27
+ return { GoogleStrategy: FakeGoogleStrategy }
28
+ })
29
+
30
+ const { createGoogleAuthenticator } = await import("./google-strategy")
31
+
32
+ const silentLogger: LoggerPort = { info: () => {}, warn: () => {}, error: () => {} }
33
+
34
+ const googleProfile = () => ({
35
+ id: "107791905418691074770",
36
+ displayName: "宏治",
37
+ name: { familyName: "F", givenName: "G" },
38
+ emails: [{ value: "user@example.com" }],
39
+ photos: [{ value: "photo-url" }],
40
+ _json: {
41
+ sub: "107791905418691074770",
42
+ email: "user@example.com",
43
+ picture: "https://example.com/p.png",
44
+ email_verified: true,
45
+ hd: "example.com",
46
+ },
47
+ })
48
+
49
+ const tokens = (over?: Record<string, unknown>) => ({
50
+ data: { access_token: "at", refresh_token: "rt", expires_in: 3600, scope: "openid email profile", id_token: "idt", ...over },
51
+ })
52
+
53
+ const makeConfig = (over?: Partial<AuthServerConfig>): AuthServerConfig => ({
54
+ google: { clientId: "cid", clientSecret: "csec", redirectURI: "http://localhost/cb" },
55
+ session: { secrets: ["s"] },
56
+ isEmailAllowed: () => true,
57
+ logger: silentLogger,
58
+ ...over,
59
+ })
60
+
61
+ /** verify を起動して throw された Response の Location を返すヘルパー。 */
62
+ const expectLoginRedirect = async (promise: Promise<unknown>): Promise<string> => {
63
+ try {
64
+ await promise
65
+ expect.unreachable("should have thrown a redirect")
66
+ } catch (error) {
67
+ expect(error).toBeInstanceOf(Response)
68
+ const location = (error as Response).headers.get("Location")
69
+ expect(location).not.toBeNull()
70
+ return location as string
71
+ }
72
+ return ""
73
+ }
74
+
75
+ beforeEach(() => {
76
+ userProfileMock.mockReset()
77
+ userProfileMock.mockResolvedValue(googleProfile())
78
+ })
79
+
80
+ describe("createGoogleAuthenticator options", () => {
81
+ it("passes injected google options with original defaults", () => {
82
+ createGoogleAuthenticator(makeConfig())
83
+ expect(captured.options).toMatchObject({
84
+ clientId: "cid",
85
+ clientSecret: "csec",
86
+ redirectURI: "http://localhost/cb",
87
+ accessType: "offline",
88
+ includeGrantedScopes: true,
89
+ prompt: "consent",
90
+ })
91
+ })
92
+
93
+ it("passes custom scopes through (sanledge 差分の吸収)", () => {
94
+ createGoogleAuthenticator(makeConfig({ google: { clientId: "c", clientSecret: "s", redirectURI: "u", scopes: ["openid", "email", "profile", "directory.readonly"] } }))
95
+ expect(captured.options?.scopes).toEqual(["openid", "email", "profile", "directory.readonly"])
96
+ })
97
+ })
98
+
99
+ describe("verify callback", () => {
100
+ it("runs backend verify → allowlist → signup in order and returns the profile with photo", async () => {
101
+ const order: string[] = []
102
+ const config = makeConfig({
103
+ backendAuth: {
104
+ verify: vi.fn(async () => {
105
+ order.push("verify")
106
+ return true
107
+ }),
108
+ signup: vi.fn(async () => {
109
+ order.push("signup")
110
+ return {}
111
+ }),
112
+ },
113
+ isEmailAllowed: (email) => {
114
+ order.push(`allowlist:${email}`)
115
+ return true
116
+ },
117
+ })
118
+ createGoogleAuthenticator(config)
119
+
120
+ const before = Date.now()
121
+ const user = await captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() })
122
+
123
+ // 検証順序: backend verify → allowlist → backend signup (原本と同一)
124
+ expect(order).toEqual(["verify", "allowlist:user@example.com", "signup"])
125
+ expect(config.backendAuth?.verify).toHaveBeenCalledWith("google", "at")
126
+ expect(config.backendAuth?.signup).toHaveBeenCalledWith("google", "at")
127
+ // プロファイル形状: _json/photos を含まず photo=picture、payload 付き
128
+ expect(user).toMatchObject({
129
+ id: "107791905418691074770",
130
+ displayName: "宏治",
131
+ emails: [{ value: "user@example.com" }],
132
+ accessToken: "at",
133
+ refreshToken: "rt",
134
+ provider: "google",
135
+ photo: "https://example.com/p.png",
136
+ })
137
+ expect(user && "_json" in user).toBe(false)
138
+ expect(user && "photos" in user).toBe(false)
139
+ expect(user?.expirationDateMs).toBeGreaterThanOrEqual(before + 3600 * 1000)
140
+ })
141
+
142
+ it("redirects with emailNotVerified when a field is missing AND email_verified is false (原本セマンティクス)", async () => {
143
+ // 原本の分岐は「必須フィールド欠落 (undefined) の場合」に限り email_verified===false を
144
+ // 判別してメッセージを出し分ける (email_verified: false 単独では発火しない) — bug-for-bug 保存
145
+ userProfileMock.mockResolvedValue({ ...googleProfile(), _json: { ...googleProfile()._json, email: undefined, email_verified: false } })
146
+ createGoogleAuthenticator(makeConfig())
147
+ const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
148
+ expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.emailNotVerified)}`)
149
+ })
150
+
151
+ it("redirects with googleFailed when the refresh token is missing", async () => {
152
+ createGoogleAuthenticator(makeConfig())
153
+ const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens({ refresh_token: undefined }) }) as Promise<unknown>)
154
+ expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.googleFailed)}`)
155
+ })
156
+
157
+ it("redirects with backendFailed when backend verify returns false", async () => {
158
+ createGoogleAuthenticator(makeConfig({ backendAuth: { verify: async () => false, signup: async () => ({}) } }))
159
+ const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
160
+ expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.backendFailed)}`)
161
+ })
162
+
163
+ it("redirects with backendUnreachable on getaddrinfo ENOTFOUND cause", async () => {
164
+ createGoogleAuthenticator(
165
+ makeConfig({
166
+ backendAuth: {
167
+ verify: async () => {
168
+ throw new Error("Network or Interface Error", { cause: new Error("getaddrinfo ENOTFOUND api.example.com") })
169
+ },
170
+ signup: async () => ({}),
171
+ },
172
+ }),
173
+ )
174
+ const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
175
+ expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.backendUnreachable)}`)
176
+ })
177
+
178
+ it("redirects with notAllowed when the allowlist rejects the email", async () => {
179
+ const signup = vi.fn(async () => ({}))
180
+ createGoogleAuthenticator(makeConfig({ isEmailAllowed: () => false, backendAuth: { verify: async () => true, signup } }))
181
+ const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
182
+ expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.notAllowed)}`)
183
+ // allowlist 拒否時は signup に到達しない (fail-closed)
184
+ expect(signup).not.toHaveBeenCalled()
185
+ })
186
+
187
+ it("redirects with unexpected on generic signup failure", async () => {
188
+ createGoogleAuthenticator(
189
+ makeConfig({
190
+ backendAuth: {
191
+ verify: async () => true,
192
+ signup: async () => {
193
+ throw new Error("boom")
194
+ },
195
+ },
196
+ }),
197
+ )
198
+ const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
199
+ expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.unexpected)}`)
200
+ })
201
+
202
+ it("skips backend verify/signup when backendAuth is omitted but still enforces the allowlist", async () => {
203
+ const allowlist = vi.fn(() => true)
204
+ createGoogleAuthenticator(makeConfig({ isEmailAllowed: allowlist }))
205
+ const user = await captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() })
206
+ expect(user?.photo).toBe("https://example.com/p.png")
207
+ expect(allowlist).toHaveBeenCalledWith("user@example.com")
208
+ })
209
+
210
+ it("honors message overrides", async () => {
211
+ createGoogleAuthenticator(makeConfig({ isEmailAllowed: () => false, messages: { notAllowed: "立入禁止" } }))
212
+ const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
213
+ expect(location).toBe(`/auth/login?error=${encodeURIComponent("立入禁止")}`)
214
+ })
215
+ })
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @module google-strategy
3
+ * @description Google OAuth authenticator factory (remix-auth v4 + local GoogleStrategy).
4
+ * Google OAuth 認証器ファクトリ (remix-auth v4 + 同梱の GoogleStrategy)。
5
+ *
6
+ * 原本 (quants-react-router auth.server.ts) の verify コールバックを挙動保存で移植。
7
+ * env 読取り・backend HTTP・allowlist・エラーメッセージはすべてポート/設定注入に置き換えた。
8
+ * 検証順序 (Google プロフィール検証 → backend verify → allowlist → backend signup)、
9
+ * リダイレクト先 (`<loginPath>?error=<encoded>`)、エラーメッセージ文言は原本と等価。
10
+ */
11
+ import type { MyGoogleProfile } from "@aiquants/auth-core"
12
+ import { type AuthMessages, resolveAuthMessages } from "@aiquants/auth-core"
13
+ import { redirect } from "react-router"
14
+ import { Authenticator } from "remix-auth"
15
+ import { GoogleStrategy } from "./google-strategy-impl"
16
+ import { defaultLogger } from "./logger"
17
+ import type { AuthServerConfig } from "./ports"
18
+
19
+ interface GoogleToken {
20
+ id_token?: string
21
+ access_token?: string
22
+ refresh_token?: string
23
+ expires_in?: number
24
+ scope?: string
25
+ }
26
+
27
+ /**
28
+ * Builds a remix-auth Authenticator with the Google strategy wired to injected ports.
29
+ * 注入ポートに結線した Google ストラテジー付き Authenticator を生成する。
30
+ */
31
+ export const createGoogleAuthenticator = (config: AuthServerConfig): Authenticator<MyGoogleProfile> => {
32
+ const logger = config.logger ?? defaultLogger
33
+ const messages: AuthMessages = resolveAuthMessages(config.messages)
34
+ const loginPath = config.loginPath ?? "/auth/login"
35
+
36
+ const googleStrategy = new GoogleStrategy<MyGoogleProfile>(
37
+ {
38
+ clientId: config.google.clientId,
39
+ clientSecret: config.google.clientSecret,
40
+ redirectURI: config.google.redirectURI,
41
+ scopes: config.google.scopes,
42
+ accessType: config.google.accessType ?? "offline",
43
+ includeGrantedScopes: config.google.includeGrantedScopes ?? true,
44
+ prompt: config.google.prompt ?? "consent",
45
+ },
46
+ /**
47
+ * Google 認証後のコールバック。Google のトークンとプロフィールを検証し、
48
+ * backend verify → allowlist → backend signup を経て MyGoogleProfile を返す。
49
+ * 失敗時は `<loginPath>?error=<encoded>` へのリダイレクト Response を throw する。
50
+ */
51
+ async ({ request: _request, tokens }) => {
52
+ const { access_token: accessToken, refresh_token: refreshToken, expires_in: expiresIn, scope, id_token: _idToken } = tokens.data as GoogleToken
53
+ const {
54
+ _json: { sub, email, picture, hd: _hd, email_verified },
55
+ photos: _photos,
56
+ ...profile
57
+ } = await GoogleStrategy.userProfile(tokens)
58
+ const {
59
+ id,
60
+ displayName,
61
+ name: { familyName: _familyName, givenName: _givenName },
62
+ emails: _emails,
63
+ } = profile
64
+ const provider = "google"
65
+
66
+ // first verify (トークン/プロフィール欠落は Google 認証失敗として扱う)
67
+ if (
68
+ accessToken === undefined ||
69
+ refreshToken === undefined ||
70
+ expiresIn === undefined ||
71
+ scope === undefined ||
72
+ id === undefined ||
73
+ displayName === undefined ||
74
+ sub === undefined ||
75
+ email === undefined ||
76
+ email_verified === undefined
77
+ // hd === undefined // Google Workspace などの組織制限をする場合は有効化
78
+ ) {
79
+ if (email_verified === false) {
80
+ const errorMessage = encodeURIComponent(messages.emailNotVerified)
81
+ throw redirect(`${loginPath}?error=${errorMessage}`)
82
+ }
83
+ const errorMessage = encodeURIComponent(messages.googleFailed)
84
+ throw redirect(`${loginPath}?error=${errorMessage}`)
85
+ }
86
+
87
+ logger.info("Authenticated on Google: ", profile.displayName)
88
+
89
+ // Backend Authentication
90
+ try {
91
+ // backendAuth ポート未指定時は verify/signup をスキップ (フロント単独運用)
92
+ if (config.backendAuth) {
93
+ const ok = await config.backendAuth.verify(provider, accessToken)
94
+
95
+ if (!ok) {
96
+ logger.error("Backend verification returned false for user:", profile.displayName)
97
+ const errorMessage = encodeURIComponent(messages.backendFailed)
98
+ throw redirect(`${loginPath}?error=${errorMessage}`)
99
+ }
100
+
101
+ logger.info("Authenticated on Backend: ", profile.displayName)
102
+ }
103
+
104
+ // メールアドレスまたはドメインベースでのアクセス制限チェック (論理和は allowlist 実装側)
105
+ const userEmail = profile.emails[0]?.value
106
+ if (!config.isEmailAllowed(userEmail)) {
107
+ const error = new Error("You are not allowed to access this application")
108
+ logger.error(error)
109
+ throw error
110
+ }
111
+
112
+ // バックエンドでのサインアップ処理 (毎ログイン実行の挙動を保存)
113
+ if (config.backendAuth) {
114
+ await config.backendAuth.signup(provider, accessToken)
115
+ }
116
+
117
+ logger.info("Authenticated complete: ", profile.displayName)
118
+
119
+ // 認証ペイロードの構築
120
+ const expirationDateMs = Date.now() + expiresIn * 1000
121
+ const authenticationPayload = {
122
+ accessToken: accessToken,
123
+ refreshToken: refreshToken,
124
+ expirationDateMs: expirationDateMs,
125
+ provider: provider,
126
+ }
127
+ // ユーザープロファイル情報
128
+ const user: MyGoogleProfile = {
129
+ ...profile,
130
+ ...authenticationPayload,
131
+ photo: picture,
132
+ }
133
+ return user
134
+ } catch (error: unknown) {
135
+ let loggable = error
136
+ if (typeof error === "object" && error !== null && "cause" in error) {
137
+ // cause があればそれを優先してログに残す (falsy なら元のエラー)
138
+ loggable = (error as { cause: unknown }).cause || error
139
+ }
140
+ logger.error("Error during backend authentication or subsequent processing:", loggable)
141
+
142
+ let friendlyErrorMessage = messages.unexpected
143
+ let conditionMet = false
144
+
145
+ // ネットワーク到達不可 (getaddrinfo ENOTFOUND) の判定
146
+ if (typeof error === "object" && error !== null && "cause" in error) {
147
+ const cause = (error as { cause: unknown }).cause
148
+ if (cause && typeof cause === "object" && "message" in cause && typeof (cause as { message: string }).message === "string" && (cause as { message: string }).message.includes("getaddrinfo ENOTFOUND")) {
149
+ friendlyErrorMessage = messages.backendUnreachable
150
+ conditionMet = true
151
+ }
152
+ }
153
+
154
+ // allowlist 拒否の判定
155
+ if (!conditionMet && error instanceof Error && error.message.includes("You are not allowed to access this application")) {
156
+ friendlyErrorMessage = messages.notAllowed
157
+ conditionMet = true
158
+ }
159
+
160
+ // 既にリダイレクト Response が throw されている場合はそのまま再 throw
161
+ if (!conditionMet && error instanceof Response && error.headers.get("Location")?.startsWith(loginPath)) {
162
+ throw error
163
+ }
164
+
165
+ const encodedErrorMessage = encodeURIComponent(friendlyErrorMessage)
166
+ throw redirect(`${loginPath}?error=${encodedErrorMessage}`)
167
+ }
168
+ },
169
+ )
170
+
171
+ const authenticator = new Authenticator<MyGoogleProfile>()
172
+ authenticator.use(googleStrategy)
173
+ return authenticator
174
+ }