Harden web wallet session lifecycle in landing and store flows
This commit is contained in:
parent
9517f13b45
commit
c616ea9e8f
@ -472,6 +472,8 @@ const flowState = {
|
|||||||
currentIntent: null,
|
currentIntent: null,
|
||||||
sessionToken: '',
|
sessionToken: '',
|
||||||
sessionExpiresAt: '',
|
sessionExpiresAt: '',
|
||||||
|
sessionWallet: '',
|
||||||
|
sessionRefreshInFlight: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const continueAction = document.getElementById('continue-action');
|
const continueAction = document.getElementById('continue-action');
|
||||||
@ -519,6 +521,7 @@ function getStoredAcknowledgement() {
|
|||||||
if (parsed && (parsed.code || parsed.token)) {
|
if (parsed && (parsed.code || parsed.token)) {
|
||||||
flowState.sessionToken = typeof parsed.session_token === 'string' ? parsed.session_token : '';
|
flowState.sessionToken = typeof parsed.session_token === 'string' ? parsed.session_token : '';
|
||||||
flowState.sessionExpiresAt = typeof parsed.session_expires_at === 'string' ? parsed.session_expires_at : '';
|
flowState.sessionExpiresAt = typeof parsed.session_expires_at === 'string' ? parsed.session_expires_at : '';
|
||||||
|
flowState.sessionWallet = typeof parsed.wallet === 'string' ? parsed.wallet : '';
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -609,7 +612,92 @@ function formatQuoteDisplay(quote) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postJSON(url, payload) {
|
function clearFlowSession(reason) {
|
||||||
|
flowState.sessionToken = '';
|
||||||
|
flowState.sessionExpiresAt = '';
|
||||||
|
flowState.sessionWallet = '';
|
||||||
|
if (reason) {
|
||||||
|
console.warn('wallet session cleared:', reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureFlowSessionHeaders(res) {
|
||||||
|
const token = String(res.headers.get('x-edut-session') || '').trim();
|
||||||
|
if (token) {
|
||||||
|
flowState.sessionToken = token;
|
||||||
|
}
|
||||||
|
const expiresAt = String(res.headers.get('x-edut-session-expires-at') || '').trim();
|
||||||
|
if (expiresAt) {
|
||||||
|
flowState.sessionExpiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseApiError(text, status) {
|
||||||
|
if (!text) {
|
||||||
|
return { message: 'HTTP ' + status, code: '' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
return {
|
||||||
|
message: String(parsed.error || ('HTTP ' + status)),
|
||||||
|
code: String(parsed.code || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// fall back to raw text
|
||||||
|
}
|
||||||
|
return { message: text, code: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalSessionCode(code) {
|
||||||
|
const normalized = String(code || '').toLowerCase();
|
||||||
|
return normalized === 'wallet_session_invalid' ||
|
||||||
|
normalized === 'wallet_session_expired' ||
|
||||||
|
normalized === 'wallet_session_revoked' ||
|
||||||
|
normalized === 'wallet_session_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeRefreshFlowSession(wallet, requestUrl) {
|
||||||
|
if (!flowState.sessionToken || !wallet || !flowState.sessionExpiresAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (String(requestUrl || '').indexOf('/secret/wallet/session/') === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expiresAt = Date.parse(flowState.sessionExpiresAt);
|
||||||
|
if (!Number.isFinite(expiresAt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((expiresAt - Date.now()) > (5 * 60 * 1000)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (flowState.sessionRefreshInFlight) {
|
||||||
|
await flowState.sessionRefreshInFlight;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flowState.sessionRefreshInFlight = postJSON(
|
||||||
|
'/secret/wallet/session/refresh',
|
||||||
|
{ wallet },
|
||||||
|
{ wallet, skipSessionRefresh: true },
|
||||||
|
).then(function (out) {
|
||||||
|
if (out && typeof out.session_token === 'string') {
|
||||||
|
flowState.sessionToken = out.session_token;
|
||||||
|
}
|
||||||
|
if (out && typeof out.session_expires_at === 'string') {
|
||||||
|
flowState.sessionExpiresAt = out.session_expires_at;
|
||||||
|
}
|
||||||
|
}).finally(function () {
|
||||||
|
flowState.sessionRefreshInFlight = null;
|
||||||
|
});
|
||||||
|
await flowState.sessionRefreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON(url, payload, options) {
|
||||||
|
const opts = options || {};
|
||||||
|
if (!opts.skipSessionRefresh) {
|
||||||
|
await maybeRefreshFlowSession(opts.wallet || flowState.sessionWallet || '', url);
|
||||||
|
}
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
@ -622,11 +710,17 @@ async function postJSON(url, payload) {
|
|||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
captureFlowSessionHeaders(res);
|
||||||
|
const text = await res.text();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await res.text();
|
const detail = parseApiError(text, res.status);
|
||||||
throw new Error(detail || ('HTTP ' + res.status));
|
if (isTerminalSessionCode(detail.code)) {
|
||||||
|
clearFlowSession(detail.code);
|
||||||
|
}
|
||||||
|
throw new Error(detail.message);
|
||||||
}
|
}
|
||||||
return res.json();
|
if (!text) return {};
|
||||||
|
return JSON.parse(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLanguage(input) {
|
function resolveLanguage(input) {
|
||||||
@ -725,6 +819,10 @@ async function startWalletFlow() {
|
|||||||
throw new Error('Wallet connection was not approved.');
|
throw new Error('Wallet connection was not approved.');
|
||||||
}
|
}
|
||||||
const address = accounts[0];
|
const address = accounts[0];
|
||||||
|
if (flowState.sessionWallet && flowState.sessionWallet.toLowerCase() !== address.toLowerCase()) {
|
||||||
|
clearFlowSession('wallet_changed');
|
||||||
|
}
|
||||||
|
flowState.sessionWallet = address;
|
||||||
const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
|
const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
|
||||||
const chainId = Number.parseInt(chainIdHex, 16);
|
const chainId = Number.parseInt(chainIdHex, 16);
|
||||||
|
|
||||||
@ -734,7 +832,7 @@ async function startWalletFlow() {
|
|||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
locale: localStorage.getItem('edut_lang') || 'en',
|
locale: localStorage.getItem('edut_lang') || 'en',
|
||||||
chain_id: chainId,
|
chain_id: chainId,
|
||||||
});
|
}, { wallet: address });
|
||||||
flowState.currentIntent = intent;
|
flowState.currentIntent = intent;
|
||||||
|
|
||||||
const typedData = {
|
const typedData = {
|
||||||
@ -781,16 +879,17 @@ async function startWalletFlow() {
|
|||||||
address,
|
address,
|
||||||
chain_id: chainId,
|
chain_id: chainId,
|
||||||
signature,
|
signature,
|
||||||
});
|
}, { wallet: address });
|
||||||
flowState.sessionToken = verification.session_token || '';
|
flowState.sessionToken = verification.session_token || '';
|
||||||
flowState.sessionExpiresAt = verification.session_expires_at || '';
|
flowState.sessionExpiresAt = verification.session_expires_at || '';
|
||||||
|
flowState.sessionWallet = address;
|
||||||
|
|
||||||
setFlowStatus('membership_quoting', 'Preparing membership mint...', false);
|
setFlowStatus('membership_quoting', 'Preparing membership mint...', false);
|
||||||
const quote = await postJSON('/secret/membership/quote', {
|
const quote = await postJSON('/secret/membership/quote', {
|
||||||
designation_code: verification.designation_code || intent.designation_code || null,
|
designation_code: verification.designation_code || intent.designation_code || null,
|
||||||
address,
|
address,
|
||||||
chain_id: chainId,
|
chain_id: chainId,
|
||||||
});
|
}, { wallet: address });
|
||||||
|
|
||||||
const txParams = quote.tx || {
|
const txParams = quote.tx || {
|
||||||
from: address,
|
from: address,
|
||||||
@ -823,7 +922,7 @@ async function startWalletFlow() {
|
|||||||
tx_hash: txHash,
|
tx_hash: txHash,
|
||||||
address,
|
address,
|
||||||
chain_id: chainId,
|
chain_id: chainId,
|
||||||
});
|
}, { wallet: address });
|
||||||
|
|
||||||
if (confirmation.status !== 'membership_active') {
|
if (confirmation.status !== 'membership_active') {
|
||||||
throw new Error('Membership transaction did not activate.');
|
throw new Error('Membership transaction did not activate.');
|
||||||
|
|||||||
@ -217,7 +217,9 @@
|
|||||||
offers: [],
|
offers: [],
|
||||||
selectedOfferId: null,
|
selectedOfferId: null,
|
||||||
sessionToken: '',
|
sessionToken: '',
|
||||||
|
sessionExpiresAt: '',
|
||||||
sessionWallet: null,
|
sessionWallet: null,
|
||||||
|
sessionRefreshInFlight: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const walletLabel = document.getElementById('wallet-label');
|
const walletLabel = document.getElementById('wallet-label');
|
||||||
@ -404,6 +406,9 @@
|
|||||||
if (typeof parsed.session_token === 'string') {
|
if (typeof parsed.session_token === 'string') {
|
||||||
state.sessionToken = parsed.session_token.trim();
|
state.sessionToken = parsed.session_token.trim();
|
||||||
}
|
}
|
||||||
|
if (typeof parsed.session_expires_at === 'string') {
|
||||||
|
state.sessionExpiresAt = parsed.session_expires_at.trim();
|
||||||
|
}
|
||||||
if (typeof parsed.wallet === 'string') {
|
if (typeof parsed.wallet === 'string') {
|
||||||
state.sessionWallet = parsed.wallet.trim();
|
state.sessionWallet = parsed.wallet.trim();
|
||||||
}
|
}
|
||||||
@ -412,6 +417,107 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSession(reason) {
|
||||||
|
state.sessionToken = '';
|
||||||
|
state.sessionExpiresAt = '';
|
||||||
|
state.sessionWallet = null;
|
||||||
|
if (reason) {
|
||||||
|
setLog('Wallet session cleared: ' + reason + '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureSessionHeaders(response) {
|
||||||
|
const token = String(response.headers.get('x-edut-session') || '').trim();
|
||||||
|
if (!token) return;
|
||||||
|
state.sessionToken = token;
|
||||||
|
const expiresAt = String(response.headers.get('x-edut-session-expires-at') || '').trim();
|
||||||
|
if (expiresAt) {
|
||||||
|
state.sessionExpiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
if (state.wallet) {
|
||||||
|
state.sessionWallet = state.wallet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseApiErrorPayload(text, fallbackStatus) {
|
||||||
|
if (!text) {
|
||||||
|
return { error: 'HTTP ' + fallbackStatus, code: '' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
return {
|
||||||
|
error: String(parsed.error || ('HTTP ' + fallbackStatus)),
|
||||||
|
code: String(parsed.code || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// non-json response
|
||||||
|
}
|
||||||
|
return { error: text, code: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalSessionCode(code) {
|
||||||
|
const normalized = String(code || '').toLowerCase();
|
||||||
|
return normalized === 'wallet_session_invalid' ||
|
||||||
|
normalized === 'wallet_session_expired' ||
|
||||||
|
normalized === 'wallet_session_revoked' ||
|
||||||
|
normalized === 'wallet_session_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeRefreshSession(pathHint) {
|
||||||
|
if (!state.sessionToken || !state.wallet || !state.sessionExpiresAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (String(pathHint || '').indexOf('/secret/wallet/session/') === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expiresAt = Date.parse(state.sessionExpiresAt);
|
||||||
|
if (!Number.isFinite(expiresAt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((expiresAt - Date.now()) > (5 * 60 * 1000)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.sessionRefreshInFlight) {
|
||||||
|
await state.sessionRefreshInFlight;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.sessionRefreshInFlight = (async function () {
|
||||||
|
const response = await fetch('/secret/wallet/session/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ wallet: state.wallet }),
|
||||||
|
});
|
||||||
|
captureSessionHeaders(response);
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = parseApiErrorPayload(text, response.status);
|
||||||
|
if (isTerminalSessionCode(detail.code)) {
|
||||||
|
clearSession(detail.code);
|
||||||
|
}
|
||||||
|
throw new Error(detail.error);
|
||||||
|
}
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : {};
|
||||||
|
} catch (err) {
|
||||||
|
// ignore parse error for non-essential refresh payload
|
||||||
|
}
|
||||||
|
if (payload && typeof payload.session_token === 'string') {
|
||||||
|
state.sessionToken = payload.session_token.trim();
|
||||||
|
}
|
||||||
|
if (payload && typeof payload.session_expires_at === 'string') {
|
||||||
|
state.sessionExpiresAt = payload.session_expires_at.trim();
|
||||||
|
}
|
||||||
|
state.sessionWallet = state.wallet;
|
||||||
|
setLog('Wallet session refreshed.');
|
||||||
|
})().finally(function () {
|
||||||
|
state.sessionRefreshInFlight = null;
|
||||||
|
});
|
||||||
|
await state.sessionRefreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
function authHeaders(extra) {
|
function authHeaders(extra) {
|
||||||
const headers = Object.assign({}, extra || {});
|
const headers = Object.assign({}, extra || {});
|
||||||
if (state.sessionToken) {
|
if (state.sessionToken) {
|
||||||
@ -422,16 +528,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(url) {
|
async function fetchJson(url) {
|
||||||
|
await maybeRefreshSession(url);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
});
|
});
|
||||||
|
captureSessionHeaders(response);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('HTTP ' + response.status);
|
const text = await response.text();
|
||||||
|
const detail = parseApiErrorPayload(text, response.status);
|
||||||
|
if (isTerminalSessionCode(detail.code)) {
|
||||||
|
clearSession(detail.code);
|
||||||
|
}
|
||||||
|
throw new Error(detail.error);
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function postJson(url, payload) {
|
||||||
|
await maybeRefreshSession(url);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
captureSessionHeaders(response);
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = parseApiErrorPayload(text, response.status);
|
||||||
|
if (isTerminalSessionCode(detail.code)) {
|
||||||
|
clearSession(detail.code);
|
||||||
|
}
|
||||||
|
throw new Error(detail.error);
|
||||||
|
}
|
||||||
|
return text ? JSON.parse(text) : {};
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOffers() {
|
async function loadOffers() {
|
||||||
try {
|
try {
|
||||||
const payload = await fetchJson('/store/offers.json');
|
const payload = await fetchJson('/store/offers.json');
|
||||||
@ -518,8 +650,9 @@
|
|||||||
state.wallet = accounts[0];
|
state.wallet = accounts[0];
|
||||||
state.ownershipProof = null;
|
state.ownershipProof = null;
|
||||||
if (state.sessionWallet && state.wallet && state.sessionWallet.toLowerCase() !== state.wallet.toLowerCase()) {
|
if (state.sessionWallet && state.wallet && state.sessionWallet.toLowerCase() !== state.wallet.toLowerCase()) {
|
||||||
state.sessionToken = '';
|
clearSession('wallet changed');
|
||||||
}
|
}
|
||||||
|
state.sessionWallet = state.wallet;
|
||||||
setLog('Wallet connected: ' + abbreviateWallet(state.wallet) + '.');
|
setLog('Wallet connected: ' + abbreviateWallet(state.wallet) + '.');
|
||||||
await refreshMembershipState();
|
await refreshMembershipState();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -623,17 +756,7 @@
|
|||||||
payload.ownership_proof = state.ownershipProof.signature;
|
payload.ownership_proof = state.ownershipProof.signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('/marketplace/checkout/quote', {
|
const quotePayload = await postJson('/marketplace/checkout/quote', payload);
|
||||||
method: 'POST',
|
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('HTTP ' + response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
const quotePayload = await response.json();
|
|
||||||
const quoteId = quotePayload.quote_id || 'unknown';
|
const quoteId = quotePayload.quote_id || 'unknown';
|
||||||
const amount = quotePayload.amount || quotePayload.amount_atomic || 'unknown';
|
const amount = quotePayload.amount || quotePayload.amount_atomic || 'unknown';
|
||||||
const total = quotePayload.total_amount || quotePayload.total_amount_atomic || amount;
|
const total = quotePayload.total_amount || quotePayload.total_amount_atomic || amount;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user