@aiquants/auth-react-router 0.7.3 → 0.9.0

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.
@@ -10,10 +10,20 @@
10
10
  */
11
11
  import type { AuthenticationPayload, MyGoogleProfile, SessionProfile } from "@aiquants/auth-core"
12
12
  import { redirect } from "react-router"
13
- import type { AuthenticateFns } from "./authenticate"
13
+ import { AuthRequiredError, type AuthenticateFns } from "./authenticate"
14
14
  import type { AuthServerConfig, LoggerPort } from "./ports"
15
15
  import type { AuthSessionStorage } from "./session"
16
16
 
17
+ /**
18
+ * Mode-neutral result of the authentication work, carrying no failure policy.
19
+ * 失敗時の方針を含まない、モード中立な認証作業の結果。
20
+ *
21
+ * リダイレクトするか否かは呼び出し元ごとの方針であり、認証**作業**の一部ではない。
22
+ * 結果をこの形に保つことで、`failureRedirect` の指定が異なる呼び出し元どうしが
23
+ * 1 リクエスト 1 回の作業を共有できる。
24
+ */
25
+ type LoaderAuthOutcome = { readonly kind: "authenticated"; readonly user: MyGoogleProfile; readonly cookie?: string } | { readonly kind: "unauthenticated"; readonly cookie?: string }
26
+
17
27
  /** createAuthenticateInLoader が返すローダーガード関数。 */
18
28
  export type AuthenticateInLoader = ReturnType<typeof createAuthenticateInLoader>
19
29
 
@@ -58,90 +68,175 @@ export const createAuthenticateInLoader = (config: AuthServerConfig, storage: Au
58
68
  return fetchPromise
59
69
  }
60
70
 
71
+ // 1 リクエスト = 1 認証。React Router は 1 データパス内の全ローダーへ**同一の** Request
72
+ // インスタンスを渡す (callLoaderOrAction が単一の request をクローズオーバーする) ため、
73
+ // Request 同一性をそのままキーにできる。
74
+ //
75
+ // キーに `failureRedirect` を含めてはならない。あれは失敗時の**方針**であって認証**作業**では
76
+ // なく、含めると root (null) と leaf (文字列) が別スロットに落ちて 1 ナビゲーションで二重に
77
+ // 認証する。期限切れ時には Google のトークン更新が 2 本走り、内容の異なる Set-Cookie が
78
+ // 2 本返る (React Router の Set-Cookie 重複排除はバイト一致のみ)。
79
+ //
80
+ // 保持は Request の寿命と同じで TTL も無効化 API も持たない。ストリーミング応答は Request を
81
+ // コネクション終了まで掴むため、エントリもその間生き残る。リソースルートはローダーが 1 本
82
+ // なのでエントリは一度書かれて二度と読まれず、陳腐化の危険は無い。ただしストリーム途中で
83
+ // 再認証する実装を将来足すと、何時間も前のトークンを掴むことになる。
84
+ const outcomeByRequest = new WeakMap<Request, Promise<LoaderAuthOutcome>>()
85
+
61
86
  /**
62
- * Handles authentication within loader requests by refreshing session tokens when needed.
63
- * ローダーリクエスト内で必要に応じてセッショントークンを更新する認証処理。
87
+ * Performs the authentication work once, independent of any failure policy.
88
+ * 失敗時の方針から独立した認証作業そのもの。
89
+ *
90
+ * 戻り値は**モード中立**で、リダイレクトするか否かの判断を含まない。これにより
91
+ * `failureRedirect` の指定が異なる複数の呼び出し元が 1 つの結果を共有できる。
64
92
  *
65
93
  * @param request - リクエストオブジェクト。
66
- * @param options - 認証オプション。`failureRedirect: null` でリダイレクトを明示的にスキップ。
67
- * @returns 認証済みユーザープロファイルとオプショナルな Set-Cookie 値。
94
+ * @returns 認証済みか未認証かを判別できる結果。
68
95
  */
