Skip to content

Commit 5f695f3

Browse files
authored
Fix: dedicated Discogs hander (#44)
* dedicated Discogs hander * remove from provider list * fix lint
1 parent 59e2fa6 commit 5f695f3

File tree

6 files changed

+282
-18
lines changed

6 files changed

+282
-18
lines changed

app/tailwind.config.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ const config: Config = {
44
content: ["./ui/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}"],
55
theme: {
66
extend: {
7-
// colors: {
8-
// link: "#00ff00",
9-
// },
107
backgroundImage: {
118
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
129
"gradient-conic":

handshake/src/core/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ export type HandlerFactory<Args, Credential = unknown> = (
77

88
export interface Handler<Credential = unknown> {
99
id: string;
10-
1110
provider: {
1211
id: string;
1312
name: string;

handshake/src/providers/discogs.ts

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import assert from "assert";
2+
import crypto from "crypto";
3+
import { z } from "zod";
4+
import {
5+
InvalidRequest,
6+
OAuthCallbackError,
7+
UnknownProviderError,
8+
} from "~/core/errors";
9+
import { debug, error, info } from "~/core/logger";
10+
import { HandlerFactory } from "~/core/types";
11+
12+
interface Args {
13+
consumerKey: string;
14+
consumerSecret: string;
15+
}
16+
17+
/**
18+
* Connect to your customers' [Discogs](https://discogs.com) accounts.
19+
*
20+
* @options
21+
* ```ts title="app/options.ts"
22+
* import { Discogs } from "handshake";
23+
*
24+
* Discogs({
25+
* consumerKey: string,
26+
* consumerSecret: string,
27+
* });
28+
* ```
29+
*
30+
* @providersetup
31+
*
32+
* @troubleshoot
33+
*/
34+
export const Discogs: HandlerFactory<Args> = (args) => {
35+
assert(args.consumerKey, "consumerKey is empty or missing");
36+
assert(args.consumerSecret, "consumerSecret is empty or missing");
37+
38+
// https://www.discogs.com/developers#page:authentication,header:authentication-oauth-flow
39+
40+
return {
41+
id: "discogs" ?? args.id,
42+
provider: {
43+
id: "discogs",
44+
name: "Discogs",
45+
type: "oauth1",
46+
documentationUrl:
47+
"https://www.discogs.com/developers#page:authentication,header:authentication-oauth-flow",
48+
website: "https://discogs.com",
49+
// oauthConfig: provider,
50+
},
51+
async getAuthorizationUrl(callbackHandlerUrl) {
52+
const nonce = crypto.randomBytes(16).toString("hex");
53+
54+
const { requestToken, requestTokenSecret, callbackConfirmed } =
55+
await createRequestToken(
56+
args.consumerKey,
57+
args.consumerSecret,
58+
callbackHandlerUrl,
59+
nonce,
60+
);
61+
62+
if (!callbackConfirmed) {
63+
// TODO handle
64+
}
65+
66+
info("requestToken", {
67+
requestToken,
68+
requestTokenSecret,
69+
callbackConfirmed,
70+
});
71+
72+
const authUrl = `https://discogs.com/oauth/authorize?oauth_token=${requestToken}`;
73+
return {
74+
url: authUrl.toString(),
75+
persist: { nonce, requestTokenSecret },
76+
};
77+
},
78+
79+
async exchange(
80+
searchParams,
81+
_,
82+
__,
83+
{ valuesFromHandler: { nonce, requestTokenSecret } },
84+
) {
85+
const querySchema = z
86+
.object({
87+
oauth_token: z.string(),
88+
oauth_verifier: z.string(),
89+
})
90+
.strict();
91+
92+
type CallbackParams = z.infer<typeof querySchema>;
93+
94+
let params: CallbackParams;
95+
try {
96+
params = querySchema.parse(Object.fromEntries(searchParams.entries()));
97+
} catch (e) {
98+
error(e);
99+
throw new InvalidRequest(`Unexpected query parameter shape.`);
100+
}
101+
102+
const { token, secret } = await requestAccessToken(
103+
args.consumerKey,
104+
args.consumerSecret,
105+
requestTokenSecret,
106+
params.oauth_token,
107+
params.oauth_verifier,
108+
nonce,
109+
);
110+
111+
return {
112+
tokens: {
113+
token: token,
114+
secret: secret,
115+
},
116+
};
117+
},
118+
};
119+
};
120+
121+
/**
122+
* @throws {UnknownProviderError}
123+
* @throws {OAuthCallbackError}
124+
*/
125+
async function createRequestToken(
126+
consumerKey: string,
127+
consumerSecret: string,
128+
callbackHandlerUrl: string,
129+
nonce: string,
130+
): Promise<{
131+
requestToken: string;
132+
requestTokenSecret: string;
133+
callbackConfirmed: boolean;
134+
}> {
135+
const url = new URL(`https://api.discogs.com/oauth/request_token`);
136+
137+
// https://www.discogs.com/developers#header-2.-send-a-get-request-to-the-discogs-request-token-url
138+
url.searchParams.set("oauth_nonce", nonce);
139+
url.searchParams.set("oauth_consumer_key", consumerKey);
140+
// ATTENTION notice the required ampersand at the end.
141+
url.searchParams.set("oauth_signature", consumerSecret + "&");
142+
url.searchParams.set("oauth_signature_method", "PLAINTEXT");
143+
url.searchParams.set("oauth_timestamp", "" + Date.now());
144+
url.searchParams.set("oauth_callback", callbackHandlerUrl);
145+
146+
let res;
147+
try {
148+
res = await fetch(url, {
149+
method: "GET",
150+
headers: {
151+
"User-Agent": "handshake/1.0",
152+
},
153+
});
154+
} catch (e) {
155+
throw new UnknownProviderError(`FETCH url=${url}: ${e}`);
156+
}
157+
158+
if (!res.ok) {
159+
const text = await res.text();
160+
error(`Discogs returned status=${res.status} url=${url} text=${text}`);
161+
// TODO expand expected errors.
162+
163+
if (text.includes("Invalid consumer.")) {
164+
throw new OAuthCallbackError("invalid_client", `Wrong consumer secret.`);
165+
}
166+
167+
// Never return the error to the user. Discogs may include the consumer
168+
// secret in the response. 😱😱
169+
if (text.includes("Expected signature base string.")) {
170+
throw new OAuthCallbackError("invalid_client", `Wrong consumer secret.`);
171+
}
172+
173+
throw new UnknownProviderError(
174+
`Unexpected error from Discogs: status=${res.status} url=${url} text=${text}`,
175+
);
176+
}
177+
178+
const text = await res.text();
179+
debug("Received from Discogs", text);
180+
const json = Object.fromEntries(new URLSearchParams(text));
181+
182+
let parsed;
183+
try {
184+
parsed = z
185+
.object({
186+
oauth_token: z.string(),
187+
oauth_token_secret: z.string(),
188+
oauth_callback_confirmed: z.enum(["true", "false"]),
189+
})
190+
.parse(json);
191+
} catch (e) {
192+
error("Failed to parse response from Discogs", text);
193+
throw new UnknownProviderError(
194+
`Failed to parse response from Discogs e: ${e}`,
195+
);
196+
}
197+
198+
return {
199+
requestToken: parsed.oauth_token,
200+
requestTokenSecret: parsed.oauth_token_secret,
201+
callbackConfirmed: parsed.oauth_callback_confirmed === "true",
202+
};
203+
}
204+
205+
async function requestAccessToken(
206+
consumerKey: string,
207+
consumerSecret: string,
208+
tokenSecret: string,
209+
oauthToken: string,
210+
verifier: string,
211+
nonce: string,
212+
) {
213+
const url = `https://api.discogs.com/oauth/access_token`;
214+
215+
// https://www.discogs.com/developers#page:authentication,header:authentication-oauth-flow
216+
let res;
217+
try {
218+
res = await fetch(url, {
219+
method: "POST",
220+
headers: {
221+
"User-Agent": "handshake/1.0",
222+
Authorization: "OAuth",
223+
},
224+
body: new URLSearchParams({
225+
oauth_nonce: nonce,
226+
oauth_token: oauthToken,
227+
oauth_consumer_key: consumerKey,
228+
oauth_signature: consumerSecret + "&" + tokenSecret,
229+
oauth_signature_method: "PLAINTEXT",
230+
oauth_timestamp: "" + Date.now(),
231+
oauth_verifier: verifier,
232+
}),
233+
});
234+
} catch (e) {
235+
throw new UnknownProviderError(`FETCH url=${url}: ${e}`);
236+
}
237+
238+
if (!res.ok) {
239+
const text = await res.text();
240+
error(`Discogs returned status=${res.status} url=${url} text=${text}`);
241+
// TODO expand expected errors.
242+
243+
if (text.includes("Invalid consumer.")) {
244+
throw new OAuthCallbackError("invalid_client", `Wrong consumer secret.`);
245+
}
246+
247+
// Discogs may include the consumer secret in the response. 😱😱 Don't
248+
// expose it to the user.
249+
if (text.includes("Expected signature base string.")) {
250+
throw new OAuthCallbackError("invalid_client", `Wrong consumer secret.`);
251+
}
252+
253+
throw new UnknownProviderError(
254+
`Unexpected error from Discogs: status=${res.status} url=${url} text=${text}`,
255+
);
256+
}
257+
258+
const text = await res.text();
259+
debug("Received from Discogs", text);
260+
const json = Object.fromEntries(new URLSearchParams(text));
261+
262+
let parsed;
263+
try {
264+
parsed = z
265+
.object({
266+
oauth_token: z.string(),
267+
oauth_token_secret: z.string(),
268+
})
269+
.parse(json);
270+
} catch (e) {
271+
error("Failed to parse response from Discogs", text);
272+
throw new UnknownProviderError(
273+
`Failed to parse response from Discogs e: ${e}`,
274+
);
275+
}
276+
277+
return {
278+
token: parsed.oauth_token,
279+
secret: parsed.oauth_token_secret,
280+
};
281+
}

handshake/src/providers/grant-oauth-providers.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -522,18 +522,6 @@ export const DigitalOcean = makeOAuthFactory({
522522
version: "2",
523523
});
524524

525-
export const Discogs = makeOAuthFactory({
526-
id: "discogs",
527-
name: "Discogs",
528-
requestTokenUrl: "https://api.discogs.com/oauth/request_token",
529-
authorization: {
530-
url: "https://discogs.com/oauth/authorize",
531-
},
532-
website: "https://www.discogs.com",
533-
token: { url: "https://api.discogs.com/oauth/access_token" },
534-
version: "1",
535-
});
536-
537525
// export const Discord = make({
538526
// id: "discord",
539527
// name: "",

handshake/src/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from "./grant-oauth-providers";
33

44
export { AmazonSeller } from "./amazon-seller";
55
export { BigCommerce } from "./bigcommerce";
6+
export { Discogs } from "./discogs";
67
export { Faire } from "./faire";
78
export type { FaireCredential, FaireScope } from "./faire";
89
export { Google } from "./google";

handshake/src/providers/shopify.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,6 @@ export interface ShopifyAppCredential {
175175
* }),
176176
* ],
177177
* };
178-
*
179-
* // ...
180178
* ```
181179
*
182180
* @providersetup

0 commit comments

Comments
 (0)