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
@@ -257,6 +266,12 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
257
266
return httpapi.ApplicationURL {}, false
258
267
}
259
268
269
+ // Check if the request is part of a logout flow.
270
+ if subdomain == appLogoutHostname {
271
+ api .handleWorkspaceAppLogout (rw , r )
272
+ return httpapi.ApplicationURL {}, false
273
+ }
274
+
260
275
// Parse the application URL from the subdomain.
261
276
app , err := httpapi .ParseSubdomainAppURL (subdomain )
262
277
if err != nil {
@@ -273,6 +288,95 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
273
288
return app , true
274
289
}
275
290
291
+ func (api * API ) handleWorkspaceAppLogout (rw http.ResponseWriter , r * http.Request ) {
292
+ ctx := r .Context ()
293
+
294
+ // Delete the API key and cookie first before attempting to parse/validate
295
+ // the redirect URI.
296
+ cookie , err := r .Cookie (httpmw .DevURLSessionTokenCookie )
297
+ if err == nil && cookie .Value != "" {
298
+ id , secret , err := httpmw .SplitAPIToken (cookie .Value )
299
+ // If it's not a valid token then we don't need to delete it from the
300
+ // database, but we'll still delete the cookie.
301
+ if err == nil {
302
+ // To avoid a situation where someone overloads the API with
303
+ // different auth formats, and tricks this endpoint into deleting an
304
+ // unchecked API key, we validate that the secret matches the secret
305
+ // we store in the database.
306
+ apiKey , err := api .Database .GetAPIKeyByID (ctx , id )
307
+ if err != nil && ! xerrors .Is (err , sql .ErrNoRows ) {
308
+ httpapi .Write (ctx , rw , http .StatusInternalServerError , codersdk.Response {
309
+ Message : "Failed to lookup API key." ,
310
+ Detail : err .Error (),
311
+ })
312
+ return
313
+ }
314
+ // This is wrapped in `err == nil` because if the API key doesn't
315
+ // exist, we still want to delete the cookie.
316
+ if err == nil {
317
+ hashedSecret := sha256 .Sum256 ([]byte (secret ))
318
+ if subtle .ConstantTimeCompare (apiKey .HashedSecret , hashedSecret [:]) != 1 {
319
+ httpapi .Write (ctx , rw , http .StatusUnauthorized , codersdk.Response {
320
+ Message : httpmw .SignedOutErrorMessage ,
321
+ Detail : "API key secret is invalid." ,
322
+ })
323
+ return
324
+ }
325
+ err = api .Database .DeleteAPIKeyByID (ctx , id )
326
+ if err != nil {
327
+ httpapi .Write (ctx , rw , http .StatusInternalServerError , codersdk.Response {
328
+ Message : "Failed to delete API key." ,
329
+ Detail : err .Error (),
330
+ })
331
+ return
332
+ }
333
+ }
334
+ }
335
+ }
336
+ if ! api .setWorkspaceAppCookie (rw , r , "" ) {
337
+ return
338
+ }
339
+
340
+ // Read the redirect URI from the query string.
341
+ redirectURI := r .URL .Query ().Get (redirectURIQueryParam )
342
+ if redirectURI == "" {
343
+ redirectURI = api .AccessURL .String ()
344
+ } else {
345
+ // Validate that the redirect URI is a valid URL and exists on the same
346
+ // host as the access URL or an app URL.
347
+ parsedRedirectURI , err := url .Parse (redirectURI )
348
+ if err != nil {
349
+ site .RenderStaticErrorPage (rw , r , site.ErrorPageData {
350
+ Status : http .StatusBadRequest ,
351
+ Title : "Invalid redirect URI" ,
352
+ Description : fmt .Sprintf ("Could not parse redirect URI %q: %s" , redirectURI , err .Error ()),
353
+ RetryEnabled : false ,
354
+ DashboardURL : api .AccessURL .String (),
355
+ })
356
+ return
357
+ }
358
+
359
+ // Check if the redirect URI is on the same host as the access URL or an
360
+ // app URL.
361
+ ok := httpapi .HostnamesMatch (api .AccessURL .Hostname (), parsedRedirectURI .Hostname ())
362
+ if ! ok && api .AppHostnameRegex != nil {
363
+ // We could also check that it's a valid application URL for
364
+ // completeness, but this check should be good enough.
365
+ _ , ok = httpapi .ExecuteHostnamePattern (api .AppHostnameRegex , parsedRedirectURI .Hostname ())
366
+ }
367
+ if ! ok {
368
+ // The redirect URI they provided is not allowed, but we don't want
369
+ // to return an error page because it'll interrupt the logout flow,
370
+ // so we just use the default access URL.
371
+ parsedRedirectURI = api .AccessURL
372
+ }
373
+
374
+ redirectURI = parsedRedirectURI .String ()
375
+ }
376
+
377
+ http .Redirect (rw , r , redirectURI , http .StatusTemporaryRedirect )
378
+ }
379
+
276
380
// lookupWorkspaceApp looks up the workspace application by slug in the given
277
381
// agent and returns it. If the application is not found or there was a server
278
382
// error while looking it up, an HTML error page is returned and false is
@@ -410,7 +514,7 @@ func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter,
410
514
// and strip that query parameter.
411
515
if encryptedAPIKey := r .URL .Query ().Get (subdomainProxyAPIKeyParam ); encryptedAPIKey != "" {
412
516
// Exchange the encoded API key for a real one.
413
- _ , apiKey , err := decryptAPIKey (r .Context (), api .Database , encryptedAPIKey )
517
+ _ , token , err := decryptAPIKey (r .Context (), api .Database , encryptedAPIKey )
414
518
if err != nil {
415
519
site .RenderStaticErrorPage (rw , r , site.ErrorPageData {
416
520
Status : http .StatusBadRequest ,
@@ -424,33 +528,7 @@ func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter,
424
528
return false
425
529
}
426
530
427
- hostSplit := strings .SplitN (api .AppHostname , "." , 2 )
428
- if len (hostSplit ) != 2 {
429
- // This should be impossible as we verify the app hostname on
430
- // startup, but we'll check anyways.
431
- api .Logger .Error (r .Context (), "could not split invalid app hostname" , slog .F ("hostname" , api .AppHostname ))
432
- site .RenderStaticErrorPage (rw , r , site.ErrorPageData {
433
- Status : http .StatusInternalServerError ,
434
- Title : "Internal Server Error" ,
435
- Description : "The app is configured with an invalid app wildcard hostname. Please contact an administrator." ,
436
- RetryEnabled : false ,
437
- DashboardURL : api .AccessURL .String (),
438
- })
439
- return false
440
- }
441
-
442
- // Set the app cookie for all subdomains of api.AppHostname. This cookie
443
- // is handled properly by the ExtractAPIKey middleware.
444
- cookieHost := "." + hostSplit [1 ]
445
- http .SetCookie (rw , & http.Cookie {
446
- Name : httpmw .DevURLSessionTokenCookie ,
447
- Value : apiKey ,
448
- Domain : cookieHost ,
449
- Path : "/" ,
450
- HttpOnly : true ,
451
- SameSite : http .SameSiteLaxMode ,
452
- Secure : api .SecureAuthCookie ,
453
- })
531
+ api .setWorkspaceAppCookie (rw , r , token )
454
532
455
533
// Strip the query parameter.
456
534
path := r .URL .Path
@@ -484,6 +562,51 @@ func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter,
484
562
return false
485
563
}
486
564
565
+ // setWorkspaceAppCookie sets a cookie on the workspace app domain. If the app
566
+ // hostname cannot be parsed properly, a static error page is rendered and false
567
+ // is returned.
568
+ //
569
+ // If an empty token is supplied, it will clear the cookie.
570
+ func (api * API ) setWorkspaceAppCookie (rw http.ResponseWriter , r * http.Request , token string ) bool {
571
+ hostSplit := strings .SplitN (api .AppHostname , "." , 2 )
572
+ if len (hostSplit ) != 2 {
573
+ // This should be impossible as we verify the app hostname on
574
+ // startup, but we'll check anyways.
575
+ api .Logger .Error (r .Context (), "could not split invalid app hostname" , slog .F ("hostname" , api .AppHostname ))
576
+ site .RenderStaticErrorPage (rw , r , site.ErrorPageData {
577
+ Status : http .StatusInternalServerError ,
578
+ Title : "Internal Server Error" ,
579
+ Description : "The app is configured with an invalid app wildcard hostname. Please contact an administrator." ,
580
+ RetryEnabled : false ,
581
+ DashboardURL : api .AccessURL .String (),
582
+ })
583
+ return false
584
+ }
585
+
586
+ // Set the app cookie for all subdomains of api.AppHostname. This cookie is
587
+ // handled properly by the ExtractAPIKey middleware.
588
+ //
589
+ // We don't set an expiration because the key in the database already has an
590
+ // expiration.
591
+ maxAge := 0
592
+ if token == "" {
593
+ maxAge = - 1
594
+ }
595
+ cookieHost := "." + hostSplit [1 ]
596
+ http .SetCookie (rw , & http.Cookie {
597
+ Name : httpmw .DevURLSessionTokenCookie ,
598
+ Value : token ,
599
+ Domain : cookieHost ,
600
+ Path : "/" ,
601
+ MaxAge : maxAge ,
602
+ HttpOnly : true ,
603
+ SameSite : http .SameSiteLaxMode ,
604
+ Secure : api .SecureAuthCookie ,
605
+ })
606
+
607
+ return true
608
+ }
609
+
487
610
// workspaceApplicationAuth is an endpoint on the main router that handles
488
611
// redirects from the subdomain handler.
489
612
//
0 commit comments