HTTPOnly Cookie not being set in browser localhost
Asked Answered
S

3

4

Problem

I have a REST API that has a login endpoint. The login endpoint accepts a username and password, the server responds by sending a HTTPOnly Cookie containing some payload (like JWT).

The approach I always use had been working for a few years until the Set-Cookie header stopped working roughly last week. I have not touched the REST API's source prior to its non-functionality, as I was working on a Svelte-based front-end.

I suspect it has something to do with the Secure attribute being set to false as it is in localhost. However, according to Using HTTP cookies, having an insecure connection should be fine as long as it's localhost. I've been developing REST APIs in this manner for some time now and was surprised to see the cookie no longer being set.

Testing the API with Postman yields the expected result of having the cookie set.

Approaches Used

I tried to recreate the general flow of the real API and stripped it down to its core essentials.

package main

import (
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/fiber/v2/middleware/cors"
    "github.com/golang-jwt/jwt/v4"
)

const idleTimeout = 5 * time.Second

func main() {
    app := fiber.New(fiber.Config{
        IdleTimeout: idleTimeout,
    })

    app.Use(cors.New(cors.Config{
        AllowOrigins:     "*",
        AllowHeaders:     "Origin, Content-Type, Accept, Range",
        AllowCredentials: true,
        AllowMethods:     "GET,POST,HEAD,DELETE,PUT",
        ExposeHeaders:    "X-Total-Count, Content-Range",
    }))

    app.Get("/", hello)
    app.Post("/login", login)

    go func() {
        if err := app.Listen("0.0.0.0:8080"); err != nil {
            log.Panic(err)
        }
    }()

    c := make(chan os.Signal, 1)
    signal.Notify(c, os.Interrupt, syscall.SIGTERM)

    _ = <-c
    fmt.Println("\n\nShutting down server...")
    _ = app.Shutdown()
}

func hello(c *fiber.Ctx) error {
    return c.SendString("Hello, World!")
}

func login(c *fiber.Ctx) error {
    type LoginInput struct {
        Email string `json:"email"`
    }

    var input LoginInput

    if err := c.BodyParser(&input); err != nil {
        return c.Status(400).SendString(err.Error())
    }

    stringUrl := fmt.Sprintf("https://jsonplaceholder.typicode.com/users?email=%s", input.Email)

    resp, err := http.Get(stringUrl)
    if err != nil {
        return c.Status(500).SendString(err.Error())
    }

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return c.Status(500).SendString(err.Error())
    }

    if len(body) > 0 {
        fmt.Println(string(body))
    } else {
        return c.Status(400).JSON(fiber.Map{
            "message": "Yeah, we couldn't find that user",
        })
    }

    token := jwt.New(jwt.SigningMethodHS256)
    cookie := new(fiber.Cookie)

    claims := token.Claims.(jwt.MapClaims)
    claims["purpose"] = "Just a test really"

    signedToken, err := token.SignedString([]byte("NiceSecret"))
    if err != nil {
        // Internal Server Error if anything goes wrong in getting the signed token
        fmt.Println(err)
        return c.SendStatus(500)
    }

    cookie.Name = "access"
    cookie.HTTPOnly = true
    cookie.Secure = false
    cookie.Domain = "localhost"
    cookie.SameSite = "Lax"
    cookie.Path = "/"
    cookie.Value = signedToken
    cookie.Expires = time.Now().Add(time.Hour * 24)

    c.Cookie(cookie)

    return c.Status(200).JSON(fiber.Map{
        "message": "You have logged in",
    })
}

What does this is basically look through JSON Placeholder's Users and if it finds one with a matching email, it sends the HTTPOnly Cookie with some data attached to it.

Seeing as it might be a problem with the library I'm using, I decided to write a Node version with Express.

import axios from 'axios'
import express from 'express'
import cookieParser from 'cookie-parser'
import jwt from 'jsonwebtoken'

const app = express()

app.use(express.json())
app.use(cookieParser())
app.use(express.urlencoded({ extended: true }))
app.disable('x-powered-by')

app.get("/", (req, res) => {
    res.send("Hello there!")
})

app.post("/login", async (req, res, next) => {
    try {
        const { email } = req.body

        const { data } = await axios.get(`https://jsonplaceholder.typicode.com/users?email=${email}`)

        if (data) {
            if (data.length > 0) {
                res.locals.user = data[0]
                next()
            } else {
                return res.status(404).json({
                    message: "No results found"
                })
            }
        }
    } catch (error) {
        return console.error(error)
    }
}, async (req, res) => {
    try {
        let { user } = res.locals

        const token = jwt.sign({
            user: user.name
        }, "mega ultra secret sauce 123")

        res
            .cookie(
                'access',
                token,
                {
                    httpOnly: true,
                    secure: false,
                    maxAge: 3600
                }
            )
            .status(200)
            .json({
                message: "You have logged in, check your cookies"
            })
    } catch (error) {
        return console.error(error)
    }
})

app.listen(8000, () => console.log(`Server is up at localhost:8000`))

Both of these do not work on the browsers I've tested them on.

Results

Go responds with this.

HTTP/1.1 200 OK
Date: Mon, 21 Feb 2022 05:17:36 GMT
Content-Type: application/json
Content-Length: 32
Vary: Origin
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Total-Count,Content-Range
Set-Cookie: access=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwdXJwb3NlIjoiSnVzdCBhIHRlc3QgcmVhbGx5In0.8YKepcvnMreP1gUoe_S3S7uYngsLFd9Rrd4Jto-6UPI; expires=Tue, 22 Feb 2022 05:17:36 GMT; domain=localhost; path=/; HttpOnly; SameSite=Lax

For the Node API, this is the response header.

HTTP/1.1 200 OK
Set-Cookie: access=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiTGVhbm5lIEdyYWhhbSIsImlhdCI6MTY0NTQyMDM4N30.z1NQcYm5XN-L6Bge_ECsMGFDCgxJi2eNy9sg8GCnhIU; Max-Age=3; Path=/; Expires=Mon, 21 Feb 2022 05:13:11 GMT; HttpOnly
Content-Type: application/json; charset=utf-8
Content-Length: 52
ETag: W/"34-TsGOkRa49turdlOQSt5gB2H3nxw"
Date: Mon, 21 Feb 2022 05:13:07 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Client Source

I'm using this as a test form to send and receive data.

<script>
    let email = "";

    async function handleSubmit() {
        try {
            let response = await fetch(`http://localhost:8000/login`, {
                method: "POST",
                body: JSON.stringify({
                    email,
                }),
                headers: {
                    "Content-Type": "application/json",
                },
            });

            if (response) {
                console.info(response);
                let result = await response.json();

                if (result) {
                    console.info(result);
                }
            }
        } catch (error) {
            alert("Something went wrong. Check your console.");
            return console.error(error);
        }
    }
</script>

<h1>Please Login</h1>

<svelte:head>
    <title>Just a basic login form</title>
</svelte:head>

<form on:submit|preventDefault={handleSubmit}>
    <label for="email">Email:</label>
    <input
        type="email"
        name="email"
        bind:value={email}
        placeholder="enter your email"
    />
</form>

Additional Information

Postman: 9.8.3

Language Versions

Go: 1.17.6

Node.js: v16.13.1

Svelte: 3.44.0

Browsers Used

Mozilla Firefox: 97.0.1

Microsoft Edge: 98.0.1108.56

Chromium: 99.0.4781.0

Selfsupporting answered 21/2, 2022 at 5:36 Comment(1)
1. Domain and localhost always leads to problems. 2. Use MaxAge instead of expires.Kamerman
S
13

Solution

It turns out the problem is in the front-end, specifically JavaScript's fetch() method.

let response = await fetch(`http://localhost:8000/login`, {
                method: "POST",
                credentials: "include", //--> send/receive cookies
                body: JSON.stringify({
                    email,
                }),
                headers: {
                    "Content-Type": "application/json",
                },
            });

You'll need credentials: include property in your RequestInit object, not just for making requests that require cookie authentication, but for also receiving said cookie.

Axios usually fills this part out automatically (based from experience), but if it doesn't, you'll also need to put withCredentials: true on the third config argument of your request to allow the browser to set the cookie.

Selfsupporting answered 22/2, 2022 at 3:52 Comment(3)
"not just for making requests that require cookie authentication, but for also receiving said cookie", this got me, I wish the browser would say there is an error and that it is immediately deleting the cookie instead of just doing nothing. Thanks.Gaullism
THANK YOU VERY MUCH!) I spent so much time on that problem and just as I thought - there is some little thing I didn't catch, and it is the reason cookie wasn't being set for me.Isotone
thank you. it works! have spend hours working on this.Volar
S
3

I just had the same issue with axios, this was causing the Set-Cookie response header to be silently ignored. Which was annoying as usually if it rejects them it will show that little yellow triangle against that header and say why in the network inspector.

I solved this by adding a request interceptor to force it true for every request:

axios.interceptors.request.use(
    (config) => {
      config.withCredentials = true
      return config
    },
    (error) => {
      return Promise.reject(error)
    }
  )
Salot answered 28/6, 2022 at 13:55 Comment(1)
Thank you for this response. How should I guess axios intercepter is blocking Set-Cookie response.Scepter
F
-1

Unfortunately some browsers including Google Chrome and Brave do not store cookies from local web pages: Why does Chrome ignore local jQuery cookies?

Use Firefox or edge instead when testing cookies on development . Also on your code frontend and backend do the following;

1.Backend

on development do the following;

const corsOptions: CorsOptions = {
  origin: 'http://localhost:3000',
  credentials: true, // Enable credentials (cookies)
};

app.use(cors(corsOptions));
          res.cookie("accessToken", accessToken, {
        httpOnly: true,
        secure: false,
      sameSite:'none',
        maxAge: 15 * 60 * 1000,
      });

on production change secure:true but others remain the same.

Frontend

 login: async (values: Values) => {
      try {
        axios.interceptors.request.use(
          (config) => {
            config.withCredentials = true
            return config
          },
          (error) => {
            return Promise.reject(error)
          }
        )
        const res = await axios.post(
          "http://localhost:8080/api/auth/login",
          values,
      
        );

you have to set this withCredentials = true ;to true Now accessing the accessTokens or Request tokens in the server do this

 const cookies = req?.headers?.cookie as string;
 const accessToken = getCookieValue(cookies,'accessToken');

function getCookieValue(cookieString:string, cookieName:string) {
  const cookies = cookieString.split('; ');
  for (const cookie of cookies) {
    const [name, value] = cookie.split('=');
    if (name === cookieName) {
      return value;
    }
  }
  return null;
}

Remember this am not using cookie-parser just inbuilt cookies in express

Freiman answered 25/1 at 23:53 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.