1
- import { Cookie , SetCookie } from "@mjackson/headers" ;
1
+ import type { SetCookieInit } from "@mjackson/headers" ;
2
2
import { createClient } from "@openauthjs/openauth/client" ;
3
3
import { encodeBase64urlNoPadding } from "@oslojs/encoding" ;
4
+ import createDebug from "debug" ;
4
5
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" ) ;
5
10
6
11
export class OpenAuthStrategy < U > extends Strategy <
7
12
U ,
@@ -17,79 +22,205 @@ export class OpenAuthStrategy<U> extends Strategy<
17
22
) {
18
23
super ( verify ) ;
19
24
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 ?? { } ;
21
41
}
22
42
23
43
override async authenticate ( request : Request ) : Promise < U > {
44
+ debug ( "Request URL" , request . url ) ;
24
45
let url = new URL ( request . url ) ;
25
46
26
47
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
+ }
27
56
28
57
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 ) ;
45
76
46
77
let headers = new Headers ( ) ;
47
78
headers . append ( "Set-Cookie" , setCookie . toString ( ) ) ;
48
- headers . append ( "Location" , redirect ) ;
49
79
50
- throw new Response ( null , { status : 302 , headers } ) ;
80
+ throw redirect ( url . toString ( ) , { headers } ) ;
51
81
}
52
82
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
+ }
55
92
56
- let verifier = params . get ( "verifier" ) ;
57
- let state = params . get ( "state" ) ;
93
+ let codeVerifier = store . get ( stateUrl ) ;
58
94
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." ) ;
62
97
}
63
- if ( ! verifier ) throw new Error ( "Missing verifier" ) ;
64
98
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 ) ;
70
101
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 ) ;
72
124
}
73
125
74
126
protected generateState ( ) {
75
127
let randomValues = new Uint8Array ( 32 ) ;
76
128
crypto . getRandomValues ( randomValues ) ;
77
129
return encodeBase64urlNoPadding ( randomValues ) ;
78
130
}
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
+ }
79
147
}
80
148
81
149
export namespace OpenAuthStrategy {
82
150
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 } ) ;
87
200
}
88
201
89
202
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 ;
94
225
}
95
226
}
0 commit comments