@pmate/account-sdk 0.6.0 → 0.6.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/package.json +29 -15
- package/src/atoms/appConfigAtom.ts +24 -0
- package/src/atoms/index.ts +1 -0
- package/src/components/AuthProviderV2.tsx +7 -22
- package/src/hooks/useAppConfig.ts +13 -6
- package/src/hooks/useProfileStepFlow.ts +28 -9
- package/src/node/index.ts +271 -0
- package/src/utils/AccountManagerV2.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmate/account-sdk",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -8,38 +8,52 @@
|
|
|
8
8
|
"!src/tests/**"
|
|
9
9
|
],
|
|
10
10
|
"exports": {
|
|
11
|
-
".": "./src/index.ts"
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./node": "./src/node/index.ts"
|
|
12
13
|
},
|
|
13
14
|
"publishConfig": {
|
|
14
15
|
"access": "public"
|
|
15
16
|
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"npm:publish": "npm publish --registry https://registry.npmjs.org/",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest"
|
|
21
|
+
},
|
|
16
22
|
"peerDependencies": {
|
|
17
23
|
"jotai": "*",
|
|
18
24
|
"jotai-family": "*",
|
|
19
25
|
"react": "*"
|
|
20
26
|
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"jotai": {
|
|
29
|
+
"optional": true
|
|
30
|
+
},
|
|
31
|
+
"jotai-family": {
|
|
32
|
+
"optional": true
|
|
33
|
+
},
|
|
34
|
+
"react": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
21
38
|
"dependencies": {
|
|
39
|
+
"@pmate/lang": "^1.0.2",
|
|
40
|
+
"@pmate/meta": "^1.1.3",
|
|
41
|
+
"@pmate/service-core": "^1.0.0",
|
|
42
|
+
"@pmate/utils": "^1.0.3",
|
|
22
43
|
"browser-image-compression": "^2.0.2",
|
|
44
|
+
"elysia": "^1.4.19",
|
|
23
45
|
"i18next": "^23.0.0",
|
|
24
46
|
"react-i18next": "^13.0.0",
|
|
25
|
-
"react-use": "^17.6.0"
|
|
26
|
-
"@pmate/lang": "1.0.2",
|
|
27
|
-
"@pmate/utils": "1.0.3",
|
|
28
|
-
"@pmate/meta": "1.1.3"
|
|
47
|
+
"react-use": "^17.6.0"
|
|
29
48
|
},
|
|
30
49
|
"devDependencies": {
|
|
31
|
-
"@types/react": "
|
|
32
|
-
"@types/react-dom": "
|
|
50
|
+
"@types/react": "catalog:",
|
|
51
|
+
"@types/react-dom": "catalog:",
|
|
33
52
|
"@vitejs/plugin-react": "^4.2.1",
|
|
34
53
|
"jsdom": "^26.1.0",
|
|
35
54
|
"react-dom": "*",
|
|
36
55
|
"typescript": "^5.2.2",
|
|
37
|
-
"vite": "
|
|
56
|
+
"vite": "catalog:",
|
|
38
57
|
"vitest": "^4.0.17"
|
|
39
|
-
},
|
|
40
|
-
"scripts": {
|
|
41
|
-
"npm:publish": "npm publish --registry https://registry.npmjs.org/",
|
|
42
|
-
"test": "vitest run",
|
|
43
|
-
"test:watch": "vitest"
|
|
44
58
|
}
|
|
45
|
-
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { atom, type Atom } from "jotai"
|
|
2
|
+
import { atomFamily, type AtomFamily } from "jotai-family"
|
|
3
|
+
import { DEFAULT_APP_ID } from "../constants"
|
|
4
|
+
import { AppService } from "../api/AppService"
|
|
5
|
+
import type { AppConfig } from "../types/app"
|
|
6
|
+
import { resolveAppId } from "../utils/resolveAppId"
|
|
7
|
+
|
|
8
|
+
export const resolveAppConfigId = (app?: string | null) => {
|
|
9
|
+
return resolveAppId(app ?? DEFAULT_APP_ID)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type AppConfigAtomFamily = AtomFamily<string, Atom<Promise<AppConfig>>>
|
|
13
|
+
|
|
14
|
+
export const appConfigAtom: AppConfigAtomFamily = atomFamily((appId: string) =>
|
|
15
|
+
atom(async () => {
|
|
16
|
+
return AppService.getAppConfig(appId)
|
|
17
|
+
})
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
export const clearAppConfigAtoms = () => {
|
|
21
|
+
for (const appId of appConfigAtom.getParams()) {
|
|
22
|
+
appConfigAtom.remove(appId)
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/atoms/index.ts
CHANGED
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
PropsWithChildren,
|
|
7
7
|
useCallback,
|
|
8
8
|
useEffect,
|
|
9
|
-
useMemo,
|
|
10
9
|
useState,
|
|
11
10
|
} from "react"
|
|
12
11
|
import { useAuthSnapshot } from "../hooks/useAuthSnapshot"
|
|
@@ -61,8 +60,8 @@ const getAuthBehaviors = (
|
|
|
61
60
|
})
|
|
62
61
|
const authBehavior =
|
|
63
62
|
matchedAuthRoute && typeof matchedAuthRoute !== "string"
|
|
64
|
-
? (matchedAuthRoute.behavior ?? "
|
|
65
|
-
: "
|
|
63
|
+
? (matchedAuthRoute.behavior ?? "redirect")
|
|
64
|
+
: "redirect"
|
|
66
65
|
const requiresAuth = Boolean(matchedAuthRoute)
|
|
67
66
|
return {
|
|
68
67
|
authBehavior,
|
|
@@ -76,7 +75,6 @@ type AuthProviderV2Props = PropsWithChildren<{
|
|
|
76
75
|
onLoginSuccess?: () => void | Promise<void>
|
|
77
76
|
rtcProvider?: React.ComponentType<{ children: React.ReactNode }>
|
|
78
77
|
pathname?: string
|
|
79
|
-
navigate?: (to: string, options?: { replace?: boolean }) => void
|
|
80
78
|
}>
|
|
81
79
|
|
|
82
80
|
const useWindowPathname = () => {
|
|
@@ -94,22 +92,9 @@ export const AuthProviderV2 = ({
|
|
|
94
92
|
authRoutes,
|
|
95
93
|
rtcProvider: RtcProvider,
|
|
96
94
|
pathname: pathnameProp,
|
|
97
|
-
navigate: navigateProp,
|
|
98
95
|
children,
|
|
99
96
|
}: AuthProviderV2Props) => {
|
|
100
97
|
const pathname = pathnameProp ?? useWindowPathname()
|
|
101
|
-
const navigate = useMemo(() => {
|
|
102
|
-
if (navigateProp) {
|
|
103
|
-
return navigateProp
|
|
104
|
-
}
|
|
105
|
-
return (to: string, options?: { replace?: boolean }) => {
|
|
106
|
-
if (options?.replace) {
|
|
107
|
-
window.location.replace(to)
|
|
108
|
-
return
|
|
109
|
-
}
|
|
110
|
-
window.location.assign(to)
|
|
111
|
-
}
|
|
112
|
-
}, [navigateProp])
|
|
113
98
|
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false)
|
|
114
99
|
const [isLoginErrorDismissed, setIsLoginErrorDismissed] = useState(false)
|
|
115
100
|
const setAccountSnapshot = useSetAtom(accountAtom)
|
|
@@ -251,7 +236,7 @@ export const AuthProviderV2 = ({
|
|
|
251
236
|
}
|
|
252
237
|
const RtcWrapper = RtcProvider ?? (({ children }) => <>{children}</>)
|
|
253
238
|
return (
|
|
254
|
-
<AuthErrorBoundary
|
|
239
|
+
<AuthErrorBoundary app={app}>
|
|
255
240
|
<RtcWrapper>{children}</RtcWrapper>
|
|
256
241
|
</AuthErrorBoundary>
|
|
257
242
|
)
|
|
@@ -297,15 +282,15 @@ class AuthErrorBoundaryInner extends Component<
|
|
|
297
282
|
|
|
298
283
|
const AuthErrorBoundary = ({
|
|
299
284
|
children,
|
|
300
|
-
|
|
285
|
+
app,
|
|
301
286
|
}: PropsWithChildren<{
|
|
302
|
-
|
|
287
|
+
app: string
|
|
303
288
|
}>) => {
|
|
304
289
|
const logout = useSetAtom(userLogoutAtom)
|
|
305
290
|
const handleAuthError = useCallback(async () => {
|
|
306
291
|
await logout()
|
|
307
|
-
|
|
308
|
-
}, [
|
|
292
|
+
Redirect.toLogin(app)
|
|
293
|
+
}, [app, logout])
|
|
309
294
|
|
|
310
295
|
return (
|
|
311
296
|
<AuthErrorBoundaryInner onAuthError={handleAuthError}>
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from "react"
|
|
2
|
+
import { resolveAppConfigId } from "../atoms/appConfigAtom"
|
|
2
3
|
import { AppService } from "../api/AppService"
|
|
3
|
-
import { DEFAULT_APP_ID } from "../constants"
|
|
4
4
|
import type { AppConfig } from "../types/app"
|
|
5
|
-
import { resolveAppId } from "../utils/resolveAppId"
|
|
6
5
|
|
|
7
6
|
type AppConfigState = {
|
|
8
7
|
appConfig: AppConfig | null
|
|
@@ -11,7 +10,7 @@ type AppConfigState = {
|
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
export const useAppConfig = (app?: string | null): AppConfigState => {
|
|
14
|
-
const resolvedApp =
|
|
13
|
+
const resolvedApp = resolveAppConfigId(app)
|
|
15
14
|
const [state, setState] = useState<AppConfigState>({
|
|
16
15
|
appConfig: null,
|
|
17
16
|
isLoading: true,
|
|
@@ -21,17 +20,25 @@ export const useAppConfig = (app?: string | null): AppConfigState => {
|
|
|
21
20
|
useEffect(() => {
|
|
22
21
|
let active = true
|
|
23
22
|
setState({ appConfig: null, isLoading: true, error: null })
|
|
23
|
+
|
|
24
24
|
AppService.getAppConfig(resolvedApp)
|
|
25
25
|
.then((appConfig) => {
|
|
26
|
-
if (!active)
|
|
26
|
+
if (!active) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
27
29
|
setState({ appConfig, isLoading: false, error: null })
|
|
28
30
|
})
|
|
29
31
|
.catch((error: unknown) => {
|
|
30
|
-
if (!active)
|
|
32
|
+
if (!active) {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
31
35
|
setState({
|
|
32
36
|
appConfig: null,
|
|
33
37
|
isLoading: false,
|
|
34
|
-
error:
|
|
38
|
+
error:
|
|
39
|
+
error instanceof Error
|
|
40
|
+
? error
|
|
41
|
+
: new Error("Failed to load app config"),
|
|
35
42
|
})
|
|
36
43
|
})
|
|
37
44
|
|
|
@@ -2,8 +2,6 @@ import { useCallback, useMemo } from "react"
|
|
|
2
2
|
import { isProfileStepType, ProfileStepType } from "../utils/profileStep"
|
|
3
3
|
import { useAppConfig } from "./useAppConfig"
|
|
4
4
|
|
|
5
|
-
const DEFAULT_CREATE_STEP: ProfileStepType = "learning-language"
|
|
6
|
-
|
|
7
5
|
type UseProfileStepFlowParams = {
|
|
8
6
|
params: URLSearchParams
|
|
9
7
|
}
|
|
@@ -13,7 +11,13 @@ export const useProfileStepFlow = ({
|
|
|
13
11
|
}: UseProfileStepFlowParams) => {
|
|
14
12
|
const appParam = params.get("app")
|
|
15
13
|
const redirectParam = params.get("redirect")
|
|
16
|
-
const { appConfig } = useAppConfig(appParam)
|
|
14
|
+
const { appConfig, error, isLoading } = useAppConfig(appParam)
|
|
15
|
+
const shouldBlockStep = Boolean(appParam) && isLoading && !appConfig && !error
|
|
16
|
+
|
|
17
|
+
// The app registry decides the canonical order of profile steps.
|
|
18
|
+
// When the config is still loading for an explicit app, we intentionally
|
|
19
|
+
// block the create flow instead of briefly falling back to a synthetic
|
|
20
|
+
// default step that does not exist in the app schema.
|
|
17
21
|
const appProfileSteps = useMemo<ProfileStepType[]>(
|
|
18
22
|
() =>
|
|
19
23
|
(appConfig?.profiles ?? [])
|
|
@@ -24,15 +28,27 @@ export const useProfileStepFlow = ({
|
|
|
24
28
|
const stepParam = params.get("step")
|
|
25
29
|
const normalizedStep: ProfileStepType | null =
|
|
26
30
|
appProfileSteps.find((item) => item === stepParam) ?? null
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
|
|
32
|
+
// step=... wins when it is valid for the current app.
|
|
33
|
+
// Otherwise we use the first configured step. If the app does not declare
|
|
34
|
+
// any profile schema, create-profile should stay unavailable instead of
|
|
35
|
+
// inventing a fallback step.
|
|
36
|
+
const defaultStep: ProfileStepType | null = shouldBlockStep
|
|
37
|
+
? null
|
|
38
|
+
: (appProfileSteps[0] ?? null)
|
|
39
|
+
const activeStep: ProfileStepType | null = normalizedStep ?? defaultStep
|
|
40
|
+
|
|
41
|
+
// createSteps stays empty while we intentionally block rendering.
|
|
42
|
+
// Once app config is ready, it mirrors the registry schema order exactly.
|
|
29
43
|
const createSteps = useMemo<ProfileStepType[]>(() => {
|
|
30
|
-
return
|
|
31
|
-
}, [appProfileSteps])
|
|
32
|
-
const currentStepIndex =
|
|
44
|
+
return shouldBlockStep ? [] : appProfileSteps
|
|
45
|
+
}, [appProfileSteps, shouldBlockStep])
|
|
46
|
+
const currentStepIndex =
|
|
47
|
+
activeStep === null ? -1 : createSteps.indexOf(activeStep)
|
|
33
48
|
const nextStep =
|
|
34
49
|
currentStepIndex >= 0 ? createSteps[currentStepIndex + 1] : undefined
|
|
35
|
-
const isCreateFlowStep =
|
|
50
|
+
const isCreateFlowStep =
|
|
51
|
+
activeStep === null ? false : createSteps.includes(activeStep)
|
|
36
52
|
const buildStepUrl = useCallback(
|
|
37
53
|
(next: ProfileStepType) => {
|
|
38
54
|
const search = new URLSearchParams()
|
|
@@ -53,7 +69,10 @@ export const useProfileStepFlow = ({
|
|
|
53
69
|
appProfileSteps,
|
|
54
70
|
buildStepUrl,
|
|
55
71
|
createSteps,
|
|
72
|
+
error,
|
|
73
|
+
isLoading,
|
|
56
74
|
isCreateFlowStep,
|
|
75
|
+
isReady: activeStep !== null,
|
|
57
76
|
nextStep,
|
|
58
77
|
}
|
|
59
78
|
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccountViewResult,
|
|
3
|
+
AuthSession,
|
|
4
|
+
AuthzCheckResult,
|
|
5
|
+
RoleBinding,
|
|
6
|
+
ServerResponse,
|
|
7
|
+
} from "@pmate/meta"
|
|
8
|
+
import { BizErrorCode } from "@pmate/meta"
|
|
9
|
+
import { ServiceError } from "@pmate/service-core"
|
|
10
|
+
import { Elysia } from "elysia"
|
|
11
|
+
|
|
12
|
+
export type AuthenticatedAccount = {
|
|
13
|
+
token: string
|
|
14
|
+
accountId: string
|
|
15
|
+
session: AuthSession
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type AuthClientLike = {
|
|
19
|
+
authenticateRequest(request: Request): Promise<AuthenticatedAccount>
|
|
20
|
+
checkAuthorization(
|
|
21
|
+
request: Request,
|
|
22
|
+
input: { account?: string; namespace: string; roles: string[] }
|
|
23
|
+
): Promise<AuthzCheckResult>
|
|
24
|
+
getAccountView(
|
|
25
|
+
request: Request,
|
|
26
|
+
query: { account?: string; mobile?: string; nickName?: string }
|
|
27
|
+
): Promise<AccountViewResult>
|
|
28
|
+
listRoleBindings(
|
|
29
|
+
request: Request,
|
|
30
|
+
query: { account?: string; namespace?: string }
|
|
31
|
+
): Promise<RoleBinding[]>
|
|
32
|
+
createRoleBinding(
|
|
33
|
+
request: Request,
|
|
34
|
+
input: { account: string; namespace: string; role: string; source?: string }
|
|
35
|
+
): Promise<RoleBinding>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class AuthClient implements AuthClientLike {
|
|
39
|
+
private readonly authApiBaseUrl: string
|
|
40
|
+
private readonly fetchImpl: typeof fetch
|
|
41
|
+
|
|
42
|
+
constructor(options: { authApiBaseUrl?: string; fetchImpl?: typeof fetch } = {}) {
|
|
43
|
+
this.authApiBaseUrl = (
|
|
44
|
+
options.authApiBaseUrl ||
|
|
45
|
+
process.env.AUTH_API_BASE_URL ||
|
|
46
|
+
"https://auth-api-v2.pmate.chat"
|
|
47
|
+
).replace(/\/+$/, "")
|
|
48
|
+
this.fetchImpl = options.fetchImpl ?? fetch
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async authenticateRequest(request: Request): Promise<AuthenticatedAccount> {
|
|
52
|
+
const token = extractBearerToken(request.headers.get("authorization"))
|
|
53
|
+
const session = await authRequest<AuthSession | null>(
|
|
54
|
+
this.fetchImpl,
|
|
55
|
+
this.authApiBaseUrl,
|
|
56
|
+
"/session",
|
|
57
|
+
{
|
|
58
|
+
method: "GET",
|
|
59
|
+
token,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
if (!session?.identity?.accountId) {
|
|
63
|
+
throw new ServiceError("Unauthorized", 401, BizErrorCode.AUTH_ERROR)
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
token,
|
|
67
|
+
accountId: session.identity.accountId,
|
|
68
|
+
session,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async checkAuthorization(
|
|
73
|
+
request: Request,
|
|
74
|
+
input: { account?: string; namespace: string; roles: string[] }
|
|
75
|
+
) {
|
|
76
|
+
const auth = await this.authenticateRequest(request)
|
|
77
|
+
return authRequest<AuthzCheckResult>(
|
|
78
|
+
this.fetchImpl,
|
|
79
|
+
this.authApiBaseUrl,
|
|
80
|
+
"/authz/check",
|
|
81
|
+
{
|
|
82
|
+
method: "POST",
|
|
83
|
+
token: auth.token,
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
account: input.account ?? auth.accountId,
|
|
86
|
+
namespace: input.namespace,
|
|
87
|
+
roles: input.roles,
|
|
88
|
+
}),
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getAccountView(
|
|
94
|
+
request: Request,
|
|
95
|
+
query: { account?: string; mobile?: string; nickName?: string }
|
|
96
|
+
) {
|
|
97
|
+
const auth = await this.authenticateRequest(request)
|
|
98
|
+
const url = new URL(`${this.authApiBaseUrl}/accounts/view`)
|
|
99
|
+
if (query.account) url.searchParams.set("account", query.account)
|
|
100
|
+
if (query.mobile) url.searchParams.set("mobile", query.mobile)
|
|
101
|
+
if (query.nickName) url.searchParams.set("nickName", query.nickName)
|
|
102
|
+
return authRequest<AccountViewResult>(this.fetchImpl, this.authApiBaseUrl, url, {
|
|
103
|
+
method: "GET",
|
|
104
|
+
token: auth.token,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async listRoleBindings(
|
|
109
|
+
request: Request,
|
|
110
|
+
query: { account?: string; namespace?: string }
|
|
111
|
+
) {
|
|
112
|
+
const auth = await this.authenticateRequest(request)
|
|
113
|
+
const url = new URL(`${this.authApiBaseUrl}/role/bindings`)
|
|
114
|
+
if (query.account) url.searchParams.set("account", query.account)
|
|
115
|
+
if (query.namespace) url.searchParams.set("namespace", query.namespace)
|
|
116
|
+
return authRequest<RoleBinding[]>(this.fetchImpl, this.authApiBaseUrl, url, {
|
|
117
|
+
method: "GET",
|
|
118
|
+
token: auth.token,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async createRoleBinding(
|
|
123
|
+
request: Request,
|
|
124
|
+
input: { account: string; namespace: string; role: string; source?: string }
|
|
125
|
+
) {
|
|
126
|
+
const auth = await this.authenticateRequest(request)
|
|
127
|
+
return authRequest<RoleBinding>(
|
|
128
|
+
this.fetchImpl,
|
|
129
|
+
this.authApiBaseUrl,
|
|
130
|
+
"/role/bindings",
|
|
131
|
+
{
|
|
132
|
+
method: "POST",
|
|
133
|
+
token: auth.token,
|
|
134
|
+
body: JSON.stringify(input),
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createAccountAuthPlugin(options: {
|
|
141
|
+
authApiBaseUrl?: string
|
|
142
|
+
fetchImpl?: typeof fetch
|
|
143
|
+
} = {}) {
|
|
144
|
+
const client = new AuthClient(options)
|
|
145
|
+
return new Elysia({ name: "pmate-account-auth" }).decorate(
|
|
146
|
+
"accountAuthClient",
|
|
147
|
+
client
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function requireAuth(
|
|
152
|
+
client: AuthClientLike,
|
|
153
|
+
options: {
|
|
154
|
+
namespace?:
|
|
155
|
+
| string
|
|
156
|
+
| ((context: {
|
|
157
|
+
request: Request
|
|
158
|
+
params?: Record<string, string | undefined>
|
|
159
|
+
query?: Record<string, unknown>
|
|
160
|
+
body?: unknown
|
|
161
|
+
}) => string | Promise<string>)
|
|
162
|
+
roles?: string[]
|
|
163
|
+
} = {}
|
|
164
|
+
) {
|
|
165
|
+
return {
|
|
166
|
+
beforeHandle: async (context: {
|
|
167
|
+
request: Request
|
|
168
|
+
params?: Record<string, string | undefined>
|
|
169
|
+
query?: Record<string, unknown>
|
|
170
|
+
body?: unknown
|
|
171
|
+
}) => {
|
|
172
|
+
if (context.request.method.toUpperCase() === "OPTIONS") {
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const auth = await client.authenticateRequest(context.request)
|
|
177
|
+
let namespace: string | undefined
|
|
178
|
+
let matchedRole: string | undefined
|
|
179
|
+
if (options.roles?.length) {
|
|
180
|
+
namespace =
|
|
181
|
+
typeof options.namespace === "function"
|
|
182
|
+
? await options.namespace(context)
|
|
183
|
+
: options.namespace
|
|
184
|
+
if (!namespace) {
|
|
185
|
+
throw new ServiceError(
|
|
186
|
+
"Namespace is required for role authorization",
|
|
187
|
+
500,
|
|
188
|
+
BizErrorCode.AUTH_ERROR
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
const result = await client.checkAuthorization(context.request, {
|
|
192
|
+
account: auth.accountId,
|
|
193
|
+
namespace,
|
|
194
|
+
roles: options.roles,
|
|
195
|
+
})
|
|
196
|
+
if (!result.allowed) {
|
|
197
|
+
throw new ServiceError("Forbidden", 403, BizErrorCode.AUTH_ERROR)
|
|
198
|
+
}
|
|
199
|
+
matchedRole = result.matchedRole
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const authContext = {
|
|
203
|
+
...auth,
|
|
204
|
+
...(namespace ? { namespace } : {}),
|
|
205
|
+
...(matchedRole ? { matchedRole } : {}),
|
|
206
|
+
}
|
|
207
|
+
;(context as Record<string, unknown>).auth = authContext
|
|
208
|
+
;(context.request as Request & { auth?: typeof authContext }).auth =
|
|
209
|
+
authContext
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
type AuthRequestInit = RequestInit & { token?: string }
|
|
215
|
+
|
|
216
|
+
async function authRequest<T>(
|
|
217
|
+
fetchImpl: typeof fetch,
|
|
218
|
+
authApiBaseUrl: string,
|
|
219
|
+
path: string | URL,
|
|
220
|
+
init: AuthRequestInit
|
|
221
|
+
): Promise<T> {
|
|
222
|
+
const { token, headers, ...rest } = init
|
|
223
|
+
const url =
|
|
224
|
+
path instanceof URL
|
|
225
|
+
? path.toString()
|
|
226
|
+
: `${authApiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`
|
|
227
|
+
const response = await fetchImpl(url, {
|
|
228
|
+
...rest,
|
|
229
|
+
headers: {
|
|
230
|
+
"Content-Type": "application/json",
|
|
231
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
232
|
+
...(headers ?? {}),
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
let json: ServerResponse<T> | null = null
|
|
237
|
+
try {
|
|
238
|
+
json = (await response.json()) as ServerResponse<T>
|
|
239
|
+
} catch {
|
|
240
|
+
json = null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!response.ok || !json?.success) {
|
|
244
|
+
throw new ServiceError(
|
|
245
|
+
json?.message || response.statusText || "Request failed",
|
|
246
|
+
response.status || 500,
|
|
247
|
+
BizErrorCode.AUTH_ERROR
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return json.data
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function extractBearerToken(header: string | null) {
|
|
255
|
+
if (!header) {
|
|
256
|
+
throw new ServiceError(
|
|
257
|
+
"Missing authorization header",
|
|
258
|
+
401,
|
|
259
|
+
BizErrorCode.AUTH_ERROR
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
const match = header.match(/^Bearer\s+(.+)$/i)
|
|
263
|
+
if (!match) {
|
|
264
|
+
throw new ServiceError(
|
|
265
|
+
"Invalid authorization header",
|
|
266
|
+
401,
|
|
267
|
+
BizErrorCode.AUTH_ERROR
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
return match[1].trim()
|
|
271
|
+
}
|
|
@@ -216,6 +216,9 @@ export class AccountManagerV2 extends EmitterV2<AccountManagerEventMap> {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
public clearSelectedProfile(): void {
|
|
219
|
+
if (!getSelectedProfileId(this.app)) {
|
|
220
|
+
return
|
|
221
|
+
}
|
|
219
222
|
clearSelectedProfileId(this.app)
|
|
220
223
|
this.emit(AccountManagerEvent.StateChange)
|
|
221
224
|
}
|