Skip to content

Commit 27a558f

Browse files
authored
Merge pull request #1 from Back-to-code/F2F-1528-rtcv-scraper-protocol-routes-proxyen-via-app
F2F-1528 rtcv scraper protocol routes proxyen via app
2 parents 3f50db5 + f175eba commit 27a558f

4 files changed

Lines changed: 213 additions & 41 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ RTCV_SERVER=http://rtcv_key_ID_here:rtcv_key_here@localhost:4000
99
# This can be set to a staging server so we don't have to run 2 instances of a scraper
1010
# RTCV_ALTERNATIVE_SERVER=http://rtcv_key_ID_here:rtcv_key_here@localhost:4000
1111

12+
# Optional: route requests through the F2F App instead of directly to RT-CV
13+
# Uses f2f:// (http) or f2fs:// (https) protocol to distinguish from RTCV_SERVER basic auth
14+
# RTCV_SERVER is still required for incoming callback authentication
15+
# F2F_APP=f2fs://keyId:keySecret@app.first2find.nl
16+
# F2F_ALTERNATIVE_APP=f2fs://keyId:keySecret@app.first2find.nl
17+
1218
# Set to true to skip the alive check that checks if the scraper is allowed to scrape.
1319
# This is useful for local development when a scraper is disabled on RT-CV.
1420
# DO NOT DEPLOY A SCRAPER WITH THIS ENABLED!

lib/app_auth.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Challenge-response authentication for the f2f-app.
3+
*
4+
* Flow:
5+
* 1. POST /api/tokens/challenge {nameSlug} -> {challenge}
6+
* 2. proof = lowercase(hex(sha512(challenge + secret)))
7+
* 3. POST /api/tokens/exchange {nameSlug, proof} -> {token}
8+
* 4. Cache token for 8 minutes (tokens are valid 10 min, 2 min buffer)
9+
*/
10+
11+
const TOKEN_CACHE_DURATION_MS = 8 * 60 * 1000
12+
13+
export interface AppAuth {
14+
url: string // from F2F_APP origin
15+
keyId: string // from F2F_APP, API token nameSlug
16+
keySecret: string // from F2F_APP, API token secret
17+
}
18+
19+
export class AppTokenManager {
20+
private token: string | null = null
21+
private tokenExpiresAt: number = 0
22+
23+
constructor(private auth: AppAuth) {}
24+
25+
async getToken(): Promise<string> {
26+
if (this.token && Date.now() < this.tokenExpiresAt) {
27+
return this.token
28+
}
29+
30+
return await this.authenticate()
31+
}
32+
33+
private async authenticate(): Promise<string> {
34+
const base = this.auth.url + "/api/tokens"
35+
const headers = { "Content-Type": "application/json" }
36+
37+
// 1. Request challenge
38+
const challengeRes = await fetch(`${base}/challenge`, {
39+
method: "POST",
40+
headers,
41+
body: JSON.stringify({ nameSlug: this.auth.keyId }),
42+
})
43+
if (!challengeRes.ok) throw new Error(`F2F App challenge failed (${challengeRes.status}): ${await challengeRes.text()}`)
44+
const { challenge } = await challengeRes.json() as { challenge: string }
45+
46+
// 2. Calculate proof (SHA-512)
47+
const msgUint8 = new TextEncoder().encode(challenge + this.auth.keySecret)
48+
const hashBuffer = await crypto.subtle.digest("SHA-512", msgUint8)
49+
const hashArray = Array.from(new Uint8Array(hashBuffer))
50+
const proof = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
51+
52+
// 3. Exchange for token
53+
const tokenRes = await fetch(`${base}/exchange`, {
54+
method: "POST",
55+
headers,
56+
body: JSON.stringify({ nameSlug: this.auth.keyId, proof }),
57+
})
58+
if (!tokenRes.ok) throw new Error(`F2F App token exchange failed (${tokenRes.status}): ${await tokenRes.text()}`)
59+
const { token } = await tokenRes.json() as { token: string }
60+
61+
// 4. Cache token
62+
this.token = token
63+
this.tokenExpiresAt = Date.now() + TOKEN_CACHE_DURATION_MS
64+
65+
return token
66+
}
67+
}

0 commit comments

Comments
 (0)