Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions backend/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
"context"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/rs/zerolog/log"
"log/slog"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
Expand Down Expand Up @@ -121,27 +121,27 @@ func AuthLoginHandler(w http.ResponseWriter, r *http.Request) {
func AuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
oauthState, err := r.Cookie("ots_oauth_state")
if err != nil {
log.Warn().Err(err).Msg("OAuth state cookie missing")
slog.Warn("OAuth state cookie missing", "error", err)
http.Redirect(w, r, "/?error=oauth_state_missing", http.StatusTemporaryRedirect)
return
}

oauthVerifier, err := r.Cookie("ots_oauth_verifier")
if err != nil {
log.Warn().Err(err).Msg("OAuth verifier cookie missing")
slog.Warn("OAuth verifier cookie missing", "error", err)
http.Redirect(w, r, "/?error=oauth_verifier_missing", http.StatusTemporaryRedirect)
return
}

oauthNonce, err := r.Cookie("ots_oauth_nonce")
if err != nil {
log.Warn().Err(err).Msg("OAuth nonce cookie missing")
slog.Warn("OAuth nonce cookie missing", "error", err)
http.Redirect(w, r, "/?error=oauth_nonce_missing", http.StatusTemporaryRedirect)
return
}

if r.FormValue("state") != oauthState.Value {
log.Warn().Msg("Invalid OAuth state")
slog.Warn("Invalid OAuth state")
http.Redirect(w, r, "/?error=invalid_oauth_state", http.StatusTemporaryRedirect)
return
}
Expand Down Expand Up @@ -179,49 +179,49 @@ func AuthCallbackHandler(w http.ResponseWriter, r *http.Request) {

token, err := getOAuthConfig().Exchange(r.Context(), code, oauth2.SetAuthURLParam("code_verifier", oauthVerifier.Value))
if err != nil {
log.Error().Err(err).Msg("OAuth exchange failed")
slog.Error("OAuth exchange failed", "error", err)
http.Error(w, "auth failed", http.StatusInternalServerError)
return
}

rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
log.Error().Msg("No id_token found in token response")
slog.Error("No id_token found in token response")
http.Error(w, "auth failed", http.StatusInternalServerError)
return
}
log.Debug().Str("id_token", rawIDToken).Msg("id token received")
slog.Debug("id token received", "id_token", rawIDToken)

provider, err := getOIDCProvider(r.Context())
if err != nil {
log.Error().Err(err).Msg("Failed initializing OIDC provider")
slog.Error("Failed initializing OIDC provider", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}

verifier := provider.Verifier(&oidc.Config{ClientID: AppConfig.GoogleClientID})
idToken, err := verifier.Verify(r.Context(), rawIDToken)
if err != nil {
log.Error().Err(err).Msg("Failed to verify ID token")
slog.Error("Failed to verify ID token", "error", err)
http.Error(w, "auth failed", http.StatusInternalServerError)
return
}

if idToken.Nonce != oauthNonce.Value {
log.Warn().Msg("Invalid nonce in ID token")
slog.Warn("Invalid nonce in ID token")
http.Error(w, "auth failed", http.StatusForbidden)
return
}

if err := idToken.VerifyAccessToken(token.AccessToken); err != nil {
log.Error().Err(err).Msg("Failed to verify access token hash")
slog.Error("Failed to verify access token hash", "error", err)
http.Error(w, "auth failed", http.StatusInternalServerError)
return
}

var ui UserInfo
if err := idToken.Claims(&ui); err != nil {
log.Error().Err(err).Msg("Failed parsing id_token claims")
slog.Error("Failed parsing id_token claims", "error", err)
http.Error(w, "auth failed", http.StatusInternalServerError)
return
}
Expand All @@ -248,7 +248,7 @@ func handleUserLogin(w http.ResponseWriter, r *http.Request, u *UserInfo) {

val, err := signSession(sess)
if err != nil {
log.Error().Err(err).Msg("Failed signing session")
slog.Error("Failed signing session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
Expand All @@ -263,7 +263,7 @@ func handleUserLogin(w http.ResponseWriter, r *http.Request, u *UserInfo) {
SameSite: http.SameSiteLaxMode,
})

log.Info().Str("user", u.ID).Msg("User logged in")
slog.Info("User logged in", "user", u.ID)
http.Redirect(w, r, "/", http.StatusSeeOther)
}

Expand Down
22 changes: 11 additions & 11 deletions backend/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"strings"
"time"

"github.com/rs/zerolog/log"
"log/slog"
"github.com/thomaspoignant/go-feature-flag/ffcontext"
)

Expand Down Expand Up @@ -71,21 +71,21 @@ func (c *Config) IsAllowed(ui *UserInfo) bool {
// Check allowed domains
for _, ad := range c.AllowedDomains {
if strings.EqualFold(ad, ui.Domain()) {
log.Debug().Str("user", ui.ID).Str("domain", ui.Domain()).Msg("user allowed via domain")
slog.Debug("user allowed via domain", "user", ui.ID, "domain", ui.Domain())
return true
}
}

// Check allowed emails
for _, ae := range c.AllowedEmails {
if strings.EqualFold(ae, ui.Email) {
log.Debug().Str("user", ui.ID).Msg("user allowed via email")
slog.Debug("user allowed via email", "user", ui.ID)
return true
}
}

// If both slices are empty, effectively everyone is blocked.
log.Warn().Str("user", ui.ID).Msg("unauthorized login attempt")
slog.Warn("unauthorized login attempt", "user", ui.ID)
return false
}

Expand All @@ -103,34 +103,34 @@ func (c *Config) FeatureFlagAuthz(ui *UserInfo) bool {
// Check if user is blocked
blocked, err := ffs.Client().BoolVariation(c.FFBlockEmails, ctx, false)
if err != nil {
log.Error().Err(err).Msg("fflags: failed to get blocked users")
slog.Error("fflags: failed to get blocked users", "error", err)
}
if blocked {
log.Warn().Str("user", ui.ID).Msg("fflags: blocked user login attempt")
slog.Warn("fflags: blocked user login attempt", "user", ui.ID)
return false
}

// Authorize domains
allowed, err = ffs.Client().BoolVariation(c.FFAuthzDomains, ctx, false)
if err != nil {
log.Error().Err(err).Msg("fflags: failed to get allowed domains")
slog.Error("fflags: failed to get allowed domains", "error", err)
}
if allowed {
log.Debug().Str("user", ui.ID).Str("domain", ui.Domain()).Msg("fflags: user allowed via domain")
slog.Debug("fflags: user allowed via domain", "user", ui.ID, "domain", ui.Domain())
return true
}

// Authorize individual users
allowed, err = ffs.Client().BoolVariation(c.FFAuthzEmails, ctx, false)
if err != nil {
log.Error().Err(err).Msg("fflags: failed to get allowed users")
slog.Error("fflags: failed to get allowed users", "error", err)
}
if allowed {
log.Debug().Str("user", ui.ID).Msg("fflags: user allowed via email")
slog.Debug("fflags: user allowed via email", "user", ui.ID)
return true
}

log.Warn().Str("user", ui.ID).Msg("fflags: unauthorized login attempt")
slog.Warn("fflags: unauthorized login attempt", "user", ui.ID)
return false
}

Expand Down
8 changes: 4 additions & 4 deletions backend/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"net/http"

"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
"log/slog"
)

type SecretRequest struct {
Expand All @@ -29,7 +29,7 @@ func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := GetSession(r)
if err != nil {
log.Warn().Err(err).Msg("Unauthorized access attempt")
slog.Warn("Unauthorized access attempt", "error", err)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
Expand All @@ -55,14 +55,14 @@ func CreateSecretHandler(w http.ResponseWriter, r *http.Request) {
}

if len(req.Secret) > AppConfig.MaxSecretLength {
log.Warn().Int("length", len(req.Secret)).Msg("Secret maximum length exceeded")
slog.Warn("Secret maximum length exceeded", "length", len(req.Secret))
http.Error(w, fmt.Sprintf("secret exceeded maximum length of %d", AppConfig.MaxSecretLength), http.StatusBadRequest)
return
}

id, err := GlobalStore.StoreSecret(r.Context(), req.Secret, req.TTLHours)
if err != nil {
log.Error().Err(err).Msg("Failed storing secret")
slog.Error("Failed storing secret", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
Expand Down
99 changes: 66 additions & 33 deletions backend/logger.go
Original file line number Diff line number Diff line change
@@ -1,44 +1,77 @@
package backend

import (
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"

"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)

// InitLogger initializes the global zerolog instance with GCP-friendly configurations.
// InitLogger initializes the global slog instance with GCP-friendly configurations.
func InitLogger(levelStr string, dev bool) {
// GCP expects the severity field instead of level
zerolog.LevelFieldName = "severity"
// GCP expects timestamp field to be timestamp
zerolog.TimestampFieldName = "timestamp"

// Parse configured log level securely
level, err := zerolog.ParseLevel(levelStr)
if err != nil {
level = zerolog.InfoLevel
var level slog.Level
switch strings.ToLower(levelStr) {
case "debug":
level = slog.LevelDebug
case "info":
level = slog.LevelInfo
case "warn", "warning":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}

zerolog.SetGlobalLevel(level)

// In a real GCP environment without a TTY, outputting to os.Stdout directly in JSON format is preferred.
// Since chi operates on a request basis, we'll setup the global logger.
var writer io.Writer
var handler slog.Handler
if dev {
writer = zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"}
// Local console-friendly format
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
a.Value = slog.StringValue(a.Value.Time().Format("15:04:05"))
}
return a
},
})
} else {
writer = os.Stdout
// GCP Cloud Run JSON-friendly format
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.LevelKey {
a.Key = "severity"
lvl := a.Value.Any().(slog.Level)
switch lvl {
case slog.LevelDebug:
a.Value = slog.StringValue("DEBUG")
case slog.LevelInfo:
a.Value = slog.StringValue("INFO")
case slog.LevelWarn:
a.Value = slog.StringValue("WARNING")
case slog.LevelError:
a.Value = slog.StringValue("ERROR")
default:
a.Value = slog.StringValue(lvl.String())
}
} else if a.Key == slog.TimeKey {
a.Key = "timestamp"
}
return a
},
})
}
log.Logger = zerolog.New(writer).With().Timestamp().Logger()

slog.SetDefault(slog.New(handler))
}

