diff --git a/buffit/index.html b/buffit/index.html
index 85a4e14..d69ed93 100644
--- a/buffit/index.html
+++ b/buffit/index.html
@@ -626,6 +626,18 @@
} catch {}
return null;
}
+ function updateTokensFromResponse(response) {
+ const newAccessToken = response.headers.get('X-New-Access-Token');
+ const newRefreshToken = response.headers.get('X-New-Refresh-Token');
+ if (newAccessToken) {
+ const tokens = loadTokens();
+ if (tokens) {
+ tokens.access_token = newAccessToken;
+ if (newRefreshToken) tokens.refresh_token = newRefreshToken;
+ saveTokens(tokens);
+ }
+ }
+ }
function setOAuthValue(key, value) {
try { sessionStorage.setItem(key, value); } catch {}
try { localStorage.setItem(key, value); } catch {}
@@ -810,14 +822,23 @@
const [channelsRes, accountRes] = await Promise.all([
fetch(PROXY_BASE + '/channels', {
method: 'GET',
- headers: { 'Authorization': 'Bearer ' + tokens.access_token },
+ headers: {
+ 'Authorization': 'Bearer ' + tokens.access_token,
+ 'X-Refresh-Token': tokens.refresh_token || '',
+ },
}),
fetch(PROXY_BASE + '/account', {
method: 'GET',
- headers: { 'Authorization': 'Bearer ' + tokens.access_token },
+ headers: {
+ 'Authorization': 'Bearer ' + tokens.access_token,
+ 'X-Refresh-Token': tokens.refresh_token || '',
+ },
}),
]);
+ updateTokensFromResponse(channelsRes);
+ updateTokensFromResponse(accountRes);
+
const channelsData = await channelsRes.json();
const accountData = await accountRes.json();
@@ -1039,6 +1060,7 @@
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + tokens.access_token,
+ 'X-Refresh-Token': tokens.refresh_token || '',
},
body: JSON.stringify({
text: text,
@@ -1046,6 +1068,7 @@
assets: assets,
}),
}).then(async (res) => {
+ updateTokensFromResponse(res);
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || 'Post failed');
@@ -1093,8 +1116,12 @@
try {
const res = await fetch(PROXY_BASE + '/channels?nocache=1', {
method: 'GET',
- headers: { 'Authorization': 'Bearer ' + tokens.access_token },
+ headers: {
+ 'Authorization': 'Bearer ' + tokens.access_token,
+ 'X-Refresh-Token': tokens.refresh_token || '',
+ },
});
+ updateTokensFromResponse(res);
const data = await res.json();
if (!res.ok || !data.success) {
diff --git a/buffit/worker.js b/buffit/worker.js
index b04d66a..7517e2f 100644
--- a/buffit/worker.js
+++ b/buffit/worker.js
@@ -16,6 +16,7 @@
const ALLOWED_ORIGIN = 'https://danielmorrisey.com';
const MEDIA_FOLDER = 'buffit';
+const TOKEN_EXPIRY_BUFFER = 300; // Refresh if within 5 minutes of expiry
const UPSTREAM = {
'/api': 'https://api.buffer.com',
@@ -38,6 +39,66 @@ function json(data, status = 200, extra = {}) {
});
}
+function decodeJWT(token) {
+ try {
+ const parts = token.split('.');
+ if (parts.length !== 3) return null;
+ const payload = JSON.parse(atob(parts[1]));
+ return payload;
+ } catch {
+ return null;
+ }
+}
+
+function isTokenExpired(token) {
+ const decoded = decodeJWT(token);
+ if (!decoded || !decoded.exp) return false;
+ const now = Math.floor(Date.now() / 1000);
+ return decoded.exp - now < TOKEN_EXPIRY_BUFFER;
+}
+
+async function refreshAccessToken(env, refreshToken) {
+ const res = await fetch('https://auth.buffer.com/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ client_id: env.BUFFER_CLIENT_ID,
+ client_secret: env.BUFFER_CLIENT_SECRET,
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken,
+ }),
+ });
+ const data = await res.json();
+ if (!res.ok || !data.access_token) {
+ throw new Error(data.error_description || 'Token refresh failed');
+ }
+ return data;
+}
+
+async function ensureValidToken(env, authHeader, refreshToken) {
+ const accessToken = authHeader?.replace('Bearer ', '');
+ if (!accessToken) return { accessToken: null, refreshToken, newTokens: null };
+
+ if (!isTokenExpired(accessToken)) {
+ return { accessToken, refreshToken, newTokens: null };
+ }
+
+ if (!refreshToken || !env.BUFFER_CLIENT_ID || !env.BUFFER_CLIENT_SECRET) {
+ return { accessToken, refreshToken, newTokens: null };
+ }
+
+ try {
+ const newTokens = await refreshAccessToken(env, refreshToken);
+ return {
+ accessToken: newTokens.access_token,
+ refreshToken: newTokens.refresh_token || refreshToken,
+ newTokens,
+ };
+ } catch (e) {
+ return { accessToken, refreshToken, newTokens: null };
+ }
+}
+
export default {
async fetch(request, env) {
const url = new URL(request.url);
@@ -86,6 +147,7 @@ export default {
// ── GET /account — fetch and cache account info from Buffer ──────────
if (request.method === 'GET' && url.pathname === '/account') {
const authHeader = request.headers.get('Authorization');
+ const refreshToken = request.headers.get('X-Refresh-Token');
if (!authHeader) {
return json({ success: false, error: 'Authorization required' }, 401, cors);
}
@@ -94,21 +156,36 @@ export default {
return json({ success: false, error: 'Account cache not configured' }, 500, cors);
}
+ const { accessToken, newTokens } = await ensureValidToken(env, authHeader, refreshToken);
+ if (!accessToken) {
+ return json({ success: false, error: 'Invalid token' }, 401, cors);
+ }
+
const nocache = url.searchParams.get('nocache') === '1';
const cacheKey = await hashToken(authHeader + ':account');
if (!nocache) {
const cached = await env.CHANNELS_KV.get(cacheKey, { type: 'json' });
if (cached) {
- return json(cached, 200, { ...cors, 'X-Cache': 'hit' });
+ const extra = { ...cors, 'X-Cache': 'hit' };
+ if (newTokens) {
+ extra['X-New-Access-Token'] = newTokens.access_token;
+ extra['X-New-Refresh-Token'] = newTokens.refresh_token;
+ }
+ return json(cached, 200, extra);
}
}
try {
- const account = await fetchAccountFromBuffer(authHeader);
+ const account = await fetchAccountFromBuffer('Bearer ' + accessToken);
const ttl = parseInt(env.CHANNELS_CACHE_TTL || '3600');
await env.CHANNELS_KV.put(cacheKey, JSON.stringify(account), { expirationTtl: ttl });
- return json(account, 200, { ...cors, 'X-Cache': nocache ? 'clear' : 'miss' });
+ const extra = { ...cors, 'X-Cache': nocache ? 'clear' : 'miss' };
+ if (newTokens) {
+ extra['X-New-Access-Token'] = newTokens.access_token;
+ extra['X-New-Refresh-Token'] = newTokens.refresh_token;
+ }
+ return json(account, 200, extra);
} catch (e) {
return json({ success: false, error: `Failed to fetch account: ${e.message}` }, 500, cors);
}
@@ -117,6 +194,7 @@ export default {
// ── POST /post — create a post on Buffer ────────────────────────────
if (request.method === 'POST' && url.pathname === '/post') {
const authHeader = request.headers.get('Authorization');
+ const refreshToken = request.headers.get('X-Refresh-Token');
if (!authHeader) {
return json({ success: false, error: 'Authorization required' }, 401, cors);
}
@@ -128,9 +206,19 @@ export default {
return json({ success: false, error: 'Invalid JSON body' }, 400, cors);
}
+ const { accessToken, newTokens } = await ensureValidToken(env, authHeader, refreshToken);
+ if (!accessToken) {
+ return json({ success: false, error: 'Invalid token' }, 401, cors);
+ }
+
try {
- const result = await createPostOnBuffer(authHeader, body);
- return json(result, 200, cors);
+ const result = await createPostOnBuffer('Bearer ' + accessToken, body);
+ const extra = { ...cors };
+ if (newTokens) {
+ extra['X-New-Access-Token'] = newTokens.access_token;
+ extra['X-New-Refresh-Token'] = newTokens.refresh_token;
+ }
+ return json(result, 200, extra);
} catch (e) {
return json({ success: false, error: `Failed to create post: ${e.message}` }, 500, cors);
}
@@ -139,6 +227,7 @@ export default {
// ── GET /channels — fetch and cache channels from Buffer ─────────────
if (request.method === 'GET' && url.pathname === '/channels') {
const authHeader = request.headers.get('Authorization');
+ const refreshToken = request.headers.get('X-Refresh-Token');
if (!authHeader) {
return json({ success: false, error: 'Authorization required' }, 401, cors);
}
@@ -147,21 +236,36 @@ export default {
return json({ success: false, error: 'Channels cache not configured' }, 500, cors);
}
+ const { accessToken, newTokens } = await ensureValidToken(env, authHeader, refreshToken);
+ if (!accessToken) {
+ return json({ success: false, error: 'Invalid token' }, 401, cors);
+ }
+
const nocache = url.searchParams.get('nocache') === '1';
const cacheKey = await hashToken(authHeader);
if (!nocache) {
const cached = await env.CHANNELS_KV.get(cacheKey, { type: 'json' });
if (cached) {
- return json(cached, 200, { ...cors, 'X-Cache': 'hit' });
+ const extra = { ...cors, 'X-Cache': 'hit' };
+ if (newTokens) {
+ extra['X-New-Access-Token'] = newTokens.access_token;
+ extra['X-New-Refresh-Token'] = newTokens.refresh_token;
+ }
+ return json(cached, 200, extra);
}
}
try {
- const channels = await fetchChannelsFromBuffer(authHeader);
+ const channels = await fetchChannelsFromBuffer('Bearer ' + accessToken);
const ttl = parseInt(env.CHANNELS_CACHE_TTL || '3600');
await env.CHANNELS_KV.put(cacheKey, JSON.stringify(channels), { expirationTtl: ttl });
- return json(channels, 200, { ...cors, 'X-Cache': nocache ? 'clear' : 'miss' });
+ const extra = { ...cors, 'X-Cache': nocache ? 'clear' : 'miss' };
+ if (newTokens) {
+ extra['X-New-Access-Token'] = newTokens.access_token;
+ extra['X-New-Refresh-Token'] = newTokens.refresh_token;
+ }
+ return json(channels, 200, extra);
} catch (e) {
return json({ success: false, error: `Failed to fetch channels: ${e.message}` }, 500, cors);
}