Skip to content

Commit 26d91bf

Browse files
refactor: upgrade LinkedInStrategy to use Arctic OAuth library
- Integrate Arctic's LinkedIn OAuth client for improved authentication - Simplify token exchange and validation logic - Add support for generating state and handling OAuth errors - Update type definitions and method implementations - Enhance debug logging and error handling
1 parent 8ba30ee commit 26d91bf

File tree

1 file changed

+74
-98
lines changed

1 file changed

+74
-98
lines changed

src/index.ts

Lines changed: 74 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
import { Cookie, SetCookie, type SetCookieInit } from "@mjackson/headers";
2+
import {
3+
LinkedIn,
4+
OAuth2RequestError,
5+
type OAuth2Tokens,
6+
UnexpectedErrorResponseBodyError,
7+
UnexpectedResponseError,
8+
generateState,
9+
} from "arctic";
210
import createDebug from "debug";
311
import { Strategy } from "remix-auth/strategy";
412
import { redirect } from "./lib/redirect.js";
513

14+
type URLConstructor = ConstructorParameters<typeof URL>[0];
15+
616
const debug = createDebug("LinkedInStrategy");
717

818
export type LinkedInScope = "openid" | "profile" | "email";
919

20+
export {
21+
OAuth2RequestError,
22+
UnexpectedResponseError,
23+
UnexpectedErrorResponseBodyError,
24+
};
25+
1026
/**
1127
* This type declares what configuration the strategy needs from the
1228
* developer to correctly work.
@@ -66,11 +82,19 @@ export class LinkedInStrategy<User> extends Strategy<
6682
> {
6783
name = LinkedInStrategyDefaultName;
6884

85+
protected client: LinkedIn;
86+
6987
constructor(
7088
protected options: LinkedInStrategy.ConstructorOptions,
7189
verify: Strategy.VerifyFunction<User, LinkedInStrategy.VerifyOptions>,
7290
) {
7391
super(verify);
92+
93+
this.client = new LinkedIn(
94+
options.clientId,
95+
options.clientSecret,
96+
options.redirectURI.toString(),
97+
);
7498
}
7599

76100
private get cookieName() {
@@ -100,112 +124,76 @@ export class LinkedInStrategy<User> extends Strategy<
100124
debug("Request URL", request.url);
101125

102126
let url = new URL(request.url);
103-
let code = url.searchParams.get("code");
104-
let state = url.searchParams.get("state");
127+
128+
let stateUrl = url.searchParams.get("state");
105129
let error = url.searchParams.get("error");
106130

107131
if (error) {
108132
let description = url.searchParams.get("error_description");
109133
let uri = url.searchParams.get("error_uri");
110-
throw new Error(`LinkedIn OAuth error: ${error}, ${description}, ${uri}`);
134+
throw new OAuth2RequestError(error, description, uri, stateUrl);
111135
}
112136

113-
if (!state) {
137+
if (!stateUrl) {
114138
debug("No state found in the URL, redirecting to authorization endpoint");
115139

116-
// Generate a random state
117-
let newState = crypto.randomUUID();
118-
debug("Generated State", newState);
140+
let state = generateState();
119141

120-
// Create authorization URL
121-
let authorizationURL = new URL(
122-
"https://www.linkedin.com/oauth/v2/authorization",
123-
);
124-
authorizationURL.searchParams.set("client_id", this.options.clientId);
125-
authorizationURL.searchParams.set(
126-
"redirect_uri",
127-
this.options.redirectURI.toString(),
128-
);
129-
authorizationURL.searchParams.set("response_type", "code");
130-
authorizationURL.searchParams.set(
131-
"scope",
132-
this.getScope(this.options.scopes),
142+
debug("Generated State", state);
143+
144+
let url = this.client.createAuthorizationURL(
145+
state,
146+
Array.isArray(this.options.scopes)
147+
? this.options.scopes
148+
: this.options.scopes
149+
? (this.options.scopes.split(
150+
LinkedInStrategyScopeSeparator,
151+
) as LinkedInScope[])
152+
: (["openid", "profile", "email"] as LinkedInScope[]),
133153
);
134-
authorizationURL.searchParams.set("state", newState);
135154

136-
// Add any additional params
137-
const params = this.authorizationParams(
138-
authorizationURL.searchParams,
155+
url.search = this.authorizationParams(
156+
url.searchParams,
139157
request,
140-
);
141-
authorizationURL.search = params.toString();
158+
).toString();
142159

143-
debug("Authorization URL", authorizationURL.toString());
160+
debug("Authorization URL", url.toString());
144161

145-
// Set cookie with state for verification later
146162
let header = new SetCookie({
147163
name: this.cookieName,
148-
value: new URLSearchParams({ state: newState }).toString(),
149-
httpOnly: true,
164+
value: new URLSearchParams({ state }).toString(),
165+
httpOnly: true, // Prevents JavaScript from accessing the cookie
150166
maxAge: 60 * 5, // 5 minutes
151-
path: "/",
152-
sameSite: "Lax",
167+
path: "/", // Allow the cookie to be sent to any path
168+
sameSite: "Lax", // Prevents it from being sent in cross-site requests
153169
...this.cookieOptions,
154170
});
155171

156-
throw redirect(authorizationURL.toString(), {
172+
throw redirect(url.toString(), {
157173
headers: { "Set-Cookie": header.toString() },
158174
});
159175
}
160176

177+
let code = url.searchParams.get("code");
178+
161179
if (!code) throw new ReferenceError("Missing code in the URL");
162180

163-
// Verify state
164181
let cookie = new Cookie(request.headers.get("cookie") ?? "");
165182
let params = new URLSearchParams(cookie.get(this.cookieName) || "");
166183

167184
if (!params.has("state")) {
168185
throw new ReferenceError("Missing state on cookie.");
169186
}
170187

171-
if (params.get("state") !== state) {
188+
if (params.get("state") !== stateUrl) {
172189
throw new RangeError("State in URL doesn't match state in cookie.");
173190
}
174191

175192
debug("Validating authorization code");
193+
let tokens = await this.client.validateAuthorizationCode(code);
176194

177-
// Exchange code for tokens
178-
const formData = new URLSearchParams();
179-
formData.set("client_id", this.options.clientId);
180-
formData.set("client_secret", this.options.clientSecret);
181-
formData.set("grant_type", "authorization_code");
182-
formData.set("code", code);
183-
formData.set("redirect_uri", this.options.redirectURI.toString());
184-
185-
const tokenResponse = await fetch(
186-
"https://www.linkedin.com/oauth/v2/accessToken",
187-
{
188-
method: "POST",
189-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
190-
body: formData,
191-
},
192-
);
193-
194-
if (!tokenResponse.ok) {
195-
const error = await tokenResponse.text();
196-
throw new Error(`Failed to get access token: ${error}`);
197-
}
198-
199-
const tokens = (await tokenResponse.json()) as {
200-
access_token: string;
201-
token_type: string;
202-
expires_in: number;
203-
refresh_token?: string;
204-
scope?: string;
205-
};
206-
207-
// Get user profile
208-
const profile = await this.userProfile(tokens.access_token);
195+
debug("Fetching user profile");
196+
let profile = await this.userProfile(tokens.accessToken());
209197

210198
debug("Verifying the user profile");
211199
let user = await this.verify({ request, tokens, profile });
@@ -216,6 +204,12 @@ export class LinkedInStrategy<User> extends Strategy<
216204

217205
/**
218206
* Return extra parameters to be included in the authorization request.
207+
*
208+
* Some OAuth 2.0 providers allow additional, non-standard parameters to be
209+
* included when requesting authorization. Since these parameters are not
210+
* standardized by the OAuth 2.0 specification, OAuth 2.0-based authentication
211+
* strategies can override this function in order to populate these
212+
* parameters as required by the provider.
219213
*/
220214
protected authorizationParams(
221215
params: URLSearchParams,
@@ -256,30 +250,18 @@ export class LinkedInStrategy<User> extends Strategy<
256250
}
257251

