@meistrari/auth-nuxt 3.17.0 → 3.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.json +1 -1
- package/dist/runtime/composables/admin.d.ts +13 -1
- package/dist/runtime/composables/admin.js +8 -0
- 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/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,4 +1,4 @@
|
|
|
1
|
-
import type { AdminApplicationRecord, AdminApplicationEntitlementRecord, AdminOrganization, CreateAdminApplicationEntitlementOptions, CreateAdminApplicationOptions, CreateAdminOrganizationOptions, GetAdminOrganizationOptions, ListAdminApplicationsOptions, ListAdminApplicationsResult, ListAdminOrganizationMembersOptions, ListAdminOrganizationMembersResult, ListAdminOrganizationsOptions, ListAdminOrganizationsResult, ListAdminUsersOptions, ListAdminUsersResult, UpdateAdminOrganizationOptions } from '@meistrari/auth-core';
|
|
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';
|
|
2
2
|
export interface UseTelaAdminReturn {
|
|
3
3
|
/**
|
|
4
4
|
* Lists users from the Better Auth `user` table as a global admin.
|
|
@@ -52,6 +52,18 @@ export interface UseTelaAdminReturn {
|
|
|
52
52
|
* @returns The paginated organization member listing
|
|
53
53
|
*/
|
|
54
54
|
listOrganizationMembers: (options: ListAdminOrganizationMembersOptions) => Promise<ListAdminOrganizationMembersResult>;
|
|
55
|
+
/**
|
|
56
|
+
* Lists teams for a single organization as a global admin.
|
|
57
|
+
* @param options - Organization ID, pagination, search, and ordering options
|
|
58
|
+
* @returns The paginated organization team listing
|
|
59
|
+
*/
|
|
60
|
+
listOrganizationTeams: (options: ListAdminOrganizationTeamsOptions) => Promise<ListAdminOrganizationTeamsResult>;
|
|
61
|
+
/**
|
|
62
|
+
* Creates a team for a single organization as a global admin.
|
|
63
|
+
* @param options - Organization ID, team name, and optional member user IDs
|
|
64
|
+
* @returns The created organization team
|
|
65
|
+
*/
|
|
66
|
+
createOrganizationTeam: (options: CreateAdminOrganizationTeamOptions) => Promise<AdminOrganizationTeamRecord>;
|
|
55
67
|
/**
|
|
56
68
|
* Creates an organization as a global admin without joining it.
|
|
57
69
|
* @param options - Organization name and optional slug, logo, metadata, and settings
|
|
@@ -130,6 +130,12 @@ export function useTelaAdmin() {
|
|
|
130
130
|
async function listOrganizationMembers(options) {
|
|
131
131
|
return await authClient.admin.listOrganizationMembers(options);
|
|
132
132
|
}
|
|
133
|
+
async function listOrganizationTeams(options) {
|
|
134
|
+
return await authClient.admin.listOrganizationTeams(options);
|
|
135
|
+
}
|
|
136
|
+
async function createOrganizationTeam(options) {
|
|
137
|
+
return await authClient.admin.createOrganizationTeam(options);
|
|
138
|
+
}
|
|
133
139
|
async function createOrganization(options) {
|
|
134
140
|
return await authClient.admin.createOrganization(options);
|
|
135
141
|
}
|
|
@@ -145,6 +151,8 @@ export function useTelaAdmin() {
|
|
|
145
151
|
listOrganizations,
|
|
146
152
|
getOrganization,
|
|
147
153
|
listOrganizationMembers,
|
|
154
|
+
listOrganizationTeams,
|
|
155
|
+
createOrganizationTeam,
|
|
148
156
|
createOrganization,
|
|
149
157
|
updateOrganization
|
|
150
158
|
};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { RefreshOutcome } from './refresh-orchestrator.js';
|
|
2
|
+
import type { RefreshPolicy } from './refresh-policy.js';
|
|
3
|
+
/** Timer surface the scheduler drives (see `worker-timer.ts`) */
|
|
4
|
+
export interface SchedulerTimer {
|
|
5
|
+
set: (delayMs: number, callback: () => void) => void;
|
|
6
|
+
clear: () => void;
|
|
7
|
+
}
|
|
8
|
+
export interface RefreshSchedulerDeps {
|
|
9
|
+
ensureFreshToken: (options?: {
|
|
10
|
+
force?: boolean;
|
|
11
|
+
}) => Promise<RefreshOutcome>;
|
|
12
|
+
/** Reads the live access token — must not be a stale captured ref */
|
|
13
|
+
readAccessToken: () => string | null;
|
|
14
|
+
parseTokenExpiry: (token: string) => number | null;
|
|
15
|
+
policy: RefreshPolicy;
|
|
16
|
+
timer: SchedulerTimer;
|
|
17
|
+
/**
|
|
18
|
+
* Invoked exactly once, only on a definitive auth failure (401/404).
|
|
19
|
+
* This is the scheduler's single exit; every other outcome reschedules
|
|
20
|
+
*/
|
|
21
|
+
onAuthFailure: (error: unknown) => void | Promise<void>;
|
|
22
|
+
now?: () => number;
|
|
23
|
+
random?: () => number;
|
|
24
|
+
/** Optional sink for unexpected tick errors (defaults to console.error) */
|
|
25
|
+
onUnexpectedError?: (error: unknown) => void;
|
|
26
|
+
}
|
|
27
|
+
export interface RefreshScheduler {
|
|
28
|
+
/** Starts the loop with an immediate tick */
|
|
29
|
+
start: () => void;
|
|
30
|
+
/** Stops the loop and clears any pending timer */
|
|
31
|
+
stop: () => void;
|
|
32
|
+
/**
|
|
33
|
+
* Runs a tick now, replacing any pending timer. Safe to spam from
|
|
34
|
+
* `visibilitychange`/`online`/`focus` listeners: in-flight dedup and
|
|
35
|
+
* the orchestrator's freshness pre-check make redundant kicks free.
|
|
36
|
+
*/
|
|
37
|
+
kick: () => void;
|
|
38
|
+
}
|
|
39
|
+
export declare function createRefreshScheduler(deps: RefreshSchedulerDeps): RefreshScheduler;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { computeBackoffDelay, computeNextRefreshDelay } from "./refresh-policy.js";
|
|
2
|
+
export function createRefreshScheduler(deps) {
|
|
3
|
+
const {
|
|
4
|
+
ensureFreshToken,
|
|
5
|
+
readAccessToken,
|
|
6
|
+
parseTokenExpiry,
|
|
7
|
+
policy,
|
|
8
|
+
timer,
|
|
9
|
+
onAuthFailure,
|
|
10
|
+
now = () => Date.now(),
|
|
11
|
+
random = Math.random,
|
|
12
|
+
onUnexpectedError = (error) => console.error("[Tela Auth SDK] Unexpected token refresh scheduler error:", error)
|
|
13
|
+
} = deps;
|
|
14
|
+
let stopped = true;
|
|
15
|
+
let authFailed = false;
|
|
16
|
+
let running = false;
|
|
17
|
+
let kickPending = false;
|
|
18
|
+
let consecutiveFailures = 0;
|
|
19
|
+
function nextBackoffDelay() {
|
|
20
|
+
const delay = Math.max(
|
|
21
|
+
computeBackoffDelay(consecutiveFailures, policy, random),
|
|
22
|
+
policy.minRefreshIntervalMs
|
|
23
|
+
);
|
|
24
|
+
consecutiveFailures = Math.min(consecutiveFailures + 1, 10);
|
|
25
|
+
return delay;
|
|
26
|
+
}
|
|
27
|
+
async function tick() {
|
|
28
|
+
if (stopped || authFailed || running) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
running = true;
|
|
32
|
+
let delay;
|
|
33
|
+
try {
|
|
34
|
+
const outcome = await ensureFreshToken();
|
|
35
|
+
if (outcome.status === "auth-failed") {
|
|
36
|
+
authFailed = true;
|
|
37
|
+
timer.clear();
|
|
38
|
+
await onAuthFailure(outcome.error);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (outcome.status === "ok") {
|
|
42
|
+
consecutiveFailures = 0;
|
|
43
|
+
const token = readAccessToken();
|
|
44
|
+
const expiry = token ? parseTokenExpiry(token) : null;
|
|
45
|
+
delay = computeNextRefreshDelay(expiry, now(), policy);
|
|
46
|
+
} else {
|
|
47
|
+
delay = nextBackoffDelay();
|
|
48
|
+
}
|
|
49
|
+
} catch (error) {
|
|
50
|
+
onUnexpectedError(error);
|
|
51
|
+
delay = nextBackoffDelay();
|
|
52
|
+
} finally {
|
|
53
|
+
running = false;
|
|
54
|
+
}
|
|
55
|
+
if (stopped || authFailed) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (kickPending) {
|
|
59
|
+
kickPending = false;
|
|
60
|
+
void tick();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
timer.set(delay, () => void tick());
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
start() {
|
|
67
|
+
if (authFailed) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
stopped = false;
|
|
71
|
+
void tick();
|
|
72
|
+
},
|
|
73
|
+
stop() {
|
|
74
|
+
stopped = true;
|
|
75
|
+
kickPending = false;
|
|
76
|
+
timer.clear();
|
|
77
|
+
},
|
|
78
|
+
kick() {
|
|
79
|
+
if (stopped || authFailed) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (running) {
|
|
83
|
+
kickPending = true;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
timer.clear();
|
|
87
|
+
void tick();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single-slot timer backed by an inline Web Worker
|
|
3
|
+
*
|
|
4
|
+
* Unlike main-thread timers, Worker timers are NOT throttled when the tab
|
|
5
|
+
* is in the background, so scheduled refreshes fire reliably. Falls back
|
|
6
|
+
* to `setTimeout` where Workers are unavailable, tracking the fallback id
|
|
7
|
+
* so a re-`set()` can never stack duplicate timer loops
|
|
8
|
+
*/
|
|
9
|
+
export interface WorkerLike {
|
|
10
|
+
onmessage: ((event: unknown) => void) | null;
|
|
11
|
+
onerror: ((event: unknown) => void) | null;
|
|
12
|
+
postMessage: (message: unknown) => void;
|
|
13
|
+
terminate: () => void;
|
|
14
|
+
}
|
|
15
|
+
export interface WorkerTimerDeps {
|
|
16
|
+
/** Creates the timer worker; injectable for tests. Throw to force fallback */
|
|
17
|
+
createWorker?: () => WorkerLike;
|
|
18
|
+
setTimeoutFn?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
|
19
|
+
clearTimeoutFn?: (id: ReturnType<typeof setTimeout>) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface WorkerTimer {
|
|
22
|
+
/** Schedules `callback` after `delayMs`, replacing any pending timer */
|
|
23
|
+
set: (delayMs: number, callback: () => void) => void;
|
|
24
|
+
/** Cancels the pending timer, whichever mode it is running in */
|
|
25
|
+
clear: () => void;
|
|
26
|
+
}
|
|
27
|
+
export declare function createWorkerTimer(deps?: WorkerTimerDeps): WorkerTimer;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const WORKER_SCRIPT = "self.onmessage=function(e){setTimeout(function(){postMessage('tick')},e.data)}";
|
|
2
|
+
function createBlobWorker() {
|
|
3
|
+
const blob = new Blob([WORKER_SCRIPT], { type: "application/javascript" });
|
|
4
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
5
|
+
try {
|
|
6
|
+
return new Worker(blobUrl);
|
|
7
|
+
} finally {
|
|
8
|
+
URL.revokeObjectURL(blobUrl);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function createWorkerTimer(deps = {}) {
|
|
12
|
+
const {
|
|
13
|
+
createWorker = createBlobWorker,
|
|
14
|
+
setTimeoutFn = (callback, delayMs) => setTimeout(callback, delayMs),
|
|
15
|
+
clearTimeoutFn = (id) => clearTimeout(id)
|
|
16
|
+
} = deps;
|
|
17
|
+
let worker = null;
|
|
18
|
+
let fallbackId = null;
|
|
19
|
+
let workerAvailable = true;
|
|
20
|
+
function clear() {
|
|
21
|
+
if (worker) {
|
|
22
|
+
worker.terminate();
|
|
23
|
+
worker = null;
|
|
24
|
+
}
|
|
25
|
+
if (fallbackId !== null) {
|
|
26
|
+
clearTimeoutFn(fallbackId);
|
|
27
|
+
fallbackId = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function scheduleFallback(delayMs, callback) {
|
|
31
|
+
fallbackId = setTimeoutFn(() => {
|
|
32
|
+
fallbackId = null;
|
|
33
|
+
callback();
|
|
34
|
+
}, delayMs);
|
|
35
|
+
}
|
|
36
|
+
function set(delayMs, callback) {
|
|
37
|
+
clear();
|
|
38
|
+
if (!workerAvailable) {
|
|
39
|
+
scheduleFallback(delayMs, callback);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const created = createWorker();
|
|
44
|
+
created.onmessage = () => {
|
|
45
|
+
if (worker !== created) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
worker = null;
|
|
49
|
+
created.terminate();
|
|
50
|
+
callback();
|
|
51
|
+
};
|
|
52
|
+
created.onerror = () => {
|
|
53
|
+
if (worker === created) {
|
|
54
|
+
worker = null;
|
|
55
|
+
}
|
|
56
|
+
created.terminate();
|
|
57
|
+
workerAvailable = false;
|
|
58
|
+
scheduleFallback(delayMs, callback);
|
|
59
|
+
};
|
|
60
|
+
created.postMessage(delayMs);
|
|
61
|
+
worker = created;
|
|
62
|
+
} catch {
|
|
63
|
+
workerAvailable = false;
|
|
64
|
+
scheduleFallback(delayMs, callback);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { set, clear };
|
|
68
|
+
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { defineNuxtPlugin, navigateTo, useCookie, useRoute, useRuntimeConfig } from "#app";
|
|
2
|
-
import {
|
|
2
|
+
import { RefreshTokenExpiredError } from "@meistrari/auth-core";
|
|
3
3
|
import { useTelaApplicationAuth } from "../composables/application/auth.js";
|
|
4
4
|
import { useApplicationSessionState } from "../composables/state.js";
|
|
5
5
|
import { createNuxtAuthClient } from "../shared.js";
|
|
6
|
+
import { ACCESS_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, readClientCookie } from "../helpers/client-cookies.js";
|
|
7
|
+
import { useRefreshOrchestrator } from "../helpers/refresh-deps.js";
|
|
8
|
+
import { classifyRefreshStatus, DEFAULT_REFRESH_POLICY, extractHttpStatus } from "../helpers/refresh-policy.js";
|
|
9
|
+
import { createRefreshScheduler } from "../helpers/refresh-scheduler.js";
|
|
10
|
+
import { createWorkerTimer } from "../helpers/worker-timer.js";
|
|
6
11
|
import { parseTokenExpiry } from "../helpers/token.js";
|
|
7
12
|
const SEVEN_DAYS = 60 * 60 * 24 * 7;
|
|
8
13
|
const FIFTEEN_MINUTES = 60 * 15;
|
|
9
|
-
const TWO_MINUTES = 2 * 60 * 1e3;
|
|
10
14
|
export default defineNuxtPlugin({
|
|
11
15
|
name: "tela-application-token-refresh",
|
|
12
16
|
enforce: "post",
|
|
@@ -21,108 +25,43 @@ export default defineNuxtPlugin({
|
|
|
21
25
|
const loginPath = appConfig.application?.loginPath ?? "/login";
|
|
22
26
|
const unauthorizedPath = appConfig.application?.unauthorizedPath ?? "/unauthorized";
|
|
23
27
|
const exemptPaths = [loginPath, unauthorizedPath];
|
|
24
|
-
const accessTokenCookie = useCookie(
|
|
28
|
+
const accessTokenCookie = useCookie(ACCESS_TOKEN_COOKIE, {
|
|
25
29
|
secure: !import.meta.dev,
|
|
26
30
|
sameSite: "lax",
|
|
27
31
|
maxAge: FIFTEEN_MINUTES
|
|
28
32
|
});
|
|
29
|
-
const refreshTokenCookie = useCookie(
|
|
33
|
+
const refreshTokenCookie = useCookie(REFRESH_TOKEN_COOKIE, {
|
|
30
34
|
httpOnly: true,
|
|
31
35
|
secure: !import.meta.dev,
|
|
32
36
|
sameSite: "lax",
|
|
33
37
|
maxAge: SEVEN_DAYS
|
|
34
38
|
});
|
|
35
|
-
|
|
36
|
-
let isRefreshing = false;
|
|
37
|
-
let refreshWorker = null;
|
|
38
|
-
let workerAvailable = true;
|
|
39
|
-
async function refreshToken() {
|
|
40
|
-
if (isRefreshing) {
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
isRefreshing = true;
|
|
44
|
-
try {
|
|
45
|
-
if (import.meta.server) {
|
|
46
|
-
const { accessToken, refreshToken: refreshToken2, user: user2, organization: organization2, assurance: assurance2 } = await authClient.application.refreshAccessToken(refreshTokenCookie.value ?? "");
|
|
47
|
-
accessTokenCookie.value = accessToken;
|
|
48
|
-
refreshTokenCookie.value = refreshToken2;
|
|
49
|
-
state.user.value = user2;
|
|
50
|
-
state.activeOrganization.value = organization2;
|
|
51
|
-
state.sessionAssurance.value = assurance2;
|
|
52
|
-
return true;
|
|
53
|
-
}
|
|
54
|
-
const { user, organization, assurance } = await $fetch("/auth/refresh", {
|
|
55
|
-
method: "POST"
|
|
56
|
-
});
|
|
57
|
-
state.user.value = user;
|
|
58
|
-
state.activeOrganization.value = organization;
|
|
59
|
-
state.sessionAssurance.value = assurance;
|
|
60
|
-
return true;
|
|
61
|
-
} catch {
|
|
62
|
-
await sdkLogout();
|
|
63
|
-
if (import.meta.client) {
|
|
64
|
-
const currentPath = decodeURI(route.path);
|
|
65
|
-
if (!exemptPaths.includes(currentPath)) {
|
|
66
|
-
await navigateTo({
|
|
67
|
-
path: loginPath,
|
|
68
|
-
query: { returnTo: route.fullPath }
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
} finally {
|
|
73
|
-
isRefreshing = false;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
function logout() {
|
|
39
|
+
function clearLocalSession() {
|
|
77
40
|
accessTokenCookie.value = null;
|
|
78
41
|
state.user.value = null;
|
|
79
42
|
state.activeOrganization.value = null;
|
|
80
43
|
state.sessionAssurance.value = null;
|
|
81
44
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const blob = new Blob([workerScript], { type: "application/javascript" });
|
|
90
|
-
const blobUrl = URL.createObjectURL(blob);
|
|
91
|
-
const worker = new Worker(blobUrl);
|
|
92
|
-
URL.revokeObjectURL(blobUrl);
|
|
93
|
-
worker.onmessage = () => {
|
|
94
|
-
worker.terminate();
|
|
95
|
-
refreshWorker = null;
|
|
96
|
-
callback();
|
|
97
|
-
};
|
|
98
|
-
worker.onerror = () => {
|
|
99
|
-
worker.terminate();
|
|
100
|
-
refreshWorker = null;
|
|
101
|
-
workerAvailable = false;
|
|
102
|
-
window.setTimeout(callback, delayMs);
|
|
103
|
-
};
|
|
104
|
-
worker.postMessage(delayMs);
|
|
105
|
-
refreshWorker = worker;
|
|
106
|
-
} catch {
|
|
107
|
-
workerAvailable = false;
|
|
108
|
-
window.setTimeout(callback, delayMs);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
async function scheduleTokenRefresh() {
|
|
112
|
-
if (!accessTokenCookie.value || isTokenExpired(accessTokenCookie.value, TWO_MINUTES)) {
|
|
113
|
-
const result = await refreshToken();
|
|
114
|
-
if (!result) {
|
|
45
|
+
if (import.meta.server) {
|
|
46
|
+
let handleServerRefreshFailure = function(error) {
|
|
47
|
+
const isAuthFailure = error instanceof RefreshTokenExpiredError || classifyRefreshStatus(extractHttpStatus(error)) === "auth";
|
|
48
|
+
if (isAuthFailure) {
|
|
49
|
+
console.error("[Tela Auth SDK] Refresh token rejected during SSR, clearing session:", error?.message);
|
|
50
|
+
refreshTokenCookie.value = null;
|
|
51
|
+
clearLocalSession();
|
|
115
52
|
return;
|
|
116
53
|
}
|
|
54
|
+
console.error("[Tela Auth SDK] Transient token refresh failure during SSR, leaving session for client recovery:", error?.message);
|
|
55
|
+
};
|
|
56
|
+
const authClient = createNuxtAuthClient(appConfig.apiUrl, () => null, () => refreshTokenCookie.value ?? null);
|
|
57
|
+
async function refreshOnServer() {
|
|
58
|
+
const { accessToken, refreshToken, user, organization, assurance } = await authClient.application.refreshAccessToken(refreshTokenCookie.value ?? "");
|
|
59
|
+
accessTokenCookie.value = accessToken;
|
|
60
|
+
refreshTokenCookie.value = refreshToken;
|
|
61
|
+
state.user.value = user;
|
|
62
|
+
state.activeOrganization.value = organization;
|
|
63
|
+
state.sessionAssurance.value = assurance;
|
|
117
64
|
}
|
|
118
|
-
const expiry = parseTokenExpiry(accessTokenCookie.value);
|
|
119
|
-
if (!expiry) {
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
const nextRefresh = Math.max(expiry - TWO_MINUTES - Date.now(), 0);
|
|
123
|
-
scheduleWorkerTimeout(nextRefresh, () => void scheduleTokenRefresh());
|
|
124
|
-
}
|
|
125
|
-
if (import.meta.server) {
|
|
126
65
|
if (accessTokenCookie.value) {
|
|
127
66
|
try {
|
|
128
67
|
const data = await authClient.application.whoAmI(accessTokenCookie.value, {
|
|
@@ -135,23 +74,21 @@ export default defineNuxtPlugin({
|
|
|
135
74
|
console.error("[Tela Auth SDK] Failed to get user and organization:", error.message);
|
|
136
75
|
if (!refreshTokenCookie.value) {
|
|
137
76
|
console.error("[Tela Auth SDK] Missing refresh token, logging out...");
|
|
138
|
-
|
|
77
|
+
clearLocalSession();
|
|
139
78
|
return;
|
|
140
79
|
}
|
|
141
80
|
try {
|
|
142
|
-
await
|
|
143
|
-
} catch (
|
|
144
|
-
|
|
145
|
-
logout();
|
|
81
|
+
await refreshOnServer();
|
|
82
|
+
} catch (refreshError) {
|
|
83
|
+
handleServerRefreshFailure(refreshError);
|
|
146
84
|
}
|
|
147
85
|
}
|
|
148
86
|
}
|
|
149
87
|
if (!accessTokenCookie.value && refreshTokenCookie.value) {
|
|
150
88
|
try {
|
|
151
|
-
await
|
|
152
|
-
} catch (
|
|
153
|
-
|
|
154
|
-
logout();
|
|
89
|
+
await refreshOnServer();
|
|
90
|
+
} catch (refreshError) {
|
|
91
|
+
handleServerRefreshFailure(refreshError);
|
|
155
92
|
}
|
|
156
93
|
}
|
|
157
94
|
return;
|
|
@@ -169,12 +106,34 @@ export default defineNuxtPlugin({
|
|
|
169
106
|
console.error("[Tela Auth SDK] Failed to load user info on client startup:", error);
|
|
170
107
|
}
|
|
171
108
|
}
|
|
172
|
-
|
|
109
|
+
const orchestrator = useRefreshOrchestrator(state);
|
|
110
|
+
const scheduler = createRefreshScheduler({
|
|
111
|
+
ensureFreshToken: orchestrator.ensureFreshToken,
|
|
112
|
+
readAccessToken: () => readClientCookie(ACCESS_TOKEN_COOKIE),
|
|
113
|
+
parseTokenExpiry,
|
|
114
|
+
policy: DEFAULT_REFRESH_POLICY,
|
|
115
|
+
timer: createWorkerTimer(),
|
|
116
|
+
// Reachable only from a definitive 401/404 — every other
|
|
117
|
+
// failure keeps the session and reschedules.
|
|
118
|
+
onAuthFailure: async () => {
|
|
119
|
+
await sdkLogout();
|
|
120
|
+
const currentPath = decodeURI(route.path);
|
|
121
|
+
if (!exemptPaths.includes(currentPath)) {
|
|
122
|
+
await navigateTo({
|
|
123
|
+
path: loginPath,
|
|
124
|
+
query: { returnTo: route.fullPath }
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
scheduler.start();
|
|
173
130
|
document.addEventListener("visibilitychange", () => {
|
|
174
|
-
if (document.visibilityState === "visible"
|
|
175
|
-
|
|
131
|
+
if (document.visibilityState === "visible") {
|
|
132
|
+
scheduler.kick();
|
|
176
133
|
}
|
|
177
134
|
});
|
|
135
|
+
window.addEventListener("online", () => scheduler.kick());
|
|
136
|
+
window.addEventListener("focus", () => scheduler.kick());
|
|
178
137
|
}
|
|
179
138
|
}
|
|
180
139
|
});
|
|
@@ -9,11 +9,17 @@ import type { FullOrganization, JWTPayloadAssurance, User } from '@meistrari/aut
|
|
|
9
9
|
* 4. Returns the user and organization data
|
|
10
10
|
*
|
|
11
11
|
* This keeps the refresh token secure by never exposing it to the client.
|
|
12
|
+
*
|
|
13
|
+
* Failures are differentiated so the client can react correctly:
|
|
14
|
+
* - Definitive auth failure → 401 `REFRESH_TOKEN_EXPIRED`, cookies deleted
|
|
15
|
+
* - Upstream rate limit → 429, cookies kept
|
|
16
|
+
* - Anything else (outage, network) → 503, cookies kept
|
|
12
17
|
*/
|
|
13
18
|
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
|
|
14
19
|
success: boolean;
|
|
15
20
|
user: User;
|
|
16
21
|
organization: FullOrganization;
|
|
17
22
|
assurance: JWTPayloadAssurance;
|
|
23
|
+
accessTokenExpiresAt: number | null;
|
|
18
24
|
}>>;
|
|
19
25
|
export default _default;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createError, useRuntimeConfig } from "#imports";
|
|
2
2
|
import { defineEventHandler, deleteCookie, getCookie, setCookie } from "h3";
|
|
3
|
+
import { parseTokenExpiry } from "../../../helpers/token.js";
|
|
3
4
|
import { createNuxtAuthClient } from "../../../shared.js";
|
|
5
|
+
import { planRefreshFailure, shouldWriteTokens } from "../../utils/refresh-upstream.js";
|
|
4
6
|
export default defineEventHandler(async (event) => {
|
|
5
7
|
const config = useRuntimeConfig();
|
|
6
8
|
const authConfig = config.public.telaAuth;
|
|
@@ -12,44 +14,62 @@ export default defineEventHandler(async (event) => {
|
|
|
12
14
|
message: "No refresh token found"
|
|
13
15
|
});
|
|
14
16
|
}
|
|
17
|
+
const authClient = createNuxtAuthClient(
|
|
18
|
+
authConfig.apiUrl,
|
|
19
|
+
() => null,
|
|
20
|
+
() => refreshToken
|
|
21
|
+
);
|
|
22
|
+
let accessToken;
|
|
23
|
+
let newRefreshToken;
|
|
24
|
+
let user;
|
|
25
|
+
let organization;
|
|
26
|
+
let assurance;
|
|
15
27
|
try {
|
|
16
|
-
|
|
17
|
-
authConfig.apiUrl,
|
|
18
|
-
() => null,
|
|
19
|
-
() => refreshToken
|
|
20
|
-
);
|
|
21
|
-
const { accessToken, refreshToken: newRefreshToken, user, organization, assurance } = await authClient.application.refreshAccessToken(refreshToken);
|
|
22
|
-
setCookie(event, "tela-access-token", accessToken, {
|
|
23
|
-
secure: !import.meta.dev,
|
|
24
|
-
sameSite: "lax",
|
|
25
|
-
maxAge: 60 * 15,
|
|
26
|
-
// 15 minutes
|
|
27
|
-
priority: "high",
|
|
28
|
-
path: "/"
|
|
29
|
-
});
|
|
30
|
-
setCookie(event, "tela-refresh-token", newRefreshToken, {
|
|
31
|
-
secure: !import.meta.dev,
|
|
32
|
-
sameSite: "lax",
|
|
33
|
-
httpOnly: true,
|
|
34
|
-
maxAge: 60 * 60 * 24 * 7,
|
|
35
|
-
// 7 days
|
|
36
|
-
priority: "high",
|
|
37
|
-
path: "/"
|
|
38
|
-
});
|
|
39
|
-
return {
|
|
40
|
-
success: true,
|
|
41
|
-
user,
|
|
42
|
-
organization,
|
|
43
|
-
assurance
|
|
44
|
-
};
|
|
28
|
+
({ accessToken, refreshToken: newRefreshToken, user, organization, assurance } = await authClient.application.refreshAccessToken(refreshToken));
|
|
45
29
|
} catch (error) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
30
|
+
const plan = planRefreshFailure(error);
|
|
31
|
+
console.error(`[Auth Refresh] Token refresh error (responding ${plan.statusCode}):`, error);
|
|
32
|
+
if (plan.deleteCookies) {
|
|
33
|
+
deleteCookie(event, "tela-access-token", { path: "/" });
|
|
34
|
+
deleteCookie(event, "tela-refresh-token", { path: "/" });
|
|
35
|
+
}
|
|
49
36
|
throw createError({
|
|
50
|
-
statusCode:
|
|
51
|
-
statusMessage:
|
|
52
|
-
message:
|
|
37
|
+
statusCode: plan.statusCode,
|
|
38
|
+
statusMessage: plan.statusMessage,
|
|
39
|
+
message: plan.message
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const currentAccessToken = getCookie(event, "tela-access-token");
|
|
43
|
+
if (!shouldWriteTokens({ accessToken, refreshToken: newRefreshToken, currentAccessToken })) {
|
|
44
|
+
console.error("[Auth Refresh] Upstream returned unusable tokens, refusing to overwrite cookies");
|
|
45
|
+
throw createError({
|
|
46
|
+
statusCode: 503,
|
|
47
|
+
statusMessage: "Service Unavailable",
|
|
48
|
+
message: "REFRESH_UPSTREAM_UNAVAILABLE"
|
|
53
49
|
});
|
|
54
50
|
}
|
|
51
|
+
setCookie(event, "tela-access-token", accessToken, {
|
|
52
|
+
secure: !import.meta.dev,
|
|
53
|
+
sameSite: "lax",
|
|
54
|
+
maxAge: 60 * 15,
|
|
55
|
+
// 15 minutes
|
|
56
|
+
priority: "high",
|
|
57
|
+
path: "/"
|
|
58
|
+
});
|
|
59
|
+
setCookie(event, "tela-refresh-token", newRefreshToken, {
|
|
60
|
+
secure: !import.meta.dev,
|
|
61
|
+
sameSite: "lax",
|
|
62
|
+
httpOnly: true,
|
|
63
|
+
maxAge: 60 * 60 * 24 * 7,
|
|
64
|
+
// 7 days
|
|
65
|
+
priority: "high",
|
|
66
|
+
path: "/"
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
success: true,
|
|
70
|
+
user,
|
|
71
|
+
organization,
|
|
72
|
+
assurance,
|
|
73
|
+
accessTokenExpiresAt: parseTokenExpiry(accessToken)
|
|
74
|
+
};
|
|
55
75
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** How the `/auth/refresh` route should respond to an upstream failure */
|
|
2
|
+
export interface RefreshFailurePlan {
|
|
3
|
+
statusCode: number;
|
|
4
|
+
statusMessage: string;
|
|
5
|
+
message: string;
|
|
6
|
+
/** Only a definitive auth failure may destroy the session cookies */
|
|
7
|
+
deleteCookies: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Maps an upstream refresh failure to the HTTP response the route should
|
|
11
|
+
* send, so the browser can distinguish "session revoked" (401, cookies
|
|
12
|
+
* deleted) from "API briefly down" (429/503, cookies kept).
|
|
13
|
+
*/
|
|
14
|
+
export declare function planRefreshFailure(error: unknown): RefreshFailurePlan;
|
|
15
|
+
/**
|
|
16
|
+
* Guards cookie writes after a successful upstream refresh
|
|
17
|
+
*
|
|
18
|
+
* Rejects empty, undecodable, and already-expired tokens, plus access
|
|
19
|
+
* tokens strictly older than the one currently in the cookie — a delayed
|
|
20
|
+
* response must not overwrite a newer rotation. Grace-window reissues
|
|
21
|
+
* return the same token (equal `exp`), which stays writable.
|
|
22
|
+
*/
|
|
23
|
+
export declare function shouldWriteTokens(input: {
|
|
24
|
+
accessToken: string | null | undefined;
|
|
25
|
+
refreshToken: string | null | undefined;
|
|
26
|
+
currentAccessToken?: string | null;
|
|
27
|
+
nowMs?: number;
|
|
28
|
+
}): boolean;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { RefreshTokenExpiredError } from "@meistrari/auth-core";
|
|
2
|
+
import { classifyRefreshStatus, extractHttpStatus } from "../../helpers/refresh-policy.js";
|
|
3
|
+
import { parseTokenExpiry } from "../../helpers/token.js";
|
|
4
|
+
export function planRefreshFailure(error) {
|
|
5
|
+
const status = extractHttpStatus(error);
|
|
6
|
+
const isAuthFailure = error instanceof RefreshTokenExpiredError || error?.code === "REFRESH_TOKEN_EXPIRED" || status !== void 0 && classifyRefreshStatus(status) === "auth";
|
|
7
|
+
if (isAuthFailure) {
|
|
8
|
+
return {
|
|
9
|
+
statusCode: 401,
|
|
10
|
+
statusMessage: "Unauthorized",
|
|
11
|
+
message: "REFRESH_TOKEN_EXPIRED",
|
|
12
|
+
deleteCookies: true
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (status === 429) {
|
|
16
|
+
return {
|
|
17
|
+
statusCode: 429,
|
|
18
|
+
statusMessage: "Too Many Requests",
|
|
19
|
+
message: "REFRESH_RATE_LIMITED",
|
|
20
|
+
deleteCookies: false
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
statusCode: 503,
|
|
25
|
+
statusMessage: "Service Unavailable",
|
|
26
|
+
message: "REFRESH_UPSTREAM_UNAVAILABLE",
|
|
27
|
+
deleteCookies: false
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function shouldWriteTokens(input) {
|
|
31
|
+
const { accessToken, refreshToken, currentAccessToken, nowMs = Date.now() } = input;
|
|
32
|
+
if (!accessToken || !refreshToken) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const expiry = parseTokenExpiry(accessToken);
|
|
36
|
+
if (expiry === null || expiry <= nowMs) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if (currentAccessToken) {
|
|
40
|
+
const currentExpiry = parseTokenExpiry(currentAccessToken);
|
|
41
|
+
if (currentExpiry !== null && expiry < currentExpiry) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meistrari/auth-nuxt",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.18.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -36,11 +36,11 @@
|
|
|
36
36
|
"docs": "nuxt-module-build prepare && typedoc"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@meistrari/auth-core": "1.
|
|
39
|
+
"@meistrari/auth-core": "1.28.1",
|
|
40
40
|
"jose": "6.1.3"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"nuxt": "^3.
|
|
43
|
+
"nuxt": "^3.10.0 || ^4.0.0",
|
|
44
44
|
"vue": "^3.0.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|