@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.
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/dist/client.d.mts +16 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -0
- package/dist/client.mjs +2 -0
- package/dist/client.mjs.map +1 -0
- package/dist/server.d.mts +299 -0
- package/dist/server.d.ts +299 -0
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -0
- package/dist/server.mjs +2 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +88 -0
- package/src/client/GoogleForm.tsx +44 -0
- package/src/client.ts +6 -0
- package/src/server/auth-server.ts +68 -0
- package/src/server/authenticate-in-loader.spec.ts +152 -0
- package/src/server/authenticate-in-loader.ts +148 -0
- package/src/server/authenticate.spec.ts +146 -0
- package/src/server/authenticate.ts +187 -0
- package/src/server/google-strategy-impl.spec.ts +108 -0
- package/src/server/google-strategy-impl.ts +148 -0
- package/src/server/google-strategy.spec.ts +215 -0
- package/src/server/google-strategy.ts +174 -0
- package/src/server/handlers.spec.ts +154 -0
- package/src/server/handlers.ts +134 -0
- package/src/server/logger.ts +14 -0
- package/src/server/ports.ts +89 -0
- package/src/server/session.spec.ts +113 -0
- package/src/server/session.ts +127 -0
- package/src/server.ts +14 -0
|
@@ -0,0 +1,148 @@
|
|
|
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 } from "./authenticate"
|
|
14
|
+
import type { AuthServerConfig, LoggerPort } from "./ports"
|
|
15
|
+
import type { AuthSessionStorage } from "./session"
|
|
16
|
+
|
|
17
|
+
/** createAuthenticateInLoader が返すローダーガード関数。 */
|
|
18
|
+
export type AuthenticateInLoader = ReturnType<typeof createAuthenticateInLoader>
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Builds the per-request loader guard wired to injected ports.
|
|
22
|
+
* 注入ポートに結線した毎リクエストローダーガードを生成する。
|
|
23
|
+
*/
|
|
24
|
+
export const createAuthenticateInLoader = (config: AuthServerConfig, storage: AuthSessionStorage, authFns: AuthenticateFns, logger: LoggerPort) => {
|
|
25
|
+
// 写真キャッシュ (インスタンススコープ。同時リクエストは pending dedup で 1 クエリに集約)
|
|
26
|
+
const openidPhotoCache = new Map<string, string>()
|
|
27
|
+
const pendingOpenidPhotoQueries = new Map<string, Promise<string>>()
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolves the user photo with cache + in-flight dedup, delegating the query to the port.
|
|
31
|
+
* キャッシュ + 実行中 dedup 付きでユーザー写真を解決する (実クエリはポートへ委譲)。
|
|
32
|
+
*/
|
|
33
|
+
const getCachedOpenidPhoto = async (openid: string): Promise<string> => {
|
|
34
|
+
const cached = openidPhotoCache.get(openid)
|
|
35
|
+
if (cached !== undefined) {
|
|
36
|
+
return cached
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pending = pendingOpenidPhotoQueries.get(openid)
|
|
40
|
+
if (pending !== undefined) {
|
|
41
|
+
return pending
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fetchPromise = (async () => {
|
|
45
|
+
try {
|
|
46
|
+
const photo = config.getUserPhoto ? await config.getUserPhoto(openid) : ""
|
|
47
|
+
openidPhotoCache.set(openid, photo)
|
|
48
|
+
return photo
|
|
49
|
+
} catch (error) {
|
|
50
|
+
openidPhotoCache.delete(openid)
|
|
51
|
+
throw error
|
|
52
|
+
} finally {
|
|
53
|
+
pendingOpenidPhotoQueries.delete(openid)
|
|
54
|
+
}
|
|
55
|
+
})()
|
|
56
|
+
|
|
57
|
+
pendingOpenidPhotoQueries.set(openid, fetchPromise)
|
|
58
|
+
return fetchPromise
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Handles authentication within loader requests by refreshing session tokens when needed.
|
|
63
|
+
* ローダーリクエスト内で必要に応じてセッショントークンを更新する認証処理。
|
|
64
|
+
*
|
|
65
|
+
* @param request - リクエストオブジェクト。
|
|
66
|
+
* @param options - 認証オプション。`failureRedirect: null` でリダイレクトを明示的にスキップ。
|
|
67
|
+
* @returns 認証済みユーザープロファイルとオプショナルな Set-Cookie 値。
|
|
68
|
+
*/
|
|
69
|
+
const authenticateInLoader = async (request: Request, options: { failureRedirect: string } | { failureRedirect: null }): Promise<{ user?: MyGoogleProfile; cookie?: string }> => {
|
|
70
|
+
// DB 接続プールが確実にオープンするのを待ってから処理を開始する (E2E 等でのコールドスタート競合回避)
|
|
71
|
+
if (config.warmup) {
|
|
72
|
+
await config.warmup()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { failureRedirect } = options
|
|
76
|
+
const session = await storage.getSession(request)
|
|
77
|
+
const currentUser = await storage.getSessionUser(request)
|
|
78
|
+
|
|
79
|
+
if (currentUser === undefined) {
|
|
80
|
+
if (failureRedirect === null) {
|
|
81
|
+
return {}
|
|
82
|
+
}
|
|
83
|
+
throw redirect(failureRedirect)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
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,
|
|
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 }
|
|
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
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return authenticateInLoader
|
|
148
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the token guard and Google refresh rotation.
|
|
3
|
+
* トークンガードと Google リフレッシュ更新のテスト。
|
|
4
|
+
*/
|
|
5
|
+
import type { SessionProfile } from "@aiquants/auth-core"
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
7
|
+
import { createAuthenticate } from "./authenticate"
|
|
8
|
+
import type { AuthServerConfig, LoggerPort } from "./ports"
|
|
9
|
+
import { createAuthSessionStorage } from "./session"
|
|
10
|
+
|
|
11
|
+
const silentLogger: LoggerPort = { info: () => {}, warn: () => {}, error: () => {} }
|
|
12
|
+
|
|
13
|
+
const baseConfig: AuthServerConfig = {
|
|
14
|
+
google: { clientId: "cid", clientSecret: "csec", redirectURI: "http://localhost/auth/api/google/callback" },
|
|
15
|
+
session: { secrets: ["test-secret"] },
|
|
16
|
+
isEmailAllowed: () => true,
|
|
17
|
+
logger: silentLogger,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const profile: SessionProfile = {
|
|
21
|
+
id: "1",
|
|
22
|
+
displayName: "User",
|
|
23
|
+
name: { familyName: "F", givenName: "G" },
|
|
24
|
+
emails: [{ value: "u@example.com" }],
|
|
25
|
+
accessToken: "at",
|
|
26
|
+
refreshToken: "rt",
|
|
27
|
+
expirationDateMs: Date.now() + 3600000,
|
|
28
|
+
provider: "google",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const setup = async (user?: SessionProfile, config: AuthServerConfig = baseConfig) => {
|
|
32
|
+
const storage = createAuthSessionStorage(config.session, { mockUser: config.mockUser })
|
|
33
|
+
const request = new Request("http://localhost/")
|
|
34
|
+
const session = await storage.getSession(request)
|
|
35
|
+
if (user) {
|
|
36
|
+
session.set("user", user)
|
|
37
|
+
}
|
|
38
|
+
const fns = createAuthenticate(config, storage, silentLogger)
|
|
39
|
+
return { storage, request, session, fns }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.unstubAllGlobals()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe("authenticate", () => {
|
|
47
|
+
it("returns tokens unchanged while not expired", async () => {
|
|
48
|
+
const { fns, request, session } = await setup(profile)
|
|
49
|
+
const tokens = await fns.authenticate(request, session)
|
|
50
|
+
expect(tokens).toEqual({ accessToken: "at", refreshToken: "rt", expirationDateMs: profile.expirationDateMs })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("redirects to the login path when the session has no tokens", async () => {
|
|
54
|
+
const { fns, request, session } = await setup()
|
|
55
|
+
try {
|
|
56
|
+
await fns.authenticate(request, session)
|
|
57
|
+
expect.unreachable("should have thrown a redirect")
|
|
58
|
+
} catch (error) {
|
|
59
|
+
expect(error).toBeInstanceOf(Response)
|
|
60
|
+
expect((error as Response).headers.get("Location")).toBe("/auth/login")
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("honors failureRedirect for missing tokens", async () => {
|
|
65
|
+
const { fns, request, session } = await setup()
|
|
66
|
+
try {
|
|
67
|
+
await fns.authenticate(request, session, { failureRedirect: "/custom" })
|
|
68
|
+
expect.unreachable("should have thrown a redirect")
|
|
69
|
+
} catch (error) {
|
|
70
|
+
expect((error as Response).headers.get("Location")).toBe("/custom")
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("refreshes expired tokens via Google and updates the session", async () => {
|
|
75
|
+
const expired = { ...profile, expirationDateMs: Date.now() - 1000 }
|
|
76
|
+
const { fns, request, session } = await setup(expired)
|
|
77
|
+
|
|
78
|
+
vi.stubGlobal(
|
|
79
|
+
"fetch",
|
|
80
|
+
vi.fn(async () => new Response(JSON.stringify({ access_token: "new-at", expires_in: 3600 }), { status: 200 })),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const tokens = await fns.authenticate(request, session)
|
|
84
|
+
expect(tokens.accessToken).toBe("new-at")
|
|
85
|
+
expect(tokens.refreshToken).toBe("rt") // Google は通常リフレッシュトークンを再発行しない
|
|
86
|
+
expect(tokens.expirationDateMs).toBeGreaterThan(Date.now())
|
|
87
|
+
// セッションも更新されている
|
|
88
|
+
expect(session.get("user").accessToken).toBe("new-at")
|
|
89
|
+
// client_id/secret は注入設定から使われる
|
|
90
|
+
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string)
|
|
91
|
+
expect(body.client_id).toBe("cid")
|
|
92
|
+
expect(body.client_secret).toBe("csec")
|
|
93
|
+
expect(body.refresh_token).toBe("rt")
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("destroys the session and redirects on invalid_grant", async () => {
|
|
97
|
+
const expired = { ...profile, expirationDateMs: Date.now() - 1000 }
|
|
98
|
+
const { fns, request, session } = await setup(expired)
|
|
99
|
+
|
|
100
|
+
vi.stubGlobal(
|
|
101
|
+
"fetch",
|
|
102
|
+
vi.fn(async () => new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 })),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
await fns.authenticate(request, session)
|
|
107
|
+
expect.unreachable("should have thrown a redirect")
|
|
108
|
+
} catch (error) {
|
|
109
|
+
expect(error).toBeInstanceOf(Response)
|
|
110
|
+
const res = error as Response
|
|
111
|
+
expect(res.headers.get("Location")).toBe("/auth/login")
|
|
112
|
+
// セッション破棄の Set-Cookie が付与される
|
|
113
|
+
expect(res.headers.get("Set-Cookie")).toContain("__session=")
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("returns mock tokens when the mockUser port matches", async () => {
|
|
118
|
+
const config: AuthServerConfig = {
|
|
119
|
+
...baseConfig,
|
|
120
|
+
mockUser: {
|
|
121
|
+
enabled: (req) => req.headers.get("Cookie")?.includes("mock_user=true") ?? false,
|
|
122
|
+
resolve: () => profile,
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
const storage = createAuthSessionStorage(config.session)
|
|
126
|
+
const request = new Request("http://localhost/", { headers: { Cookie: "mock_user=true" } })
|
|
127
|
+
const session = await storage.getSession(request)
|
|
128
|
+
const fns = createAuthenticate(config, storage, silentLogger)
|
|
129
|
+
|
|
130
|
+
const tokens = await fns.authenticate(request, session)
|
|
131
|
+
expect(tokens.accessToken).toBe("mock-access-token")
|
|
132
|
+
expect(tokens.refreshToken).toBe("mock-refresh-token")
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe("refreshedAccessToken", () => {
|
|
137
|
+
it("rejects unsupported providers", async () => {
|
|
138
|
+
const { fns, session } = await setup({ ...profile, provider: "unknown" })
|
|
139
|
+
await expect(fns.refreshedAccessToken(session)).rejects.toThrow("Provider not supported")
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("rejects when the provider is missing", async () => {
|
|
143
|
+
const { fns, session } = await setup({ ...profile, provider: "" })
|
|
144
|
+
await expect(fns.refreshedAccessToken(session)).rejects.toThrow("Provider not found in session")
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
/** createAuthenticate が返す認証関数束。 */
|
|
35
|
+
export type AuthenticateFns = ReturnType<typeof createAuthenticate>
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Builds the `authenticate` / `refreshedAccessToken` pair wired to injected config.
|
|
39
|
+
* 注入設定に結線した `authenticate` / `refreshedAccessToken` を生成する。
|
|
40
|
+
*/
|
|
41
|
+
export const createAuthenticate = (config: AuthServerConfig, storage: AuthSessionStorage, logger: LoggerPort) => {
|
|
42
|
+
const loginPath = config.loginPath ?? "/auth/login"
|
|
43
|
+
const sessionKey = storage.sessionKey
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Refreshes the Google access token using the refresh token in the session.
|
|
47
|
+
* セッション内のリフレッシュトークンで Google アクセストークンを更新する。
|
|
48
|
+
*/
|
|
49
|
+
const getRefreshedGoogleAccessToken = async (session: Session) => {
|
|
50
|
+
const userFromSession = session.get(sessionKey)
|
|
51
|
+
const refreshTokenFromSession = userFromSession?.refreshToken
|
|
52
|
+
if (!refreshTokenFromSession) {
|
|
53
|
+
throw new Error("Refresh token not found in session")
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const url = "https://www.googleapis.com/oauth2/v4/token"
|
|
57
|
+
const body = {
|
|
58
|
+
client_id: config.google.clientId,
|
|
59
|
+
client_secret: config.google.clientSecret,
|
|
60
|
+
grant_type: "refresh_token",
|
|
61
|
+
refresh_token: refreshTokenFromSession,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const response = await fetch(url, {
|
|
65
|
+
headers: {
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
},
|
|
68
|
+
method: "POST",
|
|
69
|
+
body: JSON.stringify(body),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
// Google API からエラーが返された場合 (JSON パース失敗も考慮)
|
|
74
|
+
const errorData = await response.json().catch(() => undefined)
|
|
75
|
+
const isInvalidGrant = response.status === 400 && typeof errorData === "object" && !!errorData && "error" in errorData && errorData.error === "invalid_grant"
|
|
76
|
+
if (isInvalidGrant) {
|
|
77
|
+
throw new GoogleInvalidGrantError()
|
|
78
|
+
}
|
|
79
|
+
const errorDescription = typeof errorData === "object" && errorData && "error_description" in errorData ? String(errorData.error_description) : "Unknown error"
|
|
80
|
+
throw new TokenRefreshError(`Google token refresh failed: ${response.status} ${errorDescription}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const refreshedTokens = await response.json()
|
|
84
|
+
const { access_token, expires_in } = refreshedTokens // id_token, scope, token_type は不要
|
|
85
|
+
|
|
86
|
+
if (!(access_token && expires_in)) {
|
|
87
|
+
logger.error("Invalid token response from Google:", refreshedTokens)
|
|
88
|
+
throw new Error("Invalid token response from Google")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
accessToken: access_token,
|
|
93
|
+
refreshToken: refreshTokenFromSession, // Google は通常リフレッシュトークンを再発行しない
|
|
94
|
+
expirationDateMs: Number(Date.now()) + expires_in * 1000,
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof TokenRefreshError) {
|
|
98
|
+
throw error
|
|
99
|
+
}
|
|
100
|
+
const description = error instanceof Error ? error.message : "Unknown error"
|
|
101
|
+
throw new TokenRefreshError(`Failed to refresh Google access token: ${description}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Dispatches the token refresh by session provider (currently Google only).
|
|
107
|
+
* セッションのプロバイダー別にトークンリフレッシュを振り分ける (現状 Google のみ)。
|
|
108
|
+
*/
|
|
109
|
+
const refreshedAccessToken = async (session: Session) => {
|
|
110
|
+
const sessionProfile: SessionProfile = session.get(sessionKey)
|
|
111
|
+
const provider = sessionProfile.provider
|
|
112
|
+
if (!provider) {
|
|
113
|
+
throw new Error("Provider not found in session")
|
|
114
|
+
}
|
|
115
|
+
switch (provider) {
|
|
116
|
+
case "google":
|
|
117
|
+
return await getRefreshedGoogleAccessToken(session)
|
|
118
|
+
default:
|
|
119
|
+
throw new Error("Provider not supported")
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validates session tokens, refreshing them when expired; destroys the session on refresh failure.
|
|
125
|
+
* セッショントークンを検証し、期限切れならリフレッシュする。失敗時はセッションを破棄する。
|
|
126
|
+
*/
|
|
127
|
+
const authenticate = async (request: Request, session: Session, options?: { failureRedirect?: string }) => {
|
|
128
|
+
const { failureRedirect } = options || {}
|
|
129
|
+
try {
|
|
130
|
+
// テストバックドア (ポート注入時のみ有効)
|
|
131
|
+
if (config.mockUser?.enabled(request)) {
|
|
132
|
+
return {
|
|
133
|
+
accessToken: "mock-access-token",
|
|
134
|
+
refreshToken: "mock-refresh-token",
|
|
135
|
+
expirationDateMs: Date.now() + 3600000,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// セッションから認証データを取得
|
|
140
|
+
const user = session.get(sessionKey)
|
|
141
|
+
if (!(user?.accessToken && user.refreshToken && user.expirationDateMs)) {
|
|
142
|
+
throw redirect(failureRedirect || loginPath)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const { accessToken, refreshToken, expirationDateMs } = user
|
|
146
|
+
|
|
147
|
+
// 期限切れならエラーを投げてリフレッシュ経路へ
|
|
148
|
+
if (expirationDateMs < Date.now()) {
|
|
149
|
+
throw new Error("Expired")
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 期限内ならそのまま返す
|
|
153
|
+
return { accessToken, refreshToken, expirationDateMs }
|
|
154
|
+
} catch (error) {
|
|
155
|
+
// 期限切れエラーのみリフレッシュを試みる
|
|
156
|
+
if (error instanceof Error && error.message === "Expired") {
|
|
157
|
+
try {
|
|
158
|
+
const refreshed = await refreshedAccessToken(session)
|
|
159
|
+
|
|
160
|
+
// セッションを新しいトークンで更新
|
|
161
|
+
const user = session.get(sessionKey)
|
|
162
|
+
session.set(sessionKey, { ...user, ...refreshed })
|
|
163
|
+
|
|
164
|
+
// アクセストークンを返す (commit は呼び出し側の責務)
|
|
165
|
+
return refreshed
|
|
166
|
+
} catch (refreshError) {
|
|
167
|
+
if (refreshError instanceof GoogleInvalidGrantError) {
|
|
168
|
+
logger.warn("Google refresh token invalid or revoked")
|
|
169
|
+
} else {
|
|
170
|
+
const message = refreshError instanceof Error ? refreshError.message : "Unknown error"
|
|
171
|
+
logger.error("Failed to refresh token:", message)
|
|
172
|
+
}
|
|
173
|
+
// リフレッシュに失敗した場合はセッションを破棄してログインページへ
|
|
174
|
+
const cookie = await storage.destroySession(session)
|
|
175
|
+
throw redirect(failureRedirect || loginPath, {
|
|
176
|
+
headers: { "Set-Cookie": cookie },
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// その他の予期せぬエラーは再 throw
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { authenticate, refreshedAccessToken }
|
|
187
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the ported GoogleStrategy (byte-parity with @coji/remix-auth-google).
|
|
3
|
+
* 移植した GoogleStrategy のテスト (@coji/remix-auth-google との byte 一致)。
|
|
4
|
+
*/
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
6
|
+
import type { GoogleProfile } from "./google-strategy-impl"
|
|
7
|
+
import { GoogleStrategy, GoogleStrategyDefaultName, GoogleStrategyDefaultScopes } from "./google-strategy-impl"
|
|
8
|
+
|
|
9
|
+
// authorizationParams / accessType 等は protected/private のため、テスト用サブクラスで公開する
|
|
10
|
+
class ProbeStrategy extends GoogleStrategy<{ id: string }> {
|
|
11
|
+
exposeAuthorizationParams(params: URLSearchParams, request?: Request): URLSearchParams {
|
|
12
|
+
return this.authorizationParams(params, request)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const noopVerify = async () => ({ id: "x" })
|
|
17
|
+
|
|
18
|
+
const makeProbe = (over?: Partial<ConstructorParameters<typeof GoogleStrategy>[0]>) =>
|
|
19
|
+
new ProbeStrategy(
|
|
20
|
+
{
|
|
21
|
+
clientId: "cid",
|
|
22
|
+
clientSecret: "csec",
|
|
23
|
+
redirectURI: "http://localhost/auth/api/google/callback",
|
|
24
|
+
...over,
|
|
25
|
+
},
|
|
26
|
+
noopVerify,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.unstubAllGlobals()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("constants", () => {
|
|
34
|
+
it("keeps the original default scopes (full URL form)", () => {
|
|
35
|
+
expect(GoogleStrategyDefaultScopes).toEqual(["openid", "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("uses 'google' as the strategy name", () => {
|
|
39
|
+
expect(GoogleStrategyDefaultName).toBe("google")
|
|
40
|
+
expect(makeProbe().name).toBe("google")
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe("authorizationParams", () => {
|
|
45
|
+
it("applies the original defaults (access_type=online, include_granted_scopes=false, no prompt)", () => {
|
|
46
|
+
const params = makeProbe().exposeAuthorizationParams(new URLSearchParams())
|
|
47
|
+
expect(params.get("access_type")).toBe("online")
|
|
48
|
+
expect(params.get("include_granted_scopes")).toBe("false")
|
|
49
|
+
expect(params.get("prompt")).toBeNull()
|
|
50
|
+
expect(params.get("hd")).toBeNull()
|
|
51
|
+
expect(params.get("login_hint")).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("reflects the quants config (offline / include_granted_scopes=true / prompt=consent)", () => {
|
|
55
|
+
const params = makeProbe({ accessType: "offline", includeGrantedScopes: true, prompt: "consent" }).exposeAuthorizationParams(new URLSearchParams())
|
|
56
|
+
expect(params.get("access_type")).toBe("offline")
|
|
57
|
+
expect(params.get("include_granted_scopes")).toBe("true")
|
|
58
|
+
expect(params.get("prompt")).toBe("consent")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("sets hd and login_hint only when provided", () => {
|
|
62
|
+
const params = makeProbe({ hd: "example.com", loginHint: "user@example.com" }).exposeAuthorizationParams(new URLSearchParams())
|
|
63
|
+
expect(params.get("hd")).toBe("example.com")
|
|
64
|
+
expect(params.get("login_hint")).toBe("user@example.com")
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe("userProfile", () => {
|
|
69
|
+
const rawUserinfo = {
|
|
70
|
+
sub: "107791905418691074770",
|
|
71
|
+
name: "宏治 テスト",
|
|
72
|
+
given_name: "宏治",
|
|
73
|
+
family_name: "テスト",
|
|
74
|
+
email: "user@example.com",
|
|
75
|
+
email_verified: true,
|
|
76
|
+
picture: "https://example.com/p.png",
|
|
77
|
+
locale: "ja",
|
|
78
|
+
hd: "example.com",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
it("maps the userinfo (v3) response to GoogleProfile (sub→id, picture→photos, _json=raw)", async () => {
|
|
82
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify(rawUserinfo), { status: 200 }))
|
|
83
|
+
vi.stubGlobal("fetch", fetchMock)
|
|
84
|
+
|
|
85
|
+
const profile: GoogleProfile = await GoogleStrategy.userProfile({ accessToken: () => "at" })
|
|
86
|
+
|
|
87
|
+
expect(profile.id).toBe("107791905418691074770")
|
|
88
|
+
expect(profile.displayName).toBe("宏治 テスト")
|
|
89
|
+
expect(profile.name).toEqual({ familyName: "テスト", givenName: "宏治" })
|
|
90
|
+
expect(profile.emails).toEqual([{ value: "user@example.com" }])
|
|
91
|
+
expect(profile.photos).toEqual([{ value: "https://example.com/p.png" }])
|
|
92
|
+
// _json は生レスポンスをそのまま保持 (email_verified / hd を含む — verify コールバックが依存)
|
|
93
|
+
expect(profile._json).toEqual(rawUserinfo)
|
|
94
|
+
|
|
95
|
+
// Bearer トークンを userinfo v3 へ送っている
|
|
96
|
+
const [url, init] = fetchMock.mock.calls[0]
|
|
97
|
+
expect(url).toBe("https://www.googleapis.com/oauth2/v3/userinfo")
|
|
98
|
+
expect((init as RequestInit).headers).toEqual({ Authorization: "Bearer at" })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("throws when the userinfo response is not ok", async () => {
|
|
102
|
+
vi.stubGlobal(
|
|
103
|
+
"fetch",
|
|
104
|
+
vi.fn(async () => new Response("nope", { status: 401, statusText: "Unauthorized" })),
|
|
105
|
+
)
|
|
106
|
+
await expect(GoogleStrategy.userProfile({ accessToken: () => "at" })).rejects.toThrow("Failed to fetch user profile: Unauthorized")
|
|
107
|
+
})
|
|
108
|
+
})
|