2

I am having problems with cookie based token auth in my chat app. I am using a Go backend with the standard net library to add tokens to response cookies. When a user passes the password validation (by POSTing to the /login path on the auth server), the response cookies should contain an access token for generating API tokens and a refresh token for regenerating the access token.

Here is a markup file containing the structure of the apps services in my dev environment. Each server is being run on sequential ports using Go net/http on localhost (irrelevant services are not shown).


auth_server (
    dependencies []
    url (scheme "http" domain "localhost" port "8081")
    listenAddress ":8081"
    endpoints (
        /jwtkeypub (
            methods [GET]
        )
        /register (
            methods [POST]
        )
        /logout (
            methods [POST]
        )
        /login (
            methods [POST]
        )
        /apitokens (
            methods [GET]
        )
        /accesstokens (
            methods [GET]
        )
    )
    jwtInfo (
        issuerName "auth_server"
        audienceName "auth_server"
    )
)

message_server (
    dependencies [auth_server]
    url (scheme "http" domain "localhost" port "8083")
    listenAddress ":8083"
    endpoints (
        /ws (
            methods [GET]
        )
    )
    jwtInfo (
        audienceName "message_server"
    )
)

static (
    dependencies [auth_server, message_server]
    url (scheme "http" domain "localhost" port "8080")
    listenAddress ":8080"
)

This is the code that sets the cookies on login. This happens after the password check

    // Set a new refresh token
    refreshToken := s.jwtIssuer.StringifyJwt(
        s.jwtIssuer.MintToken(userId, s.jwtIssuer.Name, RefreshTokenTTL),
    )
    kit.SetHttpOnlyCookie(w, "refreshToken", refreshToken, int(RefreshTokenTTL.Seconds()))

    // set a new access token
    accessToken := s.jwtIssuer.StringifyJwt(
        s.jwtIssuer.MintToken(userId, s.jwtAudience.Name, AccessTokenTTL),
    )
    kit.SetHttpOnlyCookie(w, "accessToken", accessToken, int(AccessTokenTTL.Seconds()))
}

func SetHttpOnlyCookie(w http.ResponseWriter, name, value string, maxAge int) {
    http.SetCookie(w, &http.Cookie{
        Name:     name,
        Value:    value,
        HttpOnly: true,
        MaxAge:   maxAge,
    })
}

And here is how I am accessing the cookie when the user requests a API token. The handler calls the GetTokenFromCookie() function and responds with a 401 if an error is returned. The error is this case is "http: named cookie not present"


func GetHttpCookie(r *http.Request, name string) (*http.Cookie, error) {
    return r.Cookie(name)
}

func GetTokenFromCookie(r *http.Request, name string) (jwt.Jwt, error) {
    tokenCookie, err := GetHttpCookie(r, name)
    if err != nil {
        // DEBUG
        log.Println(err)
        return jwt.Jwt{}, err
    }

    return jwt.FromString(tokenCookie.Value)
}

After a 200 response from the login endpoint, the page redirects to the main app page. On this page, a request is made to the auth server to receive an API token for connecting the live chat message server. As you can see from the log output on the auth server, the access token cookie was not received in the request, so the request returns a 401 code.

2023/05/19 02:33:57 GET [/jwtkeypub] - 200
2023/05/19 02:33:57 GET [/jwtkeypub] - 200
2023/05/19 02:34:23 POST [/login] - 200
2023/05/19 02:34:23 http: named cookie not present
{{ } {    } []} http: named cookie not present
2023/05/19 02:34:23 GET [/apitokens?aud=MSGSERVICE] - 401

I believe the problem lies in that I am using localhost and the browser does not transfer the cookie from locahost:8080 to localhost:8081. I was planning on implmenting some sort of mock auth that circumvents reading the cookies for the dev environment to get around this, but I am not sure if this is actually the cause of my problem. Just want to get a second look and see if I can get it working without needing to do that.

UPDATE: I have looked into the network tabs in dev tools: The images show that the response after logging in returns the cookies, but they are not subsequently sent to the auth server on port 8081. I have also looked in cookie storage after getting the 200 response from logging in, there is no cookie present even after receiving them in the response. I am using Firefox on private mode to access the site. Note that the cookie does not include MaxAge even though I set MaxAge in the Go code, this seems like a problem.

UPDATE: Here is the HAR file after logging in. You can see that the response has Max-Age, but it doesn't show up in the cookies tab afterwards.

