4
4
"bytes"
5
5
"context"
6
6
"crypto/sha256"
7
+ "crypto/subtle"
7
8
"database/sql"
8
9
"encoding/base64"
9
10
"encoding/json"
@@ -36,7 +37,15 @@ const (
36
37
// conflict with query parameters that users may use.
37
38
//nolint:gosec
38
39
subdomainProxyAPIKeyParam = "coder_application_connect_api_key_35e783"
39
- redirectURIQueryParam = "redirect_uri"
40
+ // redirectURIQueryParam is the query param for the app URL to be passed
41
+ // back to the API auth endpoint on the main access URL.
42
+ redirectURIQueryParam = "redirect_uri"
43
+ // appLogoutHostname is the hostname to use for the logout redirect. When
44
+ // the dashboard logs out, it will redirect to this subdomain of the app
45
+ // hostname, and the server will remove the cookie and redirect to the main
46
+ // login page.
47
+ // It is important that this URL can never match a valid app hostname.
48
+ appLogoutHostname = "coder-logout"
40
49
)
41
50
42
51
// nonCanonicalHeaders is a map from "canonical" headers to the actual header we
@@ -264,6 +273,12 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
264
273
return httpapi.ApplicationURL {}, false
265
274
}
266
275
276
+ // Check if the request is part of a logout flow.
277
+ if subdomain == appLogoutHostname {
278
+ api .handleWorkspaceAppLogout (rw , r )
279
+ return httpapi.ApplicationURL {}, false
280
+ }
281
+
267
282
// Parse the application URL from the subdomain.
268
283
app , err := httpapi .ParseSubdomainAppURL (subdomain )
269
284
if err != nil {
@@ -280,6 +295,95 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
280
295
return app , true
281
296
}
282
297
298
+ func (api * API ) handleWorkspaceAppLogout (rw http.ResponseWriter , r * http.Request ) {
299
+ ctx := r .Context ()
300
+
301
+ // Delete the API key and cookie first before attempting to parse/validate
302
+ // the redirect URI.
303
+ cookie , err := r .Cookie (httpmw .DevURLSessionTokenCookie )
304
+ if err == nil && cookie .Value != "" {
305
+ id , secret , err := httpmw .SplitAPIToken (cookie .Value )
306
+ // If it's not a valid token then we don't need to delete it from the
307
+ // database, but we'll still delete the cookie.
308
+ if err == nil {
309
+ // To avoid a situation where someone overloads the API with
310
+ // different auth formats, and tricks this endpoint into deleting an
311
+ // unchecked API key, we validate that the secret matches the secret
312
+ // we store in the database.
313
+ apiKey , err := api .Database .GetAPIKeyByID (ctx , id )
314
+ if err != nil && ! xerrors .Is (err , sql .ErrNoRows ) {
315
+ httpapi .Write (ctx , rw , http .StatusInternalServerError , codersdk.Response {
316
+ Message : "Failed to lookup API key." ,
317
+ Detail : err .Error (),
318
+ })
319
+ return
320
+ }
321
+ // This is wrapped in `err == nil` because if the API key doesn't
322
+ // exist, we still want to delete the cookie.
323
+ if err == nil {
324
+ hashedSecret := sha256 .Sum256 ([]byte (secret ))
325
+ if subtle .ConstantTimeCompare (apiKey .HashedSecret , hashedSecret [:]) != 1 {
326
+ httpapi .Write (ctx , rw , http .StatusUnauthorized , codersdk.Response {
327
+ Message : httpmw .SignedOutErrorMessage ,
328
+ Detail : "API key secret is invalid." ,
329
+ })
330
+ return
331
+ }
332
+ err = api .Database .DeleteAPIKeyByID (ctx , id )
333
+ if err != nil {
334
+ httpapi .Write (ctx , rw , http .StatusInternalServerError , codersdk.Response {
335
+ Message : "Failed to delete API key." ,
336
+ Detail : err .Error (),
337
+ })
338
+ return
339
+ }
340
+ }
341
+ }
342
+ }
343
+ if ! api .setWorkspaceAppCookie (rw , r , "" ) {
344
+ return
345
+ }
346
+
347
+ // Read the redirect URI from the query string.
348
+ redirectURI := r .URL .Query ().Get (redirectURIQueryParam )
349
+ if redirectURI == "" {
350
+ redirectURI = api .AccessURL .String ()
351
+ } else {
352
+ // Validate that the redirect URI is a valid URL and exists on the same
353
+ // host as the access URL or an app URL.
354
+ parsedRedirectURI , err := url .Parse (redirectURI )
355
+ if err != nil {
356
+ site .RenderStaticErrorPage (rw , r , site.ErrorPageData {
357
+ Status : http .StatusBadRequest ,
358
+ Title : "Invalid redirect URI" ,
359
+ Description : fmt .Sprintf ("Could not parse redirect URI %q: %s" , redirectURI , err .Error ()),
360
+ RetryEnabled : false ,
361
+ DashboardURL : api .AccessURL .String (),
362
+ })
363
+ return
364
+ }
365
+
366
+ // Check if the redirect URI is on the same host as the access URL or an
367
+ // app URL.
368
+ ok := httpapi .HostnamesMatch (api .AccessURL .Hostname (), parsedRedirectURI .Hostname ())
369
+ if ! ok && api .AppHostnameRegex != nil {
370
+ // We could also check that it's a valid application URL for
371
+ // completeness, but this check should be good enough.
372
+ _ , ok = httpapi .ExecuteHostnamePattern (api .AppHostnameRegex , parsedRedirectURI .Hostname ())
373
+ }
374
+ if ! ok {
375
+ // The redirect URI they provided is not allowed, but we don't want
376
+ // to return an error page because it'll interrupt the logout flow,
377
+ // so we just use the default access URL.
378
+ parsedRedirectURI = api .AccessURL
379
+ }
380
+
381
+ redirectURI = parsedRedirectURI .String ()
382
+ }
383
+
384
+ http .Redirect (rw , r , redirectURI , http .StatusTemporaryRedirect )
385
+ }
386
+
283
387
// lookupWorkspaceApp looks up the workspace application by slug in the given
284
388
// agent and returns it. If the application is not found or there was a server
285
389
// error while looking it up, an HTML error page is returned and false is
@@ -417,7 +521,7 @@ func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter,
417
521
// and strip that query parameter.
418
522
if encryptedAPIKey := r .URL .Query ().Get (subdomainProxyAPIKeyParam ); encryptedAPIKey != "" {
419
523
// Exchange the encoded API key for a real one.
420
- _ , apiKey , err := decryptAPIKey (r .Context (), api .Database , encryptedAPIKey )
524
+ _ , token , err := decryptAPIKey (r .Context (), api .Database , encryptedAPIKey )
421
525
if err != nil {
422
526
site .RenderStaticErrorPage (rw , r , site.ErrorPageData {
423
527
Status : http .StatusBadRequest ,
@@ -431,33 +535,7 @@ func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter,
431
535
return false
432
536
}
433
537
434
- hostSplit := strings .SplitN (api .AppHostname , "." , 2 )
435
- if len (hostSplit ) != 2 {
436
- // This should be impossible as we verify the app hostname on
437
- // startup, but we'll check anyways.
438
- api .Logger .Error (r .Context (), "could not split invalid app hostname" , slog .F ("hostname" , api .AppHostname ))
439
- site .RenderStaticErrorPage (rw , r , site.ErrorPageData {
440
- Status : http .StatusInternalServerError ,
441
- Title : "Internal Server Error" ,
442
- Description : "The app is configured with an invalid app wildcard hostname. Please contact an administrator." ,
443
- RetryEnabled : false ,
444
- DashboardURL : api .AccessURL .String (),
445
- })
446
- return false
447
- }
448
-
449
- // Set the app cookie for all subdomains of api.AppHostname. This cookie
450
- // is handled properly by the ExtractAPIKey middleware.
451
- cookieHost := "." + hostSplit [1 ]
452
- http .SetCookie (rw , & http.Cookie {
453
- Name : httpmw .DevURLSessionTokenCookie ,
454
- Value : apiKey ,
455
- Domain : cookieHost ,
456
- Path : "/" ,
457
- HttpOnly : true ,
458
- SameSite : http .SameSiteLaxMode ,
459
- Secure : api .SecureAuthCookie ,
460
- })
538
+ api .setWorkspaceAppCookie (rw , r , token )
461
539
462
540
// Strip the query parameter.
463
541
path := r .URL .Path
@@ -491,6 +569,51 @@ func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter,
491
569
return false
492
570
}
493
571
572
+ // setWorkspaceAppCookie sets a cookie on the workspace app domain. If the app
573
+ // hostname cannot be parsed properly, a static error page is rendered and false
574
+ // is returned.
575
+ //
576
+ // If an empty token is supplied, it will clear the cookie.
577
+ func (api * API ) setWorkspaceAppCookie (rw http.ResponseWriter , r * http.Request , token string ) bool {
578
+ hostSplit := strings .SplitN (api .AppHostname , "." , 2 )
579
+ if len (hostSplit ) != 2 {
580
+ // This should be impossible as we verify the app hostname on
581
+ // startup, but we'll check anyways.
582
+ api .Logger .Error (r .Context (), "could not split invalid app hostname" , slog .F ("hostname" , api .AppHostname ))
583
+ site .RenderStaticErrorPage (rw , r , site.ErrorPageData {
584
+ Status : http .StatusInternalServerError ,
585
+ Title : "Internal Server Error" ,
586
+ Description : "The app is configured with an invalid app wildcard hostname. Please contact an administrator." ,
587
+ RetryEnabled : false ,
588
+ DashboardURL : api .AccessURL .String (),
589
+ })
590
+ return false
591
+ }
592
+
593
+ // Set the app cookie for all subdomains of api.AppHostname. This cookie is
594
+ // handled properly by the ExtractAPIKey middleware.
595
+ //
596
+ // We don't set an expiration because the key in the database already has an
597
+ // expiration.
598
+ maxAge := 0
599
+ if token == "" {
600
+ maxAge = - 1
601
+ }
602
+ cookieHost := "." + hostSplit [1 ]
603
+ http .SetCookie (rw , & http.Cookie {
604
+ Name : httpmw .DevURLSessionTokenCookie ,
605
+ Value : token ,
606
+ Domain : cookieHost ,
607
+ Path : "/" ,
608
+ MaxAge : maxAge ,
609
+ HttpOnly : true ,
610
+ SameSite : http .SameSiteLaxMode ,
611
+ Secure : api .SecureAuthCookie ,
612
+ })
613
+
614
+ return true
615
+ }
616
+
494
617
// @Summary Redirect to URI with encrypted API key
495
618
// @ID redirect-to-uri-with-encrypted-api-key
496
619
// @Security CoderSessionToken
0 commit comments