1 | import * as https from "https";
|
2 |
|
3 | /**
|
4 | *
|
5 | * A no dependencies, fully typed library to verify hCaptcha tokens
|
6 | * submitted by users when solving CAPTCHA challenges.
|
7 | *
|
8 | * @remarks
|
9 | *
|
10 | * Note: this is an unofficial library; we are not affiliated with hCaptcha.com
|
11 | *
|
12 | * @example
|
13 | *
|
14 | * Verify a token submitted by a user:
|
15 | *
|
16 | * ```typescript
|
17 | * import { verifyHcaptchaToken } from 'verify-hcaptcha';
|
18 | *
|
19 | * (async () => {
|
20 | * const result = await verifyHcaptchaToken({
|
21 | * token: "USER-SUBMITTED-RESPONSE-TOKEN",
|
22 | * secretKey: "YOUR-SECRET-KEY",
|
23 | * siteKey: "YOUR-SITE-KEY",
|
24 | * });
|
25 | *
|
26 | * if (result.success) {
|
27 | * console.log("User is human");
|
28 | * } else {
|
29 | * console.log("User is robot");
|
30 | * }
|
31 | * })();
|
32 | * ```
|
33 | *
|
34 | * @example
|
35 | *
|
36 | * Verify a token submitted by a user and get the raw response from hCaptcha:
|
37 | *
|
38 | * ```typescript
|
39 | * import { rawVerifyHcaptchaToken } from 'verify-hcaptcha';
|
40 | *
|
41 | * (async () => {
|
42 | * const result = await rawVerifyHcaptchaToken({
|
43 | * token: "USER-SUBMITTED-RESPONSE-TOKEN",
|
44 | * secretKey: "YOUR-SECRET-KEY",
|
45 | * siteKey: "YOUR-SITE-KEY",
|
46 | * });
|
47 | *
|
48 | * if (result.success) {
|
49 | * console.log("User is human");
|
50 | * } else {
|
51 | * console.log("User is robot");
|
52 | * }
|
53 | * })();
|
54 | * ```
|
55 | *
|
56 | * @packageDocumentation
|
57 | */
|
58 |
|
59 | /**
|
60 | * `HcaptchaResponse` represents the response to the verification challenge
|
61 | * performed by calling {@link verifyHcaptchaToken}.
|
62 | *
|
63 | * @see {@link https://docs.hcaptcha.com/#verify-the-user-response-server-side}
|
64 | */
|
65 | export interface HcaptchaResponse {
|
66 | /**
|
67 | * True if the token is valid and meets the specified security criteria
|
68 | * (for example, if the site key is associated to the secret key)
|
69 | */
|
70 | readonly success: boolean;
|
71 |
|
72 | /**
|
73 | * Optional: UTC timestamp of the challenge in ISO 8601 format
|
74 | * (for example, `2021-10-02T18:12:10.149Z`)
|
75 | */
|
76 | readonly challengeTimestamp?: string;
|
77 |
|
78 | /** Optional: hostname of the website where the challenge was solved */
|
79 | readonly hostname?: string;
|
80 |
|
81 | /** Optional: true if the response will be credited */
|
82 | readonly credit?: boolean;
|
83 |
|
84 | /**
|
85 | * Optional: list of error codes
|
86 | *
|
87 | * @see {@link HcaptchaError}
|
88 | */
|
89 | readonly errorCodes?: HcaptchaError[];
|
90 |
|
91 | /** Enterprise-only feature: score for malicious activity */
|
92 | readonly score?: number;
|
93 |
|
94 | /** Enterprise-only feature: list of reasons for the malicious activity score */
|
95 | readonly scoreReasons?: string[];
|
96 | }
|
97 |
|
98 | /**
|
99 | * `HcaptchaError` collects the errors explaining why a verification challenge failed.
|
100 | *
|
101 | * @see {@link HcaptchaResponse}
|
102 | * @see {@link https://docs.hcaptcha.com/#siteverify-error-codes-table}
|
103 | */
|
104 | export type HcaptchaError =
|
105 | /** Secret key is missing */
|
106 | | "missing-input-secret"
|
107 | /** Secret key is invalid */
|
108 | | "invalid-input-secret"
|
109 | /** User response token is missing */
|
110 | | "missing-input-response"
|
111 | /** User response token is invalid */
|
112 | | "invalid-input-response"
|
113 | /** Site key is invalid */
|
114 | | "invalid-sitekey"
|
115 | /** Remote user IP is invalid */
|
116 | | "invalid-remoteip"
|
117 | /** Request is invalid */
|
118 | | "bad-request"
|
119 | /** User response token is invalid or has already been checked */
|
120 | | "invalid-or-already-seen-response"
|
121 | /** Must use the test site key when using a test verification token */
|
122 | | "not-using-dummy-passcode"
|
123 | /** Must use the test secret key when using a test verification token */
|
124 | | "not-using-dummy-secret"
|
125 | /** The site key is not associated to the secret key */
|
126 | | "sitekey-secret-mismatch";
|
127 |
|
128 | /**
|
129 | * `RawHcaptchaResponse` represents the raw response to the verification challenge
|
130 | * obtained by directly calling the hCaptcha API endpoint
|
131 | * with {@link rawVerifyHcaptchaToken}.
|
132 | *
|
133 | * @see {@link https://docs.hcaptcha.com/#verify-the-user-response-server-side}
|
134 | */
|
135 | export interface RawHcaptchaResponse {
|
136 | readonly success: boolean;
|
137 | readonly challenge_ts?: string;
|
138 | readonly hostname?: string;
|
139 | readonly credit?: boolean;
|
140 | readonly "error-codes"?: string[];
|
141 | readonly score?: number;
|
142 | readonly score_reason?: string[];
|
143 | }
|
144 |
|
145 | /**
|
146 | * `verifyHcaptchaToken` verifies with the hCaptcha API that the response token
|
147 | * obtained from a captcha challenge is valid.
|
148 | *
|
149 | * @param token - required: the token obtained from a user with a captcha challenge
|
150 | * @param secretKey - required: the secret key for your account
|
151 | * @param siteKey - optional but recommended: the site key for the website hosting the captcha challenge
|
152 | * @param remoteIp - optional: the IP address of the user submitting the challenge
|
153 | *
|
154 | * @returns a {@link HcaptchaResponse} with the verification result
|
155 | */
|
156 | export async function verifyHcaptchaToken({
|
157 | token,
|
158 | secretKey,
|
159 | siteKey,
|
160 | remoteIp,
|
161 | }: {
|
162 | token: string;
|
163 | secretKey: string;
|
164 | siteKey?: string;
|
165 | remoteIp?: string;
|
166 | }): Promise<HcaptchaResponse> {
|
167 | const {
|
168 | success,
|
169 | challenge_ts: challengeTimestamp,
|
170 | hostname,
|
171 | credit,
|
172 | "error-codes": rawErrorCodes,
|
173 | score,
|
174 | score_reason: scoreReasons,
|
175 | } = await rawVerifyHcaptchaToken({
|
176 | token,
|
177 | secretKey,
|
178 | siteKey,
|
179 | remoteIp,
|
180 | });
|
181 |
|
182 | return {
|
183 | success,
|
184 | challengeTimestamp,
|
185 | hostname,
|
186 | credit,
|
187 | errorCodes: rawErrorCodes as HcaptchaError[] | undefined,
|
188 | score,
|
189 | scoreReasons,
|
190 | };
|
191 | }
|
192 |
|
193 | /**
|
194 | * `rawVerifyHcaptchaToken` verifies with the hCaptcha API that the response token
|
195 | * obtained from a captcha challenge is valid and returns the raw hCaptcha response.
|
196 | *
|
197 | * @param token - required: the token obtained from a user with a captcha challenge
|
198 | * @param secretKey - required: the secret key for your account
|
199 | * @param siteKey - optional but recommended: the site key for the website hosting the captcha challenge
|
200 | * @param remoteIp - optional: the IP address of the user submitting the challenge
|
201 | *
|
202 | * @returns a {@link RawHcaptchaResponse} with the verification result
|
203 | */
|
204 | export async function rawVerifyHcaptchaToken({
|
205 | token,
|
206 | secretKey,
|
207 | siteKey,
|
208 | remoteIp,
|
209 | }: {
|
210 | token: string;
|
211 | secretKey: string;
|
212 | siteKey?: string;
|
213 | remoteIp?: string;
|
214 | }): Promise<RawHcaptchaResponse> {
|
215 | const form = buildForm({ token, secretKey, siteKey, remoteIp });
|
216 | const data = await postToHcaptcha({ form });
|
217 | return data;
|
218 | }
|
219 |
|
220 | function buildForm({
|
221 | token,
|
222 | secretKey,
|
223 | siteKey,
|
224 | remoteIp,
|
225 | }: {
|
226 | token?: string;
|
227 | secretKey?: string;
|
228 | siteKey?: string;
|
229 | remoteIp?: string;
|
230 | }): string {
|
231 | const form = new URLSearchParams();
|
232 | if (token) {
|
233 | form.append("response", token);
|
234 | }
|
235 |
|
236 | if (secretKey) {
|
237 | form.append("secret", secretKey);
|
238 | }
|
239 |
|
240 | if (siteKey) {
|
241 | form.append("sitekey", siteKey);
|
242 | }
|
243 |
|
244 | if (remoteIp) {
|
245 | form.append("remoteip", remoteIp);
|
246 | }
|
247 |
|
248 | return form.toString();
|
249 | }
|
250 |
|
251 | function postToHcaptcha({
|
252 | form,
|
253 | }: {
|
254 | form: string;
|
255 | }): Promise<RawHcaptchaResponse> {
|
256 | const options: https.RequestOptions = {
|
257 | host: "hcaptcha.com",
|
258 | path: "/siteverify",
|
259 | method: "POST",
|
260 | headers: {
|
261 | "Content-Type": "application/x-www-form-urlencoded",
|
262 | "Content-Length": Buffer.byteLength(form),
|
263 | },
|
264 | };
|
265 |
|
266 | return new Promise((resolve, reject) => {
|
267 | // See https://nodejs.org/api/http.html#http_class_http_clientrequest
|
268 | const req = https.request(options, (res) => {
|
269 | const chunks: string[] = [];
|
270 | res.setEncoding("utf-8");
|
271 | res
|
272 | .on("data", (data) => {
|
273 | chunks.push(data);
|
274 | })
|
275 | .on("end", () => {
|
276 | const data = JSON.parse(chunks.join("")) as RawHcaptchaResponse;
|
277 | resolve(data);
|
278 | });
|
279 | });
|
280 |
|
281 | req.on("error", (err) => {
|
282 | reject(err);
|
283 | });
|
284 |
|
285 | req.write(form);
|
286 | req.end();
|
287 | });
|
288 | }
|