@aiquants/auth-react-router 0.8.0 → 0.9.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.
- package/README.md +4 -1
- package/dist/server.d.mts +28 -16
- package/dist/server.d.ts +28 -16
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +1 -1
- package/dist/server.mjs.map +1 -1
- package/dist/styles/auth-react-router.standalone.css +1 -1
- package/package.json +6 -5
- package/src/server/authenticate-in-loader.ts +158 -69
- package/src/server/authenticate.ts +8 -1
|
@@ -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 {
|
|
13
|
+
import { type AuthenticateFns, AuthRequiredError } 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,96 +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
|
-
*
|
|
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
|
-
* @
|
|
67
|
-
* @returns 認証済みユーザープロファイルとオプショナルな Set-Cookie 値。
|
|
94
|
+
* @returns 認証済みか未認証かを判別できる結果。
|
|
68
95
|
*/
|
|
69
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
failureRedirect,
|
|
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,
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ユーザー写真をキャッシュまたはポートから取得
|
|
110
|
-
const photo = await getCachedOpenidPhoto(currentUser.id)
|
|
111
|
-
|
|
112
|
-
// ユーザープロファイル情報
|
|
113
|
-
const updatedUser: MyGoogleProfile = {
|
|
114
|
-
...currentUser,
|
|
115
|
-
...authenticationPayload,
|
|
116
|
-
photo,
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// トークンが更新 (リフレッシュ) された場合のみ、セッションを保存して Cookie ヘッダーを設定する
|
|
120
|
-
const isRefreshed = authenticatedTokens.accessToken !== currentUser.accessToken
|
|
121
|
-
const cookie = isRefreshed ? await storage.saveSession(request, updatedSessionUser) : undefined
|
|
122
|
-
|
|
123
|
-
return { user: updatedUser, cookie }
|
|
114
|
+
// 常に null モードで呼ぶ。ここで失敗方針を渡すと結果がモード依存になり共有できない
|
|
115
|
+
authenticatedTokens = await authFns.authenticate(request, session, { failureRedirect: null })
|
|
124
116
|
} catch (error) {
|
|
125
|
-
// failureRedirect: null の契約 (リダイレクトせず未認証として返す) を守る。
|
|
126
|
-
// 以前は authenticate() が投げた redirect Response を下の Location 判定が
|
|
127
|
-
// 無条件に再スローしており、null 指定でも /auth/login へ飛んでいた。
|
|
128
117
|
if (error instanceof AuthRequiredError) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
throw error
|
|
118
|
+
// 認証失敗。セッション破棄を伴う経路では破棄 Set-Cookie を運ぶ
|
|
119
|
+
// (捨てると無効セッションがブラウザに残り続ける)
|
|
120
|
+
return error.setCookie !== undefined ? { kind: "unauthenticated", cookie: error.setCookie } : { kind: "unauthenticated" }
|
|
133
121
|
}
|
|
122
|
+
// 認証失敗ではない予期せぬ失敗。セッションを破棄してログインへ飛ばすとインフラ障害が
|
|
123
|
+
// ログアウトに化けて真因が消えるため、どのモードでも素通しする
|
|
134
124
|
logger.error("Error in authenticateInLoader:", error)
|
|
135
|
-
// 認証エラー時はセッションを破棄してリダイレクト
|
|
136
|
-
if (failureRedirect !== null) {
|
|
137
|
-
// セッション破棄のみを try で保護する。redirect の throw を try 内に置くと
|
|
138
|
-
// 同じ catch(destroyError) に捕捉されて Set-Cookie 無しの fallback に差し替わってしまうため、
|
|
139
|
-
// throw redirect は必ず try の外で行う。
|
|
140
|
-
let destroyedCookie: string | undefined
|
|
141
|
-
try {
|
|
142
|
-
destroyedCookie = await storage.destroySession(session)
|
|
143
|
-
} catch (destroyError) {
|
|
144
|
-
logger.error("Failed to destroy session during auth error:", destroyError)
|
|
145
|
-
}
|
|
146
|
-
// 破棄に成功していれば Set-Cookie を付与し、失敗してもとにかくリダイレクトは試みる
|
|
147
|
-
throw destroyedCookie !== undefined ? redirect(failureRedirect, { headers: { "Set-Cookie": destroyedCookie } }) : redirect(failureRedirect)
|
|
148
|
-
}
|
|
149
125
|
throw error
|
|
150
126
|
}
|
|
127
|
+
|
|
128
|
+
// ここから先は認証済み。写真取得やセッション保存の失敗は「認証失敗」ではないため畳まず、
|
|
129
|
+
// そのまま送出して呼び出し側 (ErrorBoundary / 500) に本当の原因を見せる
|
|
130
|
+
|
|
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
|
+
}
|
|
147
|
+
|
|
148
|
+
// ユーザー写真をキャッシュまたはポートから取得
|
|
149
|
+
const photo = await getCachedOpenidPhoto(currentUser.id)
|
|
150
|
+
|
|
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 } : {}
|
|
238
|
+
}
|
|
239
|
+
throw outcome.cookie !== undefined ? redirect(failureRedirect, { headers: { "Set-Cookie": outcome.cookie } }) : redirect(failureRedirect)
|
|
151
240
|
}
|
|
152
241
|
|
|
153
242
|
return authenticateInLoader
|
|
@@ -152,7 +152,14 @@ export const createAuthenticate = (config: AuthServerConfig, storage: AuthSessio
|
|
|
152
152
|
if (failureRedirect === null) {
|
|
153
153
|
return new AuthRequiredError(setCookie)
|
|
154
154
|
}
|
|
155
|
-
|
|
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)
|
|
156
163
|
}
|
|
157
164
|
try {
|
|
158
165
|
// テストバックドア (ポート注入時のみ有効)
|