@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 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
|
+
}
|