258252
/**
259-
* Refresh the access token using a refresh token
253+
* Get a new OAuth2 Tokens object using the refresh token once the previous
254+
* access token has expired.
255+
* @param refreshToken The refresh token to use to get a new access token
256+
* @returns The new OAuth2 tokens object
257+
* @example
258+
* ```ts
259+
* let tokens = await strategy.refreshToken(refreshToken);
260+
* console.log(tokens.accessToken());
261+
* ```
260262
*/
261-
public async refreshToken(refreshToken: string) {
262-
const formData = new URLSearchParams();
263-
formData.set("client_id", this.options.clientId);
264-
formData.set("client_secret", this.options.clientSecret);
265-
formData.set("grant_type", "refresh_token");
266-
formData.set("refresh_token", refreshToken);
267-
268-
const response = await fetch(
269-
"https://www.linkedin.com/oauth/v2/accessToken",
270-
{
271-
method: "POST",
272-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
273-
body: formData,
274-
},
275-
);
276-
277-
if (!response.ok) {
278-
const error = await response.text();
279-
throw new Error(`Failed to refresh token: ${error}`);
280-
}
281-
282-
return response.json();
263+
public refreshToken(refreshToken: string) {
264+
return this.client.refreshAccessToken(refreshToken);
283265
}
284266
}
285267

@@ -288,13 +270,7 @@ export namespace LinkedInStrategy {
288270
/** The request that triggered the verification flow */
289271
request: Request;
290272
/** The OAuth2 tokens retrieved from LinkedIn */
291-
tokens: {
292-
access_token: string;
293-
token_type: string;
294-
expires_in: number;
295-
refresh_token?: string;
296-
scope?: string;
297-
};
273+
tokens: OAuth2Tokens;
298274
/** The LinkedIn profile */
299275
profile: LinkedInProfile;
300276
}
@@ -319,7 +295,7 @@ export namespace LinkedInStrategy {
319295
/**
320296
* The URL of your application where LinkedIn will redirect after authentication
321297
*/
322-
redirectURI: URL | string;
298+
redirectURI: URLConstructor;
323299

324300
/**
325301
* The scopes you want to request from LinkedIn

0 commit comments

Comments
 (0)