|
| 1 | +package oauth2provider |
| 2 | + |
| 3 | +import ( |
| 4 | +"net/http" |
| 5 | +"net/url" |
| 6 | +"testing" |
| 7 | + |
| 8 | +"github.com/stretchr/testify/require" |
| 9 | + |
| 10 | +"github.com/coder/coder/v2/codersdk" |
| 11 | +) |
| 12 | + |
| 13 | +// TestExtractTokenParams_Scopes tests OAuth2 scope parameter parsing |
| 14 | +// to ensure RFC 6749 compliance where scopes are space-delimited |
| 15 | +funcTestExtractTokenParams_Scopes(t*testing.T) { |
| 16 | +t.Parallel() |
| 17 | + |
| 18 | +testCases:= []struct { |
| 19 | +namestring |
| 20 | +scopeParamstring// Raw query param value (before URL encoding) |
| 21 | +expectedScopes []string// Expected parsed scope slice |
| 22 | +descriptionstring// Test case description |
| 23 | +}{ |
| 24 | +{ |
| 25 | +name:"SpaceSeparatedTwoScopes", |
| 26 | +scopeParam:"coder:workspace.create coder:workspace.operate", |
| 27 | +expectedScopes: []string{"coder:workspace.create","coder:workspace.operate"}, |
| 28 | +description:"RFC 6749 compliant: space-separated scopes", |
| 29 | +}, |
| 30 | +{ |
| 31 | +name:"SpaceSeparatedThreeScopes", |
| 32 | +scopeParam:"scope1 scope2 scope3", |
| 33 | +expectedScopes: []string{"scope1","scope2","scope3"}, |
| 34 | +description:"Multiple space-separated scopes", |
| 35 | +}, |
| 36 | +{ |
| 37 | +name:"SingleScope", |
| 38 | +scopeParam:"coder:workspace.create", |
| 39 | +expectedScopes: []string{"coder:workspace.create"}, |
| 40 | +description:"Single scope without spaces", |
| 41 | +}, |
| 42 | +{ |
| 43 | +name:"EmptyScope", |
| 44 | +scopeParam:"", |
| 45 | +expectedScopes: []string{}, |
| 46 | +description:"Empty scope parameter", |
| 47 | +}, |
| 48 | +{ |
| 49 | +name:"MultipleSpaces", |
| 50 | +scopeParam:"scope1 scope2 scope3", |
| 51 | +expectedScopes: []string{"scope1","scope2","scope3"}, |
| 52 | +description:"Multiple consecutive spaces should be handled gracefully", |
| 53 | +}, |
| 54 | +{ |
| 55 | +name:"LeadingAndTrailingSpaces", |
| 56 | +scopeParam:" scope1 scope2 ", |
| 57 | +expectedScopes: []string{"scope1","scope2"}, |
| 58 | +description:"Leading and trailing spaces should be trimmed", |
| 59 | +}, |
| 60 | +{ |
| 61 | +name:"ColonInScope", |
| 62 | +scopeParam:"coder:workspace:read coder:workspace:write", |
| 63 | +expectedScopes: []string{"coder:workspace:read","coder:workspace:write"}, |
| 64 | +description:"Scopes with colons (common pattern)", |
| 65 | +}, |
| 66 | +{ |
| 67 | +name:"DotInScope", |
| 68 | +scopeParam:"workspace.create workspace.delete", |
| 69 | +expectedScopes: []string{"workspace.create","workspace.delete"}, |
| 70 | +description:"Scopes with dots (common pattern)", |
| 71 | +}, |
| 72 | +{ |
| 73 | +name:"HyphenInScope", |
| 74 | +scopeParam:"workspace-read workspace-write", |
| 75 | +expectedScopes: []string{"workspace-read","workspace-write"}, |
| 76 | +description:"Scopes with hyphens", |
| 77 | +}, |
| 78 | +{ |
| 79 | +name:"UnderscoreInScope", |
| 80 | +scopeParam:"workspace_create workspace_delete", |
| 81 | +expectedScopes: []string{"workspace_create","workspace_delete"}, |
| 82 | +description:"Scopes with underscores", |
| 83 | +}, |
| 84 | +{ |
| 85 | +name:"OpenIDScopes", |
| 86 | +scopeParam:"openid profile email", |
| 87 | +expectedScopes: []string{"openid","profile","email"}, |
| 88 | +description:"Common OpenID Connect scopes", |
| 89 | +}, |
| 90 | +} |
| 91 | + |
| 92 | +for_,tc:=rangetestCases { |
| 93 | +t.Run(tc.name,func(t*testing.T) { |
| 94 | +t.Parallel() |
| 95 | + |
| 96 | +// Create a mock request with the scope parameter |
| 97 | +callbackURL,err:=url.Parse("http://localhost:3000/callback") |
| 98 | +require.NoError(t,err) |
| 99 | + |
| 100 | +// Build form values (simulating POST request body) |
| 101 | +form:= url.Values{} |
| 102 | +form.Set("grant_type","authorization_code") |
| 103 | +form.Set("client_id","test-client") |
| 104 | +form.Set("client_secret","test-secret") |
| 105 | +form.Set("code","test-code") |
| 106 | +iftc.scopeParam!="" { |
| 107 | +form.Set("scope",tc.scopeParam) |
| 108 | +} |
| 109 | + |
| 110 | +// Create request with form data already parsed |
| 111 | +// Set PostForm and Form directly to bypass the need for a request body |
| 112 | +req:=&http.Request{ |
| 113 | +Method:http.MethodPost, |
| 114 | +PostForm:form, |
| 115 | +Form:form,// Form is the combination of PostForm and URL query |
| 116 | +} |
| 117 | + |
| 118 | +// Extract token params |
| 119 | +params,validationErrs,err:=extractTokenParams(req,callbackURL) |
| 120 | + |
| 121 | +// Verify no errors occurred |
| 122 | +require.NoError(t,err,"extractTokenParams should not return error for: %s",tc.description) |
| 123 | +require.Empty(t,validationErrs,"should have no validation errors for: %s",tc.description) |
| 124 | + |
| 125 | +// Verify scopes match expected |
| 126 | +require.Equal(t,tc.expectedScopes,params.scopes,"scope parsing failed for: %s",tc.description) |
| 127 | +}) |
| 128 | +} |
| 129 | +} |
| 130 | + |
| 131 | +// TestExtractTokenParams_ScopesURLEncoded tests that URL-encoded space-separated |
| 132 | +// scopes are correctly decoded and parsed |
| 133 | +funcTestExtractTokenParams_ScopesURLEncoded(t*testing.T) { |
| 134 | +t.Parallel() |
| 135 | + |
| 136 | +testCases:= []struct { |
| 137 | +namestring |
| 138 | +rawQuerystring// Raw query string with URL encoding |
| 139 | +expectedScopes []string// Expected parsed scope slice |
| 140 | +}{ |
| 141 | +{ |
| 142 | +name:"PlusEncodedSpaces", |
| 143 | +rawQuery:"grant_type=authorization_code&client_id=test&client_secret=secret&code=code&scope=scope1+scope2+scope3", |
| 144 | +expectedScopes: []string{"scope1","scope2","scope3"}, |
| 145 | +}, |
| 146 | +{ |
| 147 | +name:"PercentEncodedSpaces", |
| 148 | +rawQuery:"grant_type=authorization_code&client_id=test&client_secret=secret&code=code&scope=scope1%20scope2%20scope3", |
| 149 | +expectedScopes: []string{"scope1","scope2","scope3"}, |
| 150 | +}, |
| 151 | +{ |
| 152 | +name:"MixedEncoding", |
| 153 | +rawQuery:"grant_type=authorization_code&client_id=test&client_secret=secret&code=code&scope=scope1+scope2%20scope3", |
| 154 | +expectedScopes: []string{"scope1","scope2","scope3"}, |
| 155 | +}, |
| 156 | +{ |
| 157 | +name:"ColonEncodedInScope", |
| 158 | +rawQuery:"grant_type=authorization_code&client_id=test&client_secret=secret&code=code&scope=coder%3Aworkspace.create+coder%3Aworkspace.operate", |
| 159 | +expectedScopes: []string{"coder:workspace.create","coder:workspace.operate"}, |
| 160 | +}, |
| 161 | +} |
| 162 | + |
| 163 | +for_,tc:=rangetestCases { |
| 164 | +t.Run(tc.name,func(t*testing.T) { |
| 165 | +t.Parallel() |
| 166 | + |
| 167 | +callbackURL,err:=url.Parse("http://localhost:3000/callback") |
| 168 | +require.NoError(t,err) |
| 169 | + |
| 170 | +// Parse the raw query string |
| 171 | +values,err:=url.ParseQuery(tc.rawQuery) |
| 172 | +require.NoError(t,err) |
| 173 | + |
| 174 | +// Create request with form data already parsed |
| 175 | +req:=&http.Request{ |
| 176 | +Method:http.MethodPost, |
| 177 | +PostForm:values, |
| 178 | +Form:values, |
| 179 | +} |
| 180 | + |
| 181 | +// Extract token params |
| 182 | +params,validationErrs,err:=extractTokenParams(req,callbackURL) |
| 183 | + |
| 184 | +// Verify no errors |
| 185 | +require.NoError(t,err) |
| 186 | +require.Empty(t,validationErrs) |
| 187 | + |
| 188 | +// Verify scopes |
| 189 | +require.Equal(t,tc.expectedScopes,params.scopes) |
| 190 | +}) |
| 191 | +} |
| 192 | +} |
| 193 | + |
| 194 | +// TestExtractTokenParams_ScopesEdgeCases tests edge cases in scope parsing |
| 195 | +funcTestExtractTokenParams_ScopesEdgeCases(t*testing.T) { |
| 196 | +t.Parallel() |
| 197 | + |
| 198 | +testCases:= []struct { |
| 199 | +namestring |
| 200 | +setupFormfunc() url.Values |
| 201 | +expectedScopes []string |
| 202 | +descriptionstring |
| 203 | +}{ |
| 204 | +{ |
| 205 | +name:"NoScopeParameter", |
| 206 | +setupForm:func() url.Values { |
| 207 | +form:= url.Values{} |
| 208 | +form.Set("grant_type","authorization_code") |
| 209 | +form.Set("client_id","test-client") |
| 210 | +form.Set("client_secret","test-secret") |
| 211 | +form.Set("code","test-code") |
| 212 | +returnform |
| 213 | +}, |
| 214 | +expectedScopes: []string{}, |
| 215 | +description:"Missing scope parameter should default to empty slice", |
| 216 | +}, |
| 217 | +{ |
| 218 | +name:"OnlySpaces", |
| 219 | +setupForm:func() url.Values { |
| 220 | +form:= url.Values{} |
| 221 | +form.Set("grant_type","authorization_code") |
| 222 | +form.Set("client_id","test-client") |
| 223 | +form.Set("client_secret","test-secret") |
| 224 | +form.Set("code","test-code") |
| 225 | +form.Set("scope"," ") |
| 226 | +returnform |
| 227 | +}, |
| 228 | +expectedScopes: []string{}, |
| 229 | +description:"Scope with only spaces should result in empty slice", |
| 230 | +}, |
| 231 | +{ |
| 232 | +name:"VeryLongScopeName", |
| 233 | +setupForm:func() url.Values { |
| 234 | +longScope:="coder:workspace:project:resource:action:create:read:write:delete:admin" |
| 235 | +form:= url.Values{} |
| 236 | +form.Set("grant_type","authorization_code") |
| 237 | +form.Set("client_id","test-client") |
| 238 | +form.Set("client_secret","test-secret") |
| 239 | +form.Set("code","test-code") |
| 240 | +form.Set("scope",longScope) |
| 241 | +returnform |
| 242 | +}, |
| 243 | +expectedScopes: []string{"coder:workspace:project:resource:action:create:read:write:delete:admin"}, |
| 244 | +description:"Very long scope names should be handled", |
| 245 | +}, |
| 246 | +} |
| 247 | + |
| 248 | +for_,tc:=rangetestCases { |
| 249 | +t.Run(tc.name,func(t*testing.T) { |
| 250 | +t.Parallel() |
| 251 | + |
| 252 | +callbackURL,err:=url.Parse("http://localhost:3000/callback") |
| 253 | +require.NoError(t,err) |
| 254 | + |
| 255 | +form:=tc.setupForm() |
| 256 | +req:=&http.Request{ |
| 257 | +Method:http.MethodPost, |
| 258 | +PostForm:form, |
| 259 | +Form:form, |
| 260 | +} |
| 261 | + |
| 262 | +params,validationErrs,err:=extractTokenParams(req,callbackURL) |
| 263 | + |
| 264 | +require.NoError(t,err,"extractTokenParams should not error for: %s",tc.description) |
| 265 | +require.Empty(t,validationErrs) |
| 266 | +require.Equal(t,tc.expectedScopes,params.scopes,"scope mismatch for: %s",tc.description) |
| 267 | +}) |
| 268 | +} |
| 269 | +} |
| 270 | + |
| 271 | +// TestExtractAuthorizeParams_Scopes tests scope parsing in the authorization endpoint |
| 272 | +funcTestExtractAuthorizeParams_Scopes(t*testing.T) { |
| 273 | +t.Parallel() |
| 274 | + |
| 275 | +testCases:= []struct { |
| 276 | +namestring |
| 277 | +scopeParamstring |
| 278 | +expectedScopes []string |
| 279 | +}{ |
| 280 | +{ |
| 281 | +name:"SpaceSeparated", |
| 282 | +scopeParam:"openid profile email", |
| 283 | +expectedScopes: []string{"openid","profile","email"}, |
| 284 | +}, |
| 285 | +{ |
| 286 | +name:"SingleScope", |
| 287 | +scopeParam:"openid", |
| 288 | +expectedScopes: []string{"openid"}, |
| 289 | +}, |
| 290 | +{ |
| 291 | +name:"EmptyScope", |
| 292 | +scopeParam:"", |
| 293 | +expectedScopes: []string{}, |
| 294 | +}, |
| 295 | +{ |
| 296 | +name:"CoderScopes", |
| 297 | +scopeParam:"coder:workspace.create coder:workspace.read coder:workspace.delete", |
| 298 | +expectedScopes: []string{"coder:workspace.create","coder:workspace.read","coder:workspace.delete"}, |
| 299 | +}, |
| 300 | +} |
| 301 | + |
| 302 | +for_,tc:=rangetestCases { |
| 303 | +t.Run(tc.name,func(t*testing.T) { |
| 304 | +t.Parallel() |
| 305 | + |
| 306 | +callbackURL,err:=url.Parse("http://localhost:3000/callback") |
| 307 | +require.NoError(t,err) |
| 308 | + |
| 309 | +// Build query parameters for GET request |
| 310 | +query:= url.Values{} |
| 311 | +query.Set("response_type","code") |
| 312 | +query.Set("client_id","test-client") |
| 313 | +query.Set("redirect_uri","http://localhost:3000/callback") |
| 314 | +iftc.scopeParam!="" { |
| 315 | +query.Set("scope",tc.scopeParam) |
| 316 | +} |
| 317 | + |
| 318 | +// Create request with query parameters |
| 319 | +reqURL,err:=url.Parse("http://localhost:8080/oauth2/authorize?"+query.Encode()) |
| 320 | +require.NoError(t,err) |
| 321 | + |
| 322 | +req:=&http.Request{ |
| 323 | +Method:http.MethodGet, |
| 324 | +URL:reqURL, |
| 325 | +} |
| 326 | + |
| 327 | +// Extract authorize params |
| 328 | +params,validationErrs,err:=extractAuthorizeParams(req,callbackURL) |
| 329 | + |
| 330 | +require.NoError(t,err) |
| 331 | +require.Empty(t,validationErrs) |
| 332 | +require.Equal(t,tc.expectedScopes,params.scope) |
| 333 | +}) |
| 334 | +} |
| 335 | +} |
| 336 | + |
| 337 | +// TestRefreshTokenGrant_Scopes tests that scopes can be requested during refresh |
| 338 | +funcTestRefreshTokenGrant_Scopes(t*testing.T) { |
| 339 | +t.Parallel() |
| 340 | + |
| 341 | +// Test that refresh token requests can include scope parameter |
| 342 | +// per RFC 6749 Section 6 |
| 343 | +form:= url.Values{} |
| 344 | +form.Set("grant_type","refresh_token") |
| 345 | +form.Set("refresh_token","test-refresh-token") |
| 346 | +form.Set("scope","reduced:scope subset:scope") |
| 347 | + |
| 348 | +callbackURL,err:=url.Parse("http://localhost:3000/callback") |
| 349 | +require.NoError(t,err) |
| 350 | + |
| 351 | +req:=&http.Request{ |
| 352 | +Method:http.MethodPost, |
| 353 | +PostForm:form, |
| 354 | +Form:form, |
| 355 | +} |
| 356 | + |
| 357 | +params,validationErrs,err:=extractTokenParams(req,callbackURL) |
| 358 | + |
| 359 | +require.NoError(t,err) |
| 360 | +require.Empty(t,validationErrs) |
| 361 | +require.Equal(t,codersdk.OAuth2ProviderGrantTypeRefreshToken,params.grantType) |
| 362 | +require.Equal(t, []string{"reduced:scope","subset:scope"},params.scopes) |
| 363 | +} |