{
  "log": {
    "version": "1.2",
    "creator": {
      "name": "Firefox",
      "version": "113.0.1"
    },
    "browser": {
      "name": "Firefox",
      "version": "113.0.1"
    },
    "pages": [
      {
        "startedDateTime": "2023-05-19T12:16:37.081-04:00",
        "id": "page_1",
        "title": "Login Page",
        "pageTimings": {
          "onContentLoad": -8105,
          "onLoad": -8077
        }
      }
    ],
    "entries": [
      {
        "pageref": "page_1",
        "startedDateTime": "2023-05-19T12:16:37.081-04:00",
        "request": {
          "bodySize": 31,
          "method": "POST",
          "url": "http://0.0.0.0:8081/login",
          "httpVersion": "HTTP/1.1",
          "headers": [
            {
              "name": "Host",
              "value": "0.0.0.0:8081"
            },
            {
              "name": "User-Agent",
              "value": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0"
            },
            {
              "name": "Accept",
              "value": "*/*"
            },
            {
              "name": "Accept-Language",
              "value": "en-US,en;q=0.5"
            },
            {
              "name": "Accept-Encoding",
              "value": "gzip, deflate"
            },
            {
              "name": "Referer",
              "value": "http://localhost:8080/"
            },
            {
              "name": "Content-Type",
              "value": "text/plain;charset=UTF-8"
            },
            {
              "name": "Content-Length",
              "value": "31"
            },
            {
              "name": "Origin",
              "value": "http://localhost:8080"
            },
            {
              "name": "DNT",
              "value": "1"
            },
            {
              "name": "Connection",
              "value": "keep-alive"
            }
          ],
          "cookies": [],
          "queryString": [],
          "headersSize": 370,
          "postData": {
            "mimeType": "text/plain;charset=UTF-8",
            "params": [],
            "text": "{\"username\":\"a\",\"password\":\"a\"}"
          }
        },
        "response": {
          "status": 200,
          "statusText": "OK",
          "httpVersion": "HTTP/1.1",
          "headers": [
            {
              "name": "Access-Control-Allow-Origin",
              "value": "*"
            },
            {
              "name": "Set-Cookie",
              "value": "refreshToken=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJQMmY2RHg1RWxlYTF5THBUaVpEejBaS3Z1dk1FUkFPZEtBVGkwNDZSc2JNPSIsImF1ZCI6InN0ZWVsaXgiLCJpc3MiOiJzdGVlbGl4IiwiZXhwIjoiMTY4NTExNzc5NyIsImp0aSI6IjIwMUQzODZDNTRBQzlEOUMwRjdCODFBMDVDNDlFQTE1In0.SbxFgEAtZbh0zS-SXZmrVW9iLk-cFz6HcDMU0FHNl-K9BwCeb_boc5igEgImMSYK-NBVQZh1km7YknE-jkBWyF0rIYjSnTzjNUHHwMnn0jE1N-dtEfNRnF1OT0R2bxPSz8gmhtJ3B839xa-jh9uMPMkXEB8BYtABgPH1FqBdijHPUtRVKq6C3ulVleurp2eyF8EHpGLc9rr5wBYSFBk0HQ3FNjjUxfRQLDnzl2xYovoQ2em4grExnkdACxCSpXNtF5bQ7lCnEZyf7-CehrRNwZCpteGKj5ux_wrX_nxma3OEWwrlatML_j-e420TM1tub0C9Ymyt0bMugHw8vaiOGA; Max-Age=604800; HttpOnly"
            },
            {
              "name": "Set-Cookie",
              "value": "accessToken=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJQMmY2RHg1RWxlYTF5THBUaVpEejBaS3Z1dk1FUkFPZEtBVGkwNDZSc2JNPSIsImF1ZCI6InN0ZWVsaXgiLCJpc3MiOiJzdGVlbGl4IiwiZXhwIjoiMTY4NDUxNDE5NyIsImp0aSI6IjY2NjU1QjAyNTc4NkRBRTE1M0VDNDI3MzBGMjMxQ0FGIn0.cIs6KGjRGTHaWX_uFTts_V2a3YcBb7LA0jNOBTZeyDmpPQgRlcABnuYkWUIdjUdR6VYnDitFRV-XK2ZSq6Pk_ZgyfvJ3yRzvWGYjXMu7Nq7MLpVvUh9mLKSbKvlqunW6YVamHSCAbYS8-D_pY9fpWxIcXw0qbwA2XfTdzr0Mrw7ntrkdyK7O1QqWamnEHCmpLfJ2XJlQsU0KaD8FjkL76pO3lWmrca3VYnTmjP1Oo1HEhbK3nImtrNeL2khAyb8ns8ROj2HX41IDNK1aHWPfn9J04pgH3AfBfcwhhqZkrKjTVFQAkSYzuvjKPWOfpgYmBMw3Y5nG_PDf-zlvVPrdpQ; Max-Age=1200; HttpOnly"
            },
            {
              "name": "Date",
              "value": "Fri, 19 May 2023 16:16:37 GMT"
            },
            {
              "name": "Content-Length",
              "value": "0"
            }
          ],
          "cookies": [
            {
              "name": "refreshToken",
              "value": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJQMmY2RHg1RWxlYTF5THBUaVpEejBaS3Z1dk1FUkFPZEtBVGkwNDZSc2JNPSIsImF1ZCI6InN0ZWVsaXgiLCJpc3MiOiJzdGVlbGl4IiwiZXhwIjoiMTY4NTExNzc5NyIsImp0aSI6IjIwMUQzODZDNTRBQzlEOUMwRjdCODFBMDVDNDlFQTE1In0.SbxFgEAtZbh0zS-SXZmrVW9iLk-cFz6HcDMU0FHNl-K9BwCeb_boc5igEgImMSYK-NBVQZh1km7YknE-jkBWyF0rIYjSnTzjNUHHwMnn0jE1N-dtEfNRnF1OT0R2bxPSz8gmhtJ3B839xa-jh9uMPMkXEB8BYtABgPH1FqBdijHPUtRVKq6C3ulVleurp2eyF8EHpGLc9rr5wBYSFBk0HQ3FNjjUxfRQLDnzl2xYovoQ2em4grExnkdACxCSpXNtF5bQ7lCnEZyf7-CehrRNwZCpteGKj5ux_wrX_nxma3OEWwrlatML_j-e420TM1tub0C9Ymyt0bMugHw8vaiOGA"
            },
            {
              "name": "accessToken",
              "value": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJQMmY2RHg1RWxlYTF5THBUaVpEejBaS3Z1dk1FUkFPZEtBVGkwNDZSc2JNPSIsImF1ZCI6InN0ZWVsaXgiLCJpc3MiOiJzdGVlbGl4IiwiZXhwIjoiMTY4NDUxNDE5NyIsImp0aSI6IjY2NjU1QjAyNTc4NkRBRTE1M0VDNDI3MzBGMjMxQ0FGIn0.cIs6KGjRGTHaWX_uFTts_V2a3YcBb7LA0jNOBTZeyDmpPQgRlcABnuYkWUIdjUdR6VYnDitFRV-XK2ZSq6Pk_ZgyfvJ3yRzvWGYjXMu7Nq7MLpVvUh9mLKSbKvlqunW6YVamHSCAbYS8-D_pY9fpWxIcXw0qbwA2XfTdzr0Mrw7ntrkdyK7O1QqWamnEHCmpLfJ2XJlQsU0KaD8FjkL76pO3lWmrca3VYnTmjP1Oo1HEhbK3nImtrNeL2khAyb8ns8ROj2HX41IDNK1aHWPfn9J04pgH3AfBfcwhhqZkrKjTVFQAkSYzuvjKPWOfpgYmBMw3Y5nG_PDf-zlvVPrdpQ"
            }
          ],
          "content": {
            "mimeType": "text/plain",
            "size": 0,
            "text": ""
          },
          "redirectURL": "",
          "headersSize": 1347,
          "bodySize": 1748
        },
        "cache": {},
        "timings": {
          "blocked": 0,
          "dns": 0,
          "connect": 0,
          "ssl": 0,
          "send": 0,
          "wait": 13,
          "receive": 0
        },
        "time": 13,
        "_securityState": "insecure",
        "serverIPAddress": "0.0.0.0",
        "connection": "8081"
      }
    ]
  }
}