69
- const authenticateInLoader = async (request: Request, options: { failureRedirect: string } | { failureRedirect: null }): Promise<{ user?: MyGoogleProfile; cookie?: string }> => {
96
+ const resolveOutcome = async (request: Request): Promise<LoaderAuthOutcome> => {
70
97
  // DB 接続プールが確実にオープンするのを待ってから処理を開始する (E2E 等でのコールドスタート競合回避)
71
98
  if (config.warmup) {
72
99
  await config.warmup()
73
100
  }
74
101
 
75
- const { failureRedirect } = options
76
102
  const session = await storage.getSession(request)
77
103
  const currentUser = await storage.getSessionUser(request)
78
104
 
79
105
  if (currentUser === undefined) {
80
- if (failureRedirect === null) {
81
- return {}
82
- }
83
- throw redirect(failureRedirect)
106
+ return { kind: "unauthenticated" }
84
107
  }
85
108
 
109
+ // try で覆うのは**認証そのもの**だけに限る。写真取得ポートやセッション保存まで含めると、
110
+ // DB プールの一時的な失敗が「認証失敗」に化け、有効なセッションを持つ利用者が
111
+ // 無関係な障害で再ログインを強いられる
112
+ let authenticatedTokens: Awaited<ReturnType<typeof authFns.authenticate>>
86
113
  try {
87
- // トークンの有効期限を確認し、必要に応じて更新
88
- const authenticatedTokens = await authFns.authenticate(request, session, {
89
- failureRedirect: failureRedirect ?? undefined,
90
- })
91
-
92
- // 認証ペイロードの構築
93
- const authenticationPayload: AuthenticationPayload = {
94
- accessToken: authenticatedTokens.accessToken,
95
- refreshToken: authenticatedTokens.refreshToken,
96
- expirationDateMs: authenticatedTokens.expirationDateMs,
97
- provider: currentUser.provider,
98
- }
99
- // セッション内のユーザ情報
100
- const updatedSessionUser: SessionProfile = {
101
- id: currentUser.id,
102
- displayName: currentUser.displayName,
103
- name: currentUser.name,
104
- emails: currentUser.emails,
105
- ...authenticationPayload,
106
- role: currentUser.role,
114
+ // 常に null モードで呼ぶ。ここで失敗方針を渡すと結果がモード依存になり共有できない
115
+ authenticatedTokens = await authFns.authenticate(request, session, { failureRedirect: null })
116
+ } catch (error) {
117
+ if (error instanceof AuthRequiredError) {
118
+ // 認証失敗。セッション破棄を伴う経路では破棄 Set-Cookie を運ぶ
119
+ // (捨てると無効セッションがブラウザに残り続ける)
120
+ return error.setCookie !== undefined ? { kind: "unauthenticated", cookie: error.setCookie } : { kind: "unauthenticated" }
107
121
  }
122
+ // 認証失敗ではない予期せぬ失敗。セッションを破棄してログインへ飛ばすとインフラ障害が
123
+ // ログアウトに化けて真因が消えるため、どのモードでも素通しする
124
+ logger.error("Error in authenticateInLoader:", error)
125
+ throw error
126
+ }
108
127
 
109
- // ユーザー写真をキャッシュまたはポートから取得
110
- const photo = await getCachedOpenidPhoto(currentUser.id)
128
+ // ここから先は認証済み。写真取得やセッション保存の失敗は「認証失敗」ではないため畳まず、
129
+ // そのまま送出して呼び出し側 (ErrorBoundary / 500) に本当の原因を見せる
111
130
 
112
- // ユーザープロファイル情報
113
- const updatedUser: MyGoogleProfile = {
114
- ...currentUser,
115
- ...authenticationPayload,
116
- photo,
117
- }
131
+ // 認証ペイロードの構築
132
+ const authenticationPayload: AuthenticationPayload = {
133
+ accessToken: authenticatedTokens.accessToken,
134
+ refreshToken: authenticatedTokens.refreshToken,
135
+ expirationDateMs: authenticatedTokens.expirationDateMs,
136
+ provider: currentUser.provider,
137
+ }
138
+ // セッション内のユーザ情報
139
+ const updatedSessionUser: SessionProfile = {
140
+ id: currentUser.id,
141
+ displayName: currentUser.displayName,
142
+ name: currentUser.name,
143
+ emails: currentUser.emails,
144
+ ...authenticationPayload,
145
+ role: currentUser.role,
146
+ }
118
147
 
119
- // トークンが更新 (リフレッシュ) された場合のみ、セッションを保存して Cookie ヘッダーを設定する
120
- const isRefreshed = authenticatedTokens.accessToken !== currentUser.accessToken
121
- const cookie = isRefreshed ? await storage.saveSession(request, updatedSessionUser) : undefined
148
+ // ユーザー写真をキャッシュまたはポートから取得
149
+ const photo = await getCachedOpenidPhoto(currentUser.id)
122
150
 
123
- return { user: updatedUser, cookie }
124
- } catch (error) {
125
- if (error instanceof Response && error.headers.get("Location")) {
126
- throw error
127
- }
128
- logger.error("Error in authenticateInLoader:", error)
129
- // 認証エラー時はセッションを破棄してリダイレクト
130
- if (failureRedirect !== null) {
131
- // セッション破棄のみを try で保護する。redirect の throw を try 内に置くと
132
- // 同じ catch(destroyError) に捕捉されて Set-Cookie 無しの fallback に差し替わってしまうため、
133
- // throw redirect は必ず try の外で行う。
134
- let destroyedCookie: string | undefined
135
- try {
136
- destroyedCookie = await storage.destroySession(session)
137
- } catch (destroyError) {
138
- logger.error("Failed to destroy session during auth error:", destroyError)
139
- }
140
- // 破棄に成功していれば Set-Cookie を付与し、失敗してもとにかくリダイレクトは試みる
141
- throw destroyedCookie !== undefined ? redirect(failureRedirect, { headers: { "Set-Cookie": destroyedCookie } }) : redirect(failureRedirect)
142
- }
143
- throw error
151
+ // ユーザープロファイル情報
152
+ const updatedUser: MyGoogleProfile = {
153
+ ...currentUser,
154
+ ...authenticationPayload,
155
+ photo,
156
+ }
157
+
158
+ // トークンが更新 (リフレッシュ) された場合のみ、セッションを保存して Cookie ヘッダーを設定する
159
+ const isRefreshed = authenticatedTokens.accessToken !== currentUser.accessToken
160
+ const cookie = isRefreshed ? await storage.saveSession(request, updatedSessionUser) : undefined
161
+
162
+ return cookie !== undefined ? { kind: "authenticated", user: updatedUser, cookie } : { kind: "authenticated", user: updatedUser }
163
+ }
164
+
165
+ /**
166
+ * Returns the per-request authentication outcome, computing it at most once.
167
+ * リクエスト単位の認証結果を返す (計算は高々 1 回)。
168
+ *
169
+ * この関数を `async` にしてはならない。`get` と `set` の間に await 境界が入ると、
170
+ * 並行する 2 つの呼び出しが両方とも miss して二重認証に戻る。
171
+ *
172
+ * @param request - リクエストオブジェクト。
173
+ * @returns 共有される認証結果の Promise。
174
+ */
175
+ const sharedOutcome = (request: Request): Promise<LoaderAuthOutcome> => {
176
+ const cached = outcomeByRequest.get(request)
177
+ if (cached !== undefined) {
178
+ return cached
179
+ }
180
+ const pending = resolveOutcome(request)
181
+ outcomeByRequest.set(request, pending)
182
+ return pending
183
+ }
184
+
185
+ /**
186
+ * Handles authentication within loader requests, redirecting on any authentication failure.
187
+ * ローダーリクエスト内の認証処理 (認証失敗時は必ずリダイレクトを throw)。
188
+ *
189
+ * `failureRedirect` に文字列を渡した場合、認証失敗は例外 (redirect Response) として送出される
190
+ * ため、正常に戻った時点で `user` は必ず存在する。呼び出し側に到達不能な `!user` ガードを
191
+ * 書かせないよう、戻り値の `user` を必須として型付けする。
192
+ *
193
+ * 認証以外の失敗 (認証処理内部の予期せぬ例外・写真取得ポートの障害・セッション保存の失敗) は
194
+ * リダイレクトへ畳まず送出する。認証の可否とは無関係な障害であり、ログアウトに化けさせると
195
+ * 有効なセッションが失われたうえ真因も消える。
196
+ *
197
+ * @param request - リクエストオブジェクト。
198
+ * @param options - 認証オプション。`failureRedirect` は認証失敗時のリダイレクト先。
199
+ * @returns 認証済みユーザープロファイルとオプショナルな Set-Cookie 値。
200
+ */
201
+ async function authenticateInLoader(request: Request, options: { failureRedirect: string }): Promise<{ user: MyGoogleProfile; cookie?: string }>
202
+ /**
203
+ * Handles authentication within loader requests without ever redirecting.
204
+ * ローダーリクエスト内の認証処理 (リダイレクトを一切発生させない)。
205
+ *
206
+ * `failureRedirect: null` では全ての**認証**失敗経路が `{}` を返す。セッション破棄を伴う失敗
207
+ * (期限切れ + リフレッシュ失敗) では破棄 Set-Cookie を `cookie` に載せて返すため、呼び出し側は
208
+ * 未認証応答にもこれを転送すること (捨てると無効セッションがブラウザに残り続ける)。
209
+ *
210
+ * 認証以外の失敗は文字列モードと同じく送出する。両モードの観測可能な差はリダイレクトの
211
+ * 有無だけであり、それ以外に差を作らない。
212
+ *
213
+ * @param request - リクエストオブジェクト。
214
+ * @param options - 認証オプション。`failureRedirect: null` でリダイレクトを明示的にスキップ。
215
+ * @returns 未認証なら `user` を持たない結果、認証済みならユーザープロファイル。
216
+ */
217
+ async function authenticateInLoader(request: Request, options: { failureRedirect: null }): Promise<{ user?: MyGoogleProfile; cookie?: string }>
218
+ /**
219
+ * Implementation signature shared by both overloads (not part of the public API).
220
+ * 両オーバーロードが共有する実装シグネチャ (公開 API ではない)。
221
+ */
222
+ async function authenticateInLoader(request: Request, options: { failureRedirect: string } | { failureRedirect: null }): Promise<{ user?: MyGoogleProfile; cookie?: string }> {
223
+ const { failureRedirect } = options
224
+ // 空文字は「既定でよい」という表明ではなく取り違えである。既定へ倒すと意図しない遷移先を
225
+ // 黙って使うことになるため、認証の成否を待たず入口で失敗させる
226
+ if (failureRedirect === "") {
227
+ throw new TypeError("failureRedirect must not be an empty string; pass null to skip redirecting")
228
+ }
229
+
230
+ const outcome = await sharedOutcome(request)
231
+ if (outcome.kind === "authenticated") {
232
+ return outcome.cookie !== undefined ? { user: outcome.user, cookie: outcome.cookie } : { user: outcome.user }
233
+ }
234
+
235
+ // 未認証。ここから先が呼び出し元ごとの失敗方針であり、共有される作業には含めない
236
+ if (failureRedirect === null) {
237
+ return outcome.cookie !== undefined ? { cookie: outcome.cookie } : {}
144
238
  }
239
+ throw outcome.cookie !== undefined ? redirect(failureRedirect, { headers: { "Set-Cookie": outcome.cookie } }) : redirect(failureRedirect)
145
240
  }
146
241
 
147
242
  return authenticateInLoader
@@ -31,6 +31,25 @@ export class GoogleInvalidGrantError extends TokenRefreshError {
31
31
  }
32
32
  }
33
33
 
34
+ /**
35
+ * Signals that authentication is required when the caller opted out of redirecting.
36
+ * 呼び出し側がリダイレクトを拒否した (`failureRedirect: null`) 際に認証要求を伝えるエラー。
37
+ *
38
+ * `redirect()` を投げてしまうと React Router がそれを本物の 302 として扱うため、
39
+ * 「リダイレクトしない」という契約を型で表現できるこのエラーへ置き換える。
40
+ * セッション破棄が発生した場合はその `Set-Cookie` を `setCookie` で運ぶ
41
+ * (捨てると無効セッションがブラウザに残り続ける)。
42
+ */
43
+ export class AuthRequiredError extends Error {
44
+ readonly setCookie?: string
45
+
46
+ constructor(setCookie?: string) {
47
+ super("Authentication required")
48
+ this.name = "AuthRequiredError"
49
+ this.setCookie = setCookie
50
+ }
51
+ }
52
+
34
53
  /** createAuthenticate が返す認証関数束。 */
35
54
  export type AuthenticateFns = ReturnType<typeof createAuthenticate>
36
55
 
@@ -124,8 +143,24 @@ export const createAuthenticate = (config: AuthServerConfig, storage: AuthSessio
124
143
  * Validates session tokens, refreshing them when expired; destroys the session on refresh failure.
125
144
  * セッショントークンを検証し、期限切れならリフレッシュする。失敗時はセッションを破棄する。
126
145
  */
127
- const authenticate = async (request: Request, session: Session, options?: { failureRedirect?: string }) => {
146
+ const authenticate = async (request: Request, session: Session, options?: { failureRedirect?: string | null }) => {
128
147
  const { failureRedirect } = options || {}
148
+
149
+ // failureRedirect が null のときはリダイレクトを作らず AuthRequiredError を投げる。
150
+ // undefined / 文字列のときは従来どおり redirect() を投げる。
151
+ const authFailure = (setCookie?: string) => {
152
+ if (failureRedirect === null) {
153
+ return new AuthRequiredError(setCookie)
154
+ }
155
+ // 未指定 (undefined) は「既定のログイン先で良い」という表明なので loginPath へ倒す。
156
+ // 空文字は表明ではなく取り違えであり、既定へ倒すと意図しない遷移先を黙って使うことに
157
+ // なるため失敗させる (`||` で両者を同一視しない)
158
+ if (failureRedirect === "") {
159
+ throw new TypeError("failureRedirect must not be an empty string; pass null to skip redirecting")
160
+ }
161
+ const target = failureRedirect ?? loginPath
162
+ return setCookie !== undefined ? redirect(target, { headers: { "Set-Cookie": setCookie } }) : redirect(target)
163
+ }
129
164
  try {
130
165
  // テストバックドア (ポート注入時のみ有効)
131
166
  if (config.mockUser?.enabled(request)) {
@@ -139,7 +174,7 @@ export const createAuthenticate = (config: AuthServerConfig, storage: AuthSessio
139
174
  // セッションから認証データを取得
140
175
  const user = session.get(sessionKey)
141
176
  if (!(user?.accessToken && user.expirationDateMs)) {
142
- throw redirect(failureRedirect || loginPath)
177
+ throw authFailure()
143
178
  }
144
179
 
145
180
  const { accessToken, refreshToken, expirationDateMs } = user
@@ -147,8 +182,8 @@ export const createAuthenticate = (config: AuthServerConfig, storage: AuthSessio
147
182
  // 期限切れならエラーを投げてリフレッシュ経路へ
148
183
  if (expirationDateMs < Date.now()) {
149
184
  if (!refreshToken) {
150
- // リフレッシュトークンがない場合は更新できないのでログインページへ
151
- throw redirect(failureRedirect || loginPath)
185
+ // リフレッシュトークンがない場合は更新できないので認証失敗として扱う
186
+ throw authFailure()
152
187
  }
153
188
  throw new Error("Expired")
154
189
  }
@@ -176,9 +211,7 @@ export const createAuthenticate = (config: AuthServerConfig, storage: AuthSessio
176
211
  }
177
212
  // リフレッシュに失敗した場合はセッションを破棄してログインページへ
178
213
  const cookie = await storage.destroySession(session)
179
- throw redirect(failureRedirect || loginPath, {
180
- headers: { "Set-Cookie": cookie },
181
- })
214
+ throw authFailure(cookie)
182
215
  }
183
216
  }
184
217
 
package/src/server.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  export type { AuthServer } from "./server/auth-server"
7
7
  export { createAuthServer } from "./server/auth-server"
8
- export { createAuthenticate, GoogleInvalidGrantError, TokenRefreshError } from "./server/authenticate"
8
+ export { AuthRequiredError, createAuthenticate, GoogleInvalidGrantError, TokenRefreshError } from "./server/authenticate"
9
9
  export { createAuthenticateInLoader } from "./server/authenticate-in-loader"
10
10
  export { createGoogleAuthenticator } from "./server/google-strategy"
11
11
  export { createAuthHandlers } from "./server/handlers"