@aiquants/auth-react-router 0.15.0 → 0.16.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.
- package/README.md +100 -7
- package/dist/admin.d.mts +13 -436
- package/dist/admin.d.ts +13 -436
- package/dist/admin.js +1 -2
- package/dist/admin.mjs +1 -2
- package/dist/client.d.mts +1 -10
- package/dist/client.d.ts +1 -10
- package/dist/client.js +0 -1
- package/dist/client.mjs +0 -1
- package/dist/server.d.mts +6 -146
- package/dist/server.d.ts +6 -146
- package/dist/server.js +0 -1
- package/dist/server.mjs +0 -1
- package/package.json +12 -16
- package/dist/admin.js.map +0 -1
- package/dist/admin.mjs.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/client.mjs.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/server.mjs.map +0 -1
- package/src/admin/app.ts +0 -9
- package/src/admin/format.ts +0 -58
- package/src/admin/labels.ts +0 -838
- package/src/admin/server.ts +0 -1143
- package/src/admin/ui.tsx +0 -276
- package/src/admin/views.tsx +0 -2120
- package/src/admin.ts +0 -5
- package/src/client/GoogleForm.tsx +0 -47
- package/src/client.ts +0 -6
- package/src/server/auth-server.ts +0 -69
- package/src/server/authenticate-in-loader.ts +0 -243
- package/src/server/authenticate.ts +0 -224
- package/src/server/google-strategy-impl.ts +0 -154
- package/src/server/google-strategy.ts +0 -183
- package/src/server/handlers.ts +0 -139
- package/src/server/logger.ts +0 -14
- package/src/server/ports.ts +0 -113
- package/src/server/session.ts +0 -132
- package/src/server.ts +0 -14
- package/src/styles/standalone.entry.css +0 -6
package/src/admin.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module GoogleForm
|
|
3
|
-
* @description Google sign-in form button (tailwind-variants styled).
|
|
4
|
-
* Google サインインフォームボタン (tailwind-variants スタイル)。
|
|
5
|
-
*
|
|
6
|
-
* 原本 (quants-react-router components/elements/GoogleForm.tsx) を挙動保存で移植。
|
|
7
|
-
* ラベル・action 値は props 化し、既定値は原本と同一 ("Sign In with Google")。
|
|
8
|
-
*/
|
|
9
|
-
// react-icons はコレクション単位の exports キー ("./fa" など) だけを公開しており、ワイルドカードを持たない。
|
|
10
|
-
// `react-icons/fa/index.js` は実ファイルが存在しても exports マップに無いため、TypeScript (bundler/nodenext)・
|
|
11
|
-
// Node ESM・esbuild/Vite の全てが解決を拒否する。この指定子に拡張子を足してはならない (#2044 の再発防止)。
|
|
12
|
-
import { FaGoogle } from "react-icons/fa"
|
|
13
|
-
import { Form } from "react-router"
|
|
14
|
-
import { tv } from "tailwind-variants"
|
|
15
|
-
|
|
16
|
-
const googleFormStyles = tv({
|
|
17
|
-
slots: {
|
|
18
|
-
googleForm: "my-4",
|
|
19
|
-
btn: "w-full flex items-center justify-center gap-3 rounded-2xl bg-gradient-to-r from-blue-500 to-indigo-500 px-6 py-3 text-white font-bold shadow-lg transition-all duration-300 hover:from-blue-600 hover:to-indigo-600 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-300",
|
|
20
|
-
icon: "",
|
|
21
|
-
btnText: "text-base tracking-wide",
|
|
22
|
-
},
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
/** GoogleForm のプロパティ。 */
|
|
26
|
-
export type GoogleFormProps = {
|
|
27
|
-
/** フォーム action の値 (loginAction の switch と一致させる)。既定 "Sign In with Google"。 */
|
|
28
|
-
actionValue?: string
|
|
29
|
-
/** ボタンの表示ラベル。既定 "Sign in with Google"。 */
|
|
30
|
-
label?: string
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Renders the Google sign-in form (POST with `_action`).
|
|
35
|
-
* Google サインインフォーム (`_action` 付き POST) を描画する。
|
|
36
|
-
*/
|
|
37
|
-
export const GoogleForm = ({ actionValue = "Sign In with Google", label = "Sign in with Google" }: GoogleFormProps = {}) => {
|
|
38
|
-
const { googleForm, btn, icon: _icon, btnText } = googleFormStyles()
|
|
39
|
-
return (
|
|
40
|
-
<Form method="POST" className={googleForm()}>
|
|
41
|
-
<button type="submit" name="_action" value={actionValue} className={btn()}>
|
|
42
|
-
<FaGoogle size={24} className="text-white drop-shadow" />
|
|
43
|
-
<span className={btnText()}>{label}</span>
|
|
44
|
-
</button>
|
|
45
|
-
</Form>
|
|
46
|
-
)
|
|
47
|
-
}
|
package/src/client.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module auth-server
|
|
3
|
-
* @description Aggregate factory bundling session, authenticator, guards and handlers.
|
|
4
|
-
* セッション・認証器・ガード・ハンドラを束ねる一括ファクトリ。
|
|
5
|
-
*
|
|
6
|
-
* daily-report の createDailyReportServer と同型の単一結線点。アプリは
|
|
7
|
-
* `createAuthServer(config)` を 1 回呼び、返り値を各 `*.server.ts` shim から再輸出する。
|
|
8
|
-
*/
|
|
9
|
-
import { createAuthenticate } from "./authenticate"
|
|
10
|
-
import { createAuthenticateInLoader } from "./authenticate-in-loader"
|
|
11
|
-
import { createGoogleAuthenticator } from "./google-strategy"
|
|
12
|
-
import { createAuthHandlers } from "./handlers"
|
|
13
|
-
import { defaultLogger } from "./logger"
|
|
14
|
-
import type { AuthServerConfig } from "./ports"
|
|
15
|
-
import { createAuthSessionStorage } from "./session"
|
|
16
|
-
|
|
17
|
-
/** createAuthServer が返す認証サーバー束。 */
|
|
18
|
-
export type AuthServer = ReturnType<typeof createAuthServer>
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Builds the full auth server bundle from a single config.
|
|
22
|
-
* 単一の設定から認証サーバー束一式を生成する。
|
|
23
|
-
*/
|
|
24
|
-
export const createAuthServer = (config: AuthServerConfig) => {
|
|
25
|
-
const logger = config.logger ?? defaultLogger
|
|
26
|
-
|
|
27
|
-
// セッションストレージ + ヘルパー (テストバックドアはポート委譲)
|
|
28
|
-
const storage = createAuthSessionStorage(config.session, {
|
|
29
|
-
mockUser: config.mockUser,
|
|
30
|
-
loginPath: config.loginPath,
|
|
31
|
-
postLoginRedirect: config.postLoginRedirect,
|
|
32
|
-
isUserActive: config.isUserActive,
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
// Google 認証器 (verify コールバックはポート結線済み)
|
|
36
|
-
const authenticator = createGoogleAuthenticator(config)
|
|
37
|
-
|
|
38
|
-
// トークンリフレッシュ
|
|
39
|
-
const authFns = createAuthenticate(config, storage, logger)
|
|
40
|
-
|
|
41
|
-
// 毎リクエストのローダーガード (写真キャッシュ内包)
|
|
42
|
-
const authenticateInLoader = createAuthenticateInLoader(config, storage, authFns, logger)
|
|
43
|
-
|
|
44
|
-
// ルートハンドラ (login/logout/callback)
|
|
45
|
-
const handlers = createAuthHandlers(config, storage, authenticator, logger)
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
// 認証器
|
|
49
|
-
authenticator,
|
|
50
|
-
// セッション操作/トークンガードの内部束 (テストでの失敗注入等に使う。プロパティ差し替えは
|
|
51
|
-
// authenticate / authenticateInLoader の内部呼び出しへ即時反映される)
|
|
52
|
-
storage,
|
|
53
|
-
authFns,
|
|
54
|
-
sessionStorage: storage.sessionStorage,
|
|
55
|
-
getSession: storage.getSession,
|
|
56
|
-
getSessionUser: storage.getSessionUser,
|
|
57
|
-
saveSession: storage.saveSession,
|
|
58
|
-
requireUser: storage.requireUser,
|
|
59
|
-
requireAdminUser: storage.requireAdminUser,
|
|
60
|
-
commitSession: storage.commitSession,
|
|
61
|
-
destroySession: storage.destroySession,
|
|
62
|
-
// トークンガード
|
|
63
|
-
authenticate: authFns.authenticate,
|
|
64
|
-
refreshedAccessToken: authFns.refreshedAccessToken,
|
|
65
|
-
authenticateInLoader,
|
|
66
|
-
// ルートハンドラ
|
|
67
|
-
...handlers,
|
|
68
|
-
}
|
|
69
|
-
}
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module authenticate-in-loader
|
|
3
|
-
* @description Per-request loader authentication guard with photo cache and warmup.
|
|
4
|
-
* 写真キャッシュとウォームアップ付きの毎リクエストローダー認証ガード。
|
|
5
|
-
*
|
|
6
|
-
* 原本 (quants-react-router authenticateInLoader.server.ts) を挙動保存で移植。
|
|
7
|
-
* DB クエリ (getOpenids) は `getUserPhoto` ポートへ、DB プールウォームアップは `warmup`
|
|
8
|
-
* ポートへ置き換えた。写真キャッシュ (Map + pending dedup) はファクトリインスタンスごとに
|
|
9
|
-
* 保持する (module-level mutable state の排除)。
|
|
10
|
-
*/
|
|
11
|
-
import type { AuthenticationPayload, MyGoogleProfile, SessionProfile } from "@aiquants/auth-core"
|
|
12
|
-
import { redirect } from "react-router"
|
|
13
|
-
import { type AuthenticateFns, AuthRequiredError } from "./authenticate"
|
|
14
|
-
import type { AuthServerConfig, LoggerPort } from "./ports"
|
|
15
|
-
import type { AuthSessionStorage } from "./session"
|
|
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
|
-
|
|
27
|
-
/** createAuthenticateInLoader が返すローダーガード関数。 */
|
|
28
|
-
export type AuthenticateInLoader = ReturnType<typeof createAuthenticateInLoader>
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Builds the per-request loader guard wired to injected ports.
|
|
32
|
-
* 注入ポートに結線した毎リクエストローダーガードを生成する。
|
|
33
|
-
*/
|
|
34
|
-
export const createAuthenticateInLoader = (config: AuthServerConfig, storage: AuthSessionStorage, authFns: AuthenticateFns, logger: LoggerPort) => {
|
|
35
|
-
// 写真キャッシュ (インスタンススコープ。同時リクエストは pending dedup で 1 クエリに集約)
|
|
36
|
-
const openidPhotoCache = new Map<string, string>()
|
|
37
|
-
const pendingOpenidPhotoQueries = new Map<string, Promise<string>>()
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Resolves the user photo with cache + in-flight dedup, delegating the query to the port.
|
|
41
|
-
* キャッシュ + 実行中 dedup 付きでユーザー写真を解決する (実クエリはポートへ委譲)。
|
|
42
|
-
*/
|
|
43
|
-
const getCachedOpenidPhoto = async (openid: string): Promise<string> => {
|
|
44
|
-
const cached = openidPhotoCache.get(openid)
|
|
45
|
-
if (cached !== undefined) {
|
|
46
|
-
return cached
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const pending = pendingOpenidPhotoQueries.get(openid)
|
|
50
|
-
if (pending !== undefined) {
|
|
51
|
-
return pending
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const fetchPromise = (async () => {
|
|
55
|
-
try {
|
|
56
|
-
const photo = config.getUserPhoto ? await config.getUserPhoto(openid) : ""
|
|
57
|
-
openidPhotoCache.set(openid, photo)
|
|
58
|
-
return photo
|
|
59
|
-
} catch (error) {
|
|
60
|
-
openidPhotoCache.delete(openid)
|
|
61
|
-
throw error
|
|
62
|
-
} finally {
|
|
63
|
-
pendingOpenidPhotoQueries.delete(openid)
|
|
64
|
-
}
|
|
65
|
-
})()
|
|
66
|
-
|
|
67
|
-
pendingOpenidPhotoQueries.set(openid, fetchPromise)
|
|
68
|
-
return fetchPromise
|
|
69
|
-
}
|
|
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
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Performs the authentication work once, independent of any failure policy.
|
|
88
|
-
* 失敗時の方針から独立した認証作業そのもの。
|
|
89
|
-
*
|
|
90
|
-
* 戻り値は**モード中立**で、リダイレクトするか否かの判断を含まない。これにより
|
|
91
|
-
* `failureRedirect` の指定が異なる複数の呼び出し元が 1 つの結果を共有できる。
|
|
92
|
-
*
|
|
93
|
-
* @param request - リクエストオブジェクト。
|
|
94
|
-
* @returns 認証済みか未認証かを判別できる結果。
|
|
95
|
-
*/
|
|
96
|
-
const resolveOutcome = async (request: Request): Promise<LoaderAuthOutcome> => {
|
|
97
|
-
// DB 接続プールが確実にオープンするのを待ってから処理を開始する (E2E 等でのコールドスタート競合回避)
|
|
98
|
-
if (config.warmup) {
|
|
99
|
-
await config.warmup()
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const session = await storage.getSession(request)
|
|
103
|
-
const currentUser = await storage.getSessionUser(request)
|
|
104
|
-
|
|
105
|
-
if (currentUser === undefined) {
|
|
106
|
-
return { kind: "unauthenticated" }
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// try で覆うのは**認証そのもの**だけに限る。写真取得ポートやセッション保存まで含めると、
|
|
110
|
-
// DB プールの一時的な失敗が「認証失敗」に化け、有効なセッションを持つ利用者が
|
|
111
|
-
// 無関係な障害で再ログインを強いられる
|
|
112
|
-
let authenticatedTokens: Awaited<ReturnType<typeof authFns.authenticate>>
|
|
113
|
-
try {
|
|
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" }
|
|
121
|
-
}
|
|
122
|
-
// 認証失敗ではない予期せぬ失敗。セッションを破棄してログインへ飛ばすとインフラ障害が
|
|
123
|
-
// ログアウトに化けて真因が消えるため、どのモードでも素通しする
|
|
124
|
-
logger.error("Error in authenticateInLoader:", error)
|
|
125
|
-
throw error
|
|
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)
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
return authenticateInLoader
|
|
243
|
-
}
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module authenticate
|
|
3
|
-
* @description Session token guard with Google refresh-token rotation.
|
|
4
|
-
* Google リフレッシュトークンによるセッショントークンガード。
|
|
5
|
-
*
|
|
6
|
-
* 原本 (quants-react-router authenticate.server.ts) を挙動保存で移植。
|
|
7
|
-
* env (GOOGLE_CLIENT_ID/SECRET) は Google 設定注入に、destroySession はセッションストレージ
|
|
8
|
-
* 注入に、テストバックドアは mockUser ポート注入に置き換えた。
|
|
9
|
-
* 期限判定 → リフレッシュ → 失敗時セッション破棄 + ログインへリダイレクトの流れは原本と等価。
|
|
10
|
-
*
|
|
11
|
-
* 参考: https://sergiodxa.com/articles/working-with-refresh-tokens-in-remix
|
|
12
|
-
*/
|
|
13
|
-
import type { SessionProfile } from "@aiquants/auth-core"
|
|
14
|
-
import { redirect, type Session } from "react-router"
|
|
15
|
-
import type { AuthServerConfig, LoggerPort } from "./ports"
|
|
16
|
-
import type { AuthSessionStorage } from "./session"
|
|
17
|
-
|
|
18
|
-
/** トークンリフレッシュ失敗の基底エラー。 */
|
|
19
|
-
export class TokenRefreshError extends Error {
|
|
20
|
-
constructor(message: string) {
|
|
21
|
-
super(message)
|
|
22
|
-
this.name = "TokenRefreshError"
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Google の invalid_grant (失効/取り消し済みリフレッシュトークン) エラー。 */
|
|
27
|
-
export class GoogleInvalidGrantError extends TokenRefreshError {
|
|
28
|
-
constructor() {
|
|
29
|
-
super("Google refresh token invalid or revoked")
|
|
30
|
-
this.name = "GoogleInvalidGrantError"
|
|
31
|
-
}
|
|
32
|
-
}
|
|
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
|
-
|
|
53
|
-
/** createAuthenticate が返す認証関数束。 */
|
|
54
|
-
export type AuthenticateFns = ReturnType<typeof createAuthenticate>
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Builds the `authenticate` / `refreshedAccessToken` pair wired to injected config.
|
|
58
|
-
* 注入設定に結線した `authenticate` / `refreshedAccessToken` を生成する。
|
|
59
|
-
*/
|
|
60
|
-
export const createAuthenticate = (config: AuthServerConfig, storage: AuthSessionStorage, logger: LoggerPort) => {
|
|
61
|
-
const loginPath = config.loginPath ?? "/auth/login"
|
|
62
|
-
const sessionKey = storage.sessionKey
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Refreshes the Google access token using the refresh token in the session.
|
|
66
|
-
* セッション内のリフレッシュトークンで Google アクセストークンを更新する。
|
|
67
|
-
*/
|
|
68
|
-
const getRefreshedGoogleAccessToken = async (session: Session) => {
|
|
69
|
-
const userFromSession = session.get(sessionKey)
|
|
70
|
-
const refreshTokenFromSession = userFromSession?.refreshToken
|
|
71
|
-
if (!refreshTokenFromSession) {
|
|
72
|
-
throw new Error("Refresh token not found in session")
|
|
73
|
-
}
|
|
74
|
-
try {
|
|
75
|
-
const url = "https://www.googleapis.com/oauth2/v4/token"
|
|
76
|
-
const body = {
|
|
77
|
-
client_id: config.google.clientId,
|
|
78
|
-
client_secret: config.google.clientSecret,
|
|
79
|
-
grant_type: "refresh_token",
|
|
80
|
-
refresh_token: refreshTokenFromSession,
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const response = await fetch(url, {
|
|
84
|
-
headers: {
|
|
85
|
-
"Content-Type": "application/json",
|
|
86
|
-
},
|
|
87
|
-
method: "POST",
|
|
88
|
-
body: JSON.stringify(body),
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
if (!response.ok) {
|
|
92
|
-
// Google API からエラーが返された場合 (JSON パース失敗も考慮)
|
|
93
|
-
const errorData = await response.json().catch(() => undefined)
|
|
94
|
-
const isInvalidGrant = response.status === 400 && typeof errorData === "object" && !!errorData && "error" in errorData && errorData.error === "invalid_grant"
|
|
95
|
-
if (isInvalidGrant) {
|
|
96
|
-
throw new GoogleInvalidGrantError()
|
|
97
|
-
}
|
|
98
|
-
const errorDescription = typeof errorData === "object" && errorData && "error_description" in errorData ? String(errorData.error_description) : "Unknown error"
|
|
99
|
-
throw new TokenRefreshError(`Google token refresh failed: ${response.status} ${errorDescription}`)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const refreshedTokens = await response.json()
|
|
103
|
-
const { access_token, expires_in } = refreshedTokens // id_token, scope, token_type は不要
|
|
104
|
-
|
|
105
|
-
if (!(access_token && expires_in)) {
|
|
106
|
-
logger.error("Invalid token response from Google:", refreshedTokens)
|
|
107
|
-
throw new Error("Invalid token response from Google")
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
accessToken: access_token,
|
|
112
|
-
refreshToken: refreshTokenFromSession, // Google は通常リフレッシュトークンを再発行しない
|
|
113
|
-
expirationDateMs: Number(Date.now()) + expires_in * 1000,
|
|
114
|
-
}
|
|
115
|
-
} catch (error) {
|
|
116
|
-
if (error instanceof TokenRefreshError) {
|
|
117
|
-
throw error
|
|
118
|
-
}
|
|
119
|
-
const description = error instanceof Error ? error.message : "Unknown error"
|
|
120
|
-
throw new TokenRefreshError(`Failed to refresh Google access token: ${description}`)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Dispatches the token refresh by session provider (currently Google only).
|
|
126
|
-
* セッションのプロバイダー別にトークンリフレッシュを振り分ける (現状 Google のみ)。
|
|
127
|
-
*/
|
|
128
|
-
const refreshedAccessToken = async (session: Session) => {
|
|
129
|
-
const sessionProfile: SessionProfile = session.get(sessionKey)
|
|
130
|
-
const provider = sessionProfile.provider
|
|
131
|
-
if (!provider) {
|
|
132
|
-
throw new Error("Provider not found in session")
|
|
133
|
-
}
|
|
134
|
-
switch (provider) {
|
|
135
|
-
case "google":
|
|
136
|
-
return await getRefreshedGoogleAccessToken(session)
|
|
137
|
-
default:
|
|
138
|
-
throw new Error("Provider not supported")
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Validates session tokens, refreshing them when expired; destroys the session on refresh failure.
|
|
144
|
-
* セッショントークンを検証し、期限切れならリフレッシュする。失敗時はセッションを破棄する。
|
|
145
|
-
*/
|
|
146
|
-
const authenticate = async (request: Request, session: Session, options?: { failureRedirect?: string | null }) => {
|
|
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
|
-
}
|
|
164
|
-
try {
|
|
165
|
-
// テストバックドア (ポート注入時のみ有効)
|
|
166
|
-
if (config.mockUser?.enabled(request)) {
|
|
167
|
-
return {
|
|
168
|
-
accessToken: "mock-access-token",
|
|
169
|
-
refreshToken: "mock-refresh-token",
|
|
170
|
-
expirationDateMs: Date.now() + 3600000,
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// セッションから認証データを取得
|
|
175
|
-
const user = session.get(sessionKey)
|
|
176
|
-
if (!(user?.accessToken && user.expirationDateMs)) {
|
|
177
|
-
throw authFailure()
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const { accessToken, refreshToken, expirationDateMs } = user
|
|
181
|
-
|
|
182
|
-
// 期限切れならエラーを投げてリフレッシュ経路へ
|
|
183
|
-
if (expirationDateMs < Date.now()) {
|
|
184
|
-
if (!refreshToken) {
|
|
185
|
-
// リフレッシュトークンがない場合は更新できないので認証失敗として扱う
|
|
186
|
-
throw authFailure()
|
|
187
|
-
}
|
|
188
|
-
throw new Error("Expired")
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// 期限内ならそのまま返す
|
|
192
|
-
return { accessToken, refreshToken, expirationDateMs }
|
|
193
|
-
} catch (error) {
|
|
194
|
-
// 期限切れエラーのみリフレッシュを試みる
|
|
195
|
-
if (error instanceof Error && error.message === "Expired") {
|
|
196
|
-
try {
|
|
197
|
-
const refreshed = await refreshedAccessToken(session)
|
|
198
|
-
|
|
199
|
-
// セッションを新しいトークンで更新
|
|
200
|
-
const user = session.get(sessionKey)
|
|
201
|
-
session.set(sessionKey, { ...user, ...refreshed })
|
|
202
|
-
|
|
203
|
-
// アクセストークンを返す (commit は呼び出し側の責務)
|
|
204
|
-
return refreshed
|
|
205
|
-
} catch (refreshError) {
|
|
206
|
-
if (refreshError instanceof GoogleInvalidGrantError) {
|
|
207
|
-
logger.warn("Google refresh token invalid or revoked")
|
|
208
|
-
} else {
|
|
209
|
-
const message = refreshError instanceof Error ? refreshError.message : "Unknown error"
|
|
210
|
-
logger.error("Failed to refresh token:", message)
|
|
211
|
-
}
|
|
212
|
-
// リフレッシュに失敗した場合はセッションを破棄してログインページへ
|
|
213
|
-
const cookie = await storage.destroySession(session)
|
|
214
|
-
throw authFailure(cookie)
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// その他の予期せぬエラーは再 throw
|
|
219
|
-
throw error
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
return { authenticate, refreshedAccessToken }
|
|
224
|
-
}
|