enter image description here

The response appears to have the cookies, but they don't get saved. enter image description here

enter image description here

And the next request to the auth server doesn't have any cookies added.

enter image description here

Rebeljah
  • 35
  • 4
  • `the browser does not transfer the cookie from locahost:8080 to localhost:8081`. This is not true. If you use a browser, you can check the network traffics in the DevTools. Use this tool to narrow down the issue. – Zeke Lu May 19 '23 at 08:25
  • I updated my question to include what I found when using dev tools. – Rebeljah May 19 '23 at 15:28
  • The server sends Max-Age with the response, but it doesn't seem to be getting set. I have included the HAR output from logging in which shows the Max-Age attribute in the headers – Rebeljah May 19 '23 at 16:24

1 Answers1

1

TL;DR:

  1. cookies won't be shared between 0.0.0.0 and localhost.
  2. both session cookies and normal cookies can be shared between http://localhost:8080 and http://localhost:8081.
  3. a request sent from the page http://localhost:8080/ to http://localhost:8081/ is considered as a cross-origin request.
  4. cross-origin requests sent by fetch should be initialized with credentials: 'include' to cause the browser to save cookies.

The HAR shows that the URL of the web page is http://localhost:8080/, but the login endpoint is http://0.0.0.0:8081/login. Cookies for 0.0.0.0 won't be shared with localhost.