// LoggerMiddleware is a custom chi-middleware that bridges chi requests to zerolog
func LoggerMiddleware(logger *zerolog.Logger) func(next http.Handler) http.Handler {
// LoggerMiddleware is a custom chi-middleware that bridges chi requests to slog
func LoggerMiddleware(logger *slog.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
Expand All @@ -52,21 +85,21 @@ func LoggerMiddleware(logger *zerolog.Logger) func(next http.Handler) http.Handl
latency := time.Since(start)

// Log using GCP friendly format
event := logger.Info()
var level slog.Level = slog.LevelInfo
if ww.Status() >= 500 {
event = logger.Error()
level = slog.LevelError
} else if ww.Status() >= 400 {
event = logger.Warn()
level = slog.LevelWarn
}

event.
Str("method", r.Method).
Str("path", r.URL.Path).
Str("remote_ip", r.RemoteAddr).
Str("user_agent", r.UserAgent()).
Int("status", ww.Status()).
Dur("latency", latency).
Msg("HTTP Request")
logger.Log(r.Context(), level, "HTTP Request",
"method", r.Method,
"path", r.URL.Path,
"remote_ip", r.RemoteAddr,
"user_agent", r.UserAgent(),
"status", ww.Status(),
"latency", latency,
)
}()

// Call next handler
Expand Down
Loading