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"
}
]
}
}
The response appears to have the cookies, but they don't get saved.
And the next request to the auth server doesn't have any cookies added.
TL;DR:
0.0.0.0
and localhost
.http://localhost:8080
and http://localhost:8081
.http://localhost:8080/
to http://localhost:8081/
is considered as a cross-origin request.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:
run the demo: go run main.go
;
open http://localhost:8080/
in the browser. The web page will do these things:
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
;http://localhost:8081/login2
(the purpose is to verify that session cookie will be shared between http://localhost:8080
and http://localhost:8081
;http://localhost:8081/login3
(the purpose is to verify that normal cookie will be shared between http://localhost:8080
and http://localhost:8081
;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>
`