@zvonimirsun/iszy-nuxt-auth-layer 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ZvonimirSun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # ISZY Nuxt 认证 Layer
2
+
3
+ 用于 ISZY Nuxt 应用的共享认证 layer,抽取前端 BFF、Redis session、登录登出、认证检查和 OAuth 回调等通用能力。
4
+
5
+ ## 功能
6
+
7
+ - 基于 Redis 的 httpOnly session cookie。
8
+ - session 轮换与短 TTL tombstone,降低并发刷新 token 时的竞态影响。
9
+ - 后端 API 代理工具,自动注入 access token,并在 401 时尝试 refresh token。
10
+ - 本地登录、登出、登录态检查和 OAuth 回调路由。
11
+ - 通用用户 store、公开用户类型和用户信息裁剪工具。
12
+
13
+ ## 使用方式
14
+
15
+ 安装依赖:
16
+
17
+ ```bash
18
+ pnpm add @zvonimirsun/iszy-nuxt-auth-layer
19
+ ```
20
+
21
+ 在 Nuxt 应用中继承该 layer:
22
+
23
+ ```ts
24
+ export default defineNuxtConfig({
25
+ extends: ['@zvonimirsun/iszy-nuxt-auth-layer'],
26
+ })
27
+ ```
28
+
29
+ 消费方应用需要按部署环境配置 `runtimeConfig.public.apiOrigin`、Redis 连接信息和 session cookie 选项。
30
+
31
+ ## 运行时配置
32
+
33
+ ```ts
34
+ export default defineNuxtConfig({
35
+ runtimeConfig: {
36
+ public: {
37
+ apiOrigin: '',
38
+ title: '',
39
+ features: {
40
+ publicRegister: false,
41
+ },
42
+ },
43
+ redis: {
44
+ host: '',
45
+ port: 6379,
46
+ password: undefined,
47
+ },
48
+ session: {
49
+ cookieName: 'NUXT_SESSION_ID',
50
+ maxAge: '7d',
51
+ domain: '',
52
+ },
53
+ },
54
+ })
55
+ ```
56
+
57
+ ## 应用差异
58
+
59
+ 该 layer 只提供通用认证链路,不接管具体应用的业务权限:
60
+
61
+ - `iszy-admin` 仍应在消费方保留 admin/superadmin 鉴权和 403 页面语义。
62
+ - `iszy-tools-next` 仍应在消费方保留工具权限、工具目录刷新和设置页展示逻辑。
63
+ - authentik 接入会在后续版本扩展,本首版只保留现有本地登录与 OAuth 基础能力。
@@ -0,0 +1,200 @@
1
+ import type { Device, ResultDto } from '@zvonimirsun/iszy-common'
2
+ import type { PublicSimpleUser } from '#shared/types/auth'
3
+ import { acceptHMRUpdate, defineStore } from 'pinia'
4
+
5
+ interface LoginAttemptFailureData {
6
+ code: 'LOGIN_FAILED'
7
+ failedCount: number
8
+ remainingAttempts: number
9
+ maxAttempts: number
10
+ windowSeconds: number
11
+ }
12
+
13
+ interface LoginBanFailureData {
14
+ code: 'LOGIN_BANNED'
15
+ retryAfterSeconds: number
16
+ bannedUntil: string
17
+ }
18
+
19
+ type LoginFailureData = LoginAttemptFailureData | LoginBanFailureData
20
+ type LoginResultData = PublicSimpleUser | LoginFailureData
21
+
22
+ type ProfileFetcher = <T>(request: string, opts?: {
23
+ signal?: AbortSignal
24
+ }) => Promise<T>
25
+
26
+ const appFetch = $fetch as unknown as <T>(request: string, opts?: Record<string, unknown>) => Promise<T>
27
+
28
+ export const useUserStore = defineStore('user', () => {
29
+ const profilePulled = ref(false)
30
+ const profile = ref<PublicSimpleUser>()
31
+ const logged = computed(() => !!profile.value)
32
+
33
+ let pullProfilePromise: Promise<boolean> | null = null
34
+ let pullProfileAbortController: AbortController | null = null
35
+
36
+ async function login(payload: {
37
+ userName: string
38
+ password: string
39
+ }) {
40
+ const { userName, password } = payload
41
+ if (!userName || !password) {
42
+ throw new Error('请输入用户名和密码')
43
+ }
44
+
45
+ try {
46
+ const res = await appFetch<ResultDto<LoginResultData>>('/api/auth/login', {
47
+ method: 'post',
48
+ body: {
49
+ userName: userName.trim(),
50
+ password,
51
+ },
52
+ })
53
+
54
+ if (res.success) {
55
+ profilePulled.value = true
56
+ updateProfile(res.data as PublicSimpleUser)
57
+ return
58
+ }
59
+
60
+ removeProfile()
61
+ throw new Error(formatLoginFailureMessage(res.message, res.data))
62
+ }
63
+ catch (error) {
64
+ removeProfile()
65
+ throw error
66
+ }
67
+ }
68
+
69
+ function formatLoginFailureMessage(message: string, data?: LoginResultData) {
70
+ const fallbackMessage = message || '登录失败'
71
+ if (!data || !('code' in data)) {
72
+ return fallbackMessage
73
+ }
74
+
75
+ if (data.code === 'LOGIN_BANNED') {
76
+ return '登录失败次数过多,请稍后再试。'
77
+ }
78
+
79
+ return fallbackMessage
80
+ }
81
+
82
+ async function logout() {
83
+ const res = await appFetch<ResultDto<void>>('/api/auth/logout', {
84
+ method: 'POST',
85
+ })
86
+
87
+ if (res?.success) {
88
+ removeProfile()
89
+ return
90
+ }
91
+
92
+ throw new Error(res?.message || '登出失败')
93
+ }
94
+
95
+ async function pullProfile(force = false, fetcher: ProfileFetcher = appFetch) {
96
+ if (profilePulled.value && !force) {
97
+ return logged.value
98
+ }
99
+
100
+ if (force) {
101
+ pullProfileAbortController?.abort()
102
+ pullProfileAbortController = null
103
+ pullProfilePromise = null
104
+ }
105
+
106
+ if (pullProfilePromise && !force) {
107
+ return pullProfilePromise
108
+ }
109
+
110
+ pullProfileAbortController = new AbortController()
111
+ pullProfilePromise = (async () => {
112
+ try {
113
+ const res = await fetcher<ResultDto<{
114
+ logged: boolean
115
+ profile?: PublicSimpleUser
116
+ }>>('/api/auth/check', {
117
+ signal: pullProfileAbortController!.signal,
118
+ })
119
+
120
+ if (res?.success && res.data?.logged) {
121
+ updateProfile(res.data.profile)
122
+ profilePulled.value = true
123
+ return true
124
+ }
125
+
126
+ removeProfile()
127
+ profilePulled.value = true
128
+ return false
129
+ }
130
+ catch (error) {
131
+ if ((error as Error).name !== 'AbortError') {
132
+ removeProfile()
133
+ profilePulled.value = true
134
+ }
135
+ throw error
136
+ }
137
+ finally {
138
+ pullProfileAbortController = null
139
+ pullProfilePromise = null
140
+ }
141
+ })()
142
+
143
+ return pullProfilePromise
144
+ }
145
+
146
+ function updateProfile(data?: PublicSimpleUser) {
147
+ profile.value = data
148
+ }
149
+
150
+ function removeProfile() {
151
+ updateProfile(undefined)
152
+ }
153
+
154
+ async function thirdPartyUnbind(type: string) {
155
+ await appFetch<ResultDto<void>>('/api/oauth/unbind', {
156
+ method: 'POST',
157
+ body: {
158
+ provider: type,
159
+ },
160
+ })
161
+ await pullProfile(true)
162
+ }
163
+
164
+ async function getDevices(): Promise<Device[]> {
165
+ const res = await appFetch<ResultDto<Device[]>>('/api/auth/devices')
166
+ return res.data || []
167
+ }
168
+
169
+ async function removeDevice({ deviceId, other }: {
170
+ deviceId?: string
171
+ all?: boolean
172
+ other?: boolean
173
+ }) {
174
+ await appFetch('/api/auth/logout', {
175
+ method: 'POST',
176
+ query: {
177
+ deviceId,
178
+ other,
179
+ },
180
+ })
181
+ }
182
+
183
+ return {
184
+ profilePulled,
185
+ profile,
186
+ logged,
187
+ login,
188
+ logout,
189
+ pullProfile,
190
+ updateProfile,
191
+ removeProfile,
192
+ thirdPartyUnbind,
193
+ getDevices,
194
+ removeDevice,
195
+ }
196
+ })
197
+
198
+ if (import.meta.hot) {
199
+ import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot))
200
+ }
package/nuxt.config.ts ADDED
@@ -0,0 +1,21 @@
1
+ export default defineNuxtConfig({
2
+ runtimeConfig: {
3
+ public: {
4
+ title: '',
5
+ apiOrigin: '',
6
+ features: {
7
+ publicRegister: false,
8
+ },
9
+ },
10
+ redis: {
11
+ host: '',
12
+ port: 6379,
13
+ password: undefined,
14
+ },
15
+ session: {
16
+ cookieName: 'NUXT_SESSION_ID',
17
+ maxAge: '7d',
18
+ domain: '',
19
+ },
20
+ },
21
+ })
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@zvonimirsun/iszy-nuxt-auth-layer",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "private": false,
6
+ "description": "ISZY Nuxt 应用共享认证 layer。",
7
+ "author": "ZvonimirSun",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+ssh://git@github.com/ZvonimirSun/iszy-nuxt-auth-layer.git"
12
+ },
13
+ "homepage": "https://github.com/ZvonimirSun/iszy-nuxt-auth-layer#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/ZvonimirSun/iszy-nuxt-auth-layer/issues"
16
+ },
17
+ "exports": {
18
+ ".": "./nuxt.config.ts"
19
+ },
20
+ "files": [
21
+ "app",
22
+ "server",
23
+ "shared",
24
+ "nuxt.config.ts",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "dependencies": {
29
+ "@zvonimirsun/iszy-common": "^1.2.0",
30
+ "ms": "^2.1.3"
31
+ },
32
+ "peerDependencies": {
33
+ "@pinia/nuxt": ">=0.11.0",
34
+ "nuxt": ">=4.0.0",
35
+ "pinia": ">=3.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@nuxt/eslint": "^1.15.2",
39
+ "@pinia/nuxt": "0.11.3",
40
+ "@types/ms": "^2.1.0",
41
+ "@types/node": "^25.9.1",
42
+ "nuxt": "^4.4.7",
43
+ "pinia": "^3.0.4",
44
+ "typescript": "^6.0.3",
45
+ "vue-tsc": "^3.3.3"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "postinstall": "nuxt prepare",
52
+ "typecheck": "nuxt typecheck"
53
+ }
54
+ }
@@ -0,0 +1,3 @@
1
+ export default defineEventHandler(async (event) => {
2
+ return proxyFetch(event)
3
+ })
@@ -0,0 +1,43 @@
1
+ import type { PublicUser, ResultDto } from '@zvonimirsun/iszy-common'
2
+ import type { PublicSimpleUser } from '#shared/types/auth'
3
+
4
+ export default defineEventHandler(async (event): Promise<ResultDto<{
5
+ logged: boolean
6
+ profile?: PublicSimpleUser
7
+ }>> => {
8
+ const session = await getRedisSession(event)
9
+ if (!session) {
10
+ return {
11
+ success: true,
12
+ data: {
13
+ logged: false,
14
+ },
15
+ message: '未登录',
16
+ }
17
+ }
18
+
19
+ try {
20
+ const res = await authFetch<ResultDto<PublicUser>>(event, '/user/me')
21
+ if (res.success && res.data) {
22
+ return {
23
+ success: true,
24
+ data: {
25
+ logged: true,
26
+ profile: toPublicSimpleUser(res.data),
27
+ },
28
+ message: '已登录',
29
+ }
30
+ }
31
+ }
32
+ catch {
33
+ // fall through to the unauthenticated response below
34
+ }
35
+
36
+ return {
37
+ success: true,
38
+ data: {
39
+ logged: false,
40
+ },
41
+ message: '未登录',
42
+ }
43
+ })
@@ -0,0 +1,62 @@
1
+ import type { PublicUser, ResultDto } from '@zvonimirsun/iszy-common'
2
+ import type { FetchError } from 'ofetch'
3
+ import type { PublicSimpleUser } from '#shared/types/auth'
4
+
5
+ interface LoginFailureData {
6
+ code: 'LOGIN_FAILED' | 'LOGIN_BANNED'
7
+ failedCount?: number
8
+ remainingAttempts?: number
9
+ maxAttempts?: number
10
+ windowSeconds?: number
11
+ retryAfterSeconds?: number
12
+ bannedUntil?: string
13
+ }
14
+
15
+ export default defineEventHandler(async (event): Promise<ResultDto<PublicSimpleUser | LoginFailureData>> => {
16
+ const body = await readBody<{
17
+ userName: string
18
+ password: string
19
+ }>(event)
20
+
21
+ if (!body.userName || !body.password) {
22
+ throw createError({
23
+ statusCode: 400,
24
+ message: '用户名和密码不能为空',
25
+ })
26
+ }
27
+
28
+ try {
29
+ const res = await authFetch<ResultDto<{
30
+ access_token: string
31
+ refresh_token: string
32
+ profile: PublicUser
33
+ }>>(event, '/auth/login', {
34
+ method: 'POST',
35
+ body: {
36
+ username: body.userName.trim(),
37
+ password: body.password,
38
+ },
39
+ })
40
+
41
+ if (res.success) {
42
+ await setRedisSession(event, {
43
+ access_token: res.data!.access_token,
44
+ refresh_token: res.data!.refresh_token,
45
+ })
46
+ }
47
+
48
+ return {
49
+ ...res,
50
+ data: res.data?.profile ? toPublicSimpleUser(res.data.profile) : undefined,
51
+ }
52
+ }
53
+ catch (error) {
54
+ await destroyRedisSession(event)
55
+ const errorData = (error as FetchError<ResultDto<LoginFailureData>>).data
56
+ return {
57
+ success: false,
58
+ message: errorData?.message || '登录失败',
59
+ data: errorData?.data,
60
+ }
61
+ }
62
+ })
@@ -0,0 +1,39 @@
1
+ import type { ResultDto } from '@zvonimirsun/iszy-common'
2
+ import type { FetchError } from 'ofetch'
3
+
4
+ export default defineEventHandler(async (event): Promise<ResultDto<void>> => {
5
+ const query = getQuery<{
6
+ deviceId?: string
7
+ other?: string
8
+ }>(event)
9
+
10
+ const session = await getRedisSession(event)
11
+ if (!session) {
12
+ return {
13
+ success: true,
14
+ message: '已登出',
15
+ }
16
+ }
17
+
18
+ try {
19
+ await authFetch(event, '/auth/logout', {
20
+ method: 'POST',
21
+ query,
22
+ })
23
+ }
24
+ catch (error) {
25
+ const errorCode = (error as FetchError)?.response?.status
26
+ if (errorCode && errorCode !== 401) {
27
+ throw error
28
+ }
29
+ }
30
+
31
+ if (!query.other && !query.deviceId) {
32
+ await destroyRedisSession(event)
33
+ }
34
+
35
+ return {
36
+ success: true,
37
+ message: '已登出',
38
+ }
39
+ })
@@ -0,0 +1,19 @@
1
+ import type { ResultDto } from '@zvonimirsun/iszy-common'
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ const { apiOrigin } = usePublicConfig()
5
+ const { provider } = event.context.params as { provider: string }
6
+ const url = getRequestURL(event)
7
+
8
+ const res = await authFetch<ResultDto<string>>(event, '/oauth/code', {
9
+ method: 'POST',
10
+ })
11
+ const code = res.data!
12
+ const state = random()
13
+
14
+ await setState(state, {
15
+ isBind: true,
16
+ })
17
+
18
+ return sendRedirect(event, `${apiOrigin}/oauth/${provider}/bind?state=${state}&client=${encodeURIComponent(url.origin)}&access_token=${code}`)
19
+ })
@@ -0,0 +1,99 @@
1
+ import type { PublicUser, ResultDto } from '@zvonimirsun/iszy-common'
2
+
3
+ function renderCallbackPage(title: string, origin: string, body: string, payload: { success: boolean, message?: string }) {
4
+ return `
5
+ <head>
6
+ <title>${title}</title>
7
+ <meta charset="UTF-8">
8
+ </head>
9
+ <body>
10
+ ${body}
11
+ <script>
12
+ if (window.opener && !window.opener.closed) {
13
+ window.opener.postMessage(${JSON.stringify(payload)}, '${origin}');
14
+ }
15
+ </script>
16
+ </body>
17
+ `
18
+ }
19
+
20
+ export default defineEventHandler(async (event) => {
21
+ const { title } = usePublicConfig()
22
+ const url = getRequestURL(event)
23
+ const query = getQuery(event)
24
+ const state = query.state as string
25
+
26
+ if (!state) {
27
+ throw createError({
28
+ statusCode: 400,
29
+ message: '缺少 state 参数',
30
+ })
31
+ }
32
+
33
+ const stateData = await getState(state)
34
+ if (!stateData) {
35
+ throw createError({
36
+ statusCode: 400,
37
+ message: '无效的 state 参数',
38
+ })
39
+ }
40
+
41
+ await removeState(state)
42
+
43
+ if (stateData.isBind) {
44
+ const error = query.error as string | undefined
45
+ if (error) {
46
+ return renderCallbackPage(`${title} - 绑定失败`, url.origin, error, {
47
+ success: false,
48
+ message: error,
49
+ })
50
+ }
51
+
52
+ return renderCallbackPage(`${title} - 绑定成功`, url.origin, '验证成功', {
53
+ success: true,
54
+ })
55
+ }
56
+
57
+ const code = query.code as string
58
+ const error = query.error as string | undefined
59
+ let errorMessage = error || (!code ? '缺少 code 参数' : '')
60
+
61
+ if (!errorMessage) {
62
+ try {
63
+ const res = await authFetch<ResultDto<{
64
+ access_token: string
65
+ refresh_token: string
66
+ profile: PublicUser
67
+ }>>(event, '/oauth/token', {
68
+ method: 'POST',
69
+ query: {
70
+ access_token: code,
71
+ },
72
+ })
73
+
74
+ if (!res.success) {
75
+ errorMessage = '获取 token 失败'
76
+ }
77
+ else {
78
+ await setRedisSession(event, {
79
+ access_token: res.data!.access_token,
80
+ refresh_token: res.data!.refresh_token,
81
+ })
82
+ }
83
+ }
84
+ catch {
85
+ errorMessage = '获取 token 失败'
86
+ }
87
+ }
88
+
89
+ if (errorMessage) {
90
+ return renderCallbackPage(`${title} - 登录失败`, url.origin, errorMessage, {
91
+ success: false,
92
+ message: errorMessage,
93
+ })
94
+ }
95
+
96
+ return renderCallbackPage(`${title} - 登录成功`, url.origin, '登录成功', {
97
+ success: true,
98
+ })
99
+ })
@@ -0,0 +1,10 @@
1
+ export default defineEventHandler(async (event) => {
2
+ const { apiOrigin } = usePublicConfig()
3
+ const { provider } = event.context.params as { provider: string }
4
+ const url = getRequestURL(event)
5
+ const state = random()
6
+
7
+ await setState(state, {})
8
+
9
+ return sendRedirect(event, `${apiOrigin}/oauth/${provider}?state=${state}&client=${encodeURIComponent(url.origin)}`)
10
+ })
@@ -0,0 +1,3 @@
1
+ export default defineResponseMiddleware(async (event) => {
2
+ await setRedisSession(event, await getRedisSession(event))
3
+ })
@@ -0,0 +1,14 @@
1
+ import redisDriver from 'unstorage/drivers/redis'
2
+
3
+ export default defineNitroPlugin(() => {
4
+ const storage = useStorage()
5
+ const redisConfig = useRuntimeConfig().redis
6
+
7
+ const driver = redisDriver({
8
+ host: redisConfig.host,
9
+ port: redisConfig.port,
10
+ password: redisConfig.password,
11
+ })
12
+
13
+ storage.mount('redis', driver)
14
+ })
@@ -0,0 +1,164 @@
1
+ import type { PublicUser, ResultDto } from '@zvonimirsun/iszy-common'
2
+ import type { H3Event } from 'h3'
3
+ import type { NitroFetchRequest, TypedInternalResponse } from 'nitropack'
4
+ import type { FetchError } from 'ofetch'
5
+ import type { SessionData } from '#shared/types/auth'
6
+ import { getProxyRequestHeaders } from 'h3'
7
+
8
+ export async function proxyFetch(event: H3Event) {
9
+ const sessionId = getSessionId(event)
10
+ const { apiOrigin } = usePublicConfig()
11
+ const target = apiOrigin + event.path.slice(4)
12
+
13
+ const headers = getForwardRequestHeaders(event)
14
+ headers['accept-encoding'] = 'identity'
15
+
16
+ const body = ['GET', 'HEAD'].includes(event.method) ? undefined : await readRawBody(event)
17
+
18
+ const doRequest = async () => {
19
+ if (sessionId) {
20
+ const sessionData = await getRedisSession(event)
21
+ if (sessionData) {
22
+ headers.authorization = `Bearer ${sessionData.access_token}`
23
+ }
24
+ }
25
+ return fetch(target, {
26
+ method: event.method,
27
+ headers,
28
+ body,
29
+ duplex: 'half',
30
+ redirect: 'manual',
31
+ } as RequestInit & { duplex: 'half' })
32
+ }
33
+
34
+ let res = await doRequest()
35
+ if (sessionId && res.status === 401) {
36
+ try {
37
+ useRedisSession(event, await refreshWithLock(sessionId, async () => refreshToken(event)))
38
+ }
39
+ catch {
40
+ await destroyRedisSession(event)
41
+ return pipeResponse(event, res)
42
+ }
43
+ res = await doRequest()
44
+ }
45
+ return pipeResponse(event, res)
46
+ }
47
+
48
+ export async function authFetch<T = unknown>(event: H3Event, ...params: Parameters<typeof $fetch>): Promise<TypedInternalResponse<NitroFetchRequest, T>> {
49
+ const sessionId = getSessionId(event)
50
+ const { apiOrigin } = usePublicConfig()
51
+ const [url, opts = {}] = params
52
+ const headers: Record<string, string> = {
53
+ ...getForwardRequestHeaders(event),
54
+ ...(opts.headers as Record<string, string> | undefined),
55
+ }
56
+
57
+ opts.headers = headers
58
+ opts.baseURL = apiOrigin
59
+ const fetcher = $fetch as unknown as (request: NitroFetchRequest, options?: typeof opts) => Promise<T>
60
+
61
+ const doRequest = async () => {
62
+ if (sessionId) {
63
+ const sessionData = await getRedisSession(event)
64
+ if (sessionData) {
65
+ headers.authorization = `Bearer ${sessionData.access_token}`
66
+ }
67
+ }
68
+ return fetcher(url, opts)
69
+ }
70
+
71
+ try {
72
+ return await doRequest() as TypedInternalResponse<NitroFetchRequest, T>
73
+ }
74
+ catch (error) {
75
+ if (sessionId && (error as FetchError)?.response?.status === 401) {
76
+ try {
77
+ useRedisSession(event, await refreshWithLock(sessionId, async () => refreshToken(event)))
78
+ return await doRequest() as TypedInternalResponse<NitroFetchRequest, T>
79
+ }
80
+ catch {
81
+ await destroyRedisSession(event)
82
+ throw error
83
+ }
84
+ }
85
+ throw error
86
+ }
87
+ }
88
+
89
+ async function refreshToken(event: H3Event): Promise<SessionData> {
90
+ const sessionData = await getRedisSession(event)
91
+ if (!sessionData) {
92
+ throw new Error('REFRESH_FAILED')
93
+ }
94
+
95
+ const { apiOrigin } = usePublicConfig()
96
+ const res = await $fetch<ResultDto<{
97
+ access_token: string
98
+ refresh_token: string
99
+ profile: PublicUser
100
+ }>>(`${apiOrigin}/auth/refresh`, {
101
+ method: 'POST',
102
+ headers: {
103
+ ...getForwardRequestHeaders(event),
104
+ Authorization: `Bearer ${sessionData.refresh_token}`,
105
+ },
106
+ })
107
+
108
+ if (!res.success) {
109
+ throw new Error('REFRESH_FAILED')
110
+ }
111
+
112
+ return rotateRedisSession(event, {
113
+ access_token: res.data!.access_token,
114
+ refresh_token: res.data!.refresh_token,
115
+ })
116
+ }
117
+
118
+ const locks = new Map<string, Promise<SessionData>>()
119
+
120
+ async function refreshWithLock(sessionId: string, fn: () => Promise<SessionData>) {
121
+ if (locks.has(sessionId)) {
122
+ return locks.get(sessionId)!
123
+ }
124
+
125
+ const promise = (async () => {
126
+ try {
127
+ return await fn()
128
+ }
129
+ finally {
130
+ locks.delete(sessionId)
131
+ }
132
+ })()
133
+
134
+ locks.set(sessionId, promise)
135
+ return promise
136
+ }
137
+
138
+ function getForwardRequestHeaders(event: H3Event) {
139
+ const headers = getProxyRequestHeaders(event, { host: false })
140
+ const remoteAddr = getRequestIP(event, { xForwardedFor: true }) || ''
141
+ const xForwardedFor = headers['x-forwarded-for'] || remoteAddr
142
+
143
+ delete headers.cookie
144
+ delete headers.authorization
145
+ if (xForwardedFor) {
146
+ headers['x-forwarded-for'] = xForwardedFor
147
+ }
148
+
149
+ return headers
150
+ }
151
+
152
+ async function pipeResponse(event: H3Event, res: Response) {
153
+ setResponseStatus(event, res.status)
154
+ for (const [key, value] of res.headers) {
155
+ if (key.toLowerCase() === 'set-cookie') {
156
+ continue
157
+ }
158
+ setHeader(event, key, value)
159
+ }
160
+ if (res.body == null) {
161
+ return send(event, '')
162
+ }
163
+ return sendStream(event, res.body)
164
+ }
@@ -0,0 +1,5 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ export function random(size = 16) {
4
+ return randomBytes(size).toString('hex').slice(0, size)
5
+ }
@@ -0,0 +1,120 @@
1
+ import type { H3Event } from 'h3'
2
+ import type { StringValue } from 'ms'
3
+ import type { SessionData, SessionTombstone } from '#shared/types/auth'
4
+ import { randomBytes } from 'node:crypto'
5
+ import ms from 'ms'
6
+
7
+ const SESSION_TOMBSTONE_TTL = 30
8
+ const SESSION_ID_LENGTH = 32
9
+
10
+ function createSessionId() {
11
+ return randomBytes(SESSION_ID_LENGTH).toString('hex').slice(0, SESSION_ID_LENGTH)
12
+ }
13
+
14
+ export function getSessionId(event: H3Event): string | undefined {
15
+ const { session: sessionConfig } = useRuntimeConfig()
16
+ return getCookie(event, sessionConfig.cookieName)
17
+ }
18
+
19
+ export function getSessionKey(sessionId: string): string {
20
+ return `session:${sessionId}`
21
+ }
22
+
23
+ function setSessionId(id: string, data: Omit<SessionData, 'id'> | SessionData): SessionData {
24
+ return {
25
+ ...data,
26
+ id,
27
+ }
28
+ }
29
+
30
+ export async function getRedisSession(event: H3Event): Promise<SessionData | null> {
31
+ const storage = useStorage<SessionData | SessionTombstone>('redis')
32
+ const sessionId = getSessionId(event)
33
+ if (!sessionId) {
34
+ return null
35
+ }
36
+
37
+ const data = await storage.getItem(getSessionKey(sessionId))
38
+ if (!data) {
39
+ return null
40
+ }
41
+
42
+ if ('redirectTo' in data) {
43
+ const newData = await storage.getItem(getSessionKey(data.redirectTo))
44
+ if (!newData || 'redirectTo' in newData) {
45
+ return null
46
+ }
47
+ useRedisSession(event, newData)
48
+ return newData
49
+ }
50
+
51
+ return data
52
+ }
53
+
54
+ export async function setRedisSession(event: H3Event, data?: Omit<SessionData, 'id'> | SessionData | null) {
55
+ const { session: sessionConfig } = useRuntimeConfig()
56
+ const cookieName = sessionConfig.cookieName
57
+ const ttl = ms(sessionConfig.maxAge as StringValue) / 1000
58
+ const storage = useStorage<SessionData>('redis')
59
+
60
+ let sessionId = getSessionId(event)
61
+ if (!data) {
62
+ if (sessionId) {
63
+ await storage.removeItem(getSessionKey(sessionId))
64
+ sessionId = undefined
65
+ deleteCookie(event, cookieName)
66
+ }
67
+ return
68
+ }
69
+
70
+ const nextSessionId = sessionId || createSessionId()
71
+ const sessionData = setSessionId(nextSessionId, data)
72
+ await storage.setItem(getSessionKey(nextSessionId), sessionData, { ttl })
73
+ setCookie(event, cookieName, nextSessionId, {
74
+ maxAge: ttl,
75
+ domain: sessionConfig.domain || undefined,
76
+ sameSite: 'lax',
77
+ httpOnly: true,
78
+ secure: true,
79
+ priority: 'high',
80
+ })
81
+ }
82
+
83
+ export async function rotateRedisSession(event: H3Event, data: Omit<SessionData, 'id'> | SessionData) {
84
+ const { session: sessionConfig } = useRuntimeConfig()
85
+ const ttl = ms(sessionConfig.maxAge as StringValue) / 1000
86
+ const storage = useStorage<SessionData | SessionTombstone>('redis')
87
+
88
+ const oldSessionId = getSessionId(event)
89
+ const sessionId = createSessionId()
90
+ const sessionData = setSessionId(sessionId, data)
91
+
92
+ await storage.setItem(getSessionKey(sessionId), sessionData, { ttl })
93
+
94
+ if (oldSessionId) {
95
+ await storage.setItem(
96
+ getSessionKey(oldSessionId),
97
+ { redirectTo: sessionId },
98
+ { ttl: SESSION_TOMBSTONE_TTL },
99
+ )
100
+ }
101
+
102
+ useRedisSession(event, sessionData)
103
+ return sessionData
104
+ }
105
+
106
+ export function useRedisSession(event: H3Event, session: SessionData) {
107
+ const { session: sessionConfig } = useRuntimeConfig()
108
+ setCookie(event, sessionConfig.cookieName, session.id, {
109
+ maxAge: ms(sessionConfig.maxAge as StringValue) / 1000,
110
+ domain: sessionConfig.domain || undefined,
111
+ sameSite: 'lax',
112
+ httpOnly: true,
113
+ secure: true,
114
+ priority: 'high',
115
+ })
116
+ }
117
+
118
+ export async function destroyRedisSession(event: H3Event): Promise<void> {
119
+ return setRedisSession(event, undefined)
120
+ }
@@ -0,0 +1,23 @@
1
+ import type { OAuthStateData } from '#shared/types/auth'
2
+ import ms from 'ms'
3
+
4
+ export function getStateKey(state: string): string {
5
+ return `oauth:state:${state}`
6
+ }
7
+
8
+ export async function setState(state: string, data: OAuthStateData) {
9
+ const storage = useStorage<OAuthStateData>('redis')
10
+ return storage.setItem(getStateKey(state), data, {
11
+ ttl: ms('5m') / 1000,
12
+ })
13
+ }
14
+
15
+ export async function getState(state: string) {
16
+ const storage = useStorage<OAuthStateData>('redis')
17
+ return storage.getItem(getStateKey(state))
18
+ }
19
+
20
+ export async function removeState(state: string) {
21
+ const storage = useStorage<OAuthStateData>('redis')
22
+ await storage.removeItem(getStateKey(state))
23
+ }
@@ -0,0 +1,19 @@
1
+ import type { PublicUser } from '@zvonimirsun/iszy-common'
2
+
3
+ export type { PublicUser, RawPrivilege, RawRole, RegisterUser, ResultDto, UpdateUser } from '@zvonimirsun/iszy-common'
4
+
5
+ export type PublicSimpleUser = Omit<PublicUser, 'status' | 'createBy' | 'updateBy' | 'privileges'>
6
+
7
+ export interface SessionData {
8
+ id: string
9
+ access_token: string
10
+ refresh_token: string
11
+ }
12
+
13
+ export interface SessionTombstone {
14
+ redirectTo: string
15
+ }
16
+
17
+ export interface OAuthStateData {
18
+ isBind?: boolean
19
+ }
@@ -0,0 +1,4 @@
1
+ export function usePublicConfig() {
2
+ const { public: publicConfig } = useRuntimeConfig()
3
+ return publicConfig
4
+ }
@@ -0,0 +1,7 @@
1
+ import type { PublicUser } from '@zvonimirsun/iszy-common'
2
+ import type { PublicSimpleUser } from '#shared/types/auth'
3
+
4
+ export function toPublicSimpleUser(user: PublicUser): PublicSimpleUser {
5
+ const { status, createBy, updateBy, privileges, ...result } = user
6
+ return result
7
+ }