Skip to content

Commit 6152b5c

Browse files
committed
Support refreshing auth in Kobo script
Untested!
1 parent c1004e5 commit 6152b5c

File tree

1 file changed

+90
-20
lines changed

1 file changed

+90
-20
lines changed

script/fetch-kobo-wishlist.ts

+90-20
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,10 @@ const TOKEN_PATH = './.kobo';
1010
// deno run --allow-net --allow-read --allow-write ./script/fetch-kobo-wishlist.ts
1111
const main = async () => {
1212
try {
13-
const existingToken = await safeRead(TOKEN_PATH);
13+
const auth = await acquireAccessToken();
1414

15-
if (existingToken) console.log('> Found existing access token');
16-
17-
const accessToken = existingToken || await acquireAccessToken();
18-
19-
if (!existingToken) await Deno.writeTextFile(TOKEN_PATH, accessToken);
20-
21-
const settings = await loadInitSettings(accessToken);
22-
const wishlist = await fetchWishlist(accessToken, settings.user_wishlist);
15+
const settings = await loadInitSettings(auth);
16+
const wishlist = await fetchWishlist(auth, settings.user_wishlist);
2317

2418
const books = wishlist.map<WishListBook>((w) => ({
2519
title: w.ProductMetadata.Book.Title,
@@ -47,17 +41,31 @@ const Kobo = {
4741
'Mozilla/5.0 (Linux; U; Android 2.0; en-us;) AppleWebKit/538.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/538.1 (Kobo Touch 0373/4.38.23171)',
4842
};
4943

44+
interface Auth {
45+
accessToken: string;
46+
refreshToken: string;
47+
}
48+
5049
// 1. authenticateDevice() with null creds
5150
// 2. login() does "activation"
5251
// 3. authenticateDevice() again with user from 1) and key from 2)
53-
const acquireAccessToken = async () => {
52+
const acquireAccessToken = async (): Promise<Auth> => {
53+
const existingTokens = await readTokens(TOKEN_PATH);
54+
55+
if (existingTokens) {
56+
console.log('> Found existing tokens');
57+
return existingTokens;
58+
}
59+
5460
console.log('> Acquiring new access token');
5561
const { user } = await authenticateDevice(null, null);
5662
const userKey = await login();
5763

58-
const { accessToken } = await authenticateDevice(user, userKey);
64+
const { auth } = await authenticateDevice(user, userKey);
65+
66+
if (!existingTokens) await writeTokens(auth);
5967

60-
return accessToken;
68+
return auth;
6169
};
6270

6371
const waitForActivation = async (activationUrl: string) => {
@@ -174,24 +182,25 @@ const authenticateDevice = async (user: User | null, userKey: string | null) =>
174182
}
175183

176184
const accessToken: string = json.AccessToken;
185+
const refreshToken: string = json.RefreshToken;
177186

178187
if (userKey) {
179188
user.key = userKey;
180189
}
181190

182-
return { user, accessToken };
191+
return { user, auth: { accessToken, refreshToken } };
183192
};
184193

185194
type Settings = {
186195
user_wishlist: string;
187196
[k: string]: unknown;
188197
};
189198

190-
const loadInitSettings = async (accessToken: string): Promise<Settings> => {
199+
const loadInitSettings = async (auth: Auth): Promise<Settings> => {
191200
const res = await fetch('https://storeapi.kobo.com/v1/initialization', {
192201
headers: {
193202
...defaultHeaders(true),
194-
...authHeaders(accessToken),
203+
...authHeaders(auth.accessToken),
195204
},
196205
});
197206

@@ -212,7 +221,60 @@ const login = async () => {
212221
return userKey;
213222
};
214223

215-
const fetchWishlist = async (accessToken: string, wishlistUrl: string) => {
224+
const refreshAuth = async (auth: Auth): Promise<Auth> => {
225+
const postData = {
226+
'AppVersion': Kobo.ApplicationVersion,
227+
'ClientKey': encodeBase64(Kobo.DefaultPlatformId),
228+
'PlatformId': Kobo.DefaultPlatformId,
229+
'RefreshToken': auth.refreshToken,
230+
};
231+
232+
const res = await fetch('https://storeapi.kobo.com/v1/auth/refresh', {
233+
method: 'POST',
234+
body: JSON.stringify(postData),
235+
headers: {
236+
...authHeaders(auth.accessToken),
237+
...defaultHeaders(true),
238+
},
239+
});
240+
241+
if (!res.ok) throw new Error(`authenticateDevice: Bad status ${res.status}`);
242+
243+
const json = await res.json();
244+
245+
if (json.TokenType != 'Bearer') {
246+
throw new Error(`refreshAuth: returned with an unsupported token type: ${json.TokenType}`);
247+
}
248+
249+
return { accessToken: json.AccessToken as string, refreshToken: json.RefreshToken as string };
250+
};
251+
252+
const fetchWithRefresh = async (auth: Auth, input: RequestInfo | URL, init?: RequestInit) => {
253+
const req = new Request(input, init);
254+
const res = await fetch(req);
255+
256+
if (res.status != 401) return res;
257+
258+
console.log(`> Got status 401, refreshing auth`);
259+
260+
// Need to refresh auth
261+
const newTokens = await refreshAuth(auth);
262+
const retriedReq = req.clone();
263+
264+
const { Authorization: bearer } = authHeaders(newTokens.accessToken);
265+
retriedReq.headers.set('Authorization', bearer);
266+
267+
const retried = await fetch(retriedReq);
268+
269+
if (retried.ok) {
270+
console.log(`> Writing new auth tokens after refresh`);
271+
await writeTokens(newTokens);
272+
}
273+
274+
return retried;
275+
};
276+
277+
const fetchWishlist = async (auth: Auth, wishlistUrl: string) => {
216278
const items = [];
217279
let page = 0;
218280

@@ -221,10 +283,10 @@ const fetchWishlist = async (accessToken: string, wishlistUrl: string) => {
221283
url.searchParams.append('PageIndex', String(page));
222284
url.searchParams.append('PageSize', '100');
223285

224-
const res = await fetch(url, {
286+
const res = await fetchWithRefresh(auth, url, {
225287
headers: {
226288
...defaultHeaders(true),
227-
...authHeaders(accessToken),
289+
...authHeaders(auth.accessToken),
228290
},
229291
});
230292

@@ -271,9 +333,13 @@ const printOverwrite = async (str: string) => {
271333
await Deno.stdout.write(enc);
272334
};
273335

274-
const safeRead = async (path: string) => {
336+
const readTokens = async (path: string): Promise<Auth | null> => {
275337
try {
276-
return await Deno.readTextFile(path);
338+
const str = await Deno.readTextFile(path);
339+
if (!str.trim().length) return null;
340+
const tokens = str.split('\n');
341+
if (tokens.length < 2) return null;
342+
return { accessToken: tokens[0], refreshToken: tokens[1] };
277343
} catch (ex) {
278344
if (!(ex instanceof Deno.errors.NotFound)) {
279345
throw ex;
@@ -283,6 +349,10 @@ const safeRead = async (path: string) => {
283349
}
284350
};
285351

352+
const writeTokens = async (tokens: Auth) => {
353+
await Deno.writeTextFile(TOKEN_PATH, `${tokens.accessToken}\n${tokens.refreshToken}`);
354+
};
355+
286356
const randomHexString = (len: number) => {
287357
const bytes = new Uint8Array(len);
288358
crypto.getRandomValues(bytes);

0 commit comments

Comments
 (0)