You can run the demo below to observe the behavior:

  1. run the demo: go run main.go;

  2. open http://localhost:8080/ in the browser. The web page will do these things:

    1. it sends a request to http://0.0.0.0:8081/login1 (the purpose is to verify that cookies for 0.0.0.0 won't be shared with localhost;
    2. it sends a request to http://localhost:8081/login2 (the purpose is to verify that session cookie will be shared between http://localhost:8080 and http://localhost:8081;
    3. it sends a request to http://localhost:8081/login3 (the purpose is to verify that normal cookie will be shared between http://localhost:8080 and http://localhost:8081;
    4. it navigates to http://localhost:8080/resource and the server will dump the request. It shows that this header is sent to the server: Cookie: login2=localhost-session; login3=localhost.

Notes: credentials: 'include' requires that the Access-Control-Allow-Origin header be set to the exact origin (that means * will be rejected), and the Access-Control-Allow-Credentials header be set to true.

package main

import (
    "fmt"
    "log"
    "net/http"
    "net/http/httputil"
)

func setHeader(w http.ResponseWriter, cookieName, cookieValue string, maxAge int) {
    w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8080")
    w.Header().Set("Access-Control-Allow-Credentials", "true")
    http.SetCookie(w, &http.Cookie{
        Name:     cookieName,
        Value:    cookieValue,
        MaxAge:   maxAge,
        HttpOnly: true,
    })
}

func main() {
    muxWeb := http.NewServeMux()
    // serve the HTML page.
    muxWeb.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        _, err := w.Write([]byte(page))
        if err != nil {
            panic(err)
        }
    }))
    // Dump the request to see what cookies is sent to the server.
    muxWeb.Handle("/resource", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        dump, err := httputil.DumpRequest(r, false)
        if err != nil {
            panic(err)
        }
        _, _ = w.Write(dump)
    }))
    web := &http.Server{
        Addr:    ":8080",
        Handler: muxWeb,
    }
    go func() {
        log.Fatal(web.ListenAndServe())
    }()

    muxAPI := http.NewServeMux()
    muxAPI.Handle("/login1", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        setHeader(w, "login1", "0.0.0.0", 1200)
    }))
    muxAPI.Handle("/login2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        setHeader(w, "login2", "localhost-session", 0)
    }))
    muxAPI.Handle("/login3", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        setHeader(w, "login3", "localhost", 1200)
    }))
    api := &http.Server{
        Addr:    ":8081",
        Handler: muxAPI,
    }
    go func() {
        log.Fatal(api.ListenAndServe())
    }()

    fmt.Println("Open http://localhost:8080/ in the browser")

    select {}
}

var page string = `
<!DOCTYPE html>
<html>
  <body>
    <script type="module">
      async function login(url) {
        const response = await fetch(url, {
          mode: 'cors',
          credentials: 'include',
        });
      }
      await login('http://0.0.0.0:8081/login1');
      await login('http://localhost:8081/login2');
      await login('http://localhost:8081/login3');

      window.location = '/resource';
    </script>
  </body>
</html>
`
Zeke Lu
  • 6,349
  • 1
  • 17
  • 23
  • 1
    Thank you this clears things up immensely! So for the purpose of cookies, different ports on localhost are considered the same domain, but they are considered different origins for CORS. Right now I am using NGINX to proxy localhost/path/to/service to each port and adding `proxy_set_header Host localhost;` to each location and this also works. If I want to remove nginx, I now know how to, thank you again. – Rebeljah May 20 '23 at 17:51
  • 1
    I removed nginx and implemented your suggestions, everything is working now! Iteration is a lot easier without nginx errors thrown in the mix! – Rebeljah May 23 '23 at 04:31