refactor: replace Bearer token auth with HttpOnly cookies
All checks were successful
check / check (push) Successful in 2m21s

- Remove POST /api/v1/register endpoint entirely
- Session creation (POST /api/v1/session) now sets neoirc_auth HttpOnly
  cookie instead of returning token in JSON body
- Login (POST /api/v1/login) now sets neoirc_auth HttpOnly cookie
  instead of returning token in JSON body
- Add PASS IRC command for setting session password (enables multi-client
  login via POST /api/v1/login)
- All per-request auth reads from neoirc_auth cookie instead of
  Authorization: Bearer header
- Cookie properties: HttpOnly, SameSite=Strict, Secure when behind TLS
- Logout and QUIT clear the auth cookie
- Update CORS to AllowCredentials:true with origin reflection
- Remove Authorization from CORS AllowedHeaders
- Update CLI client to use cookie jar (net/http/cookiejar)
- Remove Token field from SessionResponse
- Add SetPassword to DB layer, remove RegisterUser
- Comprehensive test updates for cookie-based auth
- Add tests: TestPassCommand, TestPassCommandShortPassword,
  TestPassCommandEmpty, TestSessionCookie
- Update README extensively: auth model, API reference, curl examples,
  security model, design principles, roadmap

closes #83
This commit is contained in:
user
2026-03-17 20:33:12 -07:00
parent bf4d63bc4d
commit cd9fd0c5c5
11 changed files with 625 additions and 711 deletions

View File

@@ -36,6 +36,7 @@ const (
defaultMaxBodySize = 4096
defaultHistLimit = 50
maxHistLimit = 500
authCookieName = "neoirc_auth"
)
func (hdlr *Handlers) maxBodySize() int64 {
@@ -46,23 +47,18 @@ func (hdlr *Handlers) maxBodySize() int64 {
return defaultMaxBodySize
}
// authSession extracts the session from the client token.
// authSession extracts the session from the auth cookie.
func (hdlr *Handlers) authSession(
request *http.Request,
) (int64, int64, string, error) {
auth := request.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return 0, 0, "", errUnauthorized
}
token := strings.TrimPrefix(auth, "Bearer ")
if token == "" {
cookie, err := request.Cookie(authCookieName)
if err != nil || cookie.Value == "" {
return 0, 0, "", errUnauthorized
}
sessionID, clientID, nick, err :=
hdlr.params.Database.GetSessionByToken(
request.Context(), token,
request.Context(), cookie.Value,
)
if err != nil {
return 0, 0, "", fmt.Errorf("auth: %w", err)
@@ -71,6 +67,46 @@ func (hdlr *Handlers) authSession(
return sessionID, clientID, nick, nil
}
// setAuthCookie sets the authentication cookie on the
// response.
func (hdlr *Handlers) setAuthCookie(
writer http.ResponseWriter,
request *http.Request,
token string,
) {
secure := request.TLS != nil ||
request.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
Name: authCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
}
// clearAuthCookie removes the authentication cookie from
// the client.
func (hdlr *Handlers) clearAuthCookie(
writer http.ResponseWriter,
request *http.Request,
) {
secure := request.TLS != nil ||
request.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
Name: authCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}
func (hdlr *Handlers) requireAuth(
writer http.ResponseWriter,
request *http.Request,
@@ -226,10 +262,11 @@ func (hdlr *Handlers) handleCreateSession(
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
"id": sessionID,
"nick": payload.Nick,
}, http.StatusCreated)
}
@@ -875,6 +912,11 @@ func (hdlr *Handlers) dispatchCommand(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic:
hdlr.handleTopic(
writer, request,
@@ -2005,6 +2047,8 @@ func (hdlr *Handlers) handleQuit(
request.Context(), sessionID,
)
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request,
map[string]string{"status": "quit"},
http.StatusOK)
@@ -2807,6 +2851,8 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
)
}
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)