diff --git a/server/internal/service/auth.go b/server/internal/service/auth.go index 5e3a1ee..a32dbfc 100644 --- a/server/internal/service/auth.go +++ b/server/internal/service/auth.go @@ -25,6 +25,10 @@ import ( const ( accessTokenDuration = 15 * time.Minute refreshTokenDuration = 7 * 24 * time.Hour + + authCookieName = "notatest.refresh_token" + viewCookieName = "notatest.expires_at" + authCookiePath = "/api/auth/cookie" ) var ( @@ -222,16 +226,7 @@ func (rs authResource) Login(w http.ResponseWriter, r *http.Request) { return } - // Set refresh token into a httpOnly cookie - http.SetCookie(w, &http.Cookie{ - Name: "notatest.refresh_token", - Value: tokenPair.RefreshToken, - Path: "/api/auth/cookie", - MaxAge: int(refreshTokenDuration.Seconds()), - HttpOnly: true, - Secure: rs.Config.IsProd, - SameSite: http.SameSiteStrictMode, - }) + rs.setAuthCookies(w, tokenPair, false) // Build response response := map[string]any{ @@ -327,16 +322,7 @@ func (rs authResource) UpdatePassword(w http.ResponseWriter, r *http.Request) { return } - // Set refresh token into a httpOnly cookie - http.SetCookie(w, &http.Cookie{ - Name: "notatest.refresh_token", - Value: tokenPair.RefreshToken, - Path: "/api/auth/cookie", - MaxAge: int(refreshTokenDuration.Seconds()), - HttpOnly: true, - Secure: rs.Config.IsProd, - SameSite: http.SameSiteStrictMode, - }) + rs.setAuthCookies(w, tokenPair, false) response := map[string]any{ "access_token": tokenPair.AccessToken, @@ -383,15 +369,7 @@ func (rs authResource) OwnerDelete(w http.ResponseWriter, r *http.Request) { } // Clear the refresh token cookie - http.SetCookie(w, &http.Cookie{ - Name: "notatest.refresh_token", - Value: "", - Path: "/api/auth/cookie", - MaxAge: 0, // Expires immediately - HttpOnly: true, - Secure: rs.Config.IsProd, - SameSite: http.SameSiteStrictMode, - }) + rs.setAuthCookies(w, nil, true) if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil { log.Error().Msgf("Failed to revoke refresh tokens: %s", err) @@ -555,16 +533,7 @@ func (rs authResource) RefreshAccessToken(w http.ResponseWriter, r *http.Request return } - // Set refresh token into a httpOnly cookie - http.SetCookie(w, &http.Cookie{ - Name: "notatest.refresh_token", - Value: tokenPair.RefreshToken, - Path: "/api/auth/cookie", - MaxAge: int(refreshTokenDuration.Seconds()), - HttpOnly: true, - Secure: rs.Config.IsProd, - SameSite: http.SameSiteStrictMode, - }) + rs.setAuthCookies(w, tokenPair, false) // Return the access token in the response body (it should be stored in browser's memory client-side) respondJSON(w, http.StatusOK, map[string]string{ @@ -590,15 +559,7 @@ func (rs authResource) Logout(w http.ResponseWriter, r *http.Request) { } // Clear the refresh token cookie - http.SetCookie(w, &http.Cookie{ - Name: "notatest.refresh_token", - Value: "", - Path: "/api/auth/cookie", - MaxAge: 0, // Expires immediately - HttpOnly: true, - Secure: rs.Config.IsProd, - SameSite: http.SameSiteStrictMode, - }) + rs.setAuthCookies(w, nil, true) if err := rs.Tokens.RevokeAllUserRefreshTokens(r.Context(), userID); err != nil { respondError(w, http.StatusInternalServerError, "Failed to logout") @@ -632,6 +593,48 @@ func (rs authResource) userFromCtxClaims(w http.ResponseWriter, r *http.Request) return &user } +func (rs authResource) setAuthCookies(w http.ResponseWriter, tokenPair *tokenPair, clearCookies bool) { + expirationTime := time.Now().Add(refreshTokenDuration) + expirationUnix := strconv.FormatInt(expirationTime.Unix(), 10) + + log.Debug().Msgf("Setting authentication cookies (clearCookies: %t)", clearCookies) + + var maxAge int + var value string + if clearCookies { + maxAge = 0 // Expires immediately + value = "" + } else { + maxAge = int(refreshTokenDuration.Seconds()) + value = tokenPair.RefreshToken + } + + // The actual auth cookie is httpOnly, i.e. not viewable by the client + http.SetCookie(w, &http.Cookie{ + Name: authCookieName, + Value: value, + Domain: rs.Config.Domain, + Path: authCookiePath, + MaxAge: maxAge, + HttpOnly: true, + Secure: rs.Config.IsProd, + SameSite: http.SameSiteStrictMode, + }) + + // The information cookie can be used by the client to check how long it'll take until the + // actual auth cookie expires (notably `HttpOnly: false` is a must) + http.SetCookie(w, &http.Cookie{ + Name: viewCookieName, + Value: expirationUnix, + Domain: rs.Config.Domain, + Path: authCookiePath, + MaxAge: maxAge, + HttpOnly: false, + Secure: rs.Config.IsProd, + SameSite: http.SameSiteStrictMode, + }) +} + // Helper function for generating the initial administrator level account if one doesn't already // exists in the database. func CreateAdminIfNotExists(ctx context.Context, q *data.Queries, username, password string) error {