Skip to content

Commit 93113cd

Browse files
committed
Refactor OpenAuthStrategy tests to improve structure and enhance OAuth2 flow handling
1 parent 05cf373 commit 93113cd

File tree

2 files changed

+254
-125
lines changed

2 files changed

+254
-125
lines changed

src/index.ts

Lines changed: 173 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
import { Cookie, SetCookie } from "@mjackson/headers";
1+
import type { SetCookieInit } from "@mjackson/headers";
22
import { createClient } from "@openauthjs/openauth/client";
33
import { encodeBase64urlNoPadding } from "@oslojs/encoding";
4+
import createDebug from "debug";
45
import { Strategy } from "remix-auth/strategy";
6+
import { redirect } from "./lib/redirect.js";
7+
import { StateStore } from "./lib/store.js";
8+
9+
const debug = createDebug("OAuth2Strategy");
510

611
export class OpenAuthStrategy<U> extends Strategy<
712
U,
@@ -17,79 +22,205 @@ export class OpenAuthStrategy<U> extends Strategy<
1722
) {
1823
super(verify);
1924

20-
this.client = createClient(options);
25+
this.client = createClient({
26+
clientID: options.clientId,
27+
issuer: options.issuer,
28+
});
29+
}
30+
31+
private get cookieName() {
32+
if (typeof this.options.cookie === "string") {
33+
return this.options.cookie || "oauth2";
34+
}
35+
return this.options.cookie?.name ?? "oauth2";
36+
}
37+
38+
private get cookieOptions() {
39+
if (typeof this.options.cookie !== "object") return {};
40+
return this.options.cookie ?? {};
2141
}
2242

2343
override async authenticate(request: Request): Promise<U> {
44+
debug("Request URL", request.url);
2445
let url = new URL(request.url);
2546

2647
let code = url.searchParams.get("code");
48+
let stateUrl = url.searchParams.get("state");
49+
let error = url.searchParams.get("error");
50+
51+
if (error) {
52+
let description = url.searchParams.get("error_description");
53+
let uri = url.searchParams.get("error_uri");
54+
throw new OAuth2RequestError(error, description, uri, stateUrl);
55+
}
2756

2857
if (!code) {
29-
let [verifier, redirect] = (await this.client.pkce(
30-
this.options.redirectURI,
31-
)) as [string, string];
32-
33-
let url = new URL(redirect);
34-
let state = this.generateState();
35-
url.searchParams.set("state", state);
36-
37-
let setCookie = new SetCookie({
38-
name: "openauth",
39-
path: "/",
40-
sameSite: "Lax",
41-
maxAge: 60 * 5, // 5 minutes
42-
httpOnly: true,
43-
value: new URLSearchParams({ verifier, state }).toString(),
44-
});
58+
debug("No code found in the URL, redirecting to authorization endpoint");
59+
60+
let { state, codeVerifier, url } = await this.createAuthorizationURL();
61+
62+
debug("State", state);
63+
debug("Code verifier", codeVerifier);
64+
65+
url.search = this.authorizationParams(
66+
url.searchParams,
67+
request,
68+
).toString();
69+
70+
debug("Authorization URL", url.toString());
71+
72+
let store = StateStore.fromRequest(request, this.cookieName);
73+
store.set(state, codeVerifier);
74+
75+
let setCookie = store.toSetCookie(this.cookieName, this.cookieOptions);
4576

4677
let headers = new Headers();
4778
headers.append("Set-Cookie", setCookie.toString());
48-
headers.append("Location", redirect);
4979

50-
throw new Response(null, { status: 302, headers });
80+
throw redirect(url.toString(), { headers });
5181
}
5282

53-
let cookie = new Cookie(request.headers.get("cookie") ?? "");
54-
let params = new URLSearchParams(cookie.get("openauth"));
83+
let store = StateStore.fromRequest(request);
84+
85+
if (!stateUrl) throw new ReferenceError("Missing state in URL.");
86+
87+
if (!store.has()) throw new ReferenceError("Missing state on cookie.");
88+
89+
if (!store.has(stateUrl)) {
90+
throw new RangeError("State in URL doesn't match state in cookie.");
91+
}
5592

56-
let verifier = params.get("verifier");
57-
let state = params.get("state");
93+
let codeVerifier = store.get(stateUrl);
5894

59-
if (!state) throw new Error("Missing state");
60-
if (state !== url.searchParams.get("state")) {
61-
throw new Error("Invalid state");
95+
if (!codeVerifier) {
96+
throw new ReferenceError("Missing code verifier on cookie.");
6297
}
63-
if (!verifier) throw new Error("Missing verifier");
6498

65-
let tokens = await this.client.exchange(
66-
code,
67-
this.options.redirectURI,
68-
verifier,
69-
);
99+
debug("Validating authorization code");
100+
let tokens = await this.validateAuthorizationCode(code, codeVerifier);
70101

71-
return this.verify({ tokens });
102+
debug("Verifying the user profile");
103+
let user = await this.verify({ request, tokens });
104+
105+
debug("User authenticated");
106+
return user;
107+
}
108+
109+
protected async createAuthorizationURL() {
110+
let state = this.generateState();
111+
112+
let [codeVerifier, redirect] = (await this.client.pkce(
113+
this.options.redirectUri,
114+
)) as [string, string];
115+
116+
let url = new URL(redirect);
117+
url.searchParams.set("state", state);
118+
119+
return { state, codeVerifier, url };
120+
}
121+
122+
protected validateAuthorizationCode(code: string, codeVerifier: string) {
123+
return this.client.exchange(code, this.options.redirectUri, codeVerifier);
72124
}
73125

74126
protected generateState() {
75127
let randomValues = new Uint8Array(32);
76128
crypto.getRandomValues(randomValues);
77129
return encodeBase64urlNoPadding(randomValues);
78130
}
131+
132+
/**
133+
* Return extra parameters to be included in the authorization request.
134+
*
135+
* Some OAuth 2.0 providers allow additional, non-standard parameters to be
136+
* included when requesting authorization. Since these parameters are not
137+
* standardized by the OAuth 2.0 specification, OAuth 2.0-based authentication
138+
* strategies can override this function in order to populate these
139+
* parameters as required by the provider.
140+
*/
141+
protected authorizationParams(
142+
params: URLSearchParams,
143+
request: Request,
144+
): URLSearchParams {
145+
return new URLSearchParams(params);
146+
}
79147
}
80148

81149
export namespace OpenAuthStrategy {
82150
export interface ConstructorOptions {
83-
redirectURI: string;
84-
85-
clientID: string;
86-
issuer?: string;
151+
redirectUri: string;
152+
clientId: string;
153+
issuer: string;
154+
/**
155+
* The identity provider already configured in your OpenAuth server you
156+
* want to send the user to.
157+
*
158+
* This can't be changed after the strategy is created, if you have more than one provider create multiple instances of your strategy.
159+
*
160+
* @example
161+
* authenticator.use(
162+
* new OpenAuthStrategy(
163+
* {
164+
* redirectURI,
165+
* clientID,
166+
* issuer,
167+
* provider: "google" // Set it to Google
168+
* },
169+
* verify
170+
* ),
171+
* "google" // Rename the strategy to Google
172+
* )
173+
* authenticator.use(
174+
* new OpenAuthStrategy(
175+
* {
176+
* redirectURI,
177+
* clientID,
178+
* issuer,
179+
* provider: "github" // Set it to GitHub
180+
* },
181+
* verify
182+
* ),
183+
* "github" // Rename the strategy to GitHub
184+
* )
185+
*/
186+
provider?: string;
187+
188+
/**
189+
* The name of the cookie used to keep state and code verifier around.
190+
*
191+
* The OAuth2 flow requires generating a random state and code verifier, and
192+
* then checking that the state matches when the user is redirected back to
193+
* the application. This is done to prevent CSRF attacks.
194+
*
195+
* The state and code verifier are stored in a cookie, and this option
196+
* allows you to customize the name of that cookie if needed.
197+
* @default "oauth2"
198+
*/
199+
cookie?: string | (Omit<SetCookieInit, "value"> & { name: string });
87200
}
88201

89202
export interface VerifyOptions {
90-
tokens: {
91-
access: string;
92-
refresh: string;
93-
};
203+
request: Request;
204+
tokens: { access: string; refresh: string };
205+
}
206+
}
207+
208+
export class OAuth2RequestError extends Error {
209+
code: string;
210+
description: string | null;
211+
uri: string | null;
212+
state: string | null;
213+
214+
constructor(
215+
code: string,
216+
description: string | null,
217+
uri: string | null,
218+
state: string | null,
219+
) {
220+
super(`OAuth request error: ${code}`);
221+
this.code = code;
222+
this.description = description;
223+
this.uri = uri;
224+
this.state = state;
94225
}
95226
}

0 commit comments

Comments
 (0)