@meistrari/auth-nuxt 3.18.0 → 3.19.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/dist/module.json +1 -1
- package/dist/runtime/composables/admin.d.ts +68 -3
- package/dist/runtime/composables/admin.js +45 -1
- package/dist/runtime/composables/application/auth.d.ts +14 -2
- package/dist/runtime/composables/application/auth.js +35 -16
- package/dist/runtime/helpers/client-cookies.d.ts +23 -0
- package/dist/runtime/helpers/client-cookies.js +28 -0
- package/dist/runtime/helpers/refresh-deps.d.ts +37 -0
- package/dist/runtime/helpers/refresh-deps.js +35 -0
- package/dist/runtime/helpers/refresh-orchestrator.d.ts +64 -0
- package/dist/runtime/helpers/refresh-orchestrator.js +85 -0
- package/dist/runtime/helpers/refresh-policy.d.ts +54 -0
- package/dist/runtime/helpers/refresh-policy.js +32 -0
- package/dist/runtime/helpers/refresh-scheduler.d.ts +39 -0
- package/dist/runtime/helpers/refresh-scheduler.js +90 -0
- package/dist/runtime/helpers/worker-timer.d.ts +27 -0
- package/dist/runtime/helpers/worker-timer.js +68 -0
- package/dist/runtime/plugins/application-token-refresh.js +58 -99
- package/dist/runtime/plugins/handshake.js +103 -31
- package/dist/runtime/server/routes/auth/refresh.d.ts +6 -0
- package/dist/runtime/server/routes/auth/refresh.js +55 -35
- package/dist/runtime/server/utils/refresh-upstream.d.ts +28 -0
- package/dist/runtime/server/utils/refresh-upstream.js +46 -0
- package/package.json +3 -3
package/dist/module.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { AdminApplicationRecord, AdminApplicationEntitlementRecord, AdminOrganization, AdminOrganizationTeamRecord, CreateAdminApplicationEntitlementOptions, CreateAdminApplicationOptions, CreateAdminOrganizationOptions, CreateAdminOrganizationTeamOptions, GetAdminOrganizationOptions, ListAdminApplicationsOptions, ListAdminApplicationsResult, ListAdminOrganizationMembersOptions, ListAdminOrganizationMembersResult, ListAdminOrganizationsOptions, ListAdminOrganizationsResult, ListAdminOrganizationTeamsOptions, ListAdminOrganizationTeamsResult, ListAdminUsersOptions, ListAdminUsersResult, UpdateAdminOrganizationOptions } from '@meistrari/auth-core';
|
|
1
|
+
import type { AdminApplicationRecord, AdminApplicationEntitlementRecord, AdminOrganization, AdminOrganizationTeamRecord, AdminUserAccess, CreateAdminApplicationEntitlementOptions, CreateAdminApplicationOptions, CreateAdminOrganizationOptions, CreateAdminOrganizationTeamOptions, GetAdminOrganizationOptions, InviteAdminOrganizationUsersOptions, InviteAdminOrganizationUsersResult, ListAdminApplicationsOptions, ListAdminApplicationsResult, ListAdminOrganizationMembersOptions, ListAdminOrganizationMembersResult, ListAdminOrganizationsOptions, ListAdminOrganizationsResult, ListAdminOrganizationTeamsOptions, ListAdminOrganizationTeamsResult, ListAdminUsersOptions, ListAdminUsersResult, AdminMember, RemoveAdminMemberOptions, ResendAdminInviteOptions, SetAdminUserApplicationRoleOptions, SetAdminUserTeamOptions, UpdateAdminMemberRoleOptions, UpdateAdminOrganizationOptions } from '@meistrari/auth-core';
|
|
2
2
|
export interface UseTelaAdminReturn {
|
|
3
3
|
/**
|
|
4
|
-
* Lists users
|
|
5
|
-
*
|
|
4
|
+
* Lists users as a global admin, optionally with organization previews
|
|
5
|
+
* and totals when a preview size is requested.
|
|
6
|
+
* @param options - Pagination, ordering, search, and preview options
|
|
6
7
|
* @returns The paginated user listing
|
|
7
8
|
*/
|
|
8
9
|
listUsers: (options?: ListAdminUsersOptions) => Promise<ListAdminUsersResult>;
|
|
@@ -64,6 +65,14 @@ export interface UseTelaAdminReturn {
|
|
|
64
65
|
* @returns The created organization team
|
|
65
66
|
*/
|
|
66
67
|
createOrganizationTeam: (options: CreateAdminOrganizationTeamOptions) => Promise<AdminOrganizationTeamRecord>;
|
|
68
|
+
/**
|
|
69
|
+
* Fetches the per-organization access report for a single user as a global
|
|
70
|
+
* admin, including organization roles, teams, and entitled applications with
|
|
71
|
+
* the user's effective application role.
|
|
72
|
+
* @param userId - ID of the user to fetch access for
|
|
73
|
+
* @returns The user access report
|
|
74
|
+
*/
|
|
75
|
+
getUserAccess: (userId: string) => Promise<AdminUserAccess>;
|
|
67
76
|
/**
|
|
68
77
|
* Creates an organization as a global admin without joining it.
|
|
69
78
|
* @param options - Organization name and optional slug, logo, metadata, and settings
|
|
@@ -76,6 +85,62 @@ export interface UseTelaAdminReturn {
|
|
|
76
85
|
* @returns The updated organization
|
|
77
86
|
*/
|
|
78
87
|
updateOrganization: (options: UpdateAdminOrganizationOptions) => Promise<AdminOrganization>;
|
|
88
|
+
/**
|
|
89
|
+
* Updates an organization member's role as a global admin.
|
|
90
|
+
* @param options - Membership ID and the new role
|
|
91
|
+
* @returns The updated membership
|
|
92
|
+
*/
|
|
93
|
+
updateMemberRole: (options: UpdateAdminMemberRoleOptions) => Promise<AdminMember>;
|
|
94
|
+
/**
|
|
95
|
+
* Removes an organization member as a global admin.
|
|
96
|
+
* @param options - Membership ID to remove
|
|
97
|
+
* @returns The removed membership
|
|
98
|
+
*/
|
|
99
|
+
removeMember: (options: RemoveAdminMemberOptions) => Promise<AdminMember>;
|
|
100
|
+
/**
|
|
101
|
+
* Sets a user's effective application role within an organization as a
|
|
102
|
+
* global admin. A non-null role grants access, a null role denies access
|
|
103
|
+
* (leave application), and clear removes the user-specific override.
|
|
104
|
+
* @param options - User, organization, application, and the role or clear flag
|
|
105
|
+
*/
|
|
106
|
+
setUserApplicationRole: (options: SetAdminUserApplicationRoleOptions) => Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* Reconciles a user's team memberships within an organization to a given set
|
|
109
|
+
* as a global admin.
|
|
110
|
+
* @param options - User, organization, and the desired teams (empty to clear)
|
|
111
|
+
*/
|
|
112
|
+
setUserTeam: (options: SetAdminUserTeamOptions) => Promise<void>;
|
|
113
|
+
/**
|
|
114
|
+
* Bans a user from the auth system and invalidates all their sessions.
|
|
115
|
+
* @param userId - ID of the user to ban
|
|
116
|
+
*/
|
|
117
|
+
banUser: (userId: string) => Promise<void>;
|
|
118
|
+
/**
|
|
119
|
+
* Lifts a user's ban.
|
|
120
|
+
* @param userId - ID of the user to unban
|
|
121
|
+
*/
|
|
122
|
+
unbanUser: (userId: string) => Promise<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Permanently removes a user from the auth system.
|
|
125
|
+
* @param userId - ID of the user to remove
|
|
126
|
+
*/
|
|
127
|
+
removeUser: (userId: string) => Promise<void>;
|
|
128
|
+
/**
|
|
129
|
+
* Cancels a pending invitation as a global admin.
|
|
130
|
+
* @param invitationId - ID of the invitation to cancel
|
|
131
|
+
*/
|
|
132
|
+
cancelInvite: (invitationId: string) => Promise<void>;
|
|
133
|
+
/**
|
|
134
|
+
* Re-sends an organization invitation email as a global admin.
|
|
135
|
+
* @param options - Organization, email, role, and optional application
|
|
136
|
+
*/
|
|
137
|
+
resendInvite: (options: ResendAdminInviteOptions) => Promise<void>;
|
|
138
|
+
/**
|
|
139
|
+
* Invites several users to an organization in one call, isolating failures
|
|
140
|
+
* per email in the results payload.
|
|
141
|
+
* @param options - Organization, emails, role, and optional teams/application
|
|
142
|
+
*/
|
|
143
|
+
inviteOrganizationUsers: (options: InviteAdminOrganizationUsersOptions) => Promise<InviteAdminOrganizationUsersResult>;
|
|
79
144
|
}
|
|
80
145
|
/**
|
|
81
146
|
* Composable for global admin operations.
|
|
@@ -136,12 +136,45 @@ export function useTelaAdmin() {
|
|
|
136
136
|
async function createOrganizationTeam(options) {
|
|
137
137
|
return await authClient.admin.createOrganizationTeam(options);
|
|
138
138
|
}
|
|
139
|
+
async function getUserAccess(userId) {
|
|
140
|
+
return await authClient.admin.getUserAccess(userId);
|
|
141
|
+
}
|
|
139
142
|
async function createOrganization(options) {
|
|
140
143
|
return await authClient.admin.createOrganization(options);
|
|
141
144
|
}
|
|
142
145
|
async function updateOrganization(options) {
|
|
143
146
|
return await authClient.admin.updateOrganization(options);
|
|
144
147
|
}
|
|
148
|
+
async function updateMemberRole(options) {
|
|
149
|
+
return await authClient.admin.updateMemberRole(options);
|
|
150
|
+
}
|
|
151
|
+
async function removeMember(options) {
|
|
152
|
+
return await authClient.admin.removeMember(options);
|
|
153
|
+
}
|
|
154
|
+
async function setUserApplicationRole(options) {
|
|
155
|
+
return await authClient.admin.setUserApplicationRole(options);
|
|
156
|
+
}
|
|
157
|
+
async function setUserTeam(options) {
|
|
158
|
+
return await authClient.admin.setUserTeam(options);
|
|
159
|
+
}
|
|
160
|
+
async function banUser(userId) {
|
|
161
|
+
return await authClient.admin.banUser(userId);
|
|
162
|
+
}
|
|
163
|
+
async function unbanUser(userId) {
|
|
164
|
+
return await authClient.admin.unbanUser(userId);
|
|
165
|
+
}
|
|
166
|
+
async function removeUser(userId) {
|
|
167
|
+
return await authClient.admin.removeUser(userId);
|
|
168
|
+
}
|
|
169
|
+
async function cancelInvite(invitationId) {
|
|
170
|
+
return await authClient.admin.cancelInvite(invitationId);
|
|
171
|
+
}
|
|
172
|
+
async function resendInvite(options) {
|
|
173
|
+
return await authClient.admin.resendInvite(options);
|
|
174
|
+
}
|
|
175
|
+
async function inviteOrganizationUsers(options) {
|
|
176
|
+
return await authClient.admin.inviteOrganizationUsers(options);
|
|
177
|
+
}
|
|
145
178
|
return {
|
|
146
179
|
listUsers,
|
|
147
180
|
listApplications,
|
|
@@ -153,7 +186,18 @@ export function useTelaAdmin() {
|
|
|
153
186
|
listOrganizationMembers,
|
|
154
187
|
listOrganizationTeams,
|
|
155
188
|
createOrganizationTeam,
|
|
189
|
+
getUserAccess,
|
|
156
190
|
createOrganization,
|
|
157
|
-
updateOrganization
|
|
191
|
+
updateOrganization,
|
|
192
|
+
updateMemberRole,
|
|
193
|
+
removeMember,
|
|
194
|
+
setUserApplicationRole,
|
|
195
|
+
setUserTeam,
|
|
196
|
+
banUser,
|
|
197
|
+
unbanUser,
|
|
198
|
+
removeUser,
|
|
199
|
+
cancelInvite,
|
|
200
|
+
resendInvite,
|
|
201
|
+
inviteOrganizationUsers
|
|
158
202
|
};
|
|
159
203
|
}
|
|
@@ -60,15 +60,27 @@ export interface UseTelaApplicationAuthReturn {
|
|
|
60
60
|
* for new access and refresh tokens. The server updates the cookies and returns
|
|
61
61
|
* the user and organization data.
|
|
62
62
|
*
|
|
63
|
-
*
|
|
63
|
+
* Concurrent calls (including the SDK plugin's background refresh) share
|
|
64
|
+
* one in-flight request, and transient failures are retried with backoff
|
|
65
|
+
* before this rejects.
|
|
66
|
+
*
|
|
67
|
+
* @throws {RefreshTokenExpiredError} If the refresh token is definitively expired or revoked (401/404)
|
|
68
|
+
* @throws {ApplicationError} If the refresh failed transiently (network failure, outage, rate limit) —
|
|
69
|
+
* the session is still valid and the call can be retried. Check `error.status` for the HTTP status,
|
|
70
|
+
* `undefined` meaning no HTTP response was received.
|
|
64
71
|
*/
|
|
65
72
|
refreshToken: () => Promise<void>;
|
|
66
73
|
/**
|
|
67
74
|
* Retrieves the current access token.
|
|
68
75
|
* If the token is expired, or close to expiry, it will be refreshed automatically.
|
|
69
76
|
*
|
|
77
|
+
* Stale-while-revalidate: when a refresh fails transiently (network
|
|
78
|
+
* failure, outage) but the current token is still valid, the current
|
|
79
|
+
* token is returned instead of throwing.
|
|
80
|
+
*
|
|
70
81
|
* @returns The current access token
|
|
71
|
-
* @throws {RefreshTokenExpiredError} If the token is expired
|
|
82
|
+
* @throws {RefreshTokenExpiredError} If the refresh token is definitively expired or revoked (401/404)
|
|
83
|
+
* @throws {ApplicationError} If the refresh failed transiently and the current token is hard-expired
|
|
72
84
|
*/
|
|
73
85
|
getToken: () => Promise<string | null | undefined>;
|
|
74
86
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { navigateTo, useCookie, useRuntimeConfig } from "#app";
|
|
2
|
-
import { AuthorizationFlowError, isTokenExpired, RefreshTokenExpiredError, UserNotLoggedInError } from "@meistrari/auth-core";
|
|
2
|
+
import { ApplicationError, AuthorizationFlowError, isTokenExpired, RefreshTokenExpiredError, UserNotLoggedInError } from "@meistrari/auth-core";
|
|
3
3
|
import { useApplicationSessionState } from "../state.js";
|
|
4
|
-
import {
|
|
4
|
+
import { ACCESS_TOKEN_COOKIE, readClientCookie } from "../../helpers/client-cookies.js";
|
|
5
|
+
import { useRefreshOrchestrator } from "../../helpers/refresh-deps.js";
|
|
6
|
+
import { extractHttpStatus } from "../../helpers/refresh-policy.js";
|
|
5
7
|
import { useTelaOrganization } from "./organization.js";
|
|
6
8
|
const FIFTEEN_MINUTES = 60 * 15;
|
|
7
9
|
const ONE_MINUTE = 60 * 1e3;
|
|
@@ -52,18 +54,24 @@ export function useTelaApplicationAuth() {
|
|
|
52
54
|
state.sessionAssurance.value = null;
|
|
53
55
|
await $fetch("/auth/logout", { method: "POST" });
|
|
54
56
|
}
|
|
57
|
+
const orchestrator = useRefreshOrchestrator(state);
|
|
58
|
+
function readAccessToken() {
|
|
59
|
+
return readClientCookie(ACCESS_TOKEN_COOKIE) ?? accessTokenCookie.value ?? null;
|
|
60
|
+
}
|
|
55
61
|
async function refreshToken() {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
} catch (error) {
|
|
64
|
-
console.error("[Auth Refresh] Failed to refresh token:", error);
|
|
65
|
-
throw new RefreshTokenExpiredError();
|
|
62
|
+
const outcome = await orchestrator.ensureFreshToken({ force: true });
|
|
63
|
+
if (outcome.status === "ok") {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (outcome.status === "auth-failed") {
|
|
67
|
+
console.error("[Auth Refresh] Refresh token expired or revoked:", outcome.error);
|
|
68
|
+
throw new RefreshTokenExpiredError({ cause: outcome.error, status: extractHttpStatus(outcome.error) });
|
|
66
69
|
}
|
|
70
|
+
console.error("[Auth Refresh] Transient token refresh failure:", "error" in outcome ? outcome.error : "offline");
|
|
71
|
+
throw new ApplicationError("Failed to refresh access token", {
|
|
72
|
+
cause: "error" in outcome ? outcome.error : void 0,
|
|
73
|
+
status: "error" in outcome ? extractHttpStatus(outcome.error) : void 0
|
|
74
|
+
});
|
|
67
75
|
}
|
|
68
76
|
async function initSession() {
|
|
69
77
|
if (!accessTokenCookie.value) {
|
|
@@ -81,11 +89,22 @@ export function useTelaApplicationAuth() {
|
|
|
81
89
|
await useTelaOrganization().setActiveOrganization(organizationId);
|
|
82
90
|
}
|
|
83
91
|
async function getToken() {
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
86
|
-
|
|
92
|
+
const outcome = await orchestrator.ensureFreshToken();
|
|
93
|
+
if (outcome.status === "ok") {
|
|
94
|
+
return readAccessToken();
|
|
95
|
+
}
|
|
96
|
+
if (outcome.status === "auth-failed") {
|
|
97
|
+
console.error("[Auth Refresh] Refresh token expired or revoked:", outcome.error);
|
|
98
|
+
throw new RefreshTokenExpiredError({ cause: outcome.error, status: extractHttpStatus(outcome.error) });
|
|
99
|
+
}
|
|
100
|
+
const currentToken = readAccessToken();
|
|
101
|
+
if (currentToken && !isTokenExpired(currentToken)) {
|
|
102
|
+
return currentToken;
|
|
87
103
|
}
|
|
88
|
-
|
|
104
|
+
throw new ApplicationError("Failed to refresh access token", {
|
|
105
|
+
cause: "error" in outcome ? outcome.error : void 0,
|
|
106
|
+
status: "error" in outcome ? extractHttpStatus(outcome.error) : void 0
|
|
107
|
+
});
|
|
89
108
|
}
|
|
90
109
|
return {
|
|
91
110
|
user: state.user,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Cookie holding the application access token (readable JWT) */
|
|
2
|
+
export declare const ACCESS_TOKEN_COOKIE = "tela-access-token";
|
|
3
|
+
/** Cookie holding the application refresh token (httpOnly — invisible to JS) */
|
|
4
|
+
export declare const REFRESH_TOKEN_COOKIE = "tela-refresh-token";
|
|
5
|
+
/**
|
|
6
|
+
* Reads a cookie value straight from `document.cookie`
|
|
7
|
+
*
|
|
8
|
+
* This is the source of truth for all token freshness math: `useCookie`
|
|
9
|
+
* refs captured at setup time can go stale after the server rotates
|
|
10
|
+
* cookies via `Set-Cookie`, which is exactly when the scheduling math
|
|
11
|
+
* must not be wrong
|
|
12
|
+
*
|
|
13
|
+
* @returns The decoded cookie value, or `null` when absent or on the server
|
|
14
|
+
*/
|
|
15
|
+
export declare function readClientCookie(name: string): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Re-syncs the `useCookie` refs for both auth cookies after the server
|
|
18
|
+
* rotated them, so reactive app code observes the new values.
|
|
19
|
+
*
|
|
20
|
+
* Reactive sugar only — freshness logic never depends on it, it always
|
|
21
|
+
* reads `document.cookie` via {@link readClientCookie}
|
|
22
|
+
*/
|
|
23
|
+
export declare function syncAuthCookieRefs(): void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { refreshCookie } from "#app";
|
|
2
|
+
export const ACCESS_TOKEN_COOKIE = "tela-access-token";
|
|
3
|
+
export const REFRESH_TOKEN_COOKIE = "tela-refresh-token";
|
|
4
|
+
export function readClientCookie(name) {
|
|
5
|
+
if (typeof document === "undefined") {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
const prefix = `${name}=`;
|
|
9
|
+
for (const part of document.cookie.split(";")) {
|
|
10
|
+
const trimmed = part.trim();
|
|
11
|
+
if (trimmed.startsWith(prefix)) {
|
|
12
|
+
const value = trimmed.slice(prefix.length);
|
|
13
|
+
try {
|
|
14
|
+
return decodeURIComponent(value);
|
|
15
|
+
} catch {
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
export function syncAuthCookieRefs() {
|
|
23
|
+
if (import.meta.server) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
refreshCookie(ACCESS_TOKEN_COOKIE);
|
|
27
|
+
refreshCookie(REFRESH_TOKEN_COOKIE);
|
|
28
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import type { FullOrganization, JWTPayload, User } from '@meistrari/auth-core';
|
|
3
|
+
import type { RefreshOrchestrator, RefreshOrchestratorDeps } from './refresh-orchestrator.js';
|
|
4
|
+
/** Success body of the `/auth/refresh` Nitro route */
|
|
5
|
+
export interface RefreshResponseBody {
|
|
6
|
+
success: boolean;
|
|
7
|
+
user: User;
|
|
8
|
+
organization: FullOrganization;
|
|
9
|
+
assurance: JWTPayload['assurance'];
|
|
10
|
+
/** Epoch ms expiry of the freshly issued access token */
|
|
11
|
+
accessTokenExpiresAt: number | null;
|
|
12
|
+
}
|
|
13
|
+
interface ApplicationSessionState {
|
|
14
|
+
user: Ref<User | null>;
|
|
15
|
+
activeOrganization: Ref<FullOrganization | null>;
|
|
16
|
+
sessionAssurance: Ref<JWTPayload['assurance'] | null>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Builds the orchestrator dependencies used by both the token-refresh
|
|
20
|
+
* plugin and `useTelaApplicationAuth`, so the two call sites can't drift
|
|
21
|
+
*
|
|
22
|
+
* `performRefresh` hits the Nitro `/auth/refresh` route with a bounded
|
|
23
|
+
* timeout (so a held Web Lock always releases), then updates session
|
|
24
|
+
* state and re-syncs the reactive cookie refs. Cookies themselves are
|
|
25
|
+
* rotated by the route via `Set-Cookie`
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildRefreshDeps(state: ApplicationSessionState): RefreshOrchestratorDeps;
|
|
28
|
+
/**
|
|
29
|
+
* Returns the refresh orchestrator for the current environment.
|
|
30
|
+
*
|
|
31
|
+
* On the client this is a singleton shared by the plugin and the
|
|
32
|
+
* composable, so their refreshes dedupe into one in-flight request.
|
|
33
|
+
* On the server each caller gets a throwaway instance — no state may
|
|
34
|
+
* leak across requests
|
|
35
|
+
*/
|
|
36
|
+
export declare function useRefreshOrchestrator(state: ApplicationSessionState): RefreshOrchestrator;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useCookie } from "#app";
|
|
2
|
+
import { ACCESS_TOKEN_COOKIE, readClientCookie, syncAuthCookieRefs } from "./client-cookies.js";
|
|
3
|
+
import { createRefreshOrchestrator } from "./refresh-orchestrator.js";
|
|
4
|
+
import { DEFAULT_REFRESH_POLICY } from "./refresh-policy.js";
|
|
5
|
+
import { parseTokenExpiry } from "./token.js";
|
|
6
|
+
export function buildRefreshDeps(state) {
|
|
7
|
+
const accessTokenCookie = useCookie(ACCESS_TOKEN_COOKIE);
|
|
8
|
+
return {
|
|
9
|
+
performRefresh: async () => {
|
|
10
|
+
const result = await $fetch("/auth/refresh", {
|
|
11
|
+
method: "POST",
|
|
12
|
+
timeout: 15e3
|
|
13
|
+
});
|
|
14
|
+
state.user.value = result.user;
|
|
15
|
+
state.activeOrganization.value = result.organization;
|
|
16
|
+
state.sessionAssurance.value = result.assurance;
|
|
17
|
+
syncAuthCookieRefs();
|
|
18
|
+
},
|
|
19
|
+
// document.cookie is the live source of truth on the client; the
|
|
20
|
+
// useCookie ref covers SSR, where document is unavailable.
|
|
21
|
+
readAccessToken: () => readClientCookie(ACCESS_TOKEN_COOKIE) ?? accessTokenCookie.value ?? null,
|
|
22
|
+
parseTokenExpiry,
|
|
23
|
+
policy: DEFAULT_REFRESH_POLICY
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
let sharedOrchestrator = null;
|
|
27
|
+
export function useRefreshOrchestrator(state) {
|
|
28
|
+
if (import.meta.server) {
|
|
29
|
+
return createRefreshOrchestrator(buildRefreshDeps(state));
|
|
30
|
+
}
|
|
31
|
+
if (!sharedOrchestrator) {
|
|
32
|
+
sharedOrchestrator = createRefreshOrchestrator(buildRefreshDeps(state));
|
|
33
|
+
}
|
|
34
|
+
return sharedOrchestrator;
|
|
35
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { RefreshPolicy } from './refresh-policy.js';
|
|
2
|
+
/**
|
|
3
|
+
* Result of an {@link RefreshOrchestrator.ensureFreshToken} call
|
|
4
|
+
*
|
|
5
|
+
* Always a concrete outcome — the orchestrator never throws and never
|
|
6
|
+
* resolves to `undefined`, so callers (the scheduler in particular)
|
|
7
|
+
* can't mistake a concurrent call for a failure
|
|
8
|
+
*/
|
|
9
|
+
export type RefreshOutcome = {
|
|
10
|
+
status: 'ok';
|
|
11
|
+
refreshed: boolean;
|
|
12
|
+
} | {
|
|
13
|
+
status: 'skipped-offline';
|
|
14
|
+
} | {
|
|
15
|
+
status: 'auth-failed';
|
|
16
|
+
error: unknown;
|
|
17
|
+
} | {
|
|
18
|
+
status: 'transient-failed';
|
|
19
|
+
error: unknown;
|
|
20
|
+
};
|
|
21
|
+
/** Minimal Web Locks surface used by the orchestrator. */
|
|
22
|
+
export interface RefreshLockManager {
|
|
23
|
+
request: <T>(name: string, callback: () => Promise<T>) => Promise<T>;
|
|
24
|
+
}
|
|
25
|
+
export interface RefreshOrchestratorDeps {
|
|
26
|
+
/**
|
|
27
|
+
* Performs one actual refresh: the network call plus any state/cookie
|
|
28
|
+
* updates. Must reject on failure; the rejection is classified via
|
|
29
|
+
* `extractHttpStatus`. Must be bounded (pass a fetch timeout) so a
|
|
30
|
+
* held Web Lock always releases
|
|
31
|
+
*/
|
|
32
|
+
performRefresh: () => Promise<void>;
|
|
33
|
+
/** Reads the current access token from the live cookie source of truth */
|
|
34
|
+
readAccessToken: () => string | null;
|
|
35
|
+
/** Parses a JWT expiry to epoch ms, `null` when undecodable */
|
|
36
|
+
parseTokenExpiry: (token: string) => number | null;
|
|
37
|
+
policy: RefreshPolicy;
|
|
38
|
+
now?: () => number;
|
|
39
|
+
sleep?: (ms: number) => Promise<void>;
|
|
40
|
+
random?: () => number;
|
|
41
|
+
/** Returns `false` only when the browser is known to be offline */
|
|
42
|
+
isOnline?: () => boolean;
|
|
43
|
+
/** Web Locks manager; omit/`null` where unavailable (older browsers, SSR) */
|
|
44
|
+
locks?: RefreshLockManager | null;
|
|
45
|
+
lockName?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface RefreshOrchestrator {
|
|
48
|
+
/**
|
|
49
|
+
* Ensures the access token is fresh, refreshing it if needed
|
|
50
|
+
*
|
|
51
|
+
* Concurrent callers share one in-flight promise (a single network
|
|
52
|
+
* request). Cross-tab callers are serialized by a Web Lock, and a
|
|
53
|
+
* caller that waited on the lock re-reads the cookie afterwards so it
|
|
54
|
+
* rides a sibling tab's rotation without its own network call.
|
|
55
|
+
* Transient failures back off and retry up to the policy cap;
|
|
56
|
+
* only a definitive 401/404 yields `auth-failed`
|
|
57
|
+
*
|
|
58
|
+
* @param options.force - Skip freshness checks and always hit the network
|
|
59
|
+
*/
|
|
60
|
+
ensureFreshToken: (options?: {
|
|
61
|
+
force?: boolean;
|
|
62
|
+
}) => Promise<RefreshOutcome>;
|
|
63
|
+
}
|
|
64
|
+
export declare function createRefreshOrchestrator(deps: RefreshOrchestratorDeps): RefreshOrchestrator;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { classifyRefreshStatus, computeBackoffDelay, extractHttpStatus } from "./refresh-policy.js";
|
|
2
|
+
const DEFAULT_LOCK_NAME = "tela-auth-token-refresh";
|
|
3
|
+
export function createRefreshOrchestrator(deps) {
|
|
4
|
+
const {
|
|
5
|
+
performRefresh,
|
|
6
|
+
readAccessToken,
|
|
7
|
+
parseTokenExpiry,
|
|
8
|
+
policy,
|
|
9
|
+
now = () => Date.now(),
|
|
10
|
+
sleep = async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)),
|
|
11
|
+
random = Math.random,
|
|
12
|
+
isOnline = () => typeof navigator === "undefined" || navigator.onLine !== false,
|
|
13
|
+
locks = typeof navigator !== "undefined" && "locks" in navigator ? navigator.locks : null,
|
|
14
|
+
lockName = DEFAULT_LOCK_NAME
|
|
15
|
+
} = deps;
|
|
16
|
+
let inFlight = null;
|
|
17
|
+
function isTokenFresh() {
|
|
18
|
+
const token = readAccessToken();
|
|
19
|
+
if (!token) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const expiry = parseTokenExpiry(token);
|
|
23
|
+
if (expiry === null) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return expiry - now() > policy.refreshThresholdMs;
|
|
27
|
+
}
|
|
28
|
+
async function attemptWithinLock(force) {
|
|
29
|
+
if (!force && isTokenFresh()) {
|
|
30
|
+
return { kind: "ok", refreshed: false };
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
await performRefresh();
|
|
34
|
+
return { kind: "ok", refreshed: true };
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return { kind: "error", error };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function attempt(force) {
|
|
40
|
+
if (locks) {
|
|
41
|
+
try {
|
|
42
|
+
return await locks.request(lockName, async () => await attemptWithinLock(force));
|
|
43
|
+
} catch {
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return await attemptWithinLock(force);
|
|
47
|
+
}
|
|
48
|
+
async function run(force) {
|
|
49
|
+
try {
|
|
50
|
+
for (let retry = 0; ; retry++) {
|
|
51
|
+
if (!isOnline()) {
|
|
52
|
+
return { status: "skipped-offline" };
|
|
53
|
+
}
|
|
54
|
+
const result = await attempt(force);
|
|
55
|
+
if (result.kind === "ok") {
|
|
56
|
+
return { status: "ok", refreshed: result.refreshed };
|
|
57
|
+
}
|
|
58
|
+
const kind = classifyRefreshStatus(extractHttpStatus(result.error));
|
|
59
|
+
if (kind === "auth") {
|
|
60
|
+
return { status: "auth-failed", error: result.error };
|
|
61
|
+
}
|
|
62
|
+
if (retry >= policy.maxImmediateRetries) {
|
|
63
|
+
return { status: "transient-failed", error: result.error };
|
|
64
|
+
}
|
|
65
|
+
await sleep(computeBackoffDelay(retry, policy, random));
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { status: "transient-failed", error };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function ensureFreshToken(options = {}) {
|
|
72
|
+
if (inFlight) {
|
|
73
|
+
return await inFlight;
|
|
74
|
+
}
|
|
75
|
+
const force = options.force ?? false;
|
|
76
|
+
if (!force && isTokenFresh()) {
|
|
77
|
+
return { status: "ok", refreshed: false };
|
|
78
|
+
}
|
|
79
|
+
inFlight = run(force).finally(() => {
|
|
80
|
+
inFlight = null;
|
|
81
|
+
});
|
|
82
|
+
return await inFlight;
|
|
83
|
+
}
|
|
84
|
+
return { ensureFreshToken };
|
|
85
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure refresh policy: failure classification, status extraction,
|
|
3
|
+
* backoff math, and schedule math
|
|
4
|
+
*/
|
|
5
|
+
/** How a refresh failure should be handled */
|
|
6
|
+
export type RefreshFailureKind = 'auth' | 'transient';
|
|
7
|
+
/** Timing knobs shared by the orchestrator and the scheduler */
|
|
8
|
+
export interface RefreshPolicy {
|
|
9
|
+
/** Refresh this long before the access token expires (ms) */
|
|
10
|
+
refreshThresholdMs: number;
|
|
11
|
+
/** How many immediate in-place retries a single refresh attempt gets */
|
|
12
|
+
maxImmediateRetries: number;
|
|
13
|
+
/** Base delay for exponential backoff between retries (ms) */
|
|
14
|
+
retryBaseDelayMs: number;
|
|
15
|
+
/** Upper bound for any backoff delay (ms) */
|
|
16
|
+
retryMaxDelayMs: number;
|
|
17
|
+
/** Hard floor between two scheduled refreshes (ms) */
|
|
18
|
+
minRefreshIntervalMs: number;
|
|
19
|
+
}
|
|
20
|
+
/** Default timing policy */
|
|
21
|
+
export declare const DEFAULT_REFRESH_POLICY: RefreshPolicy;
|
|
22
|
+
/**
|
|
23
|
+
* Classifies a refresh failure by HTTP status
|
|
24
|
+
*
|
|
25
|
+
* Only a definitive 401/404 means the session is gone. Everything else —
|
|
26
|
+
* 429, 5xx, other 4xx, or no status at all (network failure) — is
|
|
27
|
+
* transient and must never log the user out
|
|
28
|
+
*/
|
|
29
|
+
export declare function classifyRefreshStatus(status: number | undefined): RefreshFailureKind;
|
|
30
|
+
/**
|
|
31
|
+
* Extracts an HTTP status code from an unknown error shape
|
|
32
|
+
*
|
|
33
|
+
* Checks, in order: `status`, `statusCode`, `response.status`, then the
|
|
34
|
+
* same fields on `cause` (recursively, so wrapped SDK errors still yield
|
|
35
|
+
* the upstream status). Returns `undefined` for pure transport failures
|
|
36
|
+
*/
|
|
37
|
+
export declare function extractHttpStatus(error: unknown, depth?: number): number | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Computes a full-jitter exponential backoff delay
|
|
40
|
+
*
|
|
41
|
+
* @param attempt - Zero-based retry attempt number
|
|
42
|
+
* @param policy - Timing policy providing base and max delays
|
|
43
|
+
* @param random - Random source in [0, 1); injectable for deterministic tests
|
|
44
|
+
*/
|
|
45
|
+
export declare function computeBackoffDelay(attempt: number, policy: Pick<RefreshPolicy, 'retryBaseDelayMs' | 'retryMaxDelayMs'>, random?: () => number): number;
|
|
46
|
+
/**
|
|
47
|
+
* Computes the delay until the next scheduled refresh from the access
|
|
48
|
+
* token's expiry, clamped to a hard minimum interval
|
|
49
|
+
*
|
|
50
|
+
* A `null` expiry (missing or undecodable token) and an already-passed
|
|
51
|
+
* expiry both return `minRefreshIntervalMs` — never 0 — so a stale
|
|
52
|
+
* cookie read can never spin the scheduler into a hot loop
|
|
53
|
+
*/
|
|
54
|
+
export declare function computeNextRefreshDelay(expiryMs: number | null, nowMs: number, policy: Pick<RefreshPolicy, 'refreshThresholdMs' | 'minRefreshIntervalMs'>): number;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const DEFAULT_REFRESH_POLICY = {
|
|
2
|
+
refreshThresholdMs: 2 * 60 * 1e3,
|
|
3
|
+
maxImmediateRetries: 3,
|
|
4
|
+
retryBaseDelayMs: 1e3,
|
|
5
|
+
retryMaxDelayMs: 3e4,
|
|
6
|
+
minRefreshIntervalMs: 5e3
|
|
7
|
+
};
|
|
8
|
+
export function classifyRefreshStatus(status) {
|
|
9
|
+
return status === 401 || status === 404 ? "auth" : "transient";
|
|
10
|
+
}
|
|
11
|
+
export function extractHttpStatus(error, depth = 0) {
|
|
12
|
+
if (!error || typeof error !== "object" || depth > 4) {
|
|
13
|
+
return void 0;
|
|
14
|
+
}
|
|
15
|
+
const candidate = error;
|
|
16
|
+
for (const value of [candidate.status, candidate.statusCode, candidate.response?.status]) {
|
|
17
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return extractHttpStatus(candidate.cause, depth + 1);
|
|
22
|
+
}
|
|
23
|
+
export function computeBackoffDelay(attempt, policy, random = Math.random) {
|
|
24
|
+
const exponential = Math.min(policy.retryMaxDelayMs, policy.retryBaseDelayMs * 2 ** Math.max(attempt, 0));
|
|
25
|
+
return Math.floor(random() * exponential);
|
|
26
|
+
}
|
|
27
|
+
export function computeNextRefreshDelay(expiryMs, nowMs, policy) {
|
|
28
|
+
if (expiryMs === null) {
|
|
29
|
+
return policy.minRefreshIntervalMs;
|
|
30
|
+
}
|
|
31
|
+
return Math.max(expiryMs - policy.refreshThresholdMs - nowMs, policy.minRefreshIntervalMs);
|
|
32
|
+
}
|