@drax/identity-vue 3.67.0 → 3.68.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 CHANGED
@@ -58,7 +58,7 @@ Integration contracts:
58
58
  null-to-null logout. Legacy stores without the field use the initial null epoch.
59
59
  Late returned refresh credentials are revoked best-effort.
60
60
  - Transient refresh/profile errors retain recoverable credentials; rejected
61
- credentials (HTTP 401/403, including common-front wrapped errors) clear auth.
61
+ credentials (HTTP 401/403/498, including common-front wrapped errors) clear auth.
62
62
  Logout clears local state immediately, with best-effort server revocation even
63
63
  when the feature has subsequently been disabled. Offline revocation cannot be
64
64
  guaranteed; the backend expiration/revocation policy remains authoritative.
@@ -72,6 +72,66 @@ Focused tests:
72
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
73
  ```
74
74
 
75
+ ## Expired sessions and external login migration
76
+
77
+ Session maintenance runs even with remember-me disabled or without a refresh token.
78
+ Expired/malformed access tokens without renewal are cleared together with the profile,
79
+ refresh credentials, persisted state and shared authorization headers. With remember-me
80
+ disabled, a live access token remains usable, but legacy refresh credentials are discarded.
81
+ `AuthStore.isAuth` checks local JWT validity, not mere token presence. This check is not
82
+ signature verification: the backend remains authoritative.
83
+
84
+ Store token/clear actions synchronize both shared clients. `syncAuthHeaders()` never
85
+ installs an expired/malformed JWT; transient refresh errors preserve recoverable tokens
86
+ in storage, remove expired authorization from the clients and reject the authenticated
87
+ REST preflight before fetch. There is no navigation or automatic request replay.
88
+ Removing a header directly is only a transport operation and intentionally does **not**
89
+ clear the store: use session/store actions for credential changes. Direct `$patch` is
90
+ not a session API; normal request maintenance re-reads persisted state.
91
+
92
+ External providers must mark the credential exchange as independent using the existing
93
+ HTTP option, even if the old Drax session is recoverable:
94
+
95
+ ```ts
96
+ const response = await this.httpClient.post('/api/google/login', {credential}, {
97
+ timeout: 120000,
98
+ removeHeaders: ['Authorization'],
99
+ })
100
+ await loginWithToken(response.accessToken)
101
+ ```
102
+
103
+ Do not remove/restore global headers or set the returned token manually before
104
+ `loginWithToken`. The request option excludes Authorization case-insensitively and
105
+ skips the session preflight without changing shared state. Its 401 is the external
106
+ credential's rejection, not a reason to clear the existing Drax session. No global
107
+ response interceptor performs logout. This does not disable browser CORS preflight.
108
+
109
+ Consumer changes:
110
+
111
+ - **GoogleProvider:** add `removeHeaders: ['Authorization']` to the login POST and
112
+ remove the redundant `setHttpClientToken(response.accessToken)` call; keep
113
+ `await loginWithToken(response.accessToken)` after a successful exchange. See
114
+ Zuite's `src/modules/base/providers/GoogleProvider.ts`.
115
+ - **LoginPage:** delete the workaround that checks invalid access tokens and calls
116
+ `logout()`. Session cleanup belongs to maintenance, not a page (logout also navigates).
117
+ - **Bootstrap:** retain `app.use(pinia); await setupAuthSession(); app.use(router)`;
118
+ install Pinia's persisted-state plugin before these calls. No extra cleanup needed.
119
+ - **Router:** retain the existing `await refreshAuthSession()` plus catch and cancel
120
+ protected navigation while `!isAuthenticated() && isRememberMeEnabled() &&
121
+ authStore.refreshToken`. Do not convert transient failures into logout or redirects.
122
+ - **Backend external route:** replace the Google-specific middleware wrapper with
123
+ the supported Fastify route option `{config: {identityJwt: 'ignore'}}` on the POST
124
+ route. Keep its external credential verifier and normal authorization checks.
125
+ A public route with optional JWT authentication still rejects a supplied invalid
126
+ Bearer; only this explicit option ignores it. See identity-back's
127
+ `docs/jwt-route-policy.md`.
128
+
129
+ The two local workarounds can therefore be removed after upgrading compatible packages
130
+ and declaring the external route policy. Google SDK/signature verification itself is
131
+ consumer-owned; Drax tests use a deterministic external verifier, not Google's service.
132
+ REST request-time maintenance requires the shared client's optional `setBeforeRequest`;
133
+ GraphQL and custom clients without that hook do not get automatic preflight renewal.
134
+
75
135
  ## Registro con creación de tenant (opcional, REST)
76
136
 
77
137
  En el **backend**:
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "3.67.0",
6
+ "version": "3.68.0",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "module": "./src/index.ts",
@@ -18,12 +18,12 @@
18
18
  "format": "prettier --write src/"
19
19
  },
20
20
  "dependencies": {
21
- "@drax/common-front": "^3.65.0",
22
- "@drax/common-vue": "^3.65.0",
21
+ "@drax/common-front": "^3.68.0",
22
+ "@drax/common-vue": "^3.68.0",
23
23
  "@drax/crud-front": "^3.58.0",
24
24
  "@drax/crud-share": "^3.65.0",
25
- "@drax/crud-vue": "^3.66.0",
26
- "@drax/identity-front": "^3.67.0",
25
+ "@drax/crud-vue": "^3.68.0",
26
+ "@drax/identity-front": "^3.68.0",
27
27
  "@drax/identity-share": "^3.67.0"
28
28
  },
29
29
  "peerDependencies": {
@@ -49,5 +49,5 @@
49
49
  "vue-tsc": "^3.2.4",
50
50
  "vuetify": "^3.11.8"
51
51
  },
52
- "gitHead": "ffe6431614727caa3940fcb03da690d50c5032db"
52
+ "gitHead": "c8300dd678d4048416da13f9065d6749e715c3bf"
53
53
  }
@@ -1,5 +1,5 @@
1
- import {HttpClientFactory, HttpRestClientFactory} from '@drax/common-front'
2
- import {AuthSystemFactory} from '@drax/identity-front'
1
+ import {HttpRestClientFactory} from '@drax/common-front'
2
+ import {AuthHelper, AuthSystemFactory} from '@drax/identity-front'
3
3
  import {useAuthStore} from '../stores/AuthStore'
4
4
 
5
5
  export const isRememberMeEnabled = () => import.meta.env.VITE_DRAX_REMEMBER_ME_ENABLED === 'true'
@@ -12,11 +12,7 @@ const revisions = new WeakMap<AuthStore, number>()
12
12
  export const authSessionRevision = (store = useAuthStore()) => revisions.get(store) ?? 0
13
13
 
14
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
- }
15
+ store.syncAuthHeaders()
20
16
  }
21
17
 
22
18
  export function clearAuthSession(store = useAuthStore()) {
@@ -28,7 +24,7 @@ export function clearAuthSession(store = useAuthStore()) {
28
24
  export function isRejectedCredential(error: unknown): boolean {
29
25
  const e = error as {statusCode?: number, status?: number, response?: {status?: number}, cause?: unknown, error?: unknown}
30
26
  const status = e?.statusCode ?? e?.status ?? e?.response?.status
31
- return status === 401 || status === 403 ||
27
+ return status === 401 || status === 403 || status === 498 ||
32
28
  (!!e?.error && e.error !== error && isRejectedCredential(e.error)) ||
33
29
  (!!e?.cause && e.cause !== error && isRejectedCredential(e.cause))
34
30
  }
@@ -76,11 +72,7 @@ export function isCurrentAuthSession(store: AuthStore, token: string | null, ver
76
72
  }
77
73
 
78
74
  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 }
75
+ return !AuthHelper.isJWTValid(token ?? '', 30)
84
76
  }
85
77
 
86
78
  async function withSessionLock(action: () => Promise<void>) {
@@ -115,12 +107,25 @@ async function fetchSessionUser(store: AuthStore) {
115
107
  }
116
108
 
117
109
  export function refreshAuthSession(store = useAuthStore()): Promise<void> {
118
- if (!isRememberMeEnabled()) return Promise.resolve()
110
+
119
111
  const running = flights.get(store)
120
112
  if (running) return running
121
113
  const flight = withSessionLock(async () => {
122
114
  rereadSession(store)
123
- if (!store.refreshToken) return
115
+ const valid = AuthHelper.isJWTValid(store.accessToken ?? '')
116
+ if (!isRememberMeEnabled() || !store.refreshToken) {
117
+ if (!valid) {
118
+ if (store.accessToken || store.refreshToken || store.refreshExpiresAt || store.authUser) clearAuthSession(store)
119
+ else syncAuthHeaders(store)
120
+ } else {
121
+ // Disabling remember-me must not invalidate an otherwise live ordinary JWT.
122
+ if (!isRememberMeEnabled() && (store.refreshToken || store.refreshExpiresAt)) {
123
+ store.setAccessToken(store.accessToken!)
124
+ }
125
+ syncAuthHeaders(store)
126
+ }
127
+ return
128
+ }
124
129
  if (!needsRefresh(store.accessToken)) {
125
130
  if (!store.authUser) await fetchSessionUser(store)
126
131
  return
@@ -4,6 +4,7 @@ import type {IAuthFullUser} from "@drax/identity-front";
4
4
  //@ts-ignore
5
5
  import type from 'pinia-plugin-persistedstate'
6
6
  import {AuthHelper} from "@drax/identity-front";
7
+ import {HttpClientFactory, HttpRestClientFactory} from '@drax/common-front'
7
8
 
8
9
  export const useAuthStore = defineStore('AuthStore', {
9
10
  state: () => ({
@@ -15,8 +16,7 @@ export const useAuthStore = defineStore('AuthStore', {
15
16
  }),
16
17
  getters:{
17
18
  isAuth: (state): boolean => {
18
- //TODO verify token
19
- return !!state.accessToken
19
+ return !!state.accessToken && AuthHelper.isJWTValid(state.accessToken)
20
20
  },
21
21
  hasPermission: (state) => (permission:string) => {
22
22
  return state.authUser && state.authUser.role && state.authUser.role.permissions ? state.authUser.role.permissions.includes(permission) : false
@@ -26,6 +26,16 @@ export const useAuthStore = defineStore('AuthStore', {
26
26
  },
27
27
  },
28
28
  actions:{
29
+ syncAuthHeaders(){
30
+ const clients = new Set([HttpClientFactory.getInstance(), HttpRestClientFactory.getInstance()])
31
+ for (const client of clients) {
32
+ for (const header of Object.keys(client.baseHeaders ?? {})) {
33
+ if (header.toLowerCase() === 'authorization' && header !== 'Authorization') client.removeHeader(header)
34
+ }
35
+ if (this.accessToken && AuthHelper.isJWTValid(this.accessToken)) client.addHeader('Authorization', `Bearer ${this.accessToken}`)
36
+ else client.removeHeader('Authorization')
37
+ }
38
+ },
29
39
  persistSession(){
30
40
  // Persist synchronously so another tab holding the refresh lock sees rotation immediately.
31
41
  try {
@@ -35,6 +45,7 @@ export const useAuthStore = defineStore('AuthStore', {
35
45
  setAccessToken(accessToken:string){
36
46
  this.$patch({accessToken, refreshToken: null, refreshExpiresAt: null})
37
47
  this.persistSession()
48
+ this.syncAuthHeaders()
38
49
  },
39
50
  setSession(session: {accessToken: string, refreshToken?: string, refreshExpiresAt?: string}){
40
51
  this.$patch({
@@ -43,6 +54,7 @@ export const useAuthStore = defineStore('AuthStore', {
43
54
  refreshExpiresAt: session.refreshExpiresAt ?? null,
44
55
  })
45
56
  this.persistSession()
57
+ this.syncAuthHeaders()
46
58
  },
47
59
  setAuthUser(authUser:IAuthFullUser){
48
60
  this.authUser = authUser
@@ -55,6 +67,7 @@ export const useAuthStore = defineStore('AuthStore', {
55
67
  sessionVersion: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`,
56
68
  })
57
69
  this.persistSession()
70
+ this.syncAuthHeaders()
58
71
  }
59
72
  },
60
73