@aiquants/auth-react-router 0.4.0 → 0.5.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 +39 -1
- package/dist/admin.d.mts +268 -0
- package/dist/admin.d.ts +268 -0
- package/dist/admin.js +2 -0
- package/dist/admin.js.map +1 -0
- package/dist/admin.mjs +2 -0
- package/dist/admin.mjs.map +1 -0
- package/dist/client.d.mts +2 -2
- package/dist/client.d.ts +2 -2
- package/dist/server.d.mts +23 -4
- package/dist/server.d.ts +23 -4
- 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 +10 -3
- package/src/admin/app.ts +4 -0
- package/src/admin/labels.ts +274 -0
- package/src/admin/server.spec.ts +300 -0
- package/src/admin/server.ts +345 -0
- package/src/admin/ui.tsx +71 -0
- package/src/admin/views.spec.tsx +307 -0
- package/src/admin/views.tsx +696 -0
- package/src/admin.ts +5 -0
- package/src/server/auth-server.ts +1 -0
- package/src/server/google-strategy-impl.spec.ts +8 -0
- package/src/server/google-strategy-impl.ts +7 -1
- package/src/server/google-strategy.spec.ts +56 -0
- package/src/server/google-strategy.ts +10 -1
- package/src/server/ports.ts +22 -4
- package/src/server/session.spec.ts +52 -0
- package/src/server/session.ts +7 -2
package/src/admin.ts
ADDED
|
@@ -56,6 +56,14 @@ describe("authorizationParams", () => {
|
|
|
56
56
|
expect(params.get("access_type")).toBe("offline")
|
|
57
57
|
expect(params.get("include_granted_scopes")).toBe("true")
|
|
58
58
|
expect(params.get("prompt")).toBe("consent")
|
|
59
|
+
expect(params.get("scope")).toBe("openid https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("formats multiple scopes cleanly with space separation including custom scopes like drive.readonly", () => {
|
|
63
|
+
const customScopes = ["openid", "email", "https://www.googleapis.com/auth/drive.readonly"] as const
|
|
64
|
+
// biome-ignore lint/suspicious/noExplicitAny: Custom scopes for testing
|
|
65
|
+
const params = makeProbe({ scopes: customScopes as any }).exposeAuthorizationParams(new URLSearchParams())
|
|
66
|
+
expect(params.get("scope")).toBe("openid email https://www.googleapis.com/auth/drive.readonly")
|
|
59
67
|
})
|
|
60
68
|
|
|
61
69
|
it("sets hd and login_hint only when provided", () => {
|
|
@@ -72,6 +72,7 @@ type OAuth2AccessTokenSource = { accessToken(): string }
|
|
|
72
72
|
*/
|
|
73
73
|
export class GoogleStrategy<User> extends OAuth2Strategy<User> {
|
|
74
74
|
name = GoogleStrategyDefaultName
|
|
75
|
+
private readonly scopes: GoogleScope[]
|
|
75
76
|
private readonly accessType: "online" | "offline"
|
|
76
77
|
private readonly prompt?: "none" | "consent" | "select_account"
|
|
77
78
|
private readonly includeGrantedScopes: boolean
|
|
@@ -79,6 +80,7 @@ export class GoogleStrategy<User> extends OAuth2Strategy<User> {
|
|
|
79
80
|
private readonly loginHint?: string
|
|
80
81
|
|
|
81
82
|
constructor({ clientId, clientSecret, redirectURI, scopes, accessType, includeGrantedScopes, prompt, hd, loginHint }: GoogleStrategyOptions, verify: OAuth2Strategy<User>["verify"]) {
|
|
83
|
+
const scopeList = scopes ?? GoogleStrategyDefaultScopes
|
|
82
84
|
super(
|
|
83
85
|
{
|
|
84
86
|
clientId,
|
|
@@ -86,10 +88,11 @@ export class GoogleStrategy<User> extends OAuth2Strategy<User> {
|
|
|
86
88
|
redirectURI,
|
|
87
89
|
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
88
90
|
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
|
89
|
-
scopes:
|
|
91
|
+
scopes: scopeList,
|
|
90
92
|
},
|
|
91
93
|
verify,
|
|
92
94
|
)
|
|
95
|
+
this.scopes = scopeList
|
|
93
96
|
this.accessType = accessType ?? "online"
|
|
94
97
|
this.includeGrantedScopes = includeGrantedScopes ?? false
|
|
95
98
|
this.prompt = prompt
|
|
@@ -104,6 +107,9 @@ export class GoogleStrategy<User> extends OAuth2Strategy<User> {
|
|
|
104
107
|
protected authorizationParams(params: URLSearchParams, _request?: Request): URLSearchParams {
|
|
105
108
|
params.set("access_type", this.accessType)
|
|
106
109
|
params.set("include_granted_scopes", String(this.includeGrantedScopes))
|
|
110
|
+
if (this.scopes && this.scopes.length > 0) {
|
|
111
|
+
params.set("scope", this.scopes.join(" "))
|
|
112
|
+
}
|
|
107
113
|
if (this.prompt) {
|
|
108
114
|
params.set("prompt", this.prompt)
|
|
109
115
|
}
|
|
@@ -184,6 +184,62 @@ describe("verify callback", () => {
|
|
|
184
184
|
expect(signup).not.toHaveBeenCalled()
|
|
185
185
|
})
|
|
186
186
|
|
|
187
|
+
// ⚠️ 回帰ガード: `isEmailAllowed` は非同期実装を許すため、呼び出し側の `await` が外れると
|
|
188
|
+
// `!Promise` が常に false となり **許可判定を素通り (fail-open)** する。型検査では検出できない。
|
|
189
|
+
it("REGRESSION GUARD: an ASYNC deny predicate must still block (missing await would fail OPEN)", async () => {
|
|
190
|
+
const signup = vi.fn(async () => ({}))
|
|
191
|
+
createGoogleAuthenticator(makeConfig({ isEmailAllowed: async () => false, backendAuth: { verify: async () => true, signup } }))
|
|
192
|
+
const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
|
|
193
|
+
expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.notAllowed)}`)
|
|
194
|
+
expect(signup).not.toHaveBeenCalled()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("an ASYNC allow predicate lets the login through (await resolves the value, not the Promise)", async () => {
|
|
198
|
+
const allowlist = vi.fn(async () => true)
|
|
199
|
+
createGoogleAuthenticator(makeConfig({ isEmailAllowed: allowlist }))
|
|
200
|
+
const user = await captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() })
|
|
201
|
+
expect(user?.photo).toBe("https://example.com/p.png")
|
|
202
|
+
expect(allowlist).toHaveBeenCalledWith("user@example.com")
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it("a REJECTING allowlist (e.g. database unreachable) fails CLOSED", async () => {
|
|
206
|
+
const signup = vi.fn(async () => ({}))
|
|
207
|
+
createGoogleAuthenticator(
|
|
208
|
+
makeConfig({
|
|
209
|
+
isEmailAllowed: async () => {
|
|
210
|
+
throw new Error("ECONNREFUSED sql")
|
|
211
|
+
},
|
|
212
|
+
backendAuth: { verify: async () => true, signup },
|
|
213
|
+
}),
|
|
214
|
+
)
|
|
215
|
+
const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
|
|
216
|
+
// 許可されない以上ログインは成立しない (メッセージは汎用エラー)
|
|
217
|
+
expect(location).toContain("/auth/login?error=")
|
|
218
|
+
expect(signup).not.toHaveBeenCalled()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it("REGRESSION GUARD: a DEACTIVATED account cannot sign in even when the allowlist permits it", async () => {
|
|
222
|
+
const signup = vi.fn(async () => ({}))
|
|
223
|
+
createGoogleAuthenticator(makeConfig({ isEmailAllowed: () => true, isUserActive: async () => false, backendAuth: { verify: async () => true, signup } }))
|
|
224
|
+
const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
|
|
225
|
+
expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.notAllowed)}`)
|
|
226
|
+
expect(signup).not.toHaveBeenCalled()
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it("an ACTIVE account signs in normally, and the port receives the profile", async () => {
|
|
230
|
+
const isUserActive = vi.fn(async () => true)
|
|
231
|
+
createGoogleAuthenticator(makeConfig({ isUserActive }))
|
|
232
|
+
const user = await captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() })
|
|
233
|
+
expect(user?.photo).toBe("https://example.com/p.png")
|
|
234
|
+
expect(isUserActive).toHaveBeenCalledWith(expect.objectContaining({ emails: [{ value: "user@example.com" }] }))
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it("omitting isUserActive keeps the previous behaviour", async () => {
|
|
238
|
+
createGoogleAuthenticator(makeConfig({}))
|
|
239
|
+
const user = await captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() })
|
|
240
|
+
expect(user?.photo).toBe("https://example.com/p.png")
|
|
241
|
+
})
|
|
242
|
+
|
|
187
243
|
it("redirects with unexpected on generic signup failure", async () => {
|
|
188
244
|
createGoogleAuthenticator(
|
|
189
245
|
makeConfig({
|
|
@@ -102,13 +102,22 @@ export const createGoogleAuthenticator = (config: AuthServerConfig): Authenticat
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// メールアドレスまたはドメインベースでのアクセス制限チェック (論理和は allowlist 実装側)
|
|
105
|
+
// ⚠️ `await` 必須。非同期実装を await せずに評価すると Promise が truthy 判定され
|
|
106
|
+
// 許可判定を素通りする (fail-open)。この await は spec で門番されている。
|
|
105
107
|
const userEmail = profile.emails[0]?.value
|
|
106
|
-
if (!config.isEmailAllowed(userEmail)) {
|
|
108
|
+
if (!(await config.isEmailAllowed(userEmail))) {
|
|
107
109
|
const error = new Error("You are not allowed to access this application")
|
|
108
110
|
logger.error(error)
|
|
109
111
|
throw error
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
// 無効化済みアカウントはログインさせない (allowlist とは別軸の判定)
|
|
115
|
+
if (config.isUserActive && !(await config.isUserActive(profile))) {
|
|
116
|
+
const error = new Error("You are not allowed to access this application")
|
|
117
|
+
logger.error("Deactivated account attempted to sign in:", userEmail)
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
|
|
112
121
|
// バックエンドでのサインアップ処理 (毎ログイン実行の挙動を保存)
|
|
113
122
|
if (config.backendAuth) {
|
|
114
123
|
await config.backendAuth.signup(provider, accessToken)
|
package/src/server/ports.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* アプリ固有の結合 (env・backend HTTP・DB 由来の写真解決・DB ウォームアップ・テストバックドア) は
|
|
7
7
|
* すべてここで定義するポートとして注入する。パッケージ内には汎用ロジックだけを置く。
|
|
8
8
|
*/
|
|
9
|
-
import type { AuthMessages, SessionProfile } from "@aiquants/auth-core"
|
|
9
|
+
import type { AuthMessages, GoogleProfileBase, SessionProfile } from "@aiquants/auth-core"
|
|
10
10
|
|
|
11
11
|
/** OAuth Google の資格情報とオプション (scopes はアプリ注入 — sanledge が拡張)。 */
|
|
12
12
|
export type GoogleOAuthConfig = {
|
|
@@ -41,7 +41,7 @@ export type BackendAuthPort = {
|
|
|
41
41
|
signup: (provider: string, accessToken: string) => Promise<unknown>
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** ユーザー写真解決ポート (
|
|
44
|
+
/** ユーザー写真解決ポート (DB 参照等の実装はアプリ側)。キャッシュ + dedup はパッケージ内。 */
|
|
45
45
|
export type UserPhotoPort = (openid: string) => Promise<string>
|
|
46
46
|
|
|
47
47
|
/** 毎リクエストの DB 接続ウォームアップ (quants は 2 プール、sanledge は 1 プール)。 */
|
|
@@ -66,8 +66,26 @@ export type LoggerPort = {
|
|
|
66
66
|
export type AuthServerConfig = {
|
|
67
67
|
google: GoogleOAuthConfig
|
|
68
68
|
session: SessionConfig
|
|
69
|
-
/**
|
|
70
|
-
|
|
69
|
+
/**
|
|
70
|
+
* allowlist 判定 (auth-core の `emailDomainAllowlist` を推奨)。
|
|
71
|
+
*
|
|
72
|
+
* 非同期を許すのは、環境変数に加えてデータベース等の可変な供給元を参照する構成のため。
|
|
73
|
+
* ⚠️ 呼び出し側は必ず `await` すること。`Promise` は常に truthy なので `if (!fn(email))`
|
|
74
|
+
* のまま非同期実装を渡すと **判定が素通りして fail-open** になる (型検査では検出できない)。
|
|
75
|
+
*/
|
|
76
|
+
isEmailAllowed: (email: string | null | undefined) => boolean | Promise<boolean>
|
|
77
|
+
/**
|
|
78
|
+
* Account-status port: return `false` ONLY for a known-deactivated account.
|
|
79
|
+
* アカウント状態ポート。**無効化済みと判明した場合のみ** `false` を返す。
|
|
80
|
+
*
|
|
81
|
+
* allowlist が「そのメールアドレスがこのアプリを使ってよいか」を判定するのに対し、こちらは
|
|
82
|
+
* 「そのアカウントが現在有効か」を判定する。ログイン時と **毎リクエストのセッション解決時** の
|
|
83
|
+
* 双方で参照されるため、無効化は既存セッションに対しても即座に効く。
|
|
84
|
+
*
|
|
85
|
+
* 未登録ユーザー (初回ログインでこれから作成される) では `true` を返すこと。`false` を返すと
|
|
86
|
+
* サインアップ経路ごと塞いでしまう。省略時は常に有効として扱う (既存構成の挙動を変えない)。
|
|
87
|
+
*/
|
|
88
|
+
isUserActive?: (profile: GoogleProfileBase) => boolean | Promise<boolean>
|
|
71
89
|
/** バックエンド verify/signup。省略時はスキップ (フロント単独運用)。 */
|
|
72
90
|
backendAuth?: BackendAuthPort
|
|
73
91
|
/** ユーザー写真解決。省略時は常に空文字。 */
|
|
@@ -111,3 +111,55 @@ describe("createAuthSessionStorage", () => {
|
|
|
111
111
|
expect(destroyed).toContain("Expires=Thu, 01 Jan 1970")
|
|
112
112
|
})
|
|
113
113
|
})
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 無効化済みアカウントの即時失効。
|
|
117
|
+
* `getSessionUser` は `requireUser` と `authenticateInLoader` の双方が通る唯一の絞り込み点であり、
|
|
118
|
+
* ここで弾くことで既存セッション Cookie を持っていても全ルートで未認証扱いになる。
|
|
119
|
+
*/
|
|
120
|
+
describe("createAuthSessionStorage — isUserActive による無効化の即時反映", () => {
|
|
121
|
+
const profile = { id: "sub-1", displayName: "U", name: { familyName: "U", givenName: "U" }, emails: [{ value: "u@example.com" }] } as never
|
|
122
|
+
|
|
123
|
+
/** セッション Cookie を持つリクエストを組み立てる。 */
|
|
124
|
+
const requestWithSession = async (storage: ReturnType<typeof createAuthSessionStorage>) => {
|
|
125
|
+
const cookie = await storage.saveSession(new Request("http://x/"), profile)
|
|
126
|
+
return new Request("http://x/", { headers: { Cookie: cookie.split(";")[0] } })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
it("有効なアカウントはこれまでどおりセッションから解決される", async () => {
|
|
130
|
+
const storage = createAuthSessionStorage({ secrets: [SECRET] }, { isUserActive: () => true })
|
|
131
|
+
expect(await storage.getSessionUser(await requestWithSession(storage))).toMatchObject({ id: "sub-1" })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("無効化済みアカウントは有効な Cookie を持っていても未認証として扱われる", async () => {
|
|
135
|
+
const storage = createAuthSessionStorage({ secrets: [SECRET] }, { isUserActive: () => false })
|
|
136
|
+
expect(await storage.getSessionUser(await requestWithSession(storage))).toBeUndefined()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("無効化済みなら requireUser はログインへリダイレクトする", async () => {
|
|
140
|
+
const storage = createAuthSessionStorage({ secrets: [SECRET] }, { isUserActive: async () => false })
|
|
141
|
+
const request = await requestWithSession(storage)
|
|
142
|
+
await expect(storage.requireUser(request)).rejects.toBeInstanceOf(Response)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("ポート未指定なら従来どおり (既存構成の挙動を変えない)", async () => {
|
|
146
|
+
const storage = createAuthSessionStorage({ secrets: [SECRET] })
|
|
147
|
+
expect(await storage.getSessionUser(await requestWithSession(storage))).toMatchObject({ id: "sub-1" })
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it("ポートには解決したプロファイルが渡される", async () => {
|
|
151
|
+
const seen: unknown[] = []
|
|
152
|
+
const storage = createAuthSessionStorage(
|
|
153
|
+
{ secrets: [SECRET] },
|
|
154
|
+
{
|
|
155
|
+
isUserActive: (p) => {
|
|
156
|
+
seen.push(p)
|
|
157
|
+
return true
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
await storage.getSessionUser(await requestWithSession(storage))
|
|
162
|
+
expect(seen).toHaveLength(1)
|
|
163
|
+
expect(seen[0]).toMatchObject({ id: "sub-1" })
|
|
164
|
+
})
|
|
165
|
+
})
|
package/src/server/session.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* (2) テストバックドア (mock_user cookie) をハードコードから `mockUser` ポート注入に変えた、
|
|
9
9
|
* の 2 点のみ。cookie 形状 (`__session`/httpOnly/lax/30d、キー "user") は byte 互換。
|
|
10
10
|
*/
|
|
11
|
-
import type { SessionProfile } from "@aiquants/auth-core"
|
|
11
|
+
import type { GoogleProfileBase, SessionProfile } from "@aiquants/auth-core"
|
|
12
12
|
import { createCookieSessionStorage, redirect } from "react-router"
|
|
13
13
|
import type { MockUserPort, SessionConfig } from "./ports"
|
|
14
14
|
|
|
@@ -21,7 +21,7 @@ export type AuthSessionStorage = ReturnType<typeof createAuthSessionStorage>
|
|
|
21
21
|
*
|
|
22
22
|
* @throws secrets が空の場合 (fail-closed の設定ガード)。
|
|
23
23
|
*/
|
|
24
|
-
export const createAuthSessionStorage = (config: SessionConfig, options?: { mockUser?: MockUserPort; loginPath?: string; postLoginRedirect?: string }) => {
|
|
24
|
+
export const createAuthSessionStorage = (config: SessionConfig, options?: { mockUser?: MockUserPort; loginPath?: string; postLoginRedirect?: string; isUserActive?: (profile: GoogleProfileBase) => boolean | Promise<boolean> }) => {
|
|
25
25
|
// ファクトリ実行時検証 (import 時副作用の排除)
|
|
26
26
|
if (!config.secrets || config.secrets.length === 0 || config.secrets.some((s) => !s)) {
|
|
27
27
|
throw new Error("REMIX_SESSION_SECRET must be set")
|
|
@@ -32,6 +32,7 @@ export const createAuthSessionStorage = (config: SessionConfig, options?: { mock
|
|
|
32
32
|
const loginPath = options?.loginPath ?? "/auth/login"
|
|
33
33
|
const postLoginRedirect = options?.postLoginRedirect ?? "/"
|
|
34
34
|
const mockUser = options?.mockUser
|
|
35
|
+
const isUserActive = options?.isUserActive
|
|
35
36
|
|
|
36
37
|
const sessionStorage = createCookieSessionStorage({
|
|
37
38
|
cookie: {
|
|
@@ -65,6 +66,10 @@ export const createAuthSessionStorage = (config: SessionConfig, options?: { mock
|
|
|
65
66
|
|
|
66
67
|
const session = await getSession(request)
|
|
67
68
|
const user = session.get(sessionKey) as SessionProfile | undefined
|
|
69
|
+
if (user === undefined) return undefined
|
|
70
|
+
// 無効化済みアカウントは「未認証」として扱う。これにより、既に発行済みのセッション Cookie を
|
|
71
|
+
// 持っていてもガードは通らず、無効化がその場で全ルートへ波及する。
|
|
72
|
+
if (isUserActive && !(await isUserActive(user))) return undefined
|
|
68
73
|
return user
|
|
69
74
|
}
|
|
70
75
|
|