livedesk 0.1.396 → 0.1.397
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/bootstrap/hub-auth-handoff.js +124 -32
- package/package.json +1 -1
|
@@ -1,5 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
PRODUCTION_SUPABASE_PUBLISHABLE_KEY,
|
|
13
|
+
PRODUCTION_SUPABASE_URL
|
|
14
|
+
} from '../runtime-core/src/auth-config.js';
|
|
15
|
+
import { fetchAuthResponse } from '../runtime-core/src/auth-http.js';
|
|
3
16
|
import { normalizeRuntimeAuthSession } from '../runtime-core/src/auth-session.js';
|
|
4
17
|
|
|
5
18
|
const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
|
|
@@ -19,33 +32,113 @@ function parseStoredSession(value) {
|
|
|
19
32
|
}
|
|
20
33
|
}
|
|
21
34
|
|
|
35
|
+
function readAuthState(authPath) {
|
|
36
|
+
try {
|
|
37
|
+
const value = JSON.parse(readFileSync(authPath, 'utf8'));
|
|
38
|
+
return value && typeof value === 'object' ? value : {};
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writePrivateJsonAtomic(path, value) {
|
|
45
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
46
|
+
const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
47
|
+
try {
|
|
48
|
+
writeFileSync(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
|
|
49
|
+
try { chmodSync(temporaryPath, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
|
|
50
|
+
renameSync(temporaryPath, path);
|
|
51
|
+
try { chmodSync(path, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
|
|
52
|
+
} catch (error) {
|
|
53
|
+
try { rmSync(temporaryPath, { force: true }); } catch { /* best effort cleanup */ }
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sessionIsFresh(session, nowSeconds) {
|
|
59
|
+
const expiresAt = Number(session?.expires_at || 0);
|
|
60
|
+
return Number.isFinite(expiresAt)
|
|
61
|
+
&& expiresAt - Number(nowSeconds || 0) > MINIMUM_SESSION_LIFETIME_SECONDS;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveSupabaseAuthConfig(env = process.env) {
|
|
65
|
+
const supabaseUrl = String(
|
|
66
|
+
env.LIVEDESK_RUNTIME_SUPABASE_URL
|
|
67
|
+
|| env.LIVEDESK_SUPABASE_URL
|
|
68
|
+
|| PRODUCTION_SUPABASE_URL
|
|
69
|
+
).trim().replace(/\/+$/, '');
|
|
70
|
+
const publishableKey = String(
|
|
71
|
+
env.LIVEDESK_RUNTIME_SUPABASE_PUBLISHABLE_KEY
|
|
72
|
+
|| env.LIVEDESK_SUPABASE_PUBLISHABLE_KEY
|
|
73
|
+
|| PRODUCTION_SUPABASE_PUBLISHABLE_KEY
|
|
74
|
+
).trim();
|
|
75
|
+
return { supabaseUrl, publishableKey };
|
|
76
|
+
}
|
|
77
|
+
|
|
22
78
|
/**
|
|
23
79
|
* Contract:
|
|
24
80
|
* - Reads only the unified local Client auth store.
|
|
25
|
-
* - Returns only a refreshable
|
|
26
|
-
* enough for the new local Hub to verify it.
|
|
81
|
+
* - Returns only a refreshable session; transfer refreshes stale access tokens.
|
|
27
82
|
* - Never logs or serializes token values outside the loopback handoff.
|
|
28
83
|
*/
|
|
29
|
-
export async function readSavedClientSessionForHub(
|
|
84
|
+
export async function readSavedClientSessionForHub(stateDir) {
|
|
85
|
+
const authState = readAuthState(join(stateDir, 'auth.json'));
|
|
86
|
+
const candidate = parseStoredSession(authState?.[CLIENT_AUTH_STORAGE_KEY]);
|
|
87
|
+
const normalized = normalizeRuntimeAuthSession(candidate, { requireRefreshToken: true });
|
|
88
|
+
return normalized.ok ? normalized.session : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function refreshSavedClientSessionForHub(
|
|
92
|
+
session,
|
|
30
93
|
stateDir,
|
|
31
|
-
|
|
94
|
+
fetchImpl,
|
|
95
|
+
nowSeconds
|
|
32
96
|
) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
97
|
+
const { supabaseUrl, publishableKey } = resolveSupabaseAuthConfig();
|
|
98
|
+
const response = await fetchAuthResponse(fetchImpl, `${supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: {
|
|
101
|
+
apikey: publishableKey,
|
|
102
|
+
'Content-Type': 'application/json',
|
|
103
|
+
Accept: 'application/json'
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify({ refresh_token: session.refresh_token })
|
|
106
|
+
}, 5_000);
|
|
107
|
+
const data = await response.json().catch(() => null);
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
throw new Error(`saved-client-session-refresh-failed:${response.status}`);
|
|
110
|
+
}
|
|
111
|
+
const expiresAt = Number(data?.expires_at || 0)
|
|
112
|
+
|| (Number(data?.expires_in || 0) > 0
|
|
113
|
+
? Number(nowSeconds || 0) + Number(data.expires_in)
|
|
114
|
+
: 0);
|
|
115
|
+
const normalized = normalizeRuntimeAuthSession({
|
|
116
|
+
...data,
|
|
117
|
+
expires_at: expiresAt,
|
|
118
|
+
refresh_token: data?.refresh_token || session.refresh_token,
|
|
119
|
+
user: data?.user || session.user
|
|
120
|
+
}, { requireRefreshToken: true });
|
|
121
|
+
if (!normalized.ok || !sessionIsFresh(normalized.session, nowSeconds)) {
|
|
122
|
+
throw new Error('saved-client-session-refresh-invalid');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const authPath = join(stateDir, 'auth.json');
|
|
126
|
+
const authState = readAuthState(authPath);
|
|
127
|
+
authState[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(normalized.session);
|
|
128
|
+
writePrivateJsonAtomic(authPath, authState);
|
|
129
|
+
return normalized.session;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function resolveTransferSession(stateDir, fetchImpl, nowSeconds) {
|
|
133
|
+
const saved = await readSavedClientSessionForHub(stateDir);
|
|
134
|
+
if (!saved) {
|
|
47
135
|
return null;
|
|
48
136
|
}
|
|
137
|
+
if (sessionIsFresh(saved, nowSeconds)) {
|
|
138
|
+
return { session: saved, refreshed: false };
|
|
139
|
+
}
|
|
140
|
+
const refreshed = await refreshSavedClientSessionForHub(saved, stateDir, fetchImpl, nowSeconds);
|
|
141
|
+
return { session: refreshed, refreshed: true };
|
|
49
142
|
}
|
|
50
143
|
|
|
51
144
|
export async function transferSavedClientSessionToHub(
|
|
@@ -54,25 +147,23 @@ export async function transferSavedClientSessionToHub(
|
|
|
54
147
|
fetchImpl = fetch,
|
|
55
148
|
nowSeconds = Math.floor(Date.now() / 1000)
|
|
56
149
|
) {
|
|
57
|
-
const session = await readSavedClientSessionForHub(stateDir, nowSeconds);
|
|
58
|
-
if (!session) {
|
|
59
|
-
return { ok: false, skipped: true, reason: 'no-valid-saved-client-session' };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
150
|
try {
|
|
63
|
-
const
|
|
151
|
+
const resolved = await resolveTransferSession(stateDir, fetchImpl, nowSeconds);
|
|
152
|
+
if (!resolved) {
|
|
153
|
+
return { ok: false, skipped: true, reason: 'no-refreshable-saved-client-session' };
|
|
154
|
+
}
|
|
155
|
+
const response = await fetchAuthResponse(fetchImpl, new URL('/api/auth/session', baseUrl), {
|
|
64
156
|
method: 'POST',
|
|
65
157
|
headers: {
|
|
66
158
|
'Content-Type': 'application/json',
|
|
67
159
|
Accept: 'application/json'
|
|
68
160
|
},
|
|
69
161
|
body: JSON.stringify({
|
|
70
|
-
accessToken: session.access_token,
|
|
71
|
-
refreshToken: session.refresh_token,
|
|
72
|
-
expiresAt: session.expires_at
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
});
|
|
162
|
+
accessToken: resolved.session.access_token,
|
|
163
|
+
refreshToken: resolved.session.refresh_token,
|
|
164
|
+
expiresAt: resolved.session.expires_at
|
|
165
|
+
})
|
|
166
|
+
}, 5_000);
|
|
76
167
|
const data = await response.json().catch(() => null);
|
|
77
168
|
if (!response.ok || data?.authenticated !== true) {
|
|
78
169
|
return {
|
|
@@ -85,6 +176,7 @@ export async function transferSavedClientSessionToHub(
|
|
|
85
176
|
ok: true,
|
|
86
177
|
skipped: false,
|
|
87
178
|
authenticated: true,
|
|
179
|
+
refreshed: resolved.refreshed,
|
|
88
180
|
hostTargetOk: data?.hostTarget?.ok !== false
|
|
89
181
|
};
|
|
90
182
|
} catch (error) {
|