@drax/identity-vue 3.64.0 → 3.66.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 +70 -0
- package/package.json +8 -8
- package/src/components/IdentityLogin/IdentityLogin.vue +7 -1
- package/src/components/IdentityRememberMe.vue +26 -0
- package/src/composables/setupAuthSession.ts +183 -0
- package/src/composables/useAuth.ts +61 -19
- package/src/cruds/UserSessionCrud.ts +7 -3
- package/src/i18n/UserSession-i18n.ts +6 -2
- package/src/index.ts +3 -0
- package/src/stores/AuthStore.ts +26 -3
package/README.md
CHANGED
|
@@ -2,6 +2,76 @@
|
|
|
2
2
|
|
|
3
3
|
This template should help get you started developing with Vue 3 in Vite.
|
|
4
4
|
|
|
5
|
+
## Remember-me sessions
|
|
6
|
+
|
|
7
|
+
Remember-me is disabled unless the consuming Vite app defines
|
|
8
|
+
`VITE_DRAX_REMEMBER_ME_ENABLED=true` (exact string). `IdentityLogin` includes the
|
|
9
|
+
exported `IdentityRememberMe` checkbox, localized in English/Spanish and initially
|
|
10
|
+
unchecked. Custom login forms can use `<IdentityRememberMe v-model="rememberMe" />`
|
|
11
|
+
and `useAuth().login(username, password, rememberMe)`. Ordinary logins still call
|
|
12
|
+
`AuthSystem.login` with only two arguments and discard any previous refresh token.
|
|
13
|
+
|
|
14
|
+
Install the persisted-state plugin on Pinia, then await `setupAuthSession()` after
|
|
15
|
+
installing Pinia and **before** installing the router or starting API requests.
|
|
16
|
+
The setup function does not require a component or router context. Enable the
|
|
17
|
+
backend separately with `DRAX_REMEMBER_ME_ENABLED=true`; its optional
|
|
18
|
+
`DRAX_REFRESH_TOKEN_TTL_SECONDS` defaults to 2592000 (30 days). Neither flag
|
|
19
|
+
changes the normal JWT expiration.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import {setupAuthSession} from '@drax/identity-vue'
|
|
23
|
+
|
|
24
|
+
app.use(pinia)
|
|
25
|
+
await setupAuthSession()
|
|
26
|
+
app.use(router)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Custom guards should await `refreshAuthSession()` before existing auth/permission
|
|
30
|
+
checks, catch transient maintenance errors, retain recoverable
|
|
31
|
+
credentials and cancel protected navigation until retry rather than treating
|
|
32
|
+
network failure as logout. Zuite's `router/authGuard.ts` provides the complete
|
|
33
|
+
example, including permission checks and login redirection. Publish/upgrade
|
|
34
|
+
`common-front`, `identity-front`, `identity-share` and the identity backend/Vue
|
|
35
|
+
packages together when enabling this feature.
|
|
36
|
+
|
|
37
|
+
Integration contracts:
|
|
38
|
+
|
|
39
|
+
- `AuthSystem.login(username, password, rememberMe?)`, `refresh(refreshToken)` and
|
|
40
|
+
`revokeSession(refreshToken)` must implement the identity-front contract. Login
|
|
41
|
+
and refresh return `{accessToken, refreshToken?, refreshExpiresAt?}`. Expiration
|
|
42
|
+
dates are ISO strings. Front providers must not persist session data.
|
|
43
|
+
- The shared `HttpRestClientFactory.getInstance()` client may implement
|
|
44
|
+
`setBeforeRequest(handler)`. The handler must run **before** copying authorization
|
|
45
|
+
headers. Without this optional method, startup renewal works but automatic
|
|
46
|
+
renewal before subsequent REST requests is unavailable.
|
|
47
|
+
- The hook excludes login/refresh/logout. Renewal uses a 30-second access-token
|
|
48
|
+
expiry margin, single-flight per store/tab, and Web Locks plus synchronous
|
|
49
|
+
`AuthStore` persistence/re-reading across tabs. Browsers without Web Locks only
|
|
50
|
+
have same-tab coordination; concurrent cross-tab rotation is not guaranteed.
|
|
51
|
+
- Profile reload after renewal bypasses only its own `/api/auth/me` preflight to
|
|
52
|
+
avoid recursion. If the profile is absent after a transient failure, subsequent
|
|
53
|
+
session maintenance retries it single-flight even with a fresh JWT, without
|
|
54
|
+
rotating again. Startup does not immediately repeat a failed profile request.
|
|
55
|
+
- `AuthStore.sessionVersion` is a persisted operation epoch changed by clearing
|
|
56
|
+
auth (including login/token-login start and logout). Pending login, refresh and
|
|
57
|
+
profile results check this epoch before committing, including cross-tab
|
|
58
|
+
null-to-null logout. Legacy stores without the field use the initial null epoch.
|
|
59
|
+
Late returned refresh credentials are revoked best-effort.
|
|
60
|
+
- Transient refresh/profile errors retain recoverable credentials; rejected
|
|
61
|
+
credentials (HTTP 401/403, including common-front wrapped errors) clear auth.
|
|
62
|
+
Logout clears local state immediately, with best-effort server revocation even
|
|
63
|
+
when the feature has subsequently been disabled. Offline revocation cannot be
|
|
64
|
+
guaranteed; the backend expiration/revocation policy remains authoritative.
|
|
65
|
+
- Normal sessions are never renewed. Remember-me renewal is REST-only, not a
|
|
66
|
+
GraphQL preflight. Tokens live in localStorage via `AuthStore`; protect the app
|
|
67
|
+
against XSS and do not log or expose refresh tokens.
|
|
68
|
+
|
|
69
|
+
Focused tests:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
npm exec --workspace @drax/identity-vue -- vitest run test/auth-session.spec.ts test/auth-session.integration.spec.ts test/components/__tests__/IdentityLogin.spec.ts
|
|
73
|
+
```
|
|
74
|
+
|
|
5
75
|
## Recommended IDE Setup
|
|
6
76
|
|
|
7
77
|
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "3.
|
|
6
|
+
"version": "3.66.0",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"module": "./src/index.ts",
|
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
"format": "prettier --write src/"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@drax/common-front": "^3.
|
|
22
|
-
"@drax/common-vue": "^3.
|
|
21
|
+
"@drax/common-front": "^3.65.0",
|
|
22
|
+
"@drax/common-vue": "^3.65.0",
|
|
23
23
|
"@drax/crud-front": "^3.58.0",
|
|
24
|
-
"@drax/crud-share": "^3.
|
|
25
|
-
"@drax/crud-vue": "^3.
|
|
26
|
-
"@drax/identity-front": "^3.
|
|
27
|
-
"@drax/identity-share": "^3.
|
|
24
|
+
"@drax/crud-share": "^3.65.0",
|
|
25
|
+
"@drax/crud-vue": "^3.66.0",
|
|
26
|
+
"@drax/identity-front": "^3.65.0",
|
|
27
|
+
"@drax/identity-share": "^3.65.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"pinia": "^3.0.4",
|
|
@@ -49,5 +49,5 @@
|
|
|
49
49
|
"vue-tsc": "^3.2.4",
|
|
50
50
|
"vuetify": "^3.11.8"
|
|
51
51
|
},
|
|
52
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "904410e22198dc6f96d64db10ba96a3ffd6ec777"
|
|
53
53
|
}
|
|
@@ -3,6 +3,8 @@ import {computed, ref} from 'vue'
|
|
|
3
3
|
import {useAuth} from '../../composables/useAuth.js'
|
|
4
4
|
import IdentityProfileView from "../IdentityProfileView/IdentityProfileView.vue";
|
|
5
5
|
import {useI18n} from "vue-i18n";
|
|
6
|
+
import IdentityRememberMe from '../IdentityRememberMe.vue'
|
|
7
|
+
import {isRememberMeEnabled} from '../../composables/setupAuthSession'
|
|
6
8
|
const {t, te} = useI18n()
|
|
7
9
|
|
|
8
10
|
const {login, isAuthenticated} = useAuth()
|
|
@@ -18,6 +20,7 @@ const username = ref('')
|
|
|
18
20
|
const password = ref('')
|
|
19
21
|
const authError = ref('')
|
|
20
22
|
const loading = ref(false)
|
|
23
|
+
const rememberMe = ref(false)
|
|
21
24
|
|
|
22
25
|
const isFormValid = computed(() => username.value.trim() !== '' && password.value.trim() !== '')
|
|
23
26
|
|
|
@@ -26,7 +29,9 @@ const emit = defineEmits(['loginSuccess'])
|
|
|
26
29
|
async function submitLogin() {
|
|
27
30
|
try {
|
|
28
31
|
loading.value = true
|
|
29
|
-
|
|
32
|
+
authError.value = ''
|
|
33
|
+
if (isRememberMeEnabled() && rememberMe.value) await login(username.value, password.value, true)
|
|
34
|
+
else await login(username.value, password.value)
|
|
30
35
|
emit('loginSuccess')
|
|
31
36
|
} catch (e) {
|
|
32
37
|
const error = e as Error
|
|
@@ -87,6 +92,7 @@ function togglePasswordVisibility() {
|
|
|
87
92
|
@click:append-inner="togglePasswordVisibility"
|
|
88
93
|
autocomplete="new-password"
|
|
89
94
|
></v-text-field>
|
|
95
|
+
<identity-remember-me v-model="rememberMe" :disabled="loading" />
|
|
90
96
|
</v-card-text>
|
|
91
97
|
<v-card-actions>
|
|
92
98
|
<v-spacer></v-spacer>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {useI18n} from 'vue-i18n'
|
|
3
|
+
import {isRememberMeEnabled} from '../composables/setupAuthSession'
|
|
4
|
+
|
|
5
|
+
defineProps<{disabled?: boolean}>()
|
|
6
|
+
const model = defineModel<boolean>({default: false})
|
|
7
|
+
const {t} = useI18n({
|
|
8
|
+
useScope: 'local',
|
|
9
|
+
messages: {
|
|
10
|
+
en: {rememberMe: 'Remember my session on this device'},
|
|
11
|
+
es: {rememberMe: 'Recordar mi sesión en este dispositivo'},
|
|
12
|
+
},
|
|
13
|
+
})
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<v-checkbox
|
|
18
|
+
v-if="isRememberMeEnabled()"
|
|
19
|
+
v-model="model"
|
|
20
|
+
:label="t('rememberMe')"
|
|
21
|
+
density="compact"
|
|
22
|
+
hide-details
|
|
23
|
+
:disabled="disabled"
|
|
24
|
+
data-testid="remember-me"
|
|
25
|
+
/>
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {HttpClientFactory, HttpRestClientFactory} from '@drax/common-front'
|
|
2
|
+
import {AuthSystemFactory} from '@drax/identity-front'
|
|
3
|
+
import {useAuthStore} from '../stores/AuthStore'
|
|
4
|
+
|
|
5
|
+
export const isRememberMeEnabled = () => import.meta.env.VITE_DRAX_REMEMBER_ME_ENABLED === 'true'
|
|
6
|
+
|
|
7
|
+
type AuthStore = ReturnType<typeof useAuthStore>
|
|
8
|
+
const flights = new WeakMap<AuthStore, Promise<void>>()
|
|
9
|
+
const fetchingUser = new WeakSet<AuthStore>()
|
|
10
|
+
const lockName = 'drax-auth-session'
|
|
11
|
+
const revisions = new WeakMap<AuthStore, number>()
|
|
12
|
+
export const authSessionRevision = (store = useAuthStore()) => revisions.get(store) ?? 0
|
|
13
|
+
|
|
14
|
+
export function syncAuthHeaders(store = useAuthStore()) {
|
|
15
|
+
const clients = new Set([HttpClientFactory.getInstance(), HttpRestClientFactory.getInstance()])
|
|
16
|
+
for (const client of clients) {
|
|
17
|
+
if (store.accessToken) client.addHeader('Authorization', `Bearer ${store.accessToken}`)
|
|
18
|
+
else client.removeHeader('Authorization')
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function clearAuthSession(store = useAuthStore()) {
|
|
23
|
+
revisions.set(store, authSessionRevision(store) + 1)
|
|
24
|
+
store.clearAuth()
|
|
25
|
+
syncAuthHeaders(store)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isRejectedCredential(error: unknown): boolean {
|
|
29
|
+
const e = error as {statusCode?: number, status?: number, response?: {status?: number}, cause?: unknown, error?: unknown}
|
|
30
|
+
const status = e?.statusCode ?? e?.status ?? e?.response?.status
|
|
31
|
+
return status === 401 || status === 403 ||
|
|
32
|
+
(!!e?.error && e.error !== error && isRejectedCredential(e.error)) ||
|
|
33
|
+
(!!e?.cause && e.cause !== error && isRejectedCredential(e.cause))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readPersisted() {
|
|
37
|
+
try {
|
|
38
|
+
const raw = localStorage.getItem('AuthStore')
|
|
39
|
+
return raw ? JSON.parse(raw) as AuthStore['$state'] : null
|
|
40
|
+
} catch { return null }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function rereadSession(store: AuthStore) {
|
|
44
|
+
const persisted = readPersisted()
|
|
45
|
+
if (persisted) {
|
|
46
|
+
store.$patch({
|
|
47
|
+
accessToken: persisted.accessToken ?? null,
|
|
48
|
+
sessionVersion: persisted.sessionVersion ?? null,
|
|
49
|
+
refreshToken: persisted.refreshToken ?? null,
|
|
50
|
+
refreshExpiresAt: persisted.refreshExpiresAt ?? null,
|
|
51
|
+
authUser: persisted.authUser ?? null,
|
|
52
|
+
})
|
|
53
|
+
syncAuthHeaders(store)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isCurrentAuthOperation(store: AuthStore, version: string | null) {
|
|
58
|
+
if (store.sessionVersion !== version) return false
|
|
59
|
+
const persisted = readPersisted()
|
|
60
|
+
// Missing versions in legacy persisted stores represent the initial epoch.
|
|
61
|
+
if (persisted && (persisted.sessionVersion ?? null) !== version) {
|
|
62
|
+
rereadSession(store)
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isCurrentAuthSession(store: AuthStore, token: string | null, version = store.sessionVersion) {
|
|
69
|
+
if (!isCurrentAuthOperation(store, version) || store.accessToken !== token) return false
|
|
70
|
+
const persisted = readPersisted()
|
|
71
|
+
if (persisted && (persisted.accessToken !== token || (persisted.refreshToken ?? null) !== store.refreshToken)) {
|
|
72
|
+
rereadSession(store)
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function needsRefresh(token: string | null) {
|
|
79
|
+
try {
|
|
80
|
+
const payload = (token?.split('.')[1] ?? '').replace(/-/g, '+').replace(/_/g, '/')
|
|
81
|
+
const {exp} = JSON.parse(atob(payload))
|
|
82
|
+
return typeof exp !== 'number' || exp * 1000 <= Date.now() + 30_000
|
|
83
|
+
} catch { return true }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function withSessionLock(action: () => Promise<void>) {
|
|
87
|
+
if (typeof navigator !== 'undefined' && navigator.locks?.request) {
|
|
88
|
+
await navigator.locks.request(lockName, action)
|
|
89
|
+
} else {
|
|
90
|
+
await action()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function revokeSessionToken(token: string) {
|
|
95
|
+
await AuthSystemFactory.getInstance().revokeSession(token)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function fetchSessionUser(store: AuthStore) {
|
|
99
|
+
const token = store.accessToken
|
|
100
|
+
const revision = authSessionRevision(store)
|
|
101
|
+
const version = store.sessionVersion
|
|
102
|
+
fetchingUser.add(store)
|
|
103
|
+
try {
|
|
104
|
+
const user = await AuthSystemFactory.getInstance().me()
|
|
105
|
+
if (authSessionRevision(store) === revision && isCurrentAuthSession(store, token, version)) store.setAuthUser(user)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (authSessionRevision(store) === revision && isCurrentAuthSession(store, token, version) && isRejectedCredential(error)) {
|
|
108
|
+
clearAuthSession(store)
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
// A profile fetch failing offline must not discard successfully rotated credentials.
|
|
112
|
+
} finally {
|
|
113
|
+
fetchingUser.delete(store)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function refreshAuthSession(store = useAuthStore()): Promise<void> {
|
|
118
|
+
if (!isRememberMeEnabled()) return Promise.resolve()
|
|
119
|
+
const running = flights.get(store)
|
|
120
|
+
if (running) return running
|
|
121
|
+
const flight = withSessionLock(async () => {
|
|
122
|
+
rereadSession(store)
|
|
123
|
+
if (!store.refreshToken) return
|
|
124
|
+
if (!needsRefresh(store.accessToken)) {
|
|
125
|
+
if (!store.authUser) await fetchSessionUser(store)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
const token = store.refreshToken
|
|
129
|
+
const accessToken = store.accessToken
|
|
130
|
+
const version = store.sessionVersion
|
|
131
|
+
if (store.refreshExpiresAt && Date.parse(store.refreshExpiresAt) <= Date.now()) {
|
|
132
|
+
clearAuthSession(store)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const result = await AuthSystemFactory.getInstance().refresh(token)
|
|
137
|
+
// Logout or a new login may have happened while the request was in flight.
|
|
138
|
+
if (!isCurrentAuthSession(store, accessToken, version) || store.refreshToken !== token) {
|
|
139
|
+
syncAuthHeaders(store)
|
|
140
|
+
if (result.refreshToken) await revokeSessionToken(result.refreshToken).catch(() => {})
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
store.setSession(result)
|
|
144
|
+
syncAuthHeaders(store)
|
|
145
|
+
await fetchSessionUser(store)
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (isCurrentAuthSession(store, accessToken, version) && store.refreshToken === token && isRejectedCredential(error)) {
|
|
148
|
+
clearAuthSession(store)
|
|
149
|
+
}
|
|
150
|
+
throw error
|
|
151
|
+
}
|
|
152
|
+
}).finally(() => { flights.delete(store) })
|
|
153
|
+
flights.set(store, flight)
|
|
154
|
+
return flight
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Call after installing Pinia, before installing the router. No component/router context required. */
|
|
158
|
+
export async function setupAuthSession(): Promise<void> {
|
|
159
|
+
const store = useAuthStore()
|
|
160
|
+
const client = HttpRestClientFactory.getInstance() as ReturnType<typeof HttpRestClientFactory.getInstance> & {
|
|
161
|
+
setBeforeRequest?: (handler: (url: string) => Promise<void>) => void
|
|
162
|
+
}
|
|
163
|
+
syncAuthHeaders(store)
|
|
164
|
+
client.setBeforeRequest?.(async (url) => {
|
|
165
|
+
if (/\/api\/auth\/(login|refresh|logout)\/?(?:[?#]|$)/.test(url)) return
|
|
166
|
+
// The profile fetch uses the fresh JWT and must not await its own refresh promise.
|
|
167
|
+
if (fetchingUser.has(store) && /\/api\/auth\/me\/?(?:[?#]|$)/.test(url)) return
|
|
168
|
+
await refreshAuthSession(store)
|
|
169
|
+
})
|
|
170
|
+
try {
|
|
171
|
+
await refreshAuthSession(store)
|
|
172
|
+
} catch {
|
|
173
|
+
// Offline startup must not destroy a recoverable remembered session.
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (!store.accessToken || !store.tokenIsValid()) return
|
|
177
|
+
// Remembered maintenance already fetched (or attempted) the missing profile.
|
|
178
|
+
// Do not immediately retry a transient failure in this same setup invocation.
|
|
179
|
+
if (isRememberMeEnabled() && store.refreshToken) return
|
|
180
|
+
try {
|
|
181
|
+
await fetchSessionUser(store)
|
|
182
|
+
} catch { /* Rejected credentials have already been cleared. */ }
|
|
183
|
+
}
|
|
@@ -2,6 +2,7 @@ import {useAuthStore} from "../stores/AuthStore";
|
|
|
2
2
|
import {AuthHelper, AuthSystemFactory} from "@drax/identity-front";
|
|
3
3
|
import type {AuthSystem} from "@drax/identity-front";
|
|
4
4
|
import {useRouter} from 'vue-router'
|
|
5
|
+
import {authSessionRevision, clearAuthSession, isCurrentAuthOperation, isCurrentAuthSession, isRememberMeEnabled, rereadSession, revokeSessionToken, syncAuthHeaders} from './setupAuthSession'
|
|
5
6
|
|
|
6
7
|
export function useAuth() {
|
|
7
8
|
|
|
@@ -10,26 +11,56 @@ export function useAuth() {
|
|
|
10
11
|
|
|
11
12
|
const authSystem: AuthSystem = AuthSystemFactory.getInstance()
|
|
12
13
|
|
|
13
|
-
const login = async (username: string, password: string) => {
|
|
14
|
-
|
|
15
|
-
authStore
|
|
16
|
-
const
|
|
17
|
-
|
|
14
|
+
const login = async (username: string, password: string, rememberMe = false) => {
|
|
15
|
+
clearAuthSession(authStore)
|
|
16
|
+
const revision = authSessionRevision(authStore)
|
|
17
|
+
const version = authStore.sessionVersion
|
|
18
|
+
const remembered = isRememberMeEnabled() && rememberMe
|
|
19
|
+
const result = remembered
|
|
20
|
+
? await authSystem.login(username, password, true)
|
|
21
|
+
: await authSystem.login(username, password)
|
|
22
|
+
if (authSessionRevision(authStore) !== revision || !isCurrentAuthOperation(authStore, version)) {
|
|
23
|
+
syncAuthHeaders(authStore)
|
|
24
|
+
if (result.refreshToken) await revokeSessionToken(result.refreshToken).catch(() => {})
|
|
25
|
+
throw new Error('Authentication cancelled')
|
|
26
|
+
}
|
|
27
|
+
if (remembered) authStore.setSession(result)
|
|
28
|
+
else authStore.setAccessToken(result.accessToken)
|
|
29
|
+
syncAuthHeaders(authStore)
|
|
30
|
+
try {
|
|
31
|
+
await fetchAuthUser()
|
|
32
|
+
if (authSessionRevision(authStore) !== revision || !isCurrentAuthOperation(authStore, version)) throw new Error('Authentication cancelled')
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (authSessionRevision(authStore) === revision && isCurrentAuthOperation(authStore, version)) {
|
|
35
|
+
const token = authStore.refreshToken
|
|
36
|
+
clearAuthSession(authStore)
|
|
37
|
+
if (token) await revokeSessionToken(token).catch(() => {})
|
|
38
|
+
}
|
|
39
|
+
throw error
|
|
40
|
+
}
|
|
18
41
|
}
|
|
19
42
|
|
|
20
43
|
|
|
21
44
|
const switchTenant = async (tenantId: string) => {
|
|
22
45
|
const {accessToken} = await authSystem.switchTenant(tenantId)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
46
|
+
// Switching tenant replaces the JWT, but must not turn an ordinary session into a remembered one.
|
|
47
|
+
authStore.setSession({accessToken, refreshToken: authStore.refreshToken ?? undefined,
|
|
48
|
+
refreshExpiresAt: authStore.refreshExpiresAt ?? undefined})
|
|
49
|
+
syncAuthHeaders(authStore)
|
|
50
|
+
await fetchAuthUser()
|
|
26
51
|
}
|
|
27
52
|
|
|
28
53
|
const loginWithToken = async (token: string) => {
|
|
54
|
+
clearAuthSession(authStore)
|
|
29
55
|
authStore.setAccessToken(token)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
56
|
+
const version = authStore.sessionVersion
|
|
57
|
+
syncAuthHeaders(authStore)
|
|
58
|
+
try {
|
|
59
|
+
if(tokenIsValid()) await fetchAuthUser()
|
|
60
|
+
else clearAuthSession(authStore)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (isCurrentAuthSession(authStore, token, version)) clearAuthSession(authStore)
|
|
63
|
+
throw error
|
|
33
64
|
}
|
|
34
65
|
|
|
35
66
|
}
|
|
@@ -64,19 +95,30 @@ export function useAuth() {
|
|
|
64
95
|
}
|
|
65
96
|
|
|
66
97
|
async function fetchAuthUser() {
|
|
98
|
+
const revision = authSessionRevision(authStore)
|
|
99
|
+
const token = authStore.accessToken
|
|
100
|
+
const version = authStore.sessionVersion
|
|
67
101
|
const authUser = await authSystem.me()
|
|
68
|
-
authStore
|
|
102
|
+
if (authSessionRevision(authStore) === revision && isCurrentAuthSession(authStore, token, version)) {
|
|
103
|
+
authStore.setAuthUser(authUser)
|
|
104
|
+
}
|
|
69
105
|
return authUser
|
|
70
106
|
}
|
|
71
107
|
|
|
72
|
-
function logout() {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
function clearAuth(){
|
|
108
|
+
async function logout() {
|
|
109
|
+
rereadSession(authStore)
|
|
110
|
+
const token = authStore.refreshToken
|
|
111
|
+
clearAuthSession(authStore)
|
|
78
112
|
authSystem.logout()
|
|
79
|
-
|
|
113
|
+
// Clear and navigate immediately, even offline or with a refresh in flight.
|
|
114
|
+
const navigation = router.push({name: 'Login'})
|
|
115
|
+
try {
|
|
116
|
+
if (token) await revokeSessionToken(token)
|
|
117
|
+
} catch {
|
|
118
|
+
// Local logout must succeed even when server revocation is unavailable.
|
|
119
|
+
} finally {
|
|
120
|
+
await navigation
|
|
121
|
+
}
|
|
80
122
|
}
|
|
81
123
|
|
|
82
124
|
function hasPermission(permission: string | undefined) {
|
|
@@ -46,7 +46,9 @@ class UserSessionCrud extends EntityCrud implements IEntityCrud {
|
|
|
46
46
|
{title: 'user', key: 'user', align: 'start'},
|
|
47
47
|
{title: 'userAgent', key: 'userAgent', align: 'start'},
|
|
48
48
|
{title: 'ip', key: 'ip', align: 'start'},
|
|
49
|
-
{title: 'createdAt', key: 'createdAt', align: 'start'}
|
|
49
|
+
{title: 'createdAt', key: 'createdAt', align: 'start'},
|
|
50
|
+
{title: 'rememberMe', key: 'rememberMe', align: 'start'},
|
|
51
|
+
{title: 'refreshExpiresAt', key: 'refreshExpiresAt', align: 'start'}
|
|
50
52
|
]
|
|
51
53
|
}
|
|
52
54
|
|
|
@@ -92,7 +94,9 @@ class UserSessionCrud extends EntityCrud implements IEntityCrud {
|
|
|
92
94
|
{name: 'user', type: 'ref', label: 'user', default: null, ref: 'User', refDisplay: 'username'},
|
|
93
95
|
{name: 'userAgent', type: 'string', label: 'userAgent', default: ''},
|
|
94
96
|
{name: 'ip', type: 'string', label: 'ip', default: ''},
|
|
95
|
-
{name: 'createdAt', type: 'date', label: 'createdAt', default: null}
|
|
97
|
+
{name: 'createdAt', type: 'date', label: 'createdAt', default: null},
|
|
98
|
+
{name: 'rememberMe', type: 'boolean', label: 'rememberMe', default: null},
|
|
99
|
+
{name: 'refreshExpiresAt', type: 'date', label: 'refreshExpiresAt', default: null}
|
|
96
100
|
]
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -136,7 +140,7 @@ class UserSessionCrud extends EntityCrud implements IEntityCrud {
|
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
get exportHeaders() {
|
|
139
|
-
return ['_id','user','createdAt','userAgent','ip']
|
|
143
|
+
return ['_id','user','createdAt','userAgent','ip','rememberMe','refreshExpiresAt']
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
get isImportable() {
|
|
@@ -11,7 +11,9 @@ const messages = {
|
|
|
11
11
|
user:'user',
|
|
12
12
|
agent:'agent',
|
|
13
13
|
ip:'ip',
|
|
14
|
-
createdAt:'createdAt'
|
|
14
|
+
createdAt:'createdAt',
|
|
15
|
+
rememberMe: 'Remember me',
|
|
16
|
+
refreshExpiresAt: 'Remembered session expires at'
|
|
15
17
|
}
|
|
16
18
|
},
|
|
17
19
|
permission: {
|
|
@@ -32,7 +34,9 @@ const messages = {
|
|
|
32
34
|
user:'user',
|
|
33
35
|
agent:'agent',
|
|
34
36
|
ip:'ip',
|
|
35
|
-
createdAt:'createdAt'
|
|
37
|
+
createdAt:'createdAt',
|
|
38
|
+
rememberMe: 'Recordarme',
|
|
39
|
+
refreshExpiresAt: 'Vencimiento de sesión recordada'
|
|
36
40
|
}
|
|
37
41
|
},
|
|
38
42
|
permission: {
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export {setupAuthSession, refreshAuthSession, isRememberMeEnabled} from './composables/setupAuthSession'
|
|
2
|
+
export {default as IdentityRememberMe} from './components/IdentityRememberMe.vue'
|
|
3
|
+
|
|
1
4
|
import IdentityLogin from "./components/IdentityLogin/IdentityLogin.vue";
|
|
2
5
|
import IdentityProfileAvatar from "./components/IdentityProfileAvatar/IdentityProfileAvatar.vue";
|
|
3
6
|
import IdentityProfileDrawer from "./components/IdentityProfileDrawer/IdentityProfileDrawer.vue";
|
package/src/stores/AuthStore.ts
CHANGED
|
@@ -8,6 +8,9 @@ import {AuthHelper} from "@drax/identity-front";
|
|
|
8
8
|
export const useAuthStore = defineStore('AuthStore', {
|
|
9
9
|
state: () => ({
|
|
10
10
|
accessToken: null as string | null,
|
|
11
|
+
sessionVersion: null as string | null,
|
|
12
|
+
refreshToken: null as string | null,
|
|
13
|
+
refreshExpiresAt: null as string | null,
|
|
11
14
|
authUser: null as IAuthFullUser | null,
|
|
12
15
|
}),
|
|
13
16
|
getters:{
|
|
@@ -23,15 +26,35 @@ export const useAuthStore = defineStore('AuthStore', {
|
|
|
23
26
|
},
|
|
24
27
|
},
|
|
25
28
|
actions:{
|
|
29
|
+
persistSession(){
|
|
30
|
+
// Persist synchronously so another tab holding the refresh lock sees rotation immediately.
|
|
31
|
+
try {
|
|
32
|
+
localStorage.setItem('AuthStore', JSON.stringify(this.$state))
|
|
33
|
+
} catch { /* Storage may be unavailable (private mode / quota). */ }
|
|
34
|
+
},
|
|
26
35
|
setAccessToken(accessToken:string){
|
|
27
|
-
this
|
|
36
|
+
this.$patch({accessToken, refreshToken: null, refreshExpiresAt: null})
|
|
37
|
+
this.persistSession()
|
|
38
|
+
},
|
|
39
|
+
setSession(session: {accessToken: string, refreshToken?: string, refreshExpiresAt?: string}){
|
|
40
|
+
this.$patch({
|
|
41
|
+
accessToken: session.accessToken,
|
|
42
|
+
refreshToken: session.refreshToken ?? null,
|
|
43
|
+
refreshExpiresAt: session.refreshExpiresAt ?? null,
|
|
44
|
+
})
|
|
45
|
+
this.persistSession()
|
|
28
46
|
},
|
|
29
47
|
setAuthUser(authUser:IAuthFullUser){
|
|
30
48
|
this.authUser = authUser
|
|
49
|
+
this.persistSession()
|
|
31
50
|
},
|
|
32
51
|
clearAuth(){
|
|
33
|
-
this
|
|
34
|
-
|
|
52
|
+
this.$patch({
|
|
53
|
+
accessToken: null, refreshToken: null, refreshExpiresAt: null, authUser: null,
|
|
54
|
+
// A persisted epoch also invalidates pending work in other tabs, even null -> null.
|
|
55
|
+
sessionVersion: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`,
|
|
56
|
+
})
|
|
57
|
+
this.persistSession()
|
|
35
58
|
}
|
|
36
59
|
},
|
|
37
60
|
|