Skip to content

refactor: rework file structure#325

Merged
steveiliop56 merged 17 commits intomainfrom
refactor/file-structure
Aug 26, 2025
Merged

refactor: rework file structure#325
steveiliop56 merged 17 commits intomainfrom
refactor/file-structure

Conversation

@steveiliop56
Copy link
Copy Markdown
Member

@steveiliop56 steveiliop56 commented Aug 25, 2025

Summary by CodeRabbit

  • New Features

    • Added app and user context endpoints (/api/context/app, /api/context/user), OAuth sign-in flows, static resources serving under /resources, and a /health check.
  • Changes

    • Auth endpoints moved under /api/user/* (login, logout, totp); frontend login updated to /api/user/login.
    • Env keys: COOKIE_SECURE → SECURE_COOKIE; LOG_LEVEL default changed to "debug".
    • Logging now includes caller/file info; dev proxy for /resources added.
  • Chores

    • Added "data" to .gitignore.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Aug 25, 2025

Walkthrough

Replaces legacy handler/server architecture with a bootstrap-driven app: rewrites config and public types, introduces services (Auth/Docker/Ldap), an OAuth broker, controllers, middlewares, reorganized utils, frontend API route updates, assets rename, and removes many legacy handlers/providers/tests.

Changes

Cohort / File(s) Summary
Bootstrap orchestration
internal/bootstrap/app_bootstrap.go
Adds BootstrapApp to wire configuration, secrets, optional LDAP, services, middlewares, controllers, register routes and start the HTTP server.
Config & constants rewrite
internal/config/config.go, deleted internal/constants/constants.go
Moves/version & cookie constants into config, renames CookieSecureSecureCookie, changes LogLevel int→string, adds ResourcesDir and many auth/session/OAuth-related types; removes legacy config structs.
CLI & versioning
cmd/root.go, cmd/version.go
Switches to bootstrap.NewBootstrapApp(conf).Setup(), refactors flag/env binding, sources version metadata from config, removes legacy bootstrap helper and HandleError.
Controllers (new HTTP handlers)
internal/controller/*.go
internal/controller/context_controller.go,oauth_controller.go,proxy_controller.go,resources_controller.go,user_controller.go,health_controller.go
Adds controllers for /context, /oauth, /auth/:proxy, /resources, /user (login/totp/logout), and /health with route registration and handler logic.
Middlewares
internal/middleware/*.go
internal/middleware/context_middleware.go,ui_middleware.go,zerolog_middleware.go
Adds Context, UI (embedded assets) and Zerolog middlewares exposing Init() and Middleware() for Gin.
Services: auth/docker/ldap refactor
internal/service/*.go
internal/service/auth_service.go,docker_service.go,ldap_service.go
Converts Auth/Docker/LDAP into service types (AuthService/DockerService/LdapService) with Init(), new configs, login-attempt model, session/cookie management and updated method signatures.
OAuth services & broker
internal/service/oauth_broker_service.go,internal/service/github_oauth_service.go,internal/service/google_oauth_service.go,internal/service/generic_oauth_service.go
Introduces OAuthService interface, OAuthBrokerService, and provider-specific OAuth services with PKCE, token exchange, and userinfo retrieval.
Providers & old oauth helper removed
deleted internal/providers/*, deleted internal/oauth/oauth.go
Removes previous providers manager and old oauth helper implementations (replaced by new service implementations).
Removed legacy handlers/server/types/tests
deleted internal/handlers/*,internal/server/server.go,internal/types/*,internal/auth/auth_test.go,internal/handlers/handlers_test.go,internal/utils/utils_test.go
Deletes legacy Handlers, server bootstrap, shared API types and multiple test suites.
Utils reorganized & new helpers
internal/utils/*.go
added app_utils.go,fs_utils.go,label_utils.go,security_utils.go,string_utils.go,user_utils.go, deleted monolith internal/utils/utils.go
Reimplements utilities split across focused files: domain/redirect checks, file IO, label/header parsing, secrets/crypto, string helpers, user parsing, and others.
Assets rename
internal/assets/assets.go
Renames embedded FS export AssetsFrontendAssets.
Frontend endpoint & proxy updates
frontend/src/context/app-context.tsx,frontend/src/context/user-context.tsx,frontend/src/pages/logout-page.tsx,frontend/src/pages/totp-page.tsx,frontend/src/pages/login-page.tsx,frontend/vite.config.ts
Frontend API paths updated to /api/context/... and /api/user/..., login/logout/totp endpoints adjusted, and dev proxy added for /resources.
Build/dev and env adjustments
.env.example, .gitignore, air.toml, go.mod, main.go
.env.example renames COOKIE_SECURESECURE_COOKIE and changes LOG_LEVEL default to "debug"; adds .gitignore data; updates air.toml; adds validator v9 dependency; enables zerolog Caller() in main.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor CLI as CLI
  participant Cmd as cmd/root
  participant Boot as BootstrapApp
  participant Svc as Services
  participant Mid as Middlewares
  participant Ctl as Controllers
  participant HTTP as HTTP Server

  CLI->>Cmd: tinyauth run
  Cmd->>Boot: NewBootstrapApp(conf).Setup()
  Boot->>Svc: Init Docker/Auth/OAuthBroker/Ldap
  Boot->>Mid: Init Zerolog/UI/Context
  Boot->>Ctl: Create controllers & register routes
  Boot->>HTTP: Start server
Loading
sequenceDiagram
  autonumber
  actor User
  participant FE as Frontend
  participant OAuthCtl as OAuthController
  participant Broker as OAuthBrokerService
  participant Prov as OAuth Service
  participant Auth as AuthService

  User->>FE: Click "Login with Provider"
  FE->>OAuthCtl: GET /api/oauth/url/:provider
  OAuthCtl->>Broker: GetService(provider)
  OAuthCtl->>Prov: GenerateState & GetAuthURL
  OAuthCtl-->>FE: { url } + Set CSRF/redirect cookies

  User->>Prov: Authorize
  Prov-->>OAuthCtl: Redirect with code & state
  OAuthCtl->>Prov: VerifyCode(code)
  OAuthCtl->>Prov: Userinfo()
  Prov-->>OAuthCtl: Claims
  OAuthCtl->>Auth: CreateSessionCookie(claims)
  OAuthCtl-->>FE: Redirect to app or continue URL
Loading
sequenceDiagram
  autonumber
  participant Client
  participant ProxyCtrl as ProxyController
  participant Ctx as ContextMiddleware
  participant Dock as DockerService
  participant Auth as AuthService

  Client->>ProxyCtrl: request /api/auth/:proxy
  ProxyCtrl->>Ctx: ContextMiddleware resolves user
  ProxyCtrl->>Dock: GetLabels(id, domain)
  alt IP bypass
    ProxyCtrl-->>Client: 200 + injected headers
  else Not authenticated / unauthorized
    ProxyCtrl-->>Client: 401 or Redirect to /login?redirect_uri=...
  else Authorized
    ProxyCtrl-->>Client: 200 + user headers
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

I hopped through configs, files, and code,
Carrots of services on a new road.
Brokers chatter, middlewares hum,
Routes find homes, old handlers done.
A tiny rabbit cheers: new app — let's run! 🥕

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/file-structure

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@steveiliop56 steveiliop56 marked this pull request as draft August 25, 2025 19:21
Comment thread internal/middleware/ui_middlware.go Fixed
@codecov
Copy link
Copy Markdown

codecov Bot commented Aug 25, 2025

Codecov Report

❌ Patch coverage is 0% with 1789 lines in your changes missing coverage. Please review.
✅ Project coverage is 0.00%. Comparing base (4979121) to head (a1b6ecd).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
internal/controller/proxy_controller.go 0.00% 237 Missing ⚠️
internal/controller/user_controller.go 0.00% 207 Missing ⚠️
internal/bootstrap/app_bootstrap.go 0.00% 198 Missing ⚠️
internal/controller/oauth_controller.go 0.00% 143 Missing ⚠️
internal/middleware/context_middleware.go 0.00% 113 Missing ⚠️
internal/service/github_oauth_service.go 0.00% 106 Missing ⚠️
internal/utils/app_utils.go 0.00% 82 Missing ⚠️
cmd/root.go 0.00% 78 Missing ⚠️
internal/utils/security_utils.go 0.00% 77 Missing ⚠️
internal/service/generic_oauth_service.go 0.00% 76 Missing ⚠️
... and 16 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #325       +/-   ##
==========================================
- Coverage   20.14%   0.00%   -20.15%     
==========================================
  Files          24      31        +7     
  Lines        2214    2344      +130     
==========================================
- Hits          446       0      -446     
- Misses       1733    2344      +611     
+ Partials       35       0       -35     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread internal/middleware/ui_middleware.go Fixed
@steveiliop56 steveiliop56 marked this pull request as ready for review August 26, 2025 09:49
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 38

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
go.mod (1)

3-3: Go directive should not include a patch version.

go.mod’s go directive expects major.minor only. If you want to pin a specific toolchain patch, use the toolchain directive instead.

Apply this diff:

-go 1.23.2
+go 1.23

Optionally add a toolchain pin (if the project requires it):

+toolchain go1.23.2
internal/service/ldap_service.go (1)

139-146: Retry logic bug: returning nil error on failure marks reconnect as success

Inside operation(), you return (nil, nil) when connect fails. That stops retries and treats reconnect as success. Return the actual error to trigger backoff. Also guard ldap.Conn.Close() when nil.

 operation := func() (*ldapgo.Conn, error) {
-    ldap.Conn.Close()
-    conn, err := ldap.connect()
-    if err != nil {
-        return nil, nil
-    }
-    return conn, nil
+    if ldap.Conn != nil {
+        _ = ldap.Conn.Close()
+    }
+    conn, err := ldap.connect()
+    if err != nil {
+        return nil, err
+    }
+    return conn, nil
 }
 
-_, err := backoff.Retry(context.TODO(), operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))
+_, err := backoff.Retry(context.TODO(), operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))

This usage matches v5 API where Retry returns (result, error) and supports WithBackOff/WithMaxTries options. (pkg.go.dev, chromium.googlesource.com)

Also applies to: 148-154

internal/service/auth_service.go (3)

253-270: Delete cookie correctly (MaxAge = -1) instead of just clearing values.

Clearing session.Values leaves an empty but valid cookie. Set MaxAge = -1 to instruct the browser to remove it, and also emit a Set-Cookie with -1 for safety.

 func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
   session, err := auth.GetSession(c)
   if err != nil {
     return err
   }
 
   // Delete all values in the session
   for key := range session.Values {
     delete(session.Values, key)
   }
-  err = session.Save(c.Request, c.Writer)
+  if session.Options == nil {
+    session.Options = auth.Store.Options
+  }
+  // Invalidate the cookie in the browser
+  session.Options.MaxAge = -1
+  err = session.Save(c.Request, c.Writer)
   if err != nil {
     return err
   }
 
+  // Best-effort explicit removal as well
+  c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", auth.Config.Domain, auth.Config.SecureCookie, true)
   return nil
 }

325-348: Trim whitespace when matching OAuth groups.

CSV sources (headers/claims) often include spaces. Without trimming, "devs" vs " devs" fails to match.

   // Split the groups by comma (no need to parse since they are from the API response)
-  oauthGroups := strings.Split(context.OAuthGroups, ",")
+  raw := strings.Split(context.OAuthGroups, ",")
+  oauthGroups := make([]string, 0, len(raw))
+  for _, g := range raw {
+    g = strings.TrimSpace(g)
+    if g != "" {
+      oauthGroups = append(oauthGroups, g)
+    }
+  }

If you adopt []string for OAuthGroups in config structs, this block simplifies further.


1-437: Critical Repo-Wide Follow-Ups to Complete the Auth Refactor

Based on the verification script output, the following issues must be addressed before merging:

• Lingering old flag names
– Found an outdated cookie-secure flag in cmd/root.go:85 that no longer maps to your new SecureCookie field. Rename or alias this flag to avoid confusion.

• Config typos
FogotPasswordMessage remains in internal/config/config.go:50 and is still referenced in internal/bootstrap/app_bootstrap.go:182. Rename to ForgotPasswordMessage.
– The type PassowrdLabels in internal/config/config.go:68–71 should be corrected to PasswordLabels.

• Service initialization gap
AuthService.Init() is never called in internal/bootstrap/app_bootstrap.go after NewAuthService(...) on line 111. Either add an explicit authService.Init() or register authService with your broker so it’s initialized in the existing loop (svc.Init()) on line 124.

• Direct SetCookie calls bypassing conventions
– Detected raw c.SetCookie(...) invocations in:
internal/service/auth_service.go:75
internal/controller/oauth_controller.go:76, 82, 114, 197
These calls hard-code the path and skip your domain/SameSite defaults. Refactor them to use the central sessions.CookieStore (or at least include fmt.Sprintf(".%s", Config.Domain) and your desired SameSite policy) to keep cookie behavior consistent.

• Log level mapping
– Confirmed GetLogLevel(...) in internal/utils/app_utils.go:66–69 exists and maps at least debug to zerolog.DebugLevel. Ensure mappings for all intended levels are present—but no immediate change required here.

♻️ Duplicate comments (1)
internal/middleware/ui_middleware.go (1)

35-56: Harden path handling and gracefully degrade when assets aren’t initialized.

  • Guard against uninitialized UIFS/UIFileServer (e.g., when Init fails or isn’t called).
  • Prefer HasPrefix over Split for routing decisions.
  • Normalize and validate the path before fs.Stat to avoid oddities; use fs.ErrNotExist via errors.Is.
  • This also addresses “Uncontrolled data used in path expression” flagged previously.
@@
-import (
+import (
+	"errors"
 	"io/fs"
 	"net/http"
 	"os"
+	"path"
 	"strings"
 	"tinyauth/internal/assets"
 
 	"github.com/gin-gonic/gin"
 )
@@
 func (m *UIMiddleware) Middleware() gin.HandlerFunc {
 	return func(c *gin.Context) {
-		switch strings.Split(c.Request.URL.Path, "/")[1] {
-		case "api":
-			c.Next()
-			return
-		case "resources":
-			c.Next()
-			return
-		default:
-			_, err := fs.Stat(m.UIFS, strings.TrimPrefix(c.Request.URL.Path, "/"))
-
-			if os.IsNotExist(err) {
-				c.Request.URL.Path = "/"
-			}
-
-			m.UIFileServer.ServeHTTP(c.Writer, c.Request)
-			c.Abort()
-			return
-		}
+		// If assets were not initialized, act as a no-op.
+		if m.UIFS == nil || m.UIFileServer == nil {
+			c.Next()
+			return
+		}
+
+		p := c.Request.URL.Path
+		if strings.HasPrefix(p, "/api") || strings.HasPrefix(p, "/resources") {
+			c.Next()
+			return
+		}
+
+		// Clean path and check if asset exists; fall back to SPA index on 404.
+		reqPath := path.Clean("/" + strings.TrimPrefix(p, "/"))
+		assetPath := strings.TrimPrefix(reqPath, "/")
+		if _, err := fs.Stat(m.UIFS, assetPath); err != nil {
+			if errors.Is(err, fs.ErrNotExist) {
+				c.Request.URL.Path = "/"
+			}
+		}
+
+		m.UIFileServer.ServeHTTP(c.Writer, c.Request)
+		c.Abort()
 	}
 }
🧹 Nitpick comments (62)
frontend/src/context/user-context.tsx (2)

16-19: Endpoint update looks good; mirror in-query validation for stronger typing.

The move to /api/context/user aligns with the new API layout. As with AppContext, validating in the query keeps the provider lean and ensures type-safe context values.

Please confirm that GET /api/context/user returns the unauthenticated shape (rather than a 401) on public pages, or adjust error boundaries accordingly.

-  const { isFetching, data, error } = useSuspenseQuery({
-    queryKey: ["user"],
-    queryFn: () => axios.get("/api/context/user").then((res) => res.data),
-  });
+  const { isFetching, data, error } = useSuspenseQuery({
+    queryKey: ["context", "user"],
+    queryFn: () => axios.get("/api/context/user").then((res) => res.data),
+    select: (raw) => {
+      const validated = userContextSchema.safeParse(raw);
+      if (!validated.success) throw validated.error;
+      return validated.data;
+    },
+    staleTime: 60 * 1000, // user context might change more often; tune as needed
+  });

Apply outside-this-hunk cleanup after the above:

-  const validated = userContextSchema.safeParse(data);
-
-  if (validated.success === false) {
-    throw validated.error;
-  }
+  // data is already validated and typed by react-query's `select`.

   return (
-    <UserContext.Provider value={validated.data}>
+    <UserContext.Provider value={data}>
       {children}
     </UserContext.Provider>
   );

41-44: Tiny grammar nit in the error message.

Prefer “a UserContextProvider” over “an UserContextProvider”.

-    throw new Error(
-      "useUserContext must be used within an UserContextProvider",
-    );
+    throw new Error(
+      "useUserContext must be used within a UserContextProvider",
+    );
.gitignore (1)

28-29: Scope the ignore to the repo root to avoid accidental matches.

The pattern “data” will ignore any path containing “data” (e.g., frontend/src/data). Prefer a root-scoped directory ignore.

-# data directory
-data
+# data directory
+/data/
.env.example (1)

28-28: LOG_LEVEL parser coverage verified

  • Checked GetLogLevel in internal/utils/app_utils.go (lines 66–83): it handles "debug", "info", "warn", "error", "fatal", and "panic", defaulting to InfoLevel for unrecognized values.
  • Since .env.example is typically a developer template, defaulting to debug is reasonable; if this file doubles as a production template, you may wish to revert it to info.
  • Nit (optional): per dotenv-linter, consider moving the LOG_LEVEL entry before OAUTH_WHITELIST in .env.example for consistency.
frontend/src/pages/totp-page.tsx (1)

48-52: Optionally surface server error details for TOTP failures.

Capturing AxiosError enables more granular messages (e.g., invalid code vs. rate limit). Small UX win.

Apply this diff to type the error and show a targeted message when available:

-    onError: () => {
-      toast.error(t("totpFailTitle"), {
-        description: t("totpFailSubtitle"),
-      });
-    },
+    onError: (error: import("axios").AxiosError<{ message?: string }>) => {
+      toast.error(t("totpFailTitle"), {
+        description:
+          error.response?.data?.message ??
+          t("totpFailSubtitle"),
+      });
+    },
go.mod (1)

71-71: Consolidate to a Single Validator Major Version

We still have a direct v9 import that must be updated before removing v9 from go.mod:

  • File & Line: cmd/root.go:11
    Current import:
    "github.com/go-playground/validator"
  • To migrate to v10, update the import path and adapt any API changes:
    - cmd/root.go:11:	"github.com/go-playground/validator"
    + cmd/root.go:11:	"github.com/go-playground/validator/v10"
  • Once all code imports use /v10, remove the v9 entry from go.mod:
    - github.com/go-playground/validator v9.31.0+incompatible
  • Finally, run:
    go mod tidy
    to prune the unused v9 module.

If another dependency transitively requires v9, you’ll still get both in the graph—but migrating your own imports to v10 will at least centralize your direct usage on a single major.

internal/utils/string_utils.go (2)

7-12: Capitalize: avoid double []rune conversion; use utf8.DecodeRune; add doc comment.

Current implementation converts to []rune twice and allocates unnecessarily. Using utf8.DecodeRune is faster and clearer. Also add a doc comment for exported function.

Apply this diff:

-import (
-	"strings"
-)
+import (
+	"strings"
+	"unicode/utf8"
+)
 
-func Capitalize(str string) string {
-	if len(str) == 0 {
-		return ""
-	}
-	return strings.ToUpper(string([]rune(str)[0])) + string([]rune(str)[1:])
-}
+// Capitalize uppercases the first Unicode rune of str and returns the result.
+// If str is empty, it returns an empty string.
+func Capitalize(str string) string {
+	if str == "" {
+		return ""
+	}
+	r, size := utf8.DecodeRuneInString(str)
+	return strings.ToUpper(string(r)) + str[size:]
+}

Note: Some runes can uppercase to multiple runes in edge locales; this mirrors the existing behavior while removing extra allocations. If locale-aware titlecasing is desired later, consider golang.org/x/text/cases.


14-30: CoalesceToString: support []string; drop redundant continue; add doc comment.

This improves generality (claims often come as []string) and simplifies the loop.

Apply this diff:

-func CoalesceToString(value any) string {
-	switch v := value.(type) {
-	case []any:
-		strs := make([]string, 0, len(v))
-		for _, item := range v {
-			if str, ok := item.(string); ok {
-				strs = append(strs, str)
-				continue
-			}
-		}
-		return strings.Join(strs, ",")
-	case string:
-		return v
-	default:
-		return ""
-	}
-}
+// CoalesceToString returns a comma-joined string for string-like inputs:
+//   - []any: collects only string elements (ignores non-strings)
+//   - []string: joins elements
+//   - string: returns as-is
+// Any other type returns "".
+func CoalesceToString(value any) string {
+	switch v := value.(type) {
+	case []any:
+		strs := make([]string, 0, len(v))
+		for _, item := range v {
+			if str, ok := item.(string); ok {
+				strs = append(strs, str)
+			}
+		}
+		return strings.Join(strs, ",")
+	case []string:
+		return strings.Join(v, ",")
+	case string:
+		return v
+	default:
+		return ""
+	}
+}
internal/controller/resources_controller.go (1)

25-27: Support HEAD for static resources.

File servers handle HEAD efficiently; adding a HEAD route improves correctness and tooling compatibility.

Apply this diff:

 func (controller *ResourcesController) SetupRoutes() {
 	controller.Router.GET("/resources/*resource", controller.resourcesHandler)
+	controller.Router.HEAD("/resources/*resource", controller.resourcesHandler)
 }
internal/utils/fs_utils.go (1)

5-16: Avoid redundant os.Stat and the TOCTOU window; read once.

os.ReadFile already returns a clear error if the path is invalid. Dropping Stat removes an extra syscall and the time-of-check/time-of-use race window.

Apply this diff:

 func ReadFile(file string) (string, error) {
-	_, err := os.Stat(file)
-	if err != nil {
-		return "", err
-	}
-
-	data, err := os.ReadFile(file)
-	if err != nil {
-		return "", err
-	}
-
-	return string(data), nil
+	data, err := os.ReadFile(file)
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
 }
internal/controller/health_controller.go (2)

15-18: Add back-compat alias for previous /healthcheck route (and keep HEAD lightweight).

The legacy server exposed /api/healthcheck. This controller now mounts /health only, which can break monitors/clients. Also, the HEAD route currently shares the JSON body handler. HEAD responses should not emit a body.

Apply the diff to add /healthcheck aliases and make HEAD a no-body response:

@@
-import "github.com/gin-gonic/gin"
+import (
+	"net/http"
+	"github.com/gin-gonic/gin"
+)
@@
 func (controller *HealthController) SetupRoutes() {
 	controller.Router.GET("/health", controller.healthHandler)
 	controller.Router.HEAD("/health", controller.healthHandler)
+	// Back-compat with previous endpoint name
+	controller.Router.GET("/healthcheck", controller.healthHandler)
+	controller.Router.HEAD("/healthcheck", controller.healthHandler)
 }

And update the handler below (see next comment) to treat HEAD properly.


20-25: Unify response shape and avoid a body on HEAD.

Previous handler returned an integer status (200) and "OK". The new one returns a string "ok" and "Healthy", changing types and text. To minimize breakage, keep the old shape and suppress the response body on HEAD.

Apply:

@@
-func (controller *HealthController) healthHandler(c *gin.Context) {
-	c.JSON(200, gin.H{
-		"status":  "ok",
-		"message": "Healthy",
-	})
-}
+func (controller *HealthController) healthHandler(c *gin.Context) {
+	if c.Request.Method == http.MethodHead {
+		c.Status(http.StatusOK)
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"status":  200,
+		"message": "OK",
+	})
+}
internal/middleware/ui_middleware.go (1)

13-16: Consider unexporting internal fields.

Unless external packages need to mutate UIFS/UIFileServer, prefer unexported fields to reduce surface area.

-type UIMiddleware struct {
-	UIFS         fs.FS
-	UIFileServer http.Handler
-}
+type UIMiddleware struct {
+	uifs         fs.FS
+	uiFileServer http.Handler
+}

Note: Adjust references accordingly if you adopt this.

internal/utils/user_utils.go (1)

29-51: Optional: Short-circuit after composition if resulting users string is empty.

This helps when file exists but is empty and conf is empty.

 	return ParseUsers(users)

to

+	if strings.TrimSpace(users) == "" {
+		return []config.User{}, nil
+	}
+	return ParseUsers(users)
internal/controller/context_controller.go (2)

58-62: Route layout changed from /user and /app to /context/ — add aliases to avoid breaking clients.*

Legacy endpoints were /api/user and /api/app. This rework mounts at /context/user and /context/app, which is a breaking change for UIs and scripts.

 func (controller *ContextController) SetupRoutes() {
 	contextGroup := controller.Router.Group("/context")
 	contextGroup.GET("/user", controller.userContextHandler)
 	contextGroup.GET("/app", controller.appContextHandler)
+
+	// Back-compat with previous endpoints
+	controller.Router.GET("/user", controller.userContextHandler)
+	controller.Router.GET("/app", controller.appContextHandler)
 }

10-33: Avoid duplicating response DTOs if shared types already exist.

If types like UserContextResponse/AppContext already exist elsewhere (previously under internal/types or handlers), consider reusing them to reduce drift and maintenance.

Would you like a follow-up PR to consolidate these DTOs under internal/types and reuse across controllers?

internal/utils/label_utils.go (2)

35-43: Sanitization policy clarity.

SanitizeHeader allows spaces and tabs; that’s correct for values, but for keys we now guard separately. Consider documenting this contract in comments or splitting into SanitizeHeaderKey and SanitizeHeaderValue for clarity.

I can provide a small refactor if you want distinct functions for key/value sanitization.


10-19: Optional: wrap Decode errors with context or log them.

GetLabels previously logged parse errors; this version returns the raw error. If observability matters during discovery of mis-labeled resources, consider wrapping with context or logging at call sites.

No code change required if the new behavior is intentional.

internal/middleware/zerolog_middleware.go (4)

29-36: Clarify naming and boolean semantics of logPath

logPath returns true when logging should occur and false when it should be downgraded to debug. The name reads like it returns “path to log,” which is slightly ambiguous. Consider renaming to shouldLog or inverting the return to a skip predicate for readability.

-func (m *ZerologMiddleware) logPath(path string) bool {
+func (m *ZerologMiddleware) shouldLog(methodAndPath string) bool {
-  for _, prefix := range loggerSkipPathsPrefix {
-    if strings.HasPrefix(path, prefix) {
+  for _, prefix := range loggerSkipPathsPrefix {
+    if strings.HasPrefix(methodAndPath, prefix) {
       return false
     }
   }
   return true
}
...
- if m.logPath(method + " " + path) {
+ if m.shouldLog(method + " " + path) {

55-61: 1xx responses aren’t logged; add a default branch

Right now, 1xx statuses fall through without logging. Add a default to ensure every response is captured.

      switch {
      case code >= 200 && code < 300:
        log.Info()...Msg("Request")
      case code >= 300 && code < 400:
        log.Warn()...Msg("Request")
      case code >= 400:
        log.Error()...Msg("Request")
+     default:
+       log.Info().Str("method", method).Str("path", path).Str("address", address).Str("clientIp", clientIP).Int("status", code).Str("latency", latency).Msg("Request")
      }

62-64: Keep log fields consistent for skipped paths

Include clientIp in the debug branch so dashboards/queries can rely on a uniform schema.

-    log.Debug().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
+    log.Debug().Str("method", method).Str("path", path).Str("address", address).Str("clientIp", clientIP).Int("status", code).Str("latency", latency).Msg("Request")

12-17: Method+path matching for skips may be too narrow

You only skip GET/HEAD healthcheck and GET favicon. If the proxy or health probes use OPTIONS or if HEAD /favicon.ico is common, you’ll get unnecessary logs. Consider skipping by path alone or expanding the allowlist.

-var (
-  loggerSkipPathsPrefix = []string{
-    "GET /api/healthcheck",
-    "HEAD /api/healthcheck",
-    "GET /favicon.ico",
-  }
-)
+var loggerSkipPathsPrefix = []string{"/api/healthcheck", "/favicon.ico"}
...
- if m.logPath(method + " " + path) {
+ if m.shouldLog(path) {

Also applies to: 53-53

internal/service/oauth_broker_service.go (2)

30-44: Normalize provider names to avoid case-sensitivity bugs

If configs map contains “GitHub” or “GOOGLE”, it will fall into the default case. Normalize keys on insert and lookup.

 func (broker *OAuthBrokerService) Init() error {
-  for name, cfg := range broker.Configs {
+  for rawName, cfg := range broker.Configs {
+    name := strings.ToLower(rawName)
     switch name {
     case "github":
       service := NewGithubOAuthService(cfg)
       broker.Services[name] = service
     case "google":
       service := NewGoogleOAuthService(cfg)
       broker.Services[name] = service
     default:
       service := NewGenericOAuthService(cfg)
       broker.Services[name] = service
     }
   }
-func (broker *OAuthBrokerService) GetService(name string) (OAuthService, bool) {
-  service, exists := broker.Services[name]
+func (broker *OAuthBrokerService) GetService(name string) (OAuthService, bool) {
+  service, exists := broker.Services[strings.ToLower(name)]
   return service, exists
 }
-func (broker *OAuthBrokerService) GetUser(service string) (config.Claims, error) {
-  oauthService, exists := broker.Services[service]
+func (broker *OAuthBrokerService) GetUser(service string) (config.Claims, error) {
+  oauthService, exists := broker.Services[strings.ToLower(service)]
   if !exists {
-    return config.Claims{}, errors.New("oauth service not found")
+    return config.Claims{}, errors.New("oauth service not found")
   }
   return oauthService.Userinfo()
 }

Note: add strings to imports.

Also applies to: 65-68, 70-76


48-52: Prefer structured fields over formatted messages in logs

This makes logs easier to query and consistent with the rest of the codebase.

- log.Error().Err(err).Msgf("Failed to initialize OAuth service: %s", name)
+ log.Error().Err(err).Str("service", name).Msg("Failed to initialize OAuth service")
...
- log.Info().Msgf("Initialized OAuth service: %s", name)
+ log.Info().Str("service", name).Msg("Initialized OAuth service")
cmd/root.go (1)

44-46: Minor: simplify level assignment

GetLogLevel already returns zerolog.Level; the extra cast is redundant.

-    log.Logger = log.Level(zerolog.Level(utils.GetLogLevel(conf.LogLevel)))
+    log.Logger = log.Level(utils.GetLogLevel(conf.LogLevel))
internal/service/docker_service.go (1)

36-55: Defensive guard against uninitialized client

Even after fixing Init, a guard helps avoid panics if Docker isn’t available or Init wasn’t called.

 func (docker *DockerService) GetContainers() ([]container.Summary, error) {
+  if docker.Client == nil {
+    return nil, errors.New("docker client is not initialized")
+  }
   containers, err := docker.Client.ContainerList(docker.Context, container.ListOptions{})
   ...
 }
 
 func (docker *DockerService) InspectContainer(containerId string) (container.InspectResponse, error) {
+  if docker.Client == nil {
+    return container.InspectResponse{}, errors.New("docker client is not initialized")
+  }
   ...
 }
 
 func (docker *DockerService) DockerConnected() bool {
-  _, err := docker.Client.Ping(docker.Context)
+  if docker.Client == nil {
+    return false
+  }
+  _, err := docker.Client.Ping(docker.Context)
   return err == nil
 }

Note: add errors to imports.

internal/service/google_oauth_service.go (2)

54-59: Harden state generation: handle crypto/rand errors and use RawURLEncoding.

  • rand.Read error is ignored; if it fails, state may be all zeros.
  • RawURLEncoding avoids trailing "=" padding in URLs.
 func (oauth *GoogleOAuthService) GenerateState() string {
   b := make([]byte, 128)
-  rand.Read(b)
-  state := base64.URLEncoding.EncodeToString(b)
+  if _, err := rand.Read(b); err != nil {
+    // Fallback: extremely unlikely path; keep small constant to avoid leaking failures
+    b = []byte("state-fallback")
+  }
+  state := base64.RawURLEncoding.EncodeToString(b)
   return state
 }

31-41: Honor custom scopes when provided; default to GoogleOAuthScopes.

Currently, NewGoogleOAuthService ignores config.Scopes. Supporting overrides keeps behavior consistent with other providers.

 func NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService {
   return &GoogleOAuthService{
     Config: oauth2.Config{
       ClientID:     config.ClientID,
       ClientSecret: config.ClientSecret,
       RedirectURL:  config.RedirectURL,
-      Scopes:       GoogleOAuthScopes,
+      Scopes:       func() []string {
+        if len(config.Scopes) > 0 {
+          return config.Scopes
+        }
+        return GoogleOAuthScopes
+      }(),
       Endpoint:     endpoints.Google,
     },
   }
 }
internal/utils/security_utils.go (3)

52-59: Name shadowing: local variable ‘hkdf’ hides the imported package.

This compiles but hurts readability and confuses tooling. Rename the variable to reader or kdf.

 func DeriveKey(secret string, info string) (string, error) {
   hash := sha256.New
-  hkdf := hkdf.New(hash, []byte(secret), nil, []byte(info)) // I am not using a salt because I just want two different keys from one secret, maybe bad practice
+  // HKDF used to deterministically derive distinct keys from a single secret (no salt by design here).
+  reader := hkdf.New(hash, []byte(secret), nil, []byte(info))
   key := make([]byte, 24)

-  _, err := io.ReadFull(hkdf, key)
+  _, err := io.ReadFull(reader, key)

52-56: Production comment hygiene.

The inline “maybe bad practice” comment is speculative and will trigger audits. Replace with a clear rationale or remove it. See the diff above.


70-79: Validate input IP before CIDR checks.

If ip is invalid, cidr.Contains(nil) returns false silently. Prefer an explicit error to aid callers and logs.

 func FilterIP(filter string, ip string) (bool, error) {
   ipAddr := net.ParseIP(ip)
 
+  if ipAddr == nil {
+    return false, errors.New("invalid IP address")
+  }
+
   if strings.Contains(filter, "/") {
     _, cidr, err := net.ParseCIDR(filter)
     if err != nil {
       return false, err
     }
     return cidr.Contains(ipAddr), nil
   }
internal/controller/user_controller.go (2)

85-93: Unknown user detection is inconsistent with the rest of the codebase.

Elsewhere (“unknown” is used), here an empty string is checked. Make the guard robust to both to avoid leaking different log messages.

- if userSearch.Type == "" {
+ if userSearch.Type == "" || userSearch.Type == "unknown" {
   log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("User not found")
   controller.Auth.RecordLoginAttempt(rateIdentifier, false)
   c.JSON(401, gin.H{
     "status":  401,
     "message": "Unauthorized",
   })
   return
 }

167-176: Nit: avoid naming local variable “context”.

It can confuse readers since “context” is a ubiquitous package in Go. Consider userCtx or uc.

internal/bootstrap/app_bootstrap.go (2)

55-58: Cookie suffix is derived from only the first label; use full domain to reduce collision risk.

If domain is example.com, using only “example” increases the chance of collisions across TLDs. Using the full domain string is simpler and safer.

- cookieId := utils.GenerateIdentifier(strings.Split(domain, ".")[0])
+ cookieId := utils.GenerateIdentifier(domain)

209-216: Minor: avoid shadowing imported package names and use consistent plural naming.

The local slice variable named controller shadows the controller import. Rename to controllers for clarity. Same note for middlewares loop var; mw is clearer.

If you want, I can push a mechanical rename patch.

Also applies to: 187-205, 194-201

internal/middleware/context_middleware.go (1)

36-57: Style: prefer early returns or small helper functions over goto.

The flow is correct, but gotos reduce readability and complicate defer blocks. Consider extracting the basic auth path into a helper and return early from each branch.

Also applies to: 104-157

internal/controller/proxy_controller.go (4)

203-208: 401/403 status code drift vs legacy handler; verify intended behavior

Previously, nginx/non‑browser paths used 401 for “unauthorized” (resource/group) and 403 for IP-deny. This controller returns 403 where legacy used 401, and 401 where legacy used 403. Please confirm desired semantics and align consistently (docs, tests, and clients may rely on this).

If you intend to preserve legacy behavior, the minimal diffs are:

-            c.JSON(403, gin.H{
+            c.JSON(401, gin.H{
                 "status":  401,
                 "message": "Unauthorized",
             })
-        if req.Proxy == "nginx" || !isBrowser {
-            c.JSON(401, gin.H{
-                "status":  401,
-                "message": "Unauthorized",
-            })
+        if req.Proxy == "nginx" || !isBrowser {
+            c.JSON(403, gin.H{
+                "status":  403,
+                "message": "Forbidden",
+            })

Also applies to: 236-241, 116-121


299-301: Trust on X-Forwarded- without validation can enable open-redirect/header spoofing*

proto/host/uri are taken from X-Forwarded-* for building RedirectURI and for resource derivation. If this handler is reachable directly (misconfigured proxy) these headers can be client-controlled. Consider:

  • Gate by trusted proxy IPs or
  • Prefer request URL if X-Forwarded-* are empty/untrusted, and
  • Sanitize/validate host and require known domain suffix.

Also applies to: 78-89, 66-72


266-282: Authorization header overwrite: Basic auth can clobber upstream Authorization

You first propagate incoming Authorization, then (optionally) overwrite it with Basic. If both are relevant, downstream will only receive Basic. Consider using a different header for injected basic (e.g., X-Proxy-Authorization) or only set Basic when the original Authorization is empty.

-        c.Header("Authorization", c.Request.Header.Get("Authorization"))
+        if authz := c.Request.Header.Get("Authorization"); authz != "" {
+            c.Header("Authorization", authz)
+        }
...
-        if labels.Basic.Username != "" && utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File) != "" {
+        if labels.Basic.Username != "" &&
+           utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File) != "" &&
+           c.Writer.Header().Get("Authorization") == "" {

Also applies to: 93-105


200-206: Minor: reduce repeated Split(host, ".") and improve logs

You call strings.Split(host, ".")[0] multiple times; compute once. Also prefer structured fields consistently (resource, host).

-    appAllowed := controller.Auth.ResourceAllowed(c, userContext, labels)
+    resource := strings.Split(host, ".")[0]
+    appAllowed := controller.Auth.ResourceAllowed(c, userContext, labels)
...
-    log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User not allowed to access resource")
+    log.Warn().Str("user", userContext.Username).Str("resource", resource).Msg("User not allowed to access resource")

Also applies to: 234-240

internal/service/generic_oauth_service.go (2)

64-69: Use crypto-strong state and handle rand.Read errors

rand.Read can fail; treat failures as errors instead of silently producing weak state.

 func (generic *GenericOAuthService) GenerateState() string {
     b := make([]byte, 128)
-    rand.Read(b)
+    if _, err := rand.Read(b); err != nil {
+        // Fallback: minimal state to avoid empty value; log upstream if you have a logger here.
+        return base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("fallback-%d", time.Now().UnixNano())))
+    }
     state := base64.URLEncoding.EncodeToString(b)
     return state
 }

42-61: Set HTTP client timeout to avoid hanging on network issues

The custom HTTP client has no Timeout; add a sane default.

 httpClient := &http.Client{
-    Transport: transport,
+    Transport: transport,
+    Timeout:   10 * time.Second,
 }

Also applies to: 50-56

internal/controller/oauth_controller.go (2)

108-115: Improve CSRF mismatch logging and robustness

Err may be nil in a mismatch case; log the condition explicitly and clear the CSRF cookie to avoid reuse.

- if err != nil || state != csrfCookie {
-     log.Warn().Err(err).Msg("CSRF token mismatch or cookie missing")
+ if err != nil || state != csrfCookie {
+     if err != nil {
+         log.Warn().Err(err).Msg("CSRF cookie missing")
+     } else {
+         log.Warn().Msg("CSRF token mismatch")
+     }
+     c.SetCookie(controller.Config.CSRFCookieName, "", -1, "/", "", controller.Config.SecureCookie, true)
      c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL))
      return
  }

171-177: Session cookie: consider SameSite and Secure attributes

gin.Context.SetCookie can’t set SameSite. Consider switching to http.SetCookie for full control (SameSite=Lax/Strict) to mitigate CSRF on subsequent requests. Also ensure Secure=true in production.

internal/service/ldap_service.go (2)

34-55: Lifecycle: add a way to stop the heartbeat goroutine

Init starts a ticker loop with no shutdown. Consider accepting a context in Init (or LdapService) and stop the loop on cancel to avoid leaks during app shutdown.

Also applies to: 40-53


76-100: Thread-safety: shared ldap.Conn accessed from multiple goroutines without sync

heartbeat, Search, and Bind may race on Conn. Consider protecting Conn with a RWMutex or using atomic pointer swaps and local copies per operation.

Also applies to: 102-109, 110-128

internal/service/github_oauth_service.go (1)

59-64: Handle rand.Read error in state generation

Same as GenericOAuth; ensure cryptographic state generation doesn’t silently fail.

 b := make([]byte, 128)
-rand.Read(b)
+if _, err := rand.Read(b); err != nil {
+    return base64.URLEncoding.EncodeToString([]byte("fallback"))
+}
internal/config/config.go (7)

3-8: Unify the representation of “groups” (avoid any).

Using any for Claims.Groups invites inconsistent shapes and type assertions later. Elsewhere we store OAuthGroups as string (comma-separated). Recommend a single representation.

  • Easiest: standardize on []string for groups and convert to/from CSV once at the boundary.
  • If upstream providers vary (string vs array), add a custom unmarshaller to accept both.

Example change:

 type Claims struct {
   Name              string `json:"name"`
   Email             string `json:"email"`
   PreferredUsername string `json:"preferred_username"`
-  Groups            any    `json:"groups"`
+  Groups            []string `json:"groups"`
 }

If needed later, I can add a json.Unmarshaler that gracefully handles string or []string. Want me to push that?


10-12: Add build-time ldflags notes to version vars.

These globals are fine, but a brief comment helps future maintainers remember they’re set via -ldflags at build time.

-var Version = "development"
-var CommitHash = "n/a"
-var BuildTimestamp = "n/a"
+// Overridden at build time via -ldflags: -X tinyauth/internal/config.Version=... etc.
+var Version = "development"
+var CommitHash = "n/a"
+var BuildTimestamp = "n/a"

14-16: Prefer consts and remove duplication with AuthServiceConfig.SessionCookieName.

These cookie-name globals are static; make them const to prevent accidental mutation. Also, we already pass SessionCookieName through AuthServiceConfig, which may drift from these vars.

  • Make them const
  • Either remove SessionCookieName from AuthServiceConfig or ensure it defaults to these constants from a single place (bootstrap/config loader).
-var SessionCookieName = "tinyauth-session"
-var CSRFCookieName = "tinyauth-csrf"
-var RedirectCookieName = "tinyauth-redirect"
+const SessionCookieName = "tinyauth-session"
+const CSRFCookieName   = "tinyauth-csrf"
+const RedirectCookieName = "tinyauth-redirect"

If we keep the field in AuthServiceConfig, set it from these consts during bootstrap to avoid divergence.


50-50: Typo: FogotPasswordMessage should be ForgotPasswordMessage.

Spelling slips into the exported API. Rename the field; keep mapstructure:"forgot-password-message" as-is.

-  FogotPasswordMessage    string `mapstructure:"forgot-password-message"`
+  ForgotPasswordMessage   string `mapstructure:"forgot-password-message"`

I can follow up with a repo-wide rename to fix references.


92-101: Consider tags or dedicated loader for OAuthServiceConfig.

If this struct is intended to be hydrated from file/env, add mapstructure/json tags to avoid bespoke wiring. If it’s purely internal, add a comment stating so.


109-112: Constrain UserSearch.Type to an enum to avoid typos.

Free-form strings ("local", "ldap", "unknown") are fragile. Define a typed alias with constants.

-type UserSearch struct {
-  Username string
-  Type     string // local, ldap or unknown
-}
+type UserType string
+const (
+  UserTypeLocal   UserType = "local"
+  UserTypeLDAP    UserType = "ldap"
+  UserTypeUnknown UserType = "unknown"
+)
+type UserSearch struct {
+  Username string
+  Type     UserType
+}

114-121: Normalize OAuthGroups to []string across structs.

SessionCookie and UserContext carry OAuthGroups as CSV strings, while Claims.Groups likely becomes []string. Using []string here removes repeated split/join logic and reduces edge-case bugs (spaces, empty items).

 type SessionCookie struct {
   Username    string
   Name        string
   Email       string
   Provider    string
   TotpPending bool
-  OAuthGroups string
+  OAuthGroups []string
 }
...
 type UserContext struct {
   Username    string
   Name        string
   Email       string
   IsLoggedIn  bool
   OAuth       bool
   Provider    string
   TotpPending bool
-  OAuthGroups string
+  OAuthGroups []string
   TotpEnabled bool
 }

I can add helper utils to convert existing CSV values during session creation until callers are migrated.

Also applies to: 123-133

internal/service/auth_service.go (8)

85-110: LDAP search returns DN into Username field; consider renaming for clarity.

SearchUser stores userDN into UserSearch.Username when type is "ldap". It works with VerifyUser (Bind expects DN), but the naming is misleading and easy to misuse.

  • Option A: rename field to Identifier or DN for LDAP results.
  • Option B: add a DN string field and keep Username for the original identifier.

112-140: Consolidate authentication failure logging.

Minor: default branch already logs an "unknown type" message; the trailing "User authentication failed" log executes when search.Type == "ldap" and LDAP == nil, causing mixed semantics. Consider a single, consistent warn-level log at the final return.


142-151: Lower “user not found” log to debug to avoid log noise.

Every miss on local users will emit a warn. This can spam logs during brute-force attempts or when LDAP is primary.

-  log.Warn().Str("username", username).Msg("Local user not found")
+  log.Debug().Str("username", username).Msg("Local user not found")

223-251: Per-session MaxAge for TOTP flow and harden session field names.

  • When TotpPending, you constrain logical expiry but cookie still uses global MaxAge. Set session.Options.MaxAge = sessionExpiry for that save.
  • Optional: pull constant keys for "username"/"expiry" to avoid typos.
   if data.TotpPending {
     sessionExpiry = 3600
   } else {
     sessionExpiry = auth.Config.SessionExpiry
   }
+  // Align cookie lifetime with logical expiry
+  if session.Options == nil {
+    session.Options = auth.Store.Options
+  }
+  session.Options.MaxAge = sessionExpiry

272-309: Be defensive on expiry type and reduce double work.

If some stores deserialize numbers differently, expiry could land as int (or float64). Handle those cases, and avoid a second DeleteSessionCookie call if the first fails (log it).

I can provide a small helper to coerce expiry to int64 safely if you’d like.


350-369: Fail-closed behavior for invalid Allowed regex.

Currently returns true with an error when regex compilation fails. If callers ignore the error, routes might accidentally require auth (good) or produce surprising behavior. Consider:

  • Log the error
  • Return false, err to explicitly deny or have the caller decide

371-381: Avoid overloading config.User for BasicAuth credentials.

config.User.Password is a hashed password for local users. Here, it carries the raw password. This dual use risks confusion or misuse.

  • Introduce a small BasicCreds struct { Username, Password string } for this purpose.
  • Or return a tuple and keep parsing separate from local user representation.

383-419: IP allow/block precedence is deny-first; document it.

Block list takes precedence over allow list (a blocked IP that’s also allowed is denied). This is fine; a short comment clarifies intent.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 4979121 and 77296da.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (56)
  • .env.example (2 hunks)
  • .gitignore (1 hunks)
  • air.toml (1 hunks)
  • cmd/root.go (3 hunks)
  • cmd/version.go (2 hunks)
  • frontend/src/context/app-context.tsx (1 hunks)
  • frontend/src/context/user-context.tsx (1 hunks)
  • frontend/src/pages/logout-page.tsx (1 hunks)
  • frontend/src/pages/totp-page.tsx (1 hunks)
  • frontend/vite.config.ts (1 hunks)
  • go.mod (1 hunks)
  • internal/assets/assets.go (1 hunks)
  • internal/auth/auth_test.go (0 hunks)
  • internal/bootstrap/app_bootstrap.go (1 hunks)
  • internal/config/config.go (5 hunks)
  • internal/constants/constants.go (0 hunks)
  • internal/controller/context_controller.go (1 hunks)
  • internal/controller/health_controller.go (1 hunks)
  • internal/controller/oauth_controller.go (1 hunks)
  • internal/controller/proxy_controller.go (1 hunks)
  • internal/controller/resources_controller.go (1 hunks)
  • internal/controller/user_controller.go (1 hunks)
  • internal/handlers/context.go (0 hunks)
  • internal/handlers/handlers.go (0 hunks)
  • internal/handlers/handlers_test.go (0 hunks)
  • internal/handlers/oauth.go (0 hunks)
  • internal/handlers/proxy.go (0 hunks)
  • internal/handlers/user.go (0 hunks)
  • internal/hooks/hooks.go (0 hunks)
  • internal/middleware/context_middleware.go (1 hunks)
  • internal/middleware/ui_middleware.go (1 hunks)
  • internal/middleware/zerolog_middleware.go (1 hunks)
  • internal/oauth/oauth.go (0 hunks)
  • internal/providers/generic.go (0 hunks)
  • internal/providers/github.go (0 hunks)
  • internal/providers/google.go (0 hunks)
  • internal/providers/providers.go (0 hunks)
  • internal/server/server.go (0 hunks)
  • internal/service/auth_service.go (14 hunks)
  • internal/service/docker_service.go (3 hunks)
  • internal/service/generic_oauth_service.go (1 hunks)
  • internal/service/github_oauth_service.go (1 hunks)
  • internal/service/google_oauth_service.go (1 hunks)
  • internal/service/ldap_service.go (5 hunks)
  • internal/service/oauth_broker_service.go (1 hunks)
  • internal/types/api.go (0 hunks)
  • internal/types/types.go (0 hunks)
  • internal/utils/app_utils.go (1 hunks)
  • internal/utils/fs_utils.go (1 hunks)
  • internal/utils/label_utils.go (1 hunks)
  • internal/utils/security_utils.go (1 hunks)
  • internal/utils/string_utils.go (1 hunks)
  • internal/utils/user_utils.go (1 hunks)
  • internal/utils/utils.go (0 hunks)
  • internal/utils/utils_test.go (0 hunks)
  • main.go (1 hunks)
💤 Files with no reviewable changes (19)
  • internal/providers/generic.go
  • internal/handlers/proxy.go
  • internal/types/types.go
  • internal/providers/google.go
  • internal/constants/constants.go
  • internal/utils/utils_test.go
  • internal/handlers/user.go
  • internal/hooks/hooks.go
  • internal/providers/github.go
  • internal/handlers/context.go
  • internal/handlers/handlers.go
  • internal/auth/auth_test.go
  • internal/server/server.go
  • internal/oauth/oauth.go
  • internal/handlers/oauth.go
  • internal/handlers/handlers_test.go
  • internal/types/api.go
  • internal/utils/utils.go
  • internal/providers/providers.go
🧰 Additional context used
🧬 Code graph analysis (28)
cmd/version.go (1)
internal/config/config.go (3)
  • Version (10-10)
  • CommitHash (11-11)
  • BuildTimestamp (12-12)
main.go (1)
internal/server/server.go (1)
  • zerolog (103-130)
internal/controller/health_controller.go (1)
internal/handlers/handlers.go (1)
  • HealthcheckHandler (31-36)
internal/utils/label_utils.go (3)
internal/config/config.go (1)
  • Labels (82-90)
internal/utils/utils_test.go (3)
  • TestGetLabels (224-253)
  • TestParseHeaders (388-413)
  • TestSanitizeHeader (364-386)
internal/utils/utils.go (2)
  • GetLabels (154-164)
  • ParseHeaders (136-151)
internal/utils/string_utils.go (1)
internal/utils/utils_test.go (1)
  • TestCoalesceToString (515-548)
frontend/src/pages/totp-page.tsx (1)
frontend/src/schemas/totp-schema.ts (1)
  • TotpSchema (7-7)
internal/controller/resources_controller.go (2)
internal/config/config.go (1)
  • Config (18-59)
internal/server/server.go (1)
  • NewServer (41-95)
internal/utils/fs_utils.go (2)
internal/utils/utils.go (4)
  • ReadFile (61-73)
  • ParseSecretFile (214-225)
  • ParseFileToLine (76-88)
  • GetSecret (91-106)
internal/utils/utils_test.go (3)
  • TestReadFile (53-78)
  • TestParseSecretFile (415-426)
  • TestGetSecret (93-133)
internal/middleware/context_middleware.go (6)
internal/config/config.go (2)
  • Config (18-59)
  • UserContext (123-133)
internal/service/auth_service.go (1)
  • AuthService (37-44)
internal/service/oauth_broker_service.go (1)
  • OAuthBrokerService (18-21)
internal/utils/string_utils.go (1)
  • Capitalize (7-12)
internal/handlers/context.go (1)
  • AppContextHandler (10-35)
internal/hooks/hooks.go (1)
  • UseUserContext (30-144)
internal/middleware/zerolog_middleware.go (2)
internal/bootstrap/app_bootstrap.go (1)
  • Middleware (20-23)
internal/server/server.go (1)
  • zerolog (103-130)
internal/utils/app_utils.go (1)
internal/config/config.go (1)
  • UserContext (123-133)
internal/service/generic_oauth_service.go (2)
internal/config/config.go (3)
  • Config (18-59)
  • OAuthServiceConfig (92-101)
  • Claims (3-8)
internal/oauth/oauth.go (2)
  • NewOAuth (20-44)
  • GetAuthURL (46-48)
internal/service/oauth_broker_service.go (4)
internal/config/config.go (2)
  • Claims (3-8)
  • OAuthServiceConfig (92-101)
internal/service/github_oauth_service.go (1)
  • NewGithubOAuthService (36-46)
internal/service/google_oauth_service.go (1)
  • NewGoogleOAuthService (31-41)
internal/service/generic_oauth_service.go (1)
  • NewGenericOAuthService (25-40)
internal/controller/user_controller.go (5)
internal/config/config.go (2)
  • Config (18-59)
  • SessionCookie (114-121)
internal/service/auth_service.go (1)
  • AuthService (37-44)
internal/utils/string_utils.go (1)
  • Capitalize (7-12)
internal/utils/app_utils.go (1)
  • GetContext (50-64)
internal/auth/auth.go (1)
  • CreateSessionCookie (219-253)
internal/utils/user_utils.go (3)
internal/config/config.go (1)
  • User (103-107)
internal/utils/fs_utils.go (1)
  • ReadFile (5-17)
internal/utils/app_utils.go (1)
  • ParseFileToLine (27-39)
internal/service/github_oauth_service.go (3)
internal/config/config.go (3)
  • Config (18-59)
  • OAuthServiceConfig (92-101)
  • Claims (3-8)
internal/oauth/oauth.go (1)
  • NewOAuth (20-44)
internal/providers/github.go (1)
  • GetGithubUser (30-102)
internal/middleware/ui_middleware.go (3)
internal/assets/assets.go (1)
  • FontendAssets (10-10)
internal/bootstrap/app_bootstrap.go (1)
  • Middleware (20-23)
internal/server/server.go (1)
  • NewServer (41-95)
internal/utils/security_utils.go (3)
internal/utils/fs_utils.go (1)
  • ReadFile (5-17)
internal/utils/utils_test.go (1)
  • TestDeriveKey (497-513)
internal/utils/utils.go (3)
  • GetBasicAuth (284-287)
  • GetSecret (91-106)
  • ParseSecretFile (214-225)
internal/bootstrap/app_bootstrap.go (17)
internal/config/config.go (6)
  • Config (18-59)
  • SessionCookieName (14-14)
  • CSRFCookieName (15-15)
  • RedirectCookieName (16-16)
  • Version (10-10)
  • OAuthServiceConfig (92-101)
internal/utils/user_utils.go (1)
  • GetUsers (29-51)
internal/utils/app_utils.go (1)
  • GetUpperDomain (15-25)
internal/utils/security_utils.go (2)
  • GenerateIdentifier (120-124)
  • DeriveKey (52-68)
internal/service/auth_service.go (2)
  • AuthServiceConfig (24-35)
  • NewAuthService (46-53)
internal/service/ldap_service.go (3)
  • LdapService (23-26)
  • LdapServiceConfig (14-21)
  • NewLdapService (28-32)
internal/service/docker_service.go (1)
  • NewDockerService (21-23)
internal/service/oauth_broker_service.go (1)
  • NewOAuthBrokerService (23-28)
internal/middleware/context_middleware.go (2)
  • NewContextMiddleware (24-30)
  • ContextMiddlewareConfig (14-16)
internal/middleware/ui_middleware.go (1)
  • NewUIMiddleware (18-20)
internal/middleware/zerolog_middleware.go (1)
  • NewZerologMiddleware (21-23)
internal/controller/context_controller.go (2)
  • NewContextController (51-56)
  • ContextControllerConfig (35-44)
internal/controller/oauth_controller.go (2)
  • NewOAuthController (35-42)
  • OAuthControllerConfig (21-26)
internal/controller/proxy_controller.go (2)
  • NewProxyController (31-38)
  • ProxyControllerConfig (20-22)
internal/controller/user_controller.go (2)
  • NewUserController (34-40)
  • UserControllerConfig (24-26)
internal/controller/resources_controller.go (2)
  • NewResourcesController (18-23)
  • ResourcesControllerConfig (9-11)
internal/controller/health_controller.go (1)
  • NewHealthController (9-13)
internal/controller/proxy_controller.go (7)
internal/config/config.go (4)
  • Config (18-59)
  • UnauthorizedQuery (135-140)
  • UserContext (123-133)
  • RedirectQuery (142-144)
internal/service/docker_service.go (1)
  • DockerService (16-19)
internal/service/auth_service.go (1)
  • AuthService (37-44)
internal/utils/label_utils.go (3)
  • GetLabels (10-19)
  • ParseHeaders (21-33)
  • SanitizeHeader (35-43)
internal/utils/security_utils.go (2)
  • GetSecret (17-32)
  • GetBasicAuth (47-50)
internal/utils/app_utils.go (1)
  • GetContext (50-64)
internal/handlers/proxy.go (1)
  • ProxyHandler (15-282)
internal/service/google_oauth_service.go (3)
internal/config/config.go (3)
  • Config (18-59)
  • OAuthServiceConfig (92-101)
  • Claims (3-8)
internal/oauth/oauth.go (2)
  • GetAuthURL (46-48)
  • NewOAuth (20-44)
internal/providers/google.go (2)
  • GoogleScopes (20-22)
  • GetGoogleUser (24-56)
internal/controller/oauth_controller.go (5)
internal/config/config.go (6)
  • CSRFCookieName (15-15)
  • RedirectCookieName (16-16)
  • Config (18-59)
  • UnauthorizedQuery (135-140)
  • SessionCookie (114-121)
  • RedirectQuery (142-144)
internal/service/auth_service.go (1)
  • AuthService (37-44)
internal/service/oauth_broker_service.go (1)
  • OAuthBrokerService (18-21)
internal/utils/string_utils.go (2)
  • Capitalize (7-12)
  • CoalesceToString (14-30)
internal/handlers/oauth.go (2)
  • h (16-70)
  • h (72-223)
internal/controller/context_controller.go (3)
internal/config/config.go (1)
  • Config (18-59)
internal/utils/app_utils.go (1)
  • GetContext (50-64)
internal/handlers/context.go (2)
  • UserContextHandler (37-64)
  • AppContextHandler (10-35)
internal/service/auth_service.go (4)
internal/config/config.go (7)
  • User (103-107)
  • SessionCookieName (14-14)
  • Config (18-59)
  • UserSearch (109-112)
  • SessionCookie (114-121)
  • UserContext (123-133)
  • Labels (82-90)
internal/service/docker_service.go (1)
  • DockerService (16-19)
internal/service/ldap_service.go (1)
  • LdapService (23-26)
internal/utils/security_utils.go (2)
  • CheckFilter (93-118)
  • FilterIP (70-91)
cmd/root.go (4)
internal/config/config.go (2)
  • Config (18-59)
  • Version (10-10)
internal/utils/security_utils.go (1)
  • GetSecret (17-32)
internal/utils/app_utils.go (1)
  • GetLogLevel (66-83)
internal/bootstrap/app_bootstrap.go (1)
  • NewBootstrapApp (33-37)
internal/config/config.go (1)
internal/ldap/ldap.go (1)
  • Config (15-18)
internal/service/docker_service.go (2)
internal/utils/label_utils.go (1)
  • GetLabels (10-19)
internal/config/config.go (1)
  • Labels (82-90)
internal/service/ldap_service.go (1)
internal/config/config.go (1)
  • Config (18-59)
🪛 dotenv-linter (3.3.0)
.env.example

[warning] 8-8: [UnorderedKey] The SECURE_COOKIE key should go before the USERS key

(UnorderedKey)


[warning] 28-28: [UnorderedKey] The LOG_LEVEL key should go before the OAUTH_WHITELIST key

(UnorderedKey)

Comment thread .env.example
Comment thread cmd/root.go
Comment thread internal/assets/assets.go Outdated
Comment thread internal/bootstrap/app_bootstrap.go
Comment thread internal/config/config.go Outdated
Comment thread internal/utils/label_utils.go
Comment thread internal/utils/user_utils.go
Comment thread internal/utils/user_utils.go
Comment thread internal/utils/user_utils.go
Comment thread main.go Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
internal/service/docker_service.go (1)

25-37: Fixed: client and context are now stored on the service (prevents nil deref).

This addresses the earlier panic risk noted in prior review. Good change.

🧹 Nitpick comments (6)
internal/service/docker_service.go (6)

25-37: Prefer WithAPIVersionNegotiation and avoid shadowing package name.

Minor cleanup: let the SDK negotiate API version for you and avoid reusing the identifier “client” for the local var.

Apply this diff:

-func (docker *DockerService) Init() error {
-	client, err := client.NewClientWithOpts(client.FromEnv)
+func (docker *DockerService) Init() error {
+	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
 	if err != nil {
 		return err
 	}
 
 	ctx := context.Background()
-	client.NegotiateAPIVersion(ctx)
-
-	docker.Client = client
+	// API version negotiation handled by client.WithAPIVersionNegotiation()
+	docker.Client = cli
 	docker.Context = ctx
 	return nil
 }

39-45: Guard against uninitialized service to avoid panics.

If Init hasn’t been called, docker.Client is nil and this will panic.

Apply this diff:

 func (docker *DockerService) GetContainers() ([]container.Summary, error) {
+	if docker.Client == nil || docker.Context == nil {
+		return nil, ErrNotInitialized
+	}
 	containers, err := docker.Client.ContainerList(docker.Context, container.ListOptions{})
 	if err != nil {
 		return nil, err
 	}
 	return containers, nil
 }

Add this once in the file (see imports comment below):

var ErrNotInitialized = errors.New("docker service not initialized")

47-53: Same guard needed here.

Mirror the nil check to prevent a panic in InspectContainer.

 func (docker *DockerService) InspectContainer(containerId string) (container.InspectResponse, error) {
+	if docker.Client == nil || docker.Context == nil {
+		return container.InspectResponse{}, ErrNotInitialized
+	}
 	inspect, err := docker.Client.ContainerInspect(docker.Context, containerId)
 	if err != nil {
 		return container.InspectResponse{}, err
 	}
 	return inspect, nil
 }

55-58: Add nil guard and a short timeout to Ping().

Prevents panics and avoids blocking indefinitely if the daemon is slow/unreachable.

 func (docker *DockerService) DockerConnected() bool {
-	_, err := docker.Client.Ping(docker.Context)
+	if docker.Client == nil || docker.Context == nil {
+		return false
+	}
+	ctx, cancel := context.WithTimeout(docker.Context, 2*time.Second)
+	defer cancel()
+	_, err := docker.Client.Ping(ctx)
 	return err == nil
 }

3-14: Imports needed for suggested guards and timeout.

If you adopt the guards/timeouts above, add these imports.

 import (
 	"context"
+	"errors"
+	"time"
 	"strings"
 	"tinyauth/internal/config"
 	"tinyauth/internal/utils"
 
 	"slices"

73-96: Avoid N API-inspect calls; use summary labels and rename loop var to avoid import shadowing.

Inspecting every container is expensive; Summary already has Labels. Also, the loop variable named “container” shadows the imported package and reduces readability.

-	for _, container := range containers {
-		inspect, err := docker.InspectContainer(container.ID)
-		if err != nil {
-			log.Warn().Str("id", container.ID).Err(err).Msg("Error inspecting container, skipping")
-			continue
-		}
-
-		labels, err := utils.GetLabels(inspect.Config.Labels)
+	for _, ctr := range containers {
+		labels, err := utils.GetLabels(ctr.Labels)
 		if err != nil {
-			log.Warn().Str("id", container.ID).Err(err).Msg("Error getting container labels, skipping")
+			log.Warn().Str("id", ctr.ID).Err(err).Msg("Error getting container labels, skipping")
 			continue
 		}
 
 		// Check if the container matches the ID or domain
-		if slices.Contains(labels.Domain, domain) {
-			log.Debug().Str("id", inspect.ID).Msg("Found matching container by domain")
+		if slices.Contains(labels.Domain, domain) {
+			log.Debug().Str("id", ctr.ID).Msg("Found matching container by domain")
 			return labels, nil
 		}
 
-		if strings.TrimPrefix(inspect.Name, "/") == app {
-			log.Debug().Str("id", inspect.ID).Msg("Found matching container by name")
-			return labels, nil
-		}
+		if slices.ContainsFunc(ctr.Names, func(n string) bool { return strings.TrimPrefix(n, "/") == app }) {
+			log.Debug().Str("id", ctr.ID).Msg("Found matching container by name")
+			return labels, nil
+		}
 	}

Optional: compare domains case-insensitively if labels/domains may vary in case.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 77296da and 8435cbe.

📒 Files selected for processing (3)
  • internal/bootstrap/app_bootstrap.go (1 hunks)
  • internal/controller/proxy_controller.go (1 hunks)
  • internal/service/docker_service.go (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/controller/proxy_controller.go
  • internal/bootstrap/app_bootstrap.go
🧰 Additional context used
🧬 Code graph analysis (1)
internal/service/docker_service.go (3)
internal/utils/label_utils.go (1)
  • GetLabels (10-19)
internal/config/config.go (1)
  • Labels (82-90)
internal/docker/docker.go (2)
  • NewDocker (19-32)
  • GetLabels (55-102)
🔇 Additional comments (2)
internal/service/docker_service.go (2)

60-101: No types.Labels usage found; ignore outdated suggestion

Verified via ripgrep that there are no references to types.Labels in the codebase. The only caller of DockerService.GetLabels is in internal/controller/proxy_controller.go, which already handles both errors and the zero‐value of config.Labels correctly. You can safely disregard the original note about migrating off types.Labels.

Likely an incorrect or invalid review comment.


9-10: slices import support verified

go.mod specifies Go version 1.23 (≥ 1.21), so the import of the standard "slices" package is valid. No changes required.

Comment thread internal/service/docker_service.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
frontend/src/pages/login-page.tsx (2)

67-71: Optional: centralize API paths to avoid future sweeping renames.

Defining route constants (or a typed API client) reduces churn across the app when endpoints move again.

Apply this minimal change here:

-    mutationFn: (values: LoginSchema) => axios.post("/api/user/login", values),
+    mutationFn: (values: LoginSchema) => axios.post(USER_LOGIN_URL, values),

And add a shared constant near your API utilities (recommended), or locally at the top of this file if you prefer incremental changes:

// e.g., frontend/src/lib/api/routes.ts
export const USER_LOGIN_URL = "/api/user/login";

165-165: Use strict equality for length check.

Minor: prefer === to avoid unintended type coercion.

-        {configuredProviders.length == 0 && (
+        {configuredProviders.length === 0 && (
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 8435cbe and d3c40bb.

📒 Files selected for processing (1)
  • frontend/src/pages/login-page.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
frontend/src/pages/login-page.tsx (2)
frontend/src/schemas/login-schema.ts (1)
  • LoginSchema (8-8)
frontend/src/components/auth/login-form.tsx (1)
  • FormItem (59-81)

Comment thread frontend/src/pages/login-page.tsx
@steveiliop56
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Aug 26, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/service/auth_service.go (2)

223-244: Per-session expiry: set MaxAge dynamically (TOTP vs normal).

You compute sessionExpiry but don’t apply it to the cookie. Set session.Options.MaxAge accordingly so browser expiry matches server-side checks.

Apply:

 	var sessionExpiry int
@@
 	if data.TotpPending {
 		sessionExpiry = 3600
 	} else {
 		sessionExpiry = auth.Config.SessionExpiry
 	}
 
+	// Ensure cookie expiry aligns with our chosen session expiry
+	if session.Options == nil {
+		session.Options = &sessions.Options{}
+	}
+	session.Options.MaxAge = sessionExpiry
+
 	session.Values["username"] = data.Username

253-270: DeleteSessionCookie should set MaxAge = -1 to expire the cookie.

Clearing values and saving persists an empty cookie. Explicitly expire it.

Apply:

 func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
 	session, err := auth.GetSession(c)
 	if err != nil {
 		return err
 	}
 
-	// Delete all values in the session
-	for key := range session.Values {
-		delete(session.Values, key)
-	}
+	// Expire the cookie in the browser
+	if session.Options == nil {
+		session.Options = &sessions.Options{}
+	}
+	session.Options.MaxAge = -1
+	// Clear server-side values too
+	for k := range session.Values {
+		delete(session.Values, k)
+	}
 
 	err = session.Save(c.Request, c.Writer)
 	if err != nil {
 		return err
 	}
 
 	return nil
 }
♻️ Duplicate comments (14)
internal/controller/resources_controller.go (2)

33-42: Harden empty-dir handling and consider disabling directory listings.

  • You guard at runtime, which is good, but you still constructed a file server backed by http.Dir(config.ResourcesDir) in the constructor. If ResourcesDir == "", http.Dir("") points to the process working directory. Even if guarded, prefer not to create it at all.
  • If directory listings are not desired, wrap the filesystem to deny directory reads.

Minimal guard:

@@
-func NewResourcesController(config ResourcesControllerConfig, router *gin.RouterGroup) *ResourcesController {
+func NewResourcesController(config ResourcesControllerConfig, router *gin.RouterGroup) *ResourcesController {
@@
-	fileServer := http.StripPrefix(prefix, http.FileServer(http.Dir(config.ResourcesDir)))
+	var fileServer http.Handler = http.NotFoundHandler()
+	if config.ResourcesDir != "" {
+		fileServer = http.StripPrefix(prefix, http.FileServer(http.Dir(config.ResourcesDir)))
+	}
@@
 }

Optional no-directory-listing wrapper:

type noDirFS struct{ http.FileSystem }

func (n noDirFS) Open(name string) (http.File, error) {
	f, err := n.FileSystem.Open(name)
	if err != nil {
		return nil, err
	}
	if st, err := f.Stat(); err == nil && st.IsDir() {
		_ = f.Close()
		return nil, fs.ErrPermission
	}
	return f, nil
}

Then build the file server with http.FileServer(noDirFS{http.Dir(config.ResourcesDir)}).


19-27: Fix StripPrefix to include the router group base path; current handler will 404 when mounted under a non-root group (e.g., "/api").

http.StripPrefix("/resources", ...) ignores router.BasePath(). If this controller is attached to a group with base path (e.g., /api), requests like /api/resources/... won’t have the correct prefix stripped and will not resolve files. Compute the effective prefix once in the constructor.

Apply:

@@
 package controller
 
 import (
+	"strings"
 	"net/http"
 
 	"github.com/gin-gonic/gin"
 )
@@
 type ResourcesController struct {
 	Config     ResourcesControllerConfig
 	Router     *gin.RouterGroup
 	FileServer http.Handler
+	prefix     string
 }
@@
 func NewResourcesController(config ResourcesControllerConfig, router *gin.RouterGroup) *ResourcesController {
-	fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.ResourcesDir)))
+	// Build effective prefix: <basePath>/resources (basePath may be "/" or "").
+	base := strings.TrimRight(router.BasePath(), "/")
+	prefix := base + "/resources"
+	fileServer := http.StripPrefix(prefix, http.FileServer(http.Dir(config.ResourcesDir)))
 
 	return &ResourcesController{
 		Config:     config,
 		Router:     router,
 		FileServer: fileServer,
+		prefix:     prefix,
 	}
 }
internal/assets/assets.go (1)

7-10: Keep a deprecated compatibility alias to avoid breaking older references (Assets, FontendAssets).

This refactor renamed the exported variable; creating an alias costs nothing and eases upgrades.

 //go:embed dist
-var FrontendAssets embed.FS
+var FrontendAssets embed.FS
+
+// Deprecated: use FrontendAssets.
+var FontendAssets = FrontendAssets
+
+// Deprecated: use FrontendAssets.
+var Assets = FrontendAssets

Run to confirm no lingering references remain:

#!/bin/bash
rg -nP '\bassets\.(Assets|FontendAssets)\b' -S -g '!**/vendor/**'
internal/middleware/ui_middleware.go (1)

22-33: Good: Init no longer swallows errors; avoids nil server panics.

This addresses the prior concern. Thanks.

internal/controller/user_controller.go (3)

115-131: Good: TOTP-pending cookie errors are handled.

Prevents misleading success responses when cookie write fails.


141-156: Good: Normal login cookie errors are handled.

This resolves a prior gap.


198-205: Good: Gate on TotpPending instead of IsLoggedIn.

Aligns with the intended 2-step flow.

cmd/root.go (1)

85-86: Nice: flag now matches mapstructure key (“secure-cookie”).

Resolves the earlier mismatch that prevented the field from being populated.

internal/middleware/context_middleware.go (1)

80-84: Deleting invalid OAuth session cookie — nice

When the provider is not registered, you now delete the stale cookie before falling back to basic auth. This prevents repeated work on every request.

internal/service/github_oauth_service.go (2)

71-79: Resolved: propagate Exchange errors (thank you for fixing this).

Returning the actual error from oauth2.Exchange is correct and prevents masking failures.


87-103: Resolved: set Accept header and validate HTTP status for GitHub APIs.

The new requests add Accept: application/vnd.github+json and guard on non-2xx before unmarshalling. Looks good.

Also applies to: 116-132

internal/config/config.go (1)

66-74: BC alias for misspelled PassowrdLabels.

Previous public API exported PassowrdLabels. To avoid breakage, keep an alias to the corrected PasswordLabels.

Apply:

 type PasswordLabels struct {
 	Plain string
 	File  string
 }
+
+// Backwards-compatibility alias for the historical typo.
+// Deprecated: use PasswordLabels.
+type PassowrdLabels = PasswordLabels
internal/service/auth_service.go (2)

55-67: Cookie store: validate key lengths, fix Domain, add SameSite (was flagged earlier).

  • AES block key must be 16/24/32 bytes; validate EncryptionSecret up-front.
  • Don’t auto-prefix domain with a dot; empty domain becomes "." which is invalid and breaks localhost.
  • Add SameSite=Lax for auth flows.

Apply:

 import (
 	"fmt"
+	"net/http"
 	"regexp"
 	"strings"
 	"sync"
 	"time"
 	"tinyauth/internal/config"
 	"tinyauth/internal/utils"
@@
 func (auth *AuthService) Init() error {
-	store := sessions.NewCookieStore([]byte(auth.Config.HMACSecret), []byte(auth.Config.EncryptionSecret))
+	// Validate encryption key length (16/24/32 bytes) early
+	if l := len(auth.Config.EncryptionSecret); l != 0 && l != 16 && l != 24 && l != 32 {
+		return fmt.Errorf("invalid EncryptionSecret length: got %d, want 16/24/32", l)
+	}
+	if len(auth.Config.HMACSecret) < 32 {
+		return fmt.Errorf("HMACSecret too short: got %d, want >= 32", len(auth.Config.HMACSecret))
+	}
+	store := sessions.NewCookieStore([]byte(auth.Config.HMACSecret), []byte(auth.Config.EncryptionSecret))
 	store.Options = &sessions.Options{
 		Path:     "/",
 		MaxAge:   auth.Config.SessionExpiry,
 		Secure:   auth.Config.SecureCookie,
 		HttpOnly: true,
-		Domain:   fmt.Sprintf(".%s", auth.Config.Domain),
+		// Set host-only cookie when Domain is empty. Do not auto-prepend a dot.
+		Domain:   auth.Config.Domain,
+		SameSite: http.SameSiteLaxMode,
 	}
 
 	auth.Store = store
 	return nil
 }

69-77: Clearing broken cookie: don’t prepend dot to domain.

Empty Domain becomes ".", which many browsers reject. Use the configured domain as-is.

Apply:

-		c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.Config.Domain), auth.Config.SecureCookie, true)
+		c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", auth.Config.Domain, auth.Config.SecureCookie, true)
🧹 Nitpick comments (27)
internal/controller/resources_controller.go (1)

29-31: Nice, route surface is small and explicit. Consider adding HEAD if clients probe assets with HEAD.

Not required, but cheap to add:

 func (controller *ResourcesController) SetupRoutes() {
-	controller.Router.GET("/resources/*resource", controller.resourcesHandler)
+	controller.Router.GET("/resources/*resource", controller.resourcesHandler)
+	controller.Router.HEAD("/resources/*resource", controller.resourcesHandler)
 }
internal/middleware/ui_middleware.go (3)

35-44: Avoid brittle indexing into Split; normalize path and grab first segment safely.

strings.Split(path, "/")[1] relies on assumptions and can misbehave with unusual paths (//x, trailing slashes). Normalize and use SplitN.

@@
-	return func(c *gin.Context) {
-		switch strings.Split(c.Request.URL.Path, "/")[1] {
+	return func(c *gin.Context) {
+		p := strings.TrimPrefix(path.Clean(c.Request.URL.Path), "/")
+		first := p
+		if i := strings.IndexByte(p, '/'); i >= 0 {
+			first = p[:i]
+		}
+		switch first {
 		case "api":
 			c.Next()
 			return
 		case "resources":
 			c.Next()
 			return

Note: add the import:

-import (
+import (
 	"io/fs"
 	"net/http"
 	"os"
 	"strings"
 	"tinyauth/internal/assets"
+	"path"

45-56: SPA fallback is fine; consider handling other fs.Stat errors the same as NotExist.

Non-NotExist errors (e.g., invalid path) currently fall through without rewriting; treating them like NotExist may improve resilience.

-			_, err := fs.Stat(m.UIFS, strings.TrimPrefix(c.Request.URL.Path, "/"))
-
-			if os.IsNotExist(err) {
+			_, err := fs.Stat(m.UIFS, strings.TrimPrefix(c.Request.URL.Path, "/"))
+			if err != nil {
 				c.Request.URL.Path = "/"
 			}

13-16: Optional: add a quick nil check to fail fast if Init wasn’t called.

Defensive, in case wiring changes.

 func (m *UIMiddleware) Middleware() gin.HandlerFunc {
-	return func(c *gin.Context) {
+	return func(c *gin.Context) {
+		if m.UIFileServer == nil || m.UIFS == nil {
+			c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"status": 500, "message": "UI not initialized"})
+			return
+		}
internal/controller/user_controller.go (3)

163-172: Consider handling DeleteSessionCookie error (if it returns one).

If DeleteSessionCookie can fail, return 500 to avoid implying logout succeeded when it didn’t.

-	controller.Auth.DeleteSessionCookie(c)
+	if err := controller.Auth.DeleteSessionCookie(c); err != nil {
+		log.Error().Err(err).Msg("Failed to delete session cookie")
+		c.JSON(500, gin.H{"status": 500, "message": "Internal Server Error"})
+		return
+	}

221-224: Nit: message mentions “login attempts” in TOTP flow; consider “TOTP attempts”.

Minor clarity tweak.

-			"message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remainingTime),
+			"message": fmt.Sprintf("Too many failed TOTP attempts. Try again in %d seconds", remainingTime),

228-239: Handle missing or invalid local user before TOTP validation.

If the user was removed or has an empty secret, short-circuit with 401 rather than validating against an empty secret.

 	user := controller.Auth.GetLocalUser(context.Username)
 
-	ok := totp.Validate(req.Code, user.TotpSecret)
+	if user.Username == "" || user.TotpSecret == "" {
+		log.Warn().Str("username", context.Username).Msg("TOTP attempted for unknown user or without secret")
+		controller.Auth.RecordLoginAttempt(rateIdentifier, false)
+		c.JSON(401, gin.H{"status": 401, "message": "Unauthorized"})
+		return
+	}
+
+	ok := totp.Validate(req.Code, user.TotpSecret)
cmd/root.go (1)

120-135: Env binding loop looks good; consider a replacer to avoid manual binds next time.

Not required, but viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) would let you call viper.AutomaticEnv() without per-flag BindEnv.

internal/utils/app_utils.go (2)

76-102: Consider allowing safe relative redirects in IsRedirectSafe (reduces open-redirect surface and UX friction)

Requiring absolute URLs only is stricter than necessary and forces callers to embed full URLs. A common pattern is to allow relative-path redirects ("/foo") and, for absolute URLs, restrict to the same registrable domain.

Optional refinement:

  • Accept relative paths starting with "/" that don’t start with "//".
  • Keep the current same-registrable-domain check for absolute URLs.

Apply this diff:

 func IsRedirectSafe(redirectURL string, domain string) bool {
   if redirectURL == "" {
     return false
   }
 
   parsedURL, err := url.Parse(redirectURL)
   if err != nil {
     return false
   }
 
-  if !parsedURL.IsAbs() {
-    return false
-  }
-
-  upper, err := GetUpperDomain(redirectURL)
-  if err != nil {
-    return false
-  }
-
-  if upper != domain {
-    return false
-  }
-
-  return true
+  // Allow relative paths like "/next", but reject protocol-relative ("//") or bare paths w/o leading slash.
+  if !parsedURL.IsAbs() {
+    return strings.HasPrefix(redirectURL, "/") && !strings.HasPrefix(redirectURL, "//")
+  }
+  // Absolute URL: must match app's upper domain.
+  upper, err := GetUpperDomain(redirectURL)
+  if err != nil {
+    return false
+  }
+  return upper == domain
 }

60-74: Make GetContext robust to both pointer and value storage

Call sites set a pointer (&config.UserContext). If any future code stores a value (config.UserContext), this function will currently fail the assertion. Handle both to improve ergonomics.

Apply this diff:

 func GetContext(c *gin.Context) (config.UserContext, error) {
   userContextValue, exists := c.Get("context")
 
   if !exists {
     return config.UserContext{}, errors.New("no user context in request")
   }
 
-  userContext, ok := userContextValue.(*config.UserContext)
-
-  if !ok {
-    return config.UserContext{}, errors.New("invalid user context in request")
-  }
-
-  return *userContext, nil
+  switch v := userContextValue.(type) {
+  case *config.UserContext:
+    return *v, nil
+  case config.UserContext:
+    return v, nil
+  default:
+    return config.UserContext{}, errors.New("invalid user context in request")
+  }
 }
internal/middleware/context_middleware.go (1)

45-56: Keep provider from cookie in TOTP-pending context

You hard-code Provider: "username" during TOTP flow. If the cookie was issued with a different provider value (or future variants), this will mislabel the session context. Use the provider from the cookie.

Apply this diff:

   if cookie.TotpPending {
     c.Set("context", &config.UserContext{
       Username:    cookie.Username,
       Name:        cookie.Name,
       Email:       cookie.Email,
-      Provider:    "username",
+      Provider:    cookie.Provider,
       TotpPending: true,
       TotpEnabled: true,
     })
     c.Next()
     return
   }
internal/bootstrap/app_bootstrap.go (1)

211-223: Avoid shadowing imported package name ‘controller’

Using a local slice named controller shadows the imported package name controller, which reduces readability and can confuse tooling.

Apply this diff:

-  controller := []Controller{
+  controllers := []Controller{
     contextController,
     oauthController,
     proxyController,
     userController,
     healthController,
     resourcesController,
   }
 
-  for _, ctrl := range controller {
+  for _, ctrl := range controllers {
     log.Debug().Msgf("Setting up %T controller", ctrl)
     ctrl.SetupRoutes()
   }
internal/service/generic_oauth_service.go (2)

66-70: Harden state generation: check entropy errors and avoid padding

Check rand.Read errors, and use RawURLEncoding to avoid trailing '=' in cookies/URLs.

Apply this diff:

 func (generic *GenericOAuthService) GenerateState() string {
-  b := make([]byte, 128)
-  rand.Read(b)
-  state := base64.URLEncoding.EncodeToString(b)
+  b := make([]byte, 32) // 256 bits of entropy is plenty
+  if _, err := rand.Read(b); err != nil {
+    // Extremely unlikely; fall back to verifier which is already cryptographically strong
+    return oauth2.GenerateVerifier()
+  }
+  state := base64.RawURLEncoding.EncodeToString(b)
   return state
 }

90-100: Defensive checks and explicit JSON Accept for Userinfo

  • Guard against nil Token to avoid surprising nil derefs if VerifyCode wasn’t called.
  • Prefer an explicit request with Accept: application/json.
  • (Optional) Consider a short timeout via context.WithTimeout.

Apply this diff:

-  client := generic.Config.Client(generic.Context, generic.Token)
-
-  res, err := client.Get(generic.UserinfoURL)
+  if generic.Token == nil {
+    return user, fmt.Errorf("no OAuth token available; call VerifyCode first")
+  }
+  client := generic.Config.Client(generic.Context, generic.Token)
+  req, err := http.NewRequestWithContext(generic.Context, http.MethodGet, generic.UserinfoURL, nil)
+  if err != nil {
+    return user, err
+  }
+  req.Header.Set("Accept", "application/json")
+  res, err := client.Do(req)
   if err != nil {
     return user, err
   }
   defer res.Body.Close()
internal/controller/oauth_controller.go (4)

64-77: Avoid shadowing imported package ‘service’: rename local variable

The local variable service shadows the imported package tinyauth/internal/service. Rename to oauthSvc for clarity.

Apply this diff:

-  service, exists := controller.Broker.GetService(req.Provider)
+  oauthSvc, exists := controller.Broker.GetService(req.Provider)
   if !exists {
     ...
   }
-  state := service.GenerateState()
-  authURL := service.GetAuthURL(state)
+  state := oauthSvc.GenerateState()
+  authURL := oauthSvc.GetAuthURL(state)

118-131: Same variable shadowing in callback handler

Repeat the rename here to keep consistency.

Apply this diff:

-  service, exists := controller.Broker.GetService(req.Provider)
+  oauthSvc, exists := controller.Broker.GetService(req.Provider)
   if !exists {
     ...
   }
-  err = service.VerifyCode(code)
+  err = oauthSvc.VerifyCode(code)

168-170: Split email only once to avoid unexpected splits

Use SplitN to handle emails with multiple '@' safely and avoid double Split calls.

Apply this diff:

-  name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1])
+  parts := strings.SplitN(user.Email, "@", 2)
+  local, domain := parts[0], ""
+  if len(parts) == 2 {
+    domain = parts[1]
+  }
+  name = fmt.Sprintf("%s (%s)", utils.Capitalize(local), domain)

79-85: Redirect URI validation is much better; consider allowing relative paths only (optional)

You now gate redirects with utils.IsRedirectSafe(domain-matching). That’s good. If you want to further reduce cross-subdomain risks, consider accepting only relative paths (e.g., "/continue") here and in the callback (paired with the optional IsRedirectSafe change). That model eliminates cross-origin redirects entirely.

Also applies to: 180-186

internal/service/github_oauth_service.go (5)

92-92: Add a proper User-Agent header for GitHub API requests.

GitHub requires a valid User-Agent; default Go UA is generic. Send a descriptive UA including version.

Apply:

-req.Header.Set("Accept", "application/vnd.github+json")
+req.Header.Set("Accept", "application/vnd.github+json")
+req.Header.Set("User-Agent", "tinyauth/"+config.Version)
@@
-req.Header.Set("Accept", "application/vnd.github+json")
+req.Header.Set("Accept", "application/vnd.github+json")
+req.Header.Set("User-Agent", "tinyauth/"+config.Version)

Also applies to: 121-121


20-23: Prefer verified primary email; fall back sensibly.

GitHub’s email objects include a Verified flag. Prefer a verified primary; otherwise a verified email; finally fall back to first if none are verified.

Apply:

 type GithubEmailResponse []struct {
 	Email   string `json:"email"`
 	Primary bool   `json:"primary"`
+	Verified bool  `json:"verified"`
 }
@@
-	for _, email := range emails {
-		if email.Primary {
-			user.Email = email.Email
-			break
-		}
-	}
+	// Prefer verified primary
+	for _, email := range emails {
+		if email.Primary && email.Verified {
+			user.Email = email.Email
+			break
+		}
+	}
+	// If none selected, prefer any verified email
+	if user.Email == "" {
+		for _, email := range emails {
+			if email.Verified {
+				user.Email = email.Email
+				break
+			}
+		}
+	}
@@
-	// Use first available email if no primary email was found
+	// Use first available email if no verified email was found
 	if user.Email == "" {
 		user.Email = emails[0].Email
 	}

Also applies to: 145-159


100-103: Improve error messages with endpoint context.

Include the endpoint path in the error to simplify troubleshooting.

Apply:

-	if res.StatusCode < 200 || res.StatusCode >= 300 {
-		return user, fmt.Errorf("request failed with status: %s", res.Status)
-	}
+	if res.StatusCode < 200 || res.StatusCode >= 300 {
+		return user, fmt.Errorf("github GET /user failed: %s", res.Status)
+	}
@@
-	if res.StatusCode < 200 || res.StatusCode >= 300 {
-		return user, fmt.Errorf("request failed with status: %s", res.Status)
-	}
+	if res.StatusCode < 200 || res.StatusCode >= 300 {
+		return user, fmt.Errorf("github GET /user/emails failed: %s", res.Status)
+	}

Also applies to: 129-132


37-47: Allow overriding default scopes from config.

Hard-coding GithubOAuthScopes limits flexibility. Use config.Scopes when provided, fallback to defaults otherwise.

Apply:

 func NewGithubOAuthService(config config.OAuthServiceConfig) *GithubOAuthService {
+	scopes := config.Scopes
+	if len(scopes) == 0 {
+		scopes = GithubOAuthScopes
+	}
 	return &GithubOAuthService{
 		Config: oauth2.Config{
 			ClientID:     config.ClientID,
 			ClientSecret: config.ClientSecret,
 			RedirectURL:  config.RedirectURL,
-			Scopes:       GithubOAuthScopes,
+			Scopes:       scopes,
 			Endpoint:     endpoints.GitHub,
 		},
 	}
 }

67-69: Minor: AccessTypeOffline is Google-specific; safe to drop for GitHub.

GitHub ignores access_type; leaving it is harmless but unnecessary.

Apply:

-return github.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(github.Verifier))
+return github.Config.AuthCodeURL(state, oauth2.S256ChallengeOption(github.Verifier))
internal/config/config.go (2)

14-16: Use constants for cookie names to prevent accidental mutation.

These identifiers are effectively constants; switching to const improves safety.

Apply:

-var SessionCookieName = "tinyauth-session"
-var CSRFCookieName = "tinyauth-csrf"
-var RedirectCookieName = "tinyauth-redirect"
+const SessionCookieName = "tinyauth-session"
+const CSRFCookieName = "tinyauth-csrf"
+const RedirectCookieName = "tinyauth-redirect"

50-51: Typo: FogotPasswordMessage (consider future rename plan).

Spelling is off; consider introducing ForgotPasswordMessage in a future release, maintaining mapstructure compatibility or migration logic.

Happy to sketch a migration approach that preserves mapstructure compatibility and avoids breaking consumers.

internal/service/auth_service.go (2)

93-105: LDAP search: guard against empty DN results.

If Search returns no error but an empty DN, subsequent Bind will fail ambiguously. Treat empty DN as “unknown”.

Apply:

 	if auth.LDAP != nil {
 		userDN, err := auth.LDAP.Search(username)
 
 		if err != nil {
 			log.Warn().Err(err).Str("username", username).Msg("Failed to search for user in LDAP")
 			return config.UserSearch{}
 		}
 
-		return config.UserSearch{
+		if strings.TrimSpace(userDN) == "" {
+			return config.UserSearch{Type: "unknown"}
+		}
+		return config.UserSearch{
 			Username: userDN,
 			Type:     "ldap",
 		}
 	}

330-334: Magic string “generic”: consider a typed constant.

Makes provider checks less error-prone.

Apply:

-	if context.Provider != "generic" {
+	const providerGeneric = "generic"
+	if context.Provider != providerGeneric {
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between d3c40bb and a5e1ae0.

📒 Files selected for processing (19)
  • cmd/root.go (3 hunks)
  • internal/assets/assets.go (1 hunks)
  • internal/bootstrap/app_bootstrap.go (1 hunks)
  • internal/config/config.go (5 hunks)
  • internal/controller/oauth_controller.go (1 hunks)
  • internal/controller/proxy_controller.go (1 hunks)
  • internal/controller/resources_controller.go (1 hunks)
  • internal/controller/user_controller.go (1 hunks)
  • internal/middleware/context_middleware.go (1 hunks)
  • internal/middleware/ui_middleware.go (1 hunks)
  • internal/middleware/zerolog_middleware.go (1 hunks)
  • internal/service/auth_service.go (14 hunks)
  • internal/service/generic_oauth_service.go (1 hunks)
  • internal/service/github_oauth_service.go (1 hunks)
  • internal/service/google_oauth_service.go (1 hunks)
  • internal/utils/app_utils.go (1 hunks)
  • internal/utils/label_utils.go (1 hunks)
  • internal/utils/user_utils.go (1 hunks)
  • main.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (6)
  • internal/utils/label_utils.go
  • internal/middleware/zerolog_middleware.go
  • internal/service/google_oauth_service.go
  • internal/controller/proxy_controller.go
  • main.go
  • internal/utils/user_utils.go
🧰 Additional context used
🧬 Code graph analysis (12)
internal/middleware/context_middleware.go (7)
internal/config/config.go (2)
  • Config (18-59)
  • UserContext (123-133)
internal/service/auth_service.go (1)
  • AuthService (37-44)
internal/service/oauth_broker_service.go (1)
  • OAuthBrokerService (18-21)
internal/bootstrap/app_bootstrap.go (1)
  • Middleware (20-23)
internal/utils/string_utils.go (1)
  • Capitalize (7-12)
internal/handlers/context.go (1)
  • AppContextHandler (10-35)
internal/hooks/hooks.go (1)
  • UseUserContext (30-144)
internal/service/github_oauth_service.go (2)
internal/config/config.go (3)
  • Config (18-59)
  • OAuthServiceConfig (92-101)
  • Claims (3-8)
internal/providers/github.go (2)
  • GithubScopes (26-28)
  • GetGithubUser (30-102)
internal/utils/app_utils.go (2)
internal/config/config.go (1)
  • UserContext (123-133)
internal/utils/utils.go (2)
  • GetUpperDomain (48-58)
  • ParseFileToLine (76-88)
internal/bootstrap/app_bootstrap.go (17)
internal/config/config.go (6)
  • Config (18-59)
  • SessionCookieName (14-14)
  • CSRFCookieName (15-15)
  • RedirectCookieName (16-16)
  • Version (10-10)
  • OAuthServiceConfig (92-101)
internal/utils/user_utils.go (1)
  • GetUsers (38-61)
internal/utils/app_utils.go (1)
  • GetUpperDomain (16-35)
internal/utils/security_utils.go (2)
  • GenerateIdentifier (120-124)
  • DeriveKey (52-68)
internal/service/auth_service.go (2)
  • AuthServiceConfig (24-35)
  • NewAuthService (46-53)
internal/service/ldap_service.go (3)
  • LdapService (23-26)
  • LdapServiceConfig (14-21)
  • NewLdapService (28-32)
internal/service/docker_service.go (1)
  • NewDockerService (21-23)
internal/service/oauth_broker_service.go (1)
  • NewOAuthBrokerService (23-28)
internal/middleware/context_middleware.go (2)
  • NewContextMiddleware (24-30)
  • ContextMiddlewareConfig (14-16)
internal/middleware/ui_middleware.go (1)
  • NewUIMiddleware (18-20)
internal/middleware/zerolog_middleware.go (1)
  • NewZerologMiddleware (21-23)
internal/controller/context_controller.go (2)
  • NewContextController (51-56)
  • ContextControllerConfig (35-44)
internal/controller/oauth_controller.go (2)
  • NewOAuthController (36-43)
  • OAuthControllerConfig (21-27)
internal/controller/proxy_controller.go (2)
  • NewProxyController (31-38)
  • ProxyControllerConfig (20-22)
internal/controller/user_controller.go (2)
  • NewUserController (34-40)
  • UserControllerConfig (24-26)
internal/controller/resources_controller.go (2)
  • NewResourcesController (19-27)
  • ResourcesControllerConfig (9-11)
internal/controller/health_controller.go (1)
  • NewHealthController (9-13)
internal/controller/user_controller.go (5)
internal/config/config.go (2)
  • Config (18-59)
  • SessionCookie (114-121)
internal/service/auth_service.go (1)
  • AuthService (37-44)
internal/utils/string_utils.go (1)
  • Capitalize (7-12)
internal/utils/app_utils.go (1)
  • GetContext (60-74)
internal/auth/auth.go (1)
  • CreateSessionCookie (219-253)
internal/service/generic_oauth_service.go (1)
internal/config/config.go (3)
  • Config (18-59)
  • OAuthServiceConfig (92-101)
  • Claims (3-8)
internal/config/config.go (1)
internal/ldap/ldap.go (1)
  • Config (15-18)
internal/controller/resources_controller.go (2)
internal/config/config.go (1)
  • Config (18-59)
internal/server/server.go (1)
  • NewServer (41-95)
cmd/root.go (4)
internal/config/config.go (1)
  • Config (18-59)
internal/utils/security_utils.go (1)
  • GetSecret (17-32)
internal/utils/app_utils.go (1)
  • GetLogLevel (104-123)
internal/bootstrap/app_bootstrap.go (1)
  • NewBootstrapApp (33-37)
internal/middleware/ui_middleware.go (3)
internal/assets/assets.go (1)
  • FrontendAssets (10-10)
internal/bootstrap/app_bootstrap.go (1)
  • Middleware (20-23)
internal/server/server.go (2)
  • NewServer (41-95)
  • zerolog (103-130)
internal/controller/oauth_controller.go (6)
internal/config/config.go (6)
  • CSRFCookieName (15-15)
  • RedirectCookieName (16-16)
  • Config (18-59)
  • UnauthorizedQuery (135-140)
  • SessionCookie (114-121)
  • RedirectQuery (142-144)
internal/service/auth_service.go (1)
  • AuthService (37-44)
internal/service/oauth_broker_service.go (1)
  • OAuthBrokerService (18-21)
internal/utils/app_utils.go (1)
  • IsRedirectSafe (76-102)
internal/utils/string_utils.go (2)
  • Capitalize (7-12)
  • CoalesceToString (14-30)
internal/oauth/oauth.go (2)
  • NewOAuth (20-44)
  • GetAuthURL (46-48)
internal/service/auth_service.go (4)
internal/config/config.go (7)
  • User (103-107)
  • SessionCookieName (14-14)
  • Config (18-59)
  • UserSearch (109-112)
  • SessionCookie (114-121)
  • UserContext (123-133)
  • Labels (82-90)
internal/service/docker_service.go (1)
  • DockerService (16-19)
internal/service/ldap_service.go (1)
  • LdapService (23-26)
internal/utils/security_utils.go (2)
  • CheckFilter (93-118)
  • FilterIP (70-91)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Analyze (go)
🔇 Additional comments (4)
internal/utils/app_utils.go (1)

104-123: Trace log level mapping — good catch

Explicitly handling "trace" aligns with config validation and zerolog. Looks good.

internal/bootstrap/app_bootstrap.go (1)

47-59: The script above will locate GenerateIdentifier in the internal/utils package so we can inspect if its behavior alongside GetUpperDomain properly handles apex domains, localhost, and IPs. Once we have that, we can confirm whether the original concerns about broken cookie names hold or if any additional adjustments are needed.

internal/config/config.go (1)

92-101: Nice: OAuthServiceConfig consolidates provider settings.

The shape looks good and aligns with the new services.

internal/service/auth_service.go (1)

300-308: LGTM: session cookie extraction and validation logic is clear.

Checks for required fields and expiry are straightforward.

Comment thread cmd/root.go Outdated
Comment thread cmd/root.go Outdated
Comment thread internal/controller/user_controller.go Outdated
Comment thread internal/controller/user_controller.go
Comment thread internal/service/github_oauth_service.go
Comment thread internal/service/github_oauth_service.go
Comment thread internal/service/github_oauth_service.go
Comment thread internal/utils/app_utils.go
@steveiliop56
Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Aug 26, 2025

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (9)
internal/service/github_oauth_service.go (4)

61-69: Right-size the OAuth state; 32 random bytes are sufficient

128 random bytes produce a ~172-char URL param and add no material security. 32 bytes is standard and plenty.

 func (github *GithubOAuthService) GenerateState() string {
-	b := make([]byte, 128)
+	b := make([]byte, 32)
 	_, err := rand.Read(b)
 	if err != nil {
 		return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "state-%d", time.Now().UnixNano()))
 	}
 	state := base64.RawURLEncoding.EncodeToString(b)
 	return state
 }

104-106: Improve error messages with endpoint context and body snippet

Current error loses which endpoint failed and any API error detail. Include the path and a short body snippet for diagnostics.

-	if res.StatusCode < 200 || res.StatusCode >= 300 {
-		return user, fmt.Errorf("request failed with status: %s", res.Status)
-	}
+	if res.StatusCode < 200 || res.StatusCode >= 300 {
+		msg, _ := io.ReadAll(io.LimitReader(res.Body, 2048))
+		return user, fmt.Errorf("GET /user failed: %s: %s", res.Status, string(msg))
+	}
@@
-	if res.StatusCode < 200 || res.StatusCode >= 300 {
-		return user, fmt.Errorf("request failed with status: %s", res.Status)
-	}
+	if res.StatusCode < 200 || res.StatusCode >= 300 {
+		msg, _ := io.ReadAll(io.LimitReader(res.Body, 2048))
+		return user, fmt.Errorf("GET /user/emails failed: %s: %s", res.Status, string(msg))
+	}

Also applies to: 133-135


50-58: Add HTTP client timeout to avoid indefinite hangs

The OAuth flow and subsequent API calls can hang forever on network issues. Set a sane timeout on the HTTP client used in the context.

 func (github *GithubOAuthService) Init() error {
-	httpClient := &http.Client{}
+	httpClient := &http.Client{Timeout: 15 * time.Second}
 	ctx := context.Background()
 	ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
 	verifier := oauth2.GenerateVerifier()

86-92: Guard against missing/invalid token before building the client

If VerifyCode wasn’t called (or failed), Token may be nil/invalid. Fail fast with a clear error.

 func (github *GithubOAuthService) Userinfo() (config.Claims, error) {
 	var user config.Claims
 
-	client := github.Config.Client(github.Context, github.Token)
+	if github.Token == nil || !github.Token.Valid() || github.Token.AccessToken == "" {
+		return user, errors.New("github oauth: token not present; call VerifyCode first")
+	}
+	client := github.Config.Client(github.Context, github.Token)
cmd/root.go (3)

7-12: Correct validator import path and shadowing fix — LGTM

Importing validator via github.com/go-playground/validator/v10 and using a local variable v avoids the earlier shadowing and wrong module path issues. Thanks for addressing this.


36-41: Proper validator instantiation and struct validation

Using v := validator.New() and v.Struct(conf) is correct and addresses the earlier shadowing concern.


73-118: Secure-cookie flag name now matches config key — consider a backward-compat alias

Renaming the flag to secure-cookie fixes the prior mismatch with mapstructure:"secure-cookie". To avoid breaking existing deployments that still export COOKIE_SECURE or use --cookie-secure, add a deprecated flag + viper alias so the old inputs continue to work.

Proposed changes (place near the flag/env binding, before viper.BindPFlags):

@@
 	for _, opt := range configOptions {
@@
 		viper.BindEnv(opt.name, envVar)
 	}
 
+	// Backward-compat for the previous "cookie-secure" naming
+	// 1) Add a deprecated hidden flag so existing scripts don't break
+	rootCmd.Flags().Bool("cookie-secure", false, "[DEPRECATED] use --secure-cookie")
+	_ = rootCmd.Flags().MarkHidden("cookie-secure")
+	// 2) Bind the legacy env var and alias it to the new key
+	_ = viper.BindEnv("cookie-secure", "COOKIE_SECURE")
+	viper.RegisterAlias("secure-cookie", "cookie-secure")
internal/service/google_oauth_service.go (1)

33-43: Reuse internal/oauth.NewOAuth to inherit TLS hardening, client plumbing, and PKCE (and wire InsecureSkipVerify).

This file re-implements OAuth client/context/PKCE. You already ship internal/oauth.NewOAuth that centralizes these concerns. Compose/Embed it and pass through InsecureSkipVerify from config.

Apply this refactor:

@@
-import (
-  "context"
-  "crypto/rand"
-  "encoding/base64"
-  "encoding/json"
-  "fmt"
-  "io"
-  "net/http"
-  "strings"
-  "time"
-  "tinyauth/internal/config"
-
-  "golang.org/x/oauth2"
-  "golang.org/x/oauth2/endpoints"
-)
+import (
+  "context"
+  "crypto/rand"
+  "encoding/base64"
+  "encoding/json"
+  "fmt"
+  "io"
+  "strings"
+  "time"
+  "tinyauth/internal/config"
+  taoauth "tinyauth/internal/oauth"
+
+  "golang.org/x/oauth2"
+  "golang.org/x/oauth2/endpoints"
+)
@@
-type GoogleOAuthService struct {
-  Config   oauth2.Config
-  Context  context.Context
-  Token    *oauth2.Token
-  Verifier string
-}
+type GoogleOAuthService struct {
+  taoauth.OAuth           // embeds Config, Context, Verifier
+  Token             *oauth2.Token
+  InsecureSkipVerify bool
+}
@@
-func NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService {
-  return &GoogleOAuthService{
-    Config: oauth2.Config{
-      ClientID:     config.ClientID,
-      ClientSecret: config.ClientSecret,
-      RedirectURL:  config.RedirectURL,
-      Scopes:       GoogleOAuthScopes,
-      Endpoint:     endpoints.Google,
-    },
-  }
-}
+func NewGoogleOAuthService(cfg config.OAuthServiceConfig) *GoogleOAuthService {
+  scopes := GoogleOAuthScopes
+  if len(cfg.Scopes) > 0 {
+    scopes = cfg.Scopes
+  }
+  svc := &GoogleOAuthService{
+    InsecureSkipVerify: cfg.InsecureSkipVerify,
+  }
+  // Initialize embedded OAuth
+  svc.OAuth = *taoauth.NewOAuth(oauth2.Config{
+    ClientID:     cfg.ClientID,
+    ClientSecret: cfg.ClientSecret,
+    RedirectURL:  cfg.RedirectURL,
+    Scopes:       scopes,
+    Endpoint:     endpoints.Google,
+  }, cfg.InsecureSkipVerify)
+  return svc
+}
@@
-func (google *GoogleOAuthService) Init() error {
-  httpClient := &http.Client{}
-  ctx := context.Background()
-  ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
-  verifier := oauth2.GenerateVerifier()
-
-  google.Context = ctx
-  google.Verifier = verifier
-  return nil
-}
+// Init no longer needed; retained for interface compatibility.
+func (google *GoogleOAuthService) Init() error { return nil }

This keeps downstream code working while consolidating HTTP/TLS/PKCE in one place.

Also applies to: 45-54

internal/service/auth_service.go (1)

55-67: Validate secrets and set robust cookie options (Domain and SameSite).

  • EncryptionSecret must be 16/24/32 bytes for AES; validate early.
  • Ensure HMACSecret has sufficient entropy (e.g., >= 32 bytes).
  • Don’t prefix Domain with “.”; set as-is or leave empty for host-only.
  • Add SameSite=Lax to mitigate CSRF on session cookie.

Apply:

 func (auth *AuthService) Init() error {
-  store := sessions.NewCookieStore([]byte(auth.Config.HMACSecret), []byte(auth.Config.EncryptionSecret))
+  // Validate key lengths
+  if l := len(auth.Config.EncryptionSecret); l != 0 && l != 16 && l != 24 && l != 32 {
+    return fmt.Errorf("invalid EncryptionSecret length: got %d, want 16/24/32", l)
+  }
+  if len(auth.Config.HMACSecret) < 32 {
+    return fmt.Errorf("HMACSecret too short: got %d, want >= 32", len(auth.Config.HMACSecret))
+  }
+  store := sessions.NewCookieStore([]byte(auth.Config.HMACSecret), []byte(auth.Config.EncryptionSecret))
   store.Options = &sessions.Options{
     Path:     "/",
     MaxAge:   auth.Config.SessionExpiry,
     Secure:   auth.Config.SecureCookie,
     HttpOnly: true,
-    Domain:   fmt.Sprintf(".%s", auth.Config.Domain),
+    // Use configured domain as-is; empty means host-only cookie (ideal for localhost)
+    Domain:   auth.Config.Domain,
+    SameSite: http.SameSiteLaxMode,
   }
 
   auth.Store = store
   return nil
 }

Add missing import:

 import (
   "fmt"
   "regexp"
   "strings"
   "sync"
   "time"
   "tinyauth/internal/config"
   "tinyauth/internal/utils"
+  "net/http"
🧹 Nitpick comments (11)
internal/service/github_oauth_service.go (3)

71-73: Drop AccessTypeOffline for GitHub

The access_type=offline hint is for providers like Google; GitHub ignores it. Keep PKCE, remove the extra param.

 func (github *GithubOAuthService) GetAuthURL(state string) string {
-	return github.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(github.Verifier))
+	return github.Config.AuthCodeURL(state, oauth2.S256ChallengeOption(github.Verifier))
 }

91-98: Add context and User-Agent to GitHub API requests

  • Bind requests to the service context for cancellation.
  • GitHub recommends sending a descriptive User-Agent.
 req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
 if err != nil {
 	return user, err
 }
 
-req.Header.Set("Accept", "application/vnd.github+json")
+req = req.WithContext(github.Context)
+req.Header.Set("Accept", "application/vnd.github+json")
+req.Header.Set("User-Agent", "tinyauth/1.0 (+https://github.com/steveiliop56/tinyauth)")
 
 res, err := client.Do(req)
@@
-req, err = http.NewRequest("GET", "https://api.github.com/user/emails", nil)
+req, err = http.NewRequest("GET", "https://api.github.com/user/emails", nil)
 if err != nil {
 	return user, err
 }
 
-req.Header.Set("Accept", "application/vnd.github+json")
+req = req.WithContext(github.Context)
+req.Header.Set("Accept", "application/vnd.github+json")
+req.Header.Set("User-Agent", "tinyauth/1.0 (+https://github.com/steveiliop56/tinyauth)")
 
 res, err = client.Do(req)

Also applies to: 120-127


149-164: Email selection logic is solid; consider preferring verified emails

Looks good: primary first, else first available. If you want to be stricter, include a Verified flag in GithubEmailResponse and prefer primary+verified before falling back.

 type GithubEmailResponse []struct {
 	Email   string `json:"email"`
 	Primary bool   `json:"primary"`
+	Verified bool  `json:"verified"`
 }
@@
-	for _, email := range emails {
-		if email.Primary {
+	for _, email := range emails {
+		if email.Primary && email.Verified {
 			user.Email = email.Email
 			break
 		}
 	}
 
 	if len(emails) == 0 {
 		return user, errors.New("no emails found")
 	}
 
 	// Use first available email if no primary email was found
 	if user.Email == "" {
-		user.Email = emails[0].Email
+		// Prefer the first verified email if present
+		for _, e := range emails {
+			if e.Verified {
+				user.Email = e.Email
+				break
+			}
+		}
+		if user.Email == "" {
+			user.Email = emails[0].Email
+		}
 	}
cmd/root.go (1)

44-46: Nit: remove redundant type cast; optionally set a global log level

utils.GetLogLevel already returns zerolog.Level, so the explicit zerolog.Level(...) cast is unnecessary. If multiple loggers are created elsewhere, consider setting the global level as well.

Minimal cleanup:

-		log.Logger = log.Level(zerolog.Level(utils.GetLogLevel(conf.LogLevel)))
+		log.Logger = log.Level(utils.GetLogLevel(conf.LogLevel))

Optional broader improvement (ensure consistent filtering across all loggers):

+		zerolog.SetGlobalLevel(utils.GetLogLevel(conf.LogLevel))
-		log.Logger = log.Level(zerolog.Level(utils.GetLogLevel(conf.LogLevel)))
+		log.Logger = log.Level(utils.GetLogLevel(conf.LogLevel))
internal/service/ldap_service.go (2)

57-66: Harden connection setup: timeouts and StartTLS support (optional).

  • DialURL without a custom Dialer has no explicit connect/read timeouts.
  • For ldap:// endpoints, consider StartTLS to avoid cleartext binds when possible.

Sketch:

 import (
   "context"
   "crypto/tls"
   "fmt"
+  "net"
+  "strings"
   "time"
@@
- conn, err := ldapgo.DialURL(ldap.Config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
-   InsecureSkipVerify: ldap.Config.Insecure,
-   MinVersion:         tls.VersionTLS12,
- }))
+ dialer := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}
+ tlsCfg := &tls.Config{InsecureSkipVerify: ldap.Config.Insecure, MinVersion: tls.VersionTLS12}
+ conn, err := ldapgo.DialURL(
+   ldap.Config.Address,
+   ldapgo.DialWithDialer(dialer),
+   ldapgo.DialWithTLSConfig(tlsCfg),
+ )
  if err != nil {
    return nil, err
  }
+ // Upgrade to TLS when using ldap:// if desired
+ if strings.HasPrefix(strings.ToLower(ldap.Config.Address), "ldap://") && !ldap.Config.Insecure {
+   if err := conn.StartTLS(tlsCfg); err != nil {
+     _ = conn.Close()
+     return nil, fmt.Errorf("starttls failed: %w", err)
+   }
+ }

76-83: Sanity-check SearchFilter format to prevent surprising searches.

If SearchFilter doesn’t contain a %s placeholder, fmt.Sprintf will ignore the username and search broadly. Consider validating once at Init().

Example:

 func (ldap *LdapService) Init() error {
   _, err := ldap.connect()
   if err != nil {
     return fmt.Errorf("failed to connect to LDAP server: %w", err)
   }
+  if !strings.Contains(ldap.Config.SearchFilter, "%s") {
+    log.Warn().Str("filter", ldap.Config.SearchFilter).Msg("LDAP SearchFilter lacks %s placeholder; username will be ignored")
+  }
   go func() {

Remember to import "strings".

internal/service/google_oauth_service.go (3)

81-90: Nil-token guard in Userinfo to avoid unauthenticated requests.

If VerifyCode didn’t set Token (unexpected path or error handling elsewhere), Client(...) will inject no auth and the call will 401.

 func (google *GoogleOAuthService) Userinfo() (config.Claims, error) {
   var user config.Claims
 
-  client := google.Config.Client(google.Context, google.Token)
+  if google.Token == nil {
+    return config.Claims{}, fmt.Errorf("no token set; call VerifyCode first")
+  }
+  client := google.Config.Client(google.Context, google.Token)

56-64: Consistent receiver naming.

Only GenerateState uses receiver name oauth; others use google. Rename for consistency.

-func (oauth *GoogleOAuthService) GenerateState() string {
+func (google *GoogleOAuthService) GenerateState() string {
   b := make([]byte, 128)
   _, err := rand.Read(b)
   if err != nil {
     return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "state-%d", time.Now().UnixNano()))
   }
   state := base64.RawURLEncoding.EncodeToString(b)
   return state
 }

35-41: Honor configured scopes when provided.

NewGoogleOAuthService ignores cfg.Scopes. Default to GoogleOAuthScopes only when cfg.Scopes is empty.

-      Scopes:       GoogleOAuthScopes,
+      Scopes:       func() []string { if len(config.Scopes) > 0 { return config.Scopes }; return GoogleOAuthScopes }(),

If you adopt the larger refactor above, this change is already included there.

internal/controller/oauth_controller.go (2)

109-113: Clear CSRF cookie on mismatch to avoid stale tokens.

When the CSRF check fails, clear the cookie before redirecting.

   if err != nil || state != csrfCookie {
     log.Warn().Err(err).Msg("CSRF token mismatch or cookie missing")
-    c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL))
+    c.SetCookie(controller.Config.CSRFCookieName, "", -1, "/", controller.Config.Domain, controller.Config.SecureCookie, true)
+    c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL))
     return
   }

168-170: Guard name fallback against malformed emails.

Split on “@” assumes a valid address; add a safe fallback to avoid index errors.

- name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1])
+ local, domain, ok := strings.Cut(user.Email, "@")
+ if !ok {
+   name = user.Email
+ } else {
+   name = fmt.Sprintf("%s (%s)", utils.Capitalize(local), domain)
+ }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a5e1ae0 and a1b6ecd.

📒 Files selected for processing (9)
  • cmd/root.go (3 hunks)
  • internal/controller/oauth_controller.go (1 hunks)
  • internal/controller/user_controller.go (1 hunks)
  • internal/service/auth_service.go (14 hunks)
  • internal/service/generic_oauth_service.go (1 hunks)
  • internal/service/github_oauth_service.go (1 hunks)
  • internal/service/google_oauth_service.go (1 hunks)
  • internal/service/ldap_service.go (5 hunks)
  • internal/utils/security_utils.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/controller/user_controller.go
  • internal/service/generic_oauth_service.go
  • internal/utils/security_utils.go
🧰 Additional context used
🧬 Code graph analysis (6)
internal/service/github_oauth_service.go (2)
internal/config/config.go (3)
  • Config (18-59)
  • OAuthServiceConfig (92-101)
  • Claims (3-8)
internal/providers/github.go (2)
  • GithubScopes (26-28)
  • GetGithubUser (30-102)
internal/service/google_oauth_service.go (3)
internal/config/config.go (3)
  • Config (18-59)
  • OAuthServiceConfig (92-101)
  • Claims (3-8)
internal/oauth/oauth.go (2)
  • NewOAuth (20-44)
  • GetAuthURL (46-48)
internal/providers/google.go (1)
  • GetGoogleUser (24-56)
internal/service/ldap_service.go (1)
internal/config/config.go (1)
  • Config (18-59)
internal/service/auth_service.go (4)
internal/config/config.go (7)
  • User (103-107)
  • SessionCookieName (14-14)
  • Config (18-59)
  • UserSearch (109-112)
  • SessionCookie (114-121)
  • UserContext (123-133)
  • Labels (82-90)
internal/service/docker_service.go (1)
  • DockerService (16-19)
internal/service/ldap_service.go (1)
  • LdapService (23-26)
internal/utils/security_utils.go (2)
  • CheckFilter (93-118)
  • FilterIP (70-91)
internal/controller/oauth_controller.go (5)
internal/config/config.go (6)
  • CSRFCookieName (15-15)
  • RedirectCookieName (16-16)
  • Config (18-59)
  • UnauthorizedQuery (135-140)
  • SessionCookie (114-121)
  • RedirectQuery (142-144)
internal/service/auth_service.go (1)
  • AuthService (37-44)
internal/service/oauth_broker_service.go (1)
  • OAuthBrokerService (18-21)
internal/utils/app_utils.go (1)
  • IsRedirectSafe (76-102)
internal/utils/string_utils.go (2)
  • Capitalize (7-12)
  • CoalesceToString (14-30)
cmd/root.go (4)
internal/config/config.go (1)
  • Config (18-59)
internal/utils/security_utils.go (1)
  • GetSecret (17-32)
internal/utils/app_utils.go (1)
  • GetLogLevel (104-123)
internal/bootstrap/app_bootstrap.go (1)
  • NewBootstrapApp (33-37)
🔇 Additional comments (9)
internal/service/github_oauth_service.go (1)

75-84: Good fix: propagate Exchange errors

Returning the exchange error ensures callers can handle failures correctly. Nice.

cmd/root.go (4)

25-28: Config unmarshal + fail-fast error handling looks solid

Unmarshalling into config.Config and exiting on failure with log.Fatal is appropriate for startup-time config errors.


30-34: Secret resolution precedence handled correctly

Resolving Secret and the OAuth client secrets via utils.GetSecret before validation ensures file-based secrets are honored and the validator sees final values.


47-55: Bootstrap setup and error path — LGTM

Delegating to bootstrap.NewBootstrapApp(conf).Setup() with a clear fatal on error keeps the entrypoint clean.


120-136: Flag/env binding loop is clean and scalable

The data-driven registration plus explicit BindEnv with hyphen→underscore mapping is neat and maintainable.

internal/controller/oauth_controller.go (1)

79-85: Open-redirect hardening looks good (using IsRedirectSafe).

You now validate redirect_uri both when setting and consuming the cookie. This addresses the previous open-redirect risk.

Also applies to: 180-186

internal/service/auth_service.go (3)

183-217: Rate limiter logic is solid; concurrency handled correctly.

Clear separation of RLock in reads and Lock in updates; lock time computed in seconds from config. Looks good.


112-140: LDAP rebind pattern relies on a shared connection; confirm concurrency expectations.

VerifyUser binds as the user, then rebinds with the service account. If LDAP service is used concurrently, this can clash. The mutex fix in LdapService will mitigate, but if you anticipate high concurrency, consider per-request connections.

Would you like me to sketch a small connection pool for LDAP binds to avoid global rebind entirely?


353-372: AuthEnabled error handling verified
The only call site in internal/controller/proxy_controller.go (lines 136–144) checks the returned err and responds with a 500 or redirect, so invalid-regex errors cannot be silently ignored. No further changes are needed.

Comment thread internal/controller/oauth_controller.go
Comment thread internal/controller/oauth_controller.go
Comment thread internal/service/auth_service.go
Comment thread internal/service/ldap_service.go
Comment thread internal/service/ldap_service.go
@steveiliop56 steveiliop56 merged commit 504a3b8 into main Aug 26, 2025
8 checks passed
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/service/ldap_service.go (1)

110-128: Make heartbeat concurrency-safe and resilient to nil connection.

Avoid nil deref and races by snapshotting the conn under RLock.

-	_, err := ldap.Conn.Search(searchRequest)
+	ldap.connMu.RLock()
+	conn := ldap.Conn
+	ldap.connMu.RUnlock()
+	if conn == nil {
+		return errors.New("ldap connection is not established")
+	}
+	_, err := conn.Search(searchRequest)
internal/config/config.go (1)

21-21: Use the supported IPv4 validator tag

No custom ip4_addr validator was registered in the codebase, so go-playground/validator will reject the unknown ip4_addr tag and fail to validate valid IPv4 addresses. Update the tag to the built-in ipv4 validator:

• internal/config/config.go (around line 20)

-   Address string `validate:"required,ip4_addr" mapstructure:"address"`
+   Address string `validate:"required,ipv4"   mapstructure:"address"`
♻️ Duplicate comments (26)
frontend/src/pages/login-page.tsx (1)

68-68: Login POST: consider sending cookies explicitly (withCredentials) and re-check backend route wiring

If auth is cookie-based or sets CSRF cookies on login, passing withCredentials avoids surprises in cross-origin deployments. Dev proxy makes this same-origin, but prod/staging may not. Also do a quick pass to ensure POST /api/user/login is actually registered server-side.

Apply this diff locally:

-    mutationFn: (values: LoginSchema) => axios.post("/api/user/login", values),
+    mutationFn: (values: LoginSchema) =>
+      axios.post("/api/user/login", values, { withCredentials: true }),

Run to verify global config and backend route:

#!/bin/bash
set -euo pipefail

echo "1) Frontend: do we already set axios withCredentials globally?"
rg -nP '(axios\.defaults\.withCredentials|axios\.create\(\s*\{[^}]*withCredentials\s*:)' --type=ts --type=tsx || true

echo -e "\n2) Backend: confirm user login route exists"
rg -nPU -C3 '(?i)\b(user/login)\b' --type=go || true
.env.example (1)

5-9: Reorder SECURE_COOKIE before USERS to satisfy dotenv-linter and reduce upgrade friction

Also, ensure README/CHANGELOG clearly notes the COOKIE_SECURE → SECURE_COOKIE rename so operators update their envs.

Apply this reordering:

 APP_URL=http://localhost:3000
-USERS=your_user_password_hash
-USERS_FILE=users_file
-SECURE_COOKIE=false
+SECURE_COOKIE=false
+USERS=your_user_password_hash
+USERS_FILE=users_file

I can draft a short migration blurb for the docs if you want.

internal/service/ldap_service.go (4)

130-155: Fix backoff usage (wrong signature), add nil check on Close(), and synchronize reconnect.

As written, this won’t compile (operation returns (*Conn, error); WithMaxTries doesn’t exist in v5) and can panic on Close() when Conn is nil. Also needs locking around replacement.

 func (ldap *LdapService) reconnect() error {
@@
-	operation := func() (*ldapgo.Conn, error) {
-		ldap.Conn.Close()
-		conn, err := ldap.connect()
-		if err != nil {
-			return nil, err
-		}
-		return conn, nil
-	}
+	operation := func() error {
+		ldap.connMu.Lock()
+		if ldap.Conn != nil {
+			_ = ldap.Conn.Close()
+			ldap.Conn = nil
+		}
+		ldap.connMu.Unlock()
+		_, err := ldap.connect() // connect() sets ldap.Conn on success
+		return err
+	}
@@
-	_, err := backoff.Retry(context.TODO(), operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))
+	err := backoff.Retry(context.Background(), operation, backoff.WithBackOff(exp), backoff.WithMaxRetries(3))

3-12: Add missing imports for concurrency and error handling (sync, errors).

Upcoming fixes require sync.RWMutex and creating errors.

@@
-import (
-	"context"
-	"crypto/tls"
-	"fmt"
-	"time"
+import (
+	"context"
+	"crypto/tls"
+	"errors"
+	"fmt"
+	"sync"
+	"time"

23-26: Guard shared LDAP connection with RWMutex to prevent data races and mid-flight rebinding.

Search/Bind/heartbeat/reconnect concurrently use and replace ldap.Conn without synchronization. This can race, causing transient failures or panics.

 type LdapService struct {
 	Config LdapServiceConfig
-	Conn   *ldapgo.Conn
+	// Protects access and replacement of Conn
+	connMu sync.RWMutex
+	Conn   *ldapgo.Conn
 }

57-74: Lock assignment of the shared connection inside connect().

Ensure connection replacement is atomic and visible safely to readers.

 func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
-	conn, err := ldapgo.DialURL(ldap.Config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
+	if ldap.Config.Insecure {
+		log.Warn().Msg("LDAP TLS certificate verification is disabled (InsecureSkipVerify=true)")
+	}
+	conn, err := ldapgo.DialURL(ldap.Config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
 		InsecureSkipVerify: ldap.Config.Insecure,
 		MinVersion:         tls.VersionTLS12,
 	}))
@@
 	// Set and return the connection
-	ldap.Conn = conn
+	ldap.connMu.Lock()
+	ldap.Conn = conn
+	ldap.connMu.Unlock()
 	return conn, nil
 }
internal/assets/assets.go (1)

7-11: Preserve backward compatibility: keep a deprecated alias for FontendAssets.

If external or internal callers still reference the old misspelling, this avoids breakage.

 //go:embed dist
 var FrontendAssets embed.FS
+
+// Deprecated: use FrontendAssets.
+var FontendAssets = FrontendAssets

To confirm there are no lingering references to the old names:

#!/bin/bash
rg -nP -S '\bassets\.(Assets|FontendAssets)\b' -g '!**/vendor/**'
internal/controller/health_controller.go (1)

15-18: Align health routes with middleware skip list

The SetupRoutes method registers only the /health endpoints (and an optional /api/healthcheck alias), but the logging middleware’s loggerSkipPathsPrefix currently only skips "GET /api/health" and "HEAD /api/health". To prevent unnecessary log noise, we must update the skip list to include the actual registered paths.

Files to update:

  • internal/controller/health_controller.go — decide whether to keep the /api/healthcheck alias.
  • internal/middleware/zerolog_middleware.go — extend loggerSkipPathsPrefix.

Suggested diffs:

--- a/internal/controller/health_controller.go
+++ b/internal/controller/health_controller.go
@@ func (controller *HealthController) SetupRoutes() {
-	controller.Router.GET("/health", controller.healthHandler)
-	controller.Router.HEAD("/health", controller.healthHandler)
+	controller.Router.GET("/health", controller.healthHandler)
+	controller.Router.HEAD("/health", controller.healthHandler)
+	// Back-compat alias (remove if not needed)
+	controller.Router.GET("/api/healthcheck", controller.healthHandler)
+	controller.Router.HEAD("/api/healthcheck", controller.healthHandler)
--- a/internal/middleware/zerolog_middleware.go
+++ b/internal/middleware/zerolog_middleware.go
@@ var (
-	loggerSkipPathsPrefix = []string{
-		"GET /api/health",
-		"HEAD /api/health",
+	loggerSkipPathsPrefix = []string{
+		"GET /api/health",
+		"HEAD /api/health",
+		"GET /health",
+		"HEAD /health",
+		// Skip alias if retained
+		"GET /api/healthcheck",
+		"HEAD /api/healthcheck",
	}
)
internal/utils/user_utils.go (3)

12-16: Good fix: empty/whitespace-only input now returns an empty set, not an error.

This addresses the earlier edge case for blank input.


24-33: Nice: trims entries and skips empties.

This resolves parsing failures on trailing commas or blank items.


49-58: Good: file read errors are now propagated.

Callers will see the real cause instead of a generic parse error.

internal/controller/resources_controller.go (1)

19-27: Bug: StripPrefix ignores router BasePath; breaks under grouped routes (e.g., “/api”).

Pre-wrapping with "/resources" fails when the controller is mounted under a non-root base path, leading to 404s.

Apply this refactor to compute and cache the effective prefix and avoid per-request allocations:

@@
 package controller
 
 import (
 	"net/http"
+	"path"
 
 	"github.com/gin-gonic/gin"
 )
@@
 type ResourcesController struct {
 	Config     ResourcesControllerConfig
 	Router     *gin.RouterGroup
-	FileServer http.Handler
+	FileServer http.Handler
+	prefix     string
 }
 
 func NewResourcesController(config ResourcesControllerConfig, router *gin.RouterGroup) *ResourcesController {
-	fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.ResourcesDir)))
+	// cache raw file server; apply StripPrefix at serve time using computed prefix
+	fileServer := http.FileServer(http.Dir(config.ResourcesDir))
+	prefix := path.Join(router.BasePath(), "/resources")
 
 	return &ResourcesController{
 		Config:     config,
 		Router:     router,
-		FileServer: fileServer,
+		FileServer: fileServer,
+		prefix:     prefix,
 	}
 }
internal/utils/label_utils.go (1)

22-38: Solid: header parsing now trims, rejects blank entries, and canonicalizes keys.

This aligns with HTTP field-name rules and avoids CRLF issues in values.

internal/utils/app_utils.go (1)

22-35: Refactor GetUpperDomain to handle apex domains, IPs, and localhost correctly

The current implementation of GetUpperDomain in internal/utils/app_utils.go incorrectly:

  • Strips “example.com” down to “com”
  • Rejects valid single-label hosts (e.g. “localhost”) and IP addresses, which are useful in dev/test
  • Doesn’t distinguish between an empty host and other invalid inputs

Apply the following changes:

• File: internal/utils/app_utils.go
• Function: GetUpperDomain (lines ~22–35)

 host := appUrlParsed.Hostname()

-if netIP := net.ParseIP(host); netIP != nil {
-  return "", errors.New("IP addresses are not allowed")
-}
-
-urlParts := strings.Split(host, ".")
-
-if len(urlParts) < 2 {
-  return "", errors.New("invalid domain, must be at least second level domain")
-}
-
-return strings.Join(urlParts[1:], "."), nil
+// Empty host is always an error
+if host == "" {
+  return "", errors.New("empty host")
+}
+// Allow IPs and return them unchanged (helpful for localhost/dev URLs)
+if net.ParseIP(host) != nil {
+  return host, nil
+}
+parts := strings.Split(host, ".")
+// For apex domains (example.com) and single-label hosts, return unchanged
+if len(parts) < 3 {
+  return host, nil
+}
+// For deeper domains, drop only the left-most label
+return strings.Join(parts[1:], "."), nil

Consider adding table-driven tests covering:

  • https://sub.a.example.coma.example.com
  • https://example.comexample.com
  • http://localhost:8080localhost
  • http://127.0.0.1:3000127.0.0.1
  • malformed URLs (empty host or invalid URL) → error
internal/service/google_oauth_service.go (2)

45-53: Consider reusing shared OAuth plumbing to reduce surface area.

Leverage internal/oauth.NewOAuth (if present) for TLS hardening, timeouts, and PKCE to avoid divergent behavior across providers.

Also applies to: 66-68, 70-79, 81-113


81-89: Defensive check: guard against nil token before building the client.

If VerifyCode wasn’t called or failed, google.Token may be nil. Building the client without a token triggers underlying token retrieval attempts and confusing errors.

 func (google *GoogleOAuthService) Userinfo() (config.Claims, error) {
   var user config.Claims

-  client := google.Config.Client(google.Context, google.Token)
+  if google.Token == nil {
+    return config.Claims{}, fmt.Errorf("no token set; call VerifyCode first")
+  }
+  client := google.Config.Client(google.Context, google.Token)
internal/controller/proxy_controller.go (2)

123-136: Good fix: return after redirect to avoid double-redirect path

The early return after the error redirect prevents subsequent redirects from running. Matches prior feedback.


215-219: Username displayed on unauthorized page now matches legacy behavior

For OAuth you now pass email; for basic you pass username. This restores expected UX.

Also applies to: 250-254

internal/controller/oauth_controller.go (1)

75-85: Do not prefix cookie Domain with a dot; allow empty for host-only cookies

Using fmt.Sprintf(".%s", domain) yields "." on localhost and is non-standard. Pass the domain as-is; empty yields host-only cookie.

- c.SetCookie(controller.Config.CSRFCookieName, state, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.Config.Domain), controller.Config.SecureCookie, true)
+ c.SetCookie(controller.Config.CSRFCookieName, state, int(time.Hour.Seconds()), "/", controller.Config.Domain, controller.Config.SecureCookie, true)
@@
- c.SetCookie(controller.Config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.Config.Domain), controller.Config.SecureCookie, true)
+ c.SetCookie(controller.Config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", controller.Config.Domain, controller.Config.SecureCookie, true)
@@
- c.SetCookie(controller.Config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.Config.Domain), controller.Config.SecureCookie, true)
+ c.SetCookie(controller.Config.CSRFCookieName, "", -1, "/", controller.Config.Domain, controller.Config.SecureCookie, true)
@@
- c.SetCookie(controller.Config.RedirectCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.Config.Domain), controller.Config.SecureCookie, true)
+ c.SetCookie(controller.Config.RedirectCookieName, "", -1, "/", controller.Config.Domain, controller.Config.SecureCookie, true)

Also applies to: 112-116, 198-199

internal/service/github_oauth_service.go (3)

61-68: Shorten state size and keep padding-free encoding; simplify fallback

128 random bytes produce a very long state (~172 URL chars). 32 bytes are plenty. Also, fmt.Appendf is unusual here; Sprintf is simpler.

 func (github *GithubOAuthService) GenerateState() string {
-	b := make([]byte, 128)
+	b := make([]byte, 32)
 	_, err := rand.Read(b)
 	if err != nil {
-		return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "state-%d", time.Now().UnixNano()))
+		return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("state-%d", time.Now().UnixNano())))
 	}
 	state := base64.RawURLEncoding.EncodeToString(b)
 	return state
 }

50-57: Add an HTTP client timeout to avoid indefinite hangs

The default http.Client has no timeout; network stalls can hang your OAuth flow forever. Set a sane timeout (e.g., 15s).

Apply:

 func (github *GithubOAuthService) Init() error {
-	httpClient := &http.Client{}
+	httpClient := &http.Client{Timeout: 15 * time.Second}
 	ctx := context.Background()
 	ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
 	verifier := oauth2.GenerateVerifier()

86-93: Guard against missing/invalid token before calling GitHub APIs

If VerifyCode wasn’t called or failed, github.Token can be nil/empty. Fail fast with a clear error.

 func (github *GithubOAuthService) Userinfo() (config.Claims, error) {
 	var user config.Claims
 
+	if github.Token == nil || github.Token.AccessToken == "" {
+		return user, errors.New("github oauth: token not present; call VerifyCode first")
+	}
+
 	client := github.Config.Client(github.Context, github.Token)
internal/config/config.go (1)

71-74: Add BC alias for previously exported misspelling PassowrdLabels

Older code (including third-party users) may still refer to PassowrdLabels. Provide a type alias for backward compatibility.

 type PasswordLabels struct {
 	Plain string
 	File  string
 }
+
+// Backward-compatibility alias for the previous misspelling.
+// Deprecated: use PasswordLabels.
+type PassowrdLabels = PasswordLabels
internal/service/auth_service.go (3)

55-63: Cookie options: fix Domain handling, add SameSite, and validate key lengths

  • fmt.Sprintf(".%s", domain) yields a bare "." when domain is empty; breaks cookies on localhost.
  • Add SameSite=Lax for CSRF hardening (reasonable default for session cookies).
  • Fail fast on invalid key lengths for securecookie (block key must be 16/24/32 bytes); also assert HMAC secret is set.

Apply:

 func (auth *AuthService) Init() error {
-	store := sessions.NewCookieStore([]byte(auth.Config.HMACSecret), []byte(auth.Config.EncryptionSecret))
+	// Validate secrets early
+	if l := len(auth.Config.EncryptionSecret); l != 0 && l != 16 && l != 24 && l != 32 {
+		return fmt.Errorf("invalid EncryptionSecret length: got %d, want 16/24/32", l)
+	}
+	if len(auth.Config.HMACSecret) == 0 {
+		return fmt.Errorf("HMACSecret must be set")
+	}
+	store := sessions.NewCookieStore([]byte(auth.Config.HMACSecret), []byte(auth.Config.EncryptionSecret))
 	store.Options = &sessions.Options{
 		Path:     "/",
 		MaxAge:   auth.Config.SessionExpiry,
 		Secure:   auth.Config.SecureCookie,
 		HttpOnly: true,
-		Domain:   fmt.Sprintf(".%s", auth.Config.Domain),
+		// Only set Domain when configured; host-only cookie is fine for localhost.
+		Domain:   auth.Config.Domain,
+		SameSite: http.SameSiteLaxMode,
 	}

Add missing import:

 import (
 	"fmt"
+	"net/http"
 	"regexp"
 	"strings"
 	"sync"
 	"time"
 	"tinyauth/internal/config"
 	"tinyauth/internal/utils"

74-77: Clear-cookie Domain has the same leading-dot issue

Avoid emitting a bare "." domain when clearing the cookie.

-		c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.Config.Domain), auth.Config.SecureCookie, true)
+		c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", auth.Config.Domain, auth.Config.SecureCookie, true)

269-271: Same fix when deleting the session cookie

Ensure the clear-cookie path also uses the corrected Domain handling.

-	c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.Config.Domain), auth.Config.SecureCookie, true)
+	c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", auth.Config.Domain, auth.Config.SecureCookie, true)
🧹 Nitpick comments (55)
frontend/src/context/app-context.tsx (1)

18-19: Proxy configuration validated; endpoint path correct

All calls to /api/context/app are correctly routed by the dev‐server proxy in vite.config.ts (the /api rule rewrites /api/context/app → /context/app and targets http://tinyauth-backend:3000/api). No instances of the old /api/app remain in the frontend code.

Optional performance tweak (since the app context is mostly static):

   const { isFetching, data, error } = useSuspenseQuery({
     queryKey: ["app"],
     queryFn: () => axios.get("/api/context/app").then((res) => res.data),
+    staleTime: 5 * 60 * 1000,      // keep fresh for 5 minutes
+    cacheTime: 30 * 60 * 1000,     // retain cache for 30 minutes
+    retry: 1,                      // one retry on failure
   });

– No further action required for proxy wiring.
– Consider adding the caching knobs above to reduce unnecessary refetches.

frontend/src/context/user-context.tsx (2)

41-44: Grammar nit: “a UserContextProvider”

Minor copy tweak in the thrown message.

-      "useUserContext must be used within an UserContextProvider",
+      "useUserContext must be used within a UserContextProvider",

18-19: Migration complete: add caching & retry to user context query

  • Verified no remaining references to the old /api/user endpoint for context retrieval—only frontend/src/context/user-context.tsx:18 now points to /api/context/user.
  • As with the app context, you can optionally mirror caching and retry settings to prevent auth-edge thrash:
   const { isFetching, data, error } = useSuspenseQuery({
     queryKey: ["user"],
     queryFn: () => axios.get("/api/context/user").then((res) => res.data),
+    staleTime: 30 * 1000,          // cache valid for 30 seconds
+    gcTime: 10 * 60 * 1000,        // garbage-collect after 10 minutes
+    retry: (failureCount, error) => failureCount < 1,  // single retry on failure
   });
frontend/src/pages/logout-page.tsx (1)

29-45: Endpoint update to /api/user/logout looks right; consider clearing cached contexts on success

Approving the path change. Optionally clear the "user" and "app" queries before redirect to prevent any brief stale UI if the redirect is delayed.

   const logoutMutation = useMutation({
-    mutationFn: () => axios.post("/api/user/logout"),
+    mutationFn: () => axios.post("/api/user/logout"),
     mutationKey: ["logout"],
     onSuccess: () => {
+      // Clear cached context before redirect to avoid stale flashes
+      qc.removeQueries({ queryKey: ["user"], exact: true });
+      qc.removeQueries({ queryKey: ["app"], exact: true });
       toast.success(t("logoutSuccessTitle"), {
         description: t("logoutSuccessSubtitle"),
       });
 
       setTimeout(async () => {
         window.location.replace("/login");
       }, 500);
     },

Additional change outside this hunk:

// Add import
import { useQueryClient } from "@tanstack/react-query";

// Create client near other hooks
const qc = useQueryClient();

If the backend enforces CSRF, confirm axios is configured with the appropriate xsrfCookieName/xsrfHeaderName so this POST succeeds without manual headers.

.gitignore (1)

28-29: Scope the data ignore to repository root to avoid unintended matches

Plain “data” will ignore any directory named data anywhere in the tree. If you only intend to ignore the root-level data dir, anchor it.

-# data directory
-data
+# data directory
+/data
frontend/src/pages/totp-page.tsx (1)

35-35: Include credentials with TOTP POST to send session cookies

We didn’t detect any global Axios configuration for withCredentials, so this call will not send cookies by default. To ensure the TOTP-pending session (tracked via an HTTP-only cookie) is recognized server-side, add the withCredentials: true option or configure a shared Axios instance accordingly.

• File: frontend/src/pages/totp-page.tsx (around line 35)

- mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values),
+ mutationFn: (values: TotpSchema) =>
+   axios.post("/api/user/totp", values, { withCredentials: true }),

• Alternatively, create or update a centralized Axios client (e.g. in src/utils/api.ts):

export const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  withCredentials: true,
});

and use apiClient.post(...) instead of axios.post(...).

.env.example (1)

23-31: Reorder .env.example and surface allowed log levels in docs

  • Moved LOG_LEVEL=debug above OAUTH_WHITELIST to satisfy dotenv-linter’s natural key ordering.
  • Verified that GetLogLevel(level string) in internal/utils/app_utils.go handles case-insensitive string levels (trace, debug, info, warn, error, fatal, panic).
  • Confirmed the Config struct in internal/config/config.go uses a validate:"oneof=trace debug info warn error fatal panic" tag to enforce allowed values.
  • Noted that the CLI flag in cmd/root.go (around line 107) defaults to "info" with a description of "Log level."—consider expanding that help text (and any README.md docs) to enumerate the permitted levels.

Apply this local reordering:

-OAUTH_WHITELIST=
-GENERIC_NAME=My OAuth
-SESSION_EXPIRY=7200
-LOGIN_TIMEOUT=300
-LOGIN_MAX_RETRIES=5
-LOG_LEVEL=debug
+LOG_LEVEL=debug
+OAUTH_WHITELIST=
+GENERIC_NAME=My OAuth
+SESSION_EXPIRY=7200
+LOGIN_TIMEOUT=300
+LOGIN_MAX_RETRIES=5

Optional refactors recommended:

  • ✏️ Update the CLI help string in cmd/root.go to list allowed log levels.
  • 📚 Extend any user-facing documentation (e.g. README.md) to include the full set of valid log-level values.
internal/middleware/zerolog_middleware.go (3)

11-36: Tighten skip matching: HasPrefix can over-skip (e.g., GET /api/healthz). Use exact matches via a set.

Current HasPrefix on the "METHOD path" string will also skip endpoints that merely start with the listed prefixes. Switch to exact matching to avoid unintended debug-only logging.

Apply this diff:

@@
-var (
-	loggerSkipPathsPrefix = []string{
-		"GET /api/health",
-		"HEAD /api/health",
-		"GET /favicon.ico",
-	}
-)
+var (
+	loggerSkipPaths = map[string]struct{}{
+		"GET /api/health":  {},
+		"HEAD /api/health": {},
+		"GET /favicon.ico": {},
+	}
+)
@@
-func (m *ZerologMiddleware) logPath(path string) bool {
-	for _, prefix := range loggerSkipPathsPrefix {
-		if strings.HasPrefix(path, prefix) {
-			return false
-		}
-	}
-	return true
-}
+func (m *ZerologMiddleware) logPath(methodPath string) bool {
+	_, skip := loggerSkipPaths[methodPath]
+	return !skip
+}

50-64: Use structured duration and conventional levels: <400=Info, 4xx=Warn, 5xx=Error.

This improves queryability (numeric duration) and aligns severity with typical HTTP semantics.

-		latency := time.Since(tStart).String()
+		latency := time.Since(tStart)
@@
-		if m.logPath(method + " " + path) {
-			switch {
-			case code >= 200 && code < 300:
-				log.Info().Str("method", method).Str("path", path).Str("address", address).Str("clientIp", clientIP).Int("status", code).Str("latency", latency).Msg("Request")
-			case code >= 300 && code < 400:
-				log.Warn().Str("method", method).Str("path", path).Str("address", address).Str("clientIp", clientIP).Int("status", code).Str("latency", latency).Msg("Request")
-			case code >= 400:
-				log.Error().Str("method", method).Str("path", path).Str("address", address).Str("clientIp", clientIP).Int("status", code).Str("latency", latency).Msg("Request")
-			}
-		} else {
-			log.Debug().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
+		if m.logPath(method + " " + path) {
+			switch {
+			case code < 400:
+				log.Info().Str("method", method).Str("path", path).Str("address", address).Str("clientIp", clientIP).Int("status", code).Dur("latency", latency).Msg("Request")
+			case code < 500:
+				log.Warn().Str("method", method).Str("path", path).Str("address", address).Str("clientIp", clientIP).Int("status", code).Dur("latency", latency).Msg("Request")
+			default:
+				log.Error().Str("method", method).Str("path", path).Str("address", address).Str("clientIp", clientIP).Int("status", code).Dur("latency", latency).Msg("Request")
+			}
+		} else {
+			log.Debug().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Dur("latency", latency).Msg("Request")
 		}

44-49: Consider adding route pattern and errors for better observability.

Add c.FullPath() (route) to reduce log cardinality and attach c.Errors when present.

Example:

-		method := c.Request.Method
-		path := c.Request.URL.Path
+		method := c.Request.Method
+		path := c.Request.URL.Path
+		route := c.FullPath() // may be empty for unmatched routes

Then extend logs with .Str("route", route) and, if you want error details:

// pseudo-pattern for each event
evt := log.Info() /* or Warn/Error */
if len(c.Errors) > 0 {
    evt = evt.Strs("errors", c.Errors.Errors())
}
evt.Msg("Request")
internal/service/ldap_service.go (1)

40-53: Graceful shutdown and ticker leak: prefer NewTicker + stop on context.

time.Tick leaks and you have no way to stop the goroutine. Consider accepting a context in Init() or wiring one from bootstrap, then use time.NewTicker and stop it on ctx.Done().

I can draft a context-aware Init(ctx) and a shutdown hook in bootstrap if helpful.

internal/utils/fs_utils.go (1)

5-17: Remove redundant Stat() to avoid TOCTOU and extra syscall.

os.ReadFile already returns a precise error; the pre-check adds a race window and overhead.

 func ReadFile(file string) (string, error) {
-	_, err := os.Stat(file)
-	if err != nil {
-		return "", err
-	}
-
-	data, err := os.ReadFile(file)
-	if err != nil {
-		return "", err
-	}
-
-	return string(data), nil
+	data, err := os.ReadFile(file)
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
 }
main.go (1)

13-13: Good fix: removed hardcoded Fatal level; consider configurable level and optional Caller toggle.

Nice improvement dropping the Fatal filter and adding Caller(). For ops flexibility, allow overriding the global log level via an env (e.g., LOG_LEVEL). Optionally disable Caller() in production to reduce noise/overhead.

Apply this diff:

 func main() {
-	log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Caller().Logger()
+	// Build console logger
+	log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Caller().Logger()
+
+	// Optional: make log level configurable via LOG_LEVEL (defaults to Info)
+	if lvlStr := os.Getenv("LOG_LEVEL"); lvlStr != "" {
+		if lvl, err := zerolog.ParseLevel(lvlStr); err == nil {
+			zerolog.SetGlobalLevel(lvl)
+		} else {
+			// keep default if parsing fails
+		}
+	}
 	cmd.Execute()
 }
internal/utils/string_utils.go (2)

3-5: Import utf8 if optimizing Capitalize.

The proposed Capitalize refactor below needs utf8 decoding.

 import (
-	"strings"
+	"strings"
+	"unicode/utf8"
 )

7-12: Avoid double []rune allocation; use utf8.DecodeRune to uppercase first glyph.

Current code constructs []rune twice and slices it, which is unnecessary. The change below preserves existing semantics (uses strings.ToUpper on the first rune, so ß -> SS) while reducing allocations.

 func Capitalize(str string) string {
 	if len(str) == 0 {
 		return ""
 	}
-	return strings.ToUpper(string([]rune(str)[0])) + string([]rune(str)[1:])
+	// Uppercase only the first Unicode rune; leave the remainder untouched.
+	r, size := utf8.DecodeRuneInString(str)
+	return strings.ToUpper(str[:size]) + str[size:]
 }
internal/controller/health_controller.go (2)

3-3: Import net/http and use http.Status constants.*

Small cleanup and correctness for HEAD semantics in the handler below.

-import "github.com/gin-gonic/gin"
+import (
+	"net/http"
+	"github.com/gin-gonic/gin"
+)

20-25: Return empty body for HEAD; avoid writing JSON on HEAD requests.

HEAD responses should not include a message body. Short-circuit HEAD with StatusOK; send JSON only for GET.

-func (controller *HealthController) healthHandler(c *gin.Context) {
-	c.JSON(200, gin.H{
-		"status":  "ok",
-		"message": "Healthy",
-	})
-}
+func (controller *HealthController) healthHandler(c *gin.Context) {
+	if c.Request.Method == http.MethodHead {
+		c.Status(http.StatusOK)
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"status":  "ok",
+		"message": "Healthy",
+	})
+}
internal/middleware/ui_middleware.go (2)

3-11: Prepare for path sanitation if applied below.

If you adopt the path.Clean tweak in Middleware(), add the import.

 import (
 	"io/fs"
 	"net/http"
 	"os"
 	"strings"
 	"tinyauth/internal/assets"
+	"path"
 
 	"github.com/gin-gonic/gin"
 )

35-56: Refactor path handling to avoid indexing and sanitize the clean path

I verified that in app_bootstrap.go you’re always calling Init() on every middleware (including UIMiddleware) before registering it with Gin, so m.UIFS and m.UIFileServer are guaranteed to be non-nil in production. Guarding against uninitialized middleware is therefore unnecessary here (though you can still add a nil-check if you plan to use this handler standalone or in tests).

However, improving how you detect API/resource prefixes and sanitize incoming paths will make the middleware more robust and clear. I recommend an optional refactor along these lines:

• Replace strings.Split(c.Request.URL.Path, "/")[1] with explicit HasPrefix/equality checks for /api and /resources.
• Use path.Clean (after trimming the leading slash) to normalize the asset path (and handle ".""").
• Continue falling back to / for the SPA index when fs.Stat returns a not-exists error.

Example diff snippet (omitting the nil-guard check since bootstrap covers initialization):

 func (m *UIMiddleware) Middleware() gin.HandlerFunc {
-	return func(c *gin.Context) {
-		switch strings.Split(c.Request.URL.Path, "/")[1] {
-		case "api", "resources":
-			c.Next()
-			return
-		default:
-			_, err := fs.Stat(m.UIFS, strings.TrimPrefix(c.Request.URL.Path, "/"))
-			if os.IsNotExist(err) {
-				c.Request.URL.Path = "/"
-			}
-			m.UIFileServer.ServeHTTP(c.Writer, c.Request)
-			c.Abort()
-			return
-		}
-	}
+	return func(c *gin.Context) {
+		p := c.Request.URL.Path
+		if p == "/api" || strings.HasPrefix(p, "/api/") ||
+		   p == "/resources" || strings.HasPrefix(p, "/resources/") {
+			c.Next()
+			return
+		}
+
+		// Clean and prepare the relative path for asset lookup
+		rel := strings.TrimPrefix(p, "/")
+		rel = path.Clean(rel)
+		if rel == "." {
+			rel = ""
+		}
+
+		// If asset missing, serve SPA index
+		if _, err := fs.Stat(m.UIFS, rel); os.IsNotExist(err) {
+			c.Request.URL.Path = "/"
+		}
+		m.UIFileServer.ServeHTTP(c.Writer, c.Request)
+		c.Abort()
+		return
+	}

Feel free to adopt or adapt this optional refactor to improve readability and safety without changing existing behavior.

internal/controller/context_controller.go (2)

64-89: Confirm API contract: HTTP 200 on unauthorized with payload status 401.

If clients expect a real 401, return it at the HTTP layer. Otherwise, keep 200 for legacy reasons. Also, avoid naming the variable “context” for readability.

Option A — return 401:

-func (controller *ContextController) userContextHandler(c *gin.Context) {
-	context, err := utils.GetContext(c)
+func (controller *ContextController) userContextHandler(c *gin.Context) {
+	userCtx, err := utils.GetContext(c)
 
 	userContext := UserContextResponse{
-		Status:      200,
+		Status:      200,
 		Message:     "Success",
-		IsLoggedIn:  context.IsLoggedIn,
-		Username:    context.Username,
-		Name:        context.Name,
-		Email:       context.Email,
-		Provider:    context.Provider,
-		Oauth:       context.OAuth,
-		TotpPending: context.TotpPending,
+		IsLoggedIn:  userCtx.IsLoggedIn,
+		Username:    userCtx.Username,
+		Name:        userCtx.Name,
+		Email:       userCtx.Email,
+		Provider:    userCtx.Provider,
+		Oauth:       userCtx.OAuth,
+		TotpPending: userCtx.TotpPending,
 	}
 
 	if err != nil {
 		log.Debug().Err(err).Msg("No user context found in request")
 		userContext.Status = 401
 		userContext.Message = "Unauthorized"
 		userContext.IsLoggedIn = false
-		c.JSON(200, userContext)
+		c.JSON(401, userContext)
 		return
 	}
 
-	c.JSON(200, userContext)
+	c.JSON(200, userContext)
 }

If keeping 200 is intentional, please confirm and consider adding a comment to document the contract.


91-104: Minor: prefer http.StatusOK over magic 200.

Not critical, but consistent use of http constants improves readability.

-	c.JSON(200, AppContextResponse{
-		Status:                200,
+	c.JSON(http.StatusOK, AppContextResponse{
+		Status:                http.StatusOK,
 		Message:               "Success",
 		ConfiguredProviders:   controller.Config.ConfiguredProviders,
 		DisableContinue:       controller.Config.DisableContinue,
 		Title:                 controller.Config.Title,
 		GenericName:           controller.Config.GenericName,
 		Domain:                controller.Config.Domain,
 		ForgotPasswordMessage: controller.Config.ForgotPasswordMessage,
 		BackgroundImage:       controller.Config.BackgroundImage,
 		OAuthAutoRedirect:     controller.Config.OAuthAutoRedirect,
 	})

Add import:

 import (
 	"tinyauth/internal/utils"
 
 	"github.com/gin-gonic/gin"
 	"github.com/rs/zerolog/log"
+	"net/http"
 )
internal/utils/user_utils.go (1)

20-22: Remove unreachable length check after Split.

After trimming and the prior empty-string guard, strings.Split(...) always yields len >= 1. This check never fires.

Apply this diff to simplify:

-	if len(userList) == 0 {
-		return []config.User{}, errors.New("invalid user format")
-	}
internal/controller/resources_controller.go (1)

33-42: Serve using the cached server with the computed prefix and standard status code.

Avoid returning JSON for static file 404s unless you intend JSON for all clients.

Apply this diff:

 func (controller *ResourcesController) resourcesHandler(c *gin.Context) {
 	if controller.Config.ResourcesDir == "" {
-		c.JSON(404, gin.H{
-			"status":  404,
-			"message": "Resources not found",
-		})
+		c.AbortWithStatus(http.StatusNotFound)
 		return
 	}
-	controller.FileServer.ServeHTTP(c.Writer, c.Request)
+	http.StripPrefix(controller.prefix, controller.FileServer).ServeHTTP(c.Writer, c.Request)
 }

Optional hardening: if directory listings are undesirable, wrap the filesystem to reject directory requests.

internal/service/oauth_broker_service.go (5)

3-8: Import fmt/sort/strings for better errors, determinism, and provider key handling.

Apply this diff:

 import (
+	"fmt"
 	"errors"
 	"tinyauth/internal/config"
+	"sort"
+	"strings"
 
 	"github.com/rs/zerolog/log"
 )

30-43: Normalize provider keys; warn on unknown instead of silently defaulting.

Lowercasing avoids accidental duplicates (“Google” vs “google”); logging on unknown keys helps detect typos.

Apply this diff:

 func (broker *OAuthBrokerService) Init() error {
 	for name, cfg := range broker.Configs {
-		switch name {
+		key := strings.ToLower(strings.TrimSpace(name))
+		switch key {
 		case "github":
 			service := NewGithubOAuthService(cfg)
-			broker.Services[name] = service
+			broker.Services[key] = service
 		case "google":
 			service := NewGoogleOAuthService(cfg)
-			broker.Services[name] = service
+			broker.Services[key] = service
 		default:
+			log.Warn().Msgf("Unknown OAuth provider %q, using generic", name)
 			service := NewGenericOAuthService(cfg)
-			broker.Services[name] = service
+			broker.Services[key] = service
 		}
 	}

Note: If external callers use original-cased names, keep a mapping alias or document the normalized key requirement.


45-55: Don’t bail on first init error; initialize all and aggregate errors.

This prevents one bad provider from blocking others.

Example approach:

-	for name, service := range broker.Services {
-		err := service.Init()
-		if err != nil {
-			log.Error().Err(err).Msgf("Failed to initialize OAuth service: %s", name)
-			return err
-		}
-		log.Info().Msgf("Initialized OAuth service: %s", name)
-	}
+	var initErr error
+	for name, service := range broker.Services {
+		if err := service.Init(); err != nil {
+			log.Error().Err(err).Msgf("Failed to initialize OAuth service: %s", name)
+			initErr = errors.Join(initErr, fmt.Errorf("%s: %w", name, err))
+			continue
+		}
+		log.Info().Msgf("Initialized OAuth service: %s", name)
+	}
+	if initErr != nil {
+		return initErr
+	}

57-63: Return a deterministic list of configured services.

Sorting helps with tests and predictable UIs.

Apply this diff:

 func (broker *OAuthBrokerService) GetConfiguredServices() []string {
 	services := make([]string, 0, len(broker.Services))
 	for name := range broker.Services {
 		services = append(services, name)
 	}
+	sort.Strings(services)
 	return services
 }

70-76: Include provider name in not-found errors.

Improves debuggability; also ensure Init was called before use.

Apply this diff:

-		return config.Claims{}, errors.New("oauth service not found")
+		return config.Claims{}, fmt.Errorf("oauth service %q not found", service)

If this map is accessed concurrently, a read/write lock around broker.Services is advised.

Would you like a follow-up patch adding a RWMutex if the broker is used across goroutines post-initialization?

internal/utils/label_utils.go (1)

29-35: Harden header key validation against forbidden separators.

Beyond spaces/tabs, RFC token disallows these separators: ()<>@,;:"/[]?={}. Reject them explicitly.

Apply this diff:

-		key := SanitizeHeader(strings.TrimSpace(split[0]))
-		if strings.ContainsAny(key, " \t") {
+		key := SanitizeHeader(strings.TrimSpace(split[0]))
+		// Reject whitespace or separators in header field-name
+		if strings.ContainsAny(key, " \t()<>@,;:\\\"/[]?={}") {
 			continue
 		}

Optional: instead of sanitizing keys, consider rejecting keys that change after sanitization to avoid silent mutation.

internal/utils/security_utils.go (1)

120-124: Avoid shadowing the uuid package name.

Rename the local variable for readability.

Apply this diff:

 func GenerateIdentifier(str string) string {
-	uuid := uuid.NewSHA1(uuid.NameSpaceURL, []byte(str))
-	uuidString := uuid.String()
-	return strings.Split(uuidString, "-")[0]
+	id := uuid.NewSHA1(uuid.NameSpaceURL, []byte(str))
+	idString := id.String()
+	return strings.Split(idString, "-")[0]
 }
internal/controller/user_controller.go (2)

115-131: Nit: Avoid err shadowing; use inline short-if to constrain scope.

Current block declares a new err that shadows the outer one. Inline keeps scope tight and avoids confusion.

-      err := controller.Auth.CreateSessionCookie(c, &config.SessionCookie{
+      if err := controller.Auth.CreateSessionCookie(c, &config.SessionCookie{
         Username:    user.Username,
         Name:        utils.Capitalize(req.Username),
         Email:       fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.Config.Domain),
         Provider:    "username",
         TotpPending: true,
-      })
-
-      if err != nil {
+      }); err != nil {
         log.Error().Err(err).Msg("Failed to create session cookie")
         c.JSON(500, gin.H{
           "status":  500,
           "message": "Internal Server Error",
         })
         return
-      }
+      }

221-224: Message clarity: distinguish TOTP vs login rate limits.

This branch is in the TOTP handler; the message still says “login attempts”. Minor UX papercut.

-      "message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remainingTime),
+      "message": fmt.Sprintf("Too many failed TOTP attempts. Try again in %d seconds", remainingTime),
internal/utils/app_utils.go (1)

76-102: Optional: Enforce HTTPS in IsRedirectSafe.

If you want to prevent downgrades, also require parsedURL.Scheme == "https".

   if !parsedURL.IsAbs() {
     return false
   }
+  if strings.ToLower(parsedURL.Scheme) != "https" {
+    return false
+  }
internal/service/google_oauth_service.go (3)

45-53: Set HTTP client timeout to avoid hangs.

The default client has no timeout, risking stuck goroutines on network stalls.

 func (google *GoogleOAuthService) Init() error {
-  httpClient := &http.Client{}
+  httpClient := &http.Client{
+    Timeout: 10 * time.Second,
+  }
   ctx := context.Background()
   ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
   verifier := oauth2.GenerateVerifier()

56-64: Fallback state generation: use Sprintf for broader Go version compatibility.

fmt.Appendf requires newer Go; fmt.Sprintf is clearer.

-    return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "state-%d", time.Now().UnixNano()))
+    return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("state-%d", time.Now().UnixNano())))

33-43: Avoid import shadowing and future-proof scopes

Verified that OAuthServiceConfig (internal/config/config.go:95) includes a Scopes []string field. To prevent shadowing the package name and allow callers to override the default Google scopes, please:

• In internal/service/google_oauth_service.go, rename the parameter from config to cfg
• Initialize oauth2.Config.Scopes by preferring cfg.Scopes when non-empty, otherwise falling back to GoogleOAuthScopes

Suggested diff:

 func NewGoogleOAuthService(cfg config.OAuthServiceConfig) *GoogleOAuthService {
   // prefer caller-provided scopes if available
+  scopes := GoogleOAuthScopes
+  if len(cfg.Scopes) > 0 {
+    scopes = cfg.Scopes
+  }

   return &GoogleOAuthService{
     Config: oauth2.Config{
       ClientID:     cfg.ClientID,
       ClientSecret: cfg.ClientSecret,
       RedirectURL:  cfg.RedirectURL,
-      Scopes:       GoogleOAuthScopes,
+      Scopes:       scopes,
       Endpoint:     endpoints.Google,
     },
   }
 }
cmd/root.go (1)

44-46: Nit: Redundant cast; GetLogLevel already returns zerolog.Level.

No behavior change; small cleanup.

-    log.Logger = log.Level(zerolog.Level(utils.GetLogLevel(conf.LogLevel)))
+    log.Logger = log.Level(utils.GetLogLevel(conf.LogLevel))
internal/middleware/context_middleware.go (1)

45-56: Optional: Replace goto with early returns for readability.

The label pattern works but increases cognitive load. Early returns would simplify control flow.

If you’re open to it, I can provide a refactor patch converting the basic: label to a helper function and early returns.

Also applies to: 105-157

internal/controller/proxy_controller.go (3)

102-105: Avoid double GetSecret() reads and unintended Authorization override

Currently you (a) read the secret twice and (b) always overwrite Authorization with Basic, even if an Authorization header was already propagated. Cache the secret and only set Basic when no Authorization is already present (or make it explicit that you intend to override).

Apply to 102–105:

-    if labels.Basic.Username != "" && utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File) != "" {
-        log.Debug().Str("username", labels.Basic.Username).Msg("Setting basic auth header")
-        c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Basic.Username, utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File))))
-    }
+    pwd := utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File)
+    if labels.Basic.Username != "" && pwd != "" {
+        if c.Writer.Header().Get("Authorization") == "" {
+            log.Debug().Str("username", labels.Basic.Username).Msg("Setting basic auth header")
+            c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Basic.Username, pwd)))
+        } else {
+            log.Debug().Msg("Authorization already set; skipping Basic override")
+        }
+    }

Repeat analogous changes at 167–170 and 280–283.

Also applies to: 167-170, 280-283


95-101: DRY: factor header application into a helper to reduce duplication

Parsing and applying label headers repeats three times. Extract into a small helper to keep logic consistent.

Example helper (outside this file, e.g., internal/utils/header.go):

func ApplyLabelHeaders(c *gin.Context, labels config.Labels) {
  headers := utils.ParseHeaders(labels.Headers)
  for k, v := range headers {
    log.Debug().Str("header", k).Msg("Setting header")
    c.Header(k, v)
  }
}

Then replace the repeated blocks with:

utils.ApplyLabelHeaders(c, labels)

Also applies to: 160-166, 273-278


66-73: Be defensive if X-Forwarded-Host/Uri are missing

When proxies misconfigure, host/uri can be empty, producing id=="" and odd redirects. Add a sanity fallback or 400 guard.

 uri := c.Request.Header.Get("X-Forwarded-Uri")
 proto := c.Request.Header.Get("X-Forwarded-Proto")
 host := c.Request.Header.Get("X-Forwarded-Host")
+if host == "" || proto == "" || uri == "" {
+  log.Warn().Msg("Missing X-Forwarded headers")
+  c.JSON(http.StatusBadRequest, gin.H{"status": 400, "message": "Bad Request"})
+  return
+}
internal/bootstrap/app_bootstrap.go (4)

211-223: Rename local slice from “controller” to “controllers” to avoid shadowing the imported package name

Prevents confusion and improves readability.

-  controller := []Controller{
+  controllers := []Controller{
     contextController,
     oauthController,
     proxyController,
     userController,
     healthController,
     resourcesController,
   }
 
-  for _, ctrl := range controller {
+  for _, ctrl := range controllers {
     log.Debug().Msgf("Setting up %T controller", ctrl)
     ctrl.SetupRoutes()
   }

145-151: Add gin.Recovery() to guard against panics in handlers

Ensures a 500 instead of crashing the process in production.

 engine := gin.New()
 
 if config.Version != "development" {
   gin.SetMode(gin.ReleaseMode)
 }
 
+// Recover from panics to avoid process crashes.
+engine.Use(gin.Recovery())

163-170: Avoid shadowing the middleware package with the loop variable

Minor naming cleanup.

-for _, middleware := range middlewares {
-  log.Debug().Str("middleware", fmt.Sprintf("%T", middleware)).Msg("Initializing middleware")
-  err := middleware.Init()
+for _, m := range middlewares {
+  log.Debug().Str("middleware", fmt.Sprintf("%T", m)).Msg("Initializing middleware")
+  err := m.Init()
   if err != nil {
     return fmt.Errorf("failed to initialize middleware %T: %w", middleware, err)
   }
-  engine.Use(middleware.Middleware())
+  engine.Use(m.Middleware())
 }

248-257: Normalize Generic OAuth scopes to avoid empty/whitespace-only entries

strings.Split can yield [""] when GenericScopes is empty, causing some providers to reject requests.

-      Scopes:             strings.Split(app.Config.GenericScopes, ","),
+      Scopes:             strings.FieldsFunc(app.Config.GenericScopes, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }),
internal/controller/oauth_controller.go (1)

109-114: Clear CSRF cookie on validation failure to avoid sticky state

On mismatch, drop the cookie before redirecting.

 if err != nil || state != csrfCookie {
   log.Warn().Err(err).Msg("CSRF token mismatch or cookie missing")
+  c.SetCookie(controller.Config.CSRFCookieName, "", -1, "/", controller.Config.Domain, controller.Config.SecureCookie, true)
   c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.Config.AppURL))
   return
 }
internal/service/generic_oauth_service.go (1)

52-54: Set an HTTP client timeout to avoid hangs

Add a sane Timeout to the OAuth HTTP client.

 httpClient := &http.Client{
   Transport: transport,
+  Timeout:   15 * time.Second,
 }
internal/service/docker_service.go (2)

25-37: Nit: avoid shadowing the imported client package; enable API version negotiation in constructor

Use cli var name, and consider WithAPIVersionNegotiation to simplify.

 func (docker *DockerService) Init() error {
-  client, err := client.NewClientWithOpts(client.FromEnv)
+  cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
   if err != nil {
     return err
   }
 
   ctx := context.Background()
-  client.NegotiateAPIVersion(ctx)
-
-  docker.Client = client
+  docker.Client = cli
   docker.Context = ctx
   return nil
 }

73-95: Variable shadowing with import name; minor rename improves clarity

Rename loop variable to avoid clashing with the removed container import and improve readability.

- for _, container := range containers {
-   inspect, err := docker.InspectContainer(container.ID)
+ for _, ctr := range containers {
+   inspect, err := docker.InspectContainer(ctr.ID)
    if err != nil {
-     log.Warn().Str("id", container.ID).Err(err).Msg("Error inspecting container, skipping")
+     log.Warn().Str("id", ctr.ID).Err(err).Msg("Error inspecting container, skipping")
      continue
    }
 
    labels, err := utils.GetLabels(inspect.Config.Labels)
    if err != nil {
-     log.Warn().Str("id", container.ID).Err(err).Msg("Error getting container labels, skipping")
+     log.Warn().Str("id", ctr.ID).Err(err).Msg("Error getting container labels, skipping")
      continue
    }
@@
-   if strings.TrimPrefix(inspect.Name, "/") == app {
+   if strings.TrimPrefix(inspect.Name, "/") == app {
      log.Debug().Str("id", inspect.ID).Msg("Found matching container by name")
      return labels, nil
    }
  }
internal/service/github_oauth_service.go (3)

96-96: Set GitHub API version header to avoid defaults and warnings

GitHub recommends sending X-GitHub-Api-Version. Add it to both requests.

 req.Header.Set("Accept", "application/vnd.github+json")
+req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
@@
 req.Header.Set("Accept", "application/vnd.github+json")
+req.Header.Set("X-GitHub-Api-Version", "2022-11-28")

Also applies to: 125-125


104-106: Make error messages endpoint-specific

Small DX polish: include the endpoint for faster triage.

-	if res.StatusCode < 200 || res.StatusCode >= 300 {
-		return user, fmt.Errorf("request failed with status: %s", res.Status)
-	}
+	if res.StatusCode < 200 || res.StatusCode >= 300 {
+		return user, fmt.Errorf("GET /user failed: %s", res.Status)
+	}

133-135: Same as above for /user/emails

-	if res.StatusCode < 200 || res.StatusCode >= 300 {
-		return user, fmt.Errorf("request failed with status: %s", res.Status)
-	}
+	if res.StatusCode < 200 || res.StatusCode >= 300 {
+		return user, fmt.Errorf("GET /user/emails failed: %s", res.Status)
+	}
internal/config/config.go (2)

3-8: Consider tightening Claims.Groups type

Groups as any makes downstream handling error-prone. If feasible, prefer []string (common case) or json.RawMessage to carry opaque structures.


14-16: Consider constants for cookie names

If these are not intended to be mutated at runtime, const communicates intent and prevents accidental changes.

internal/service/auth_service.go (1)

183-217: Potential unbounded growth of LoginAttempts map

Consider expiring old entries to prevent unbounded memory growth under attack or long-running processes. A periodic cleanup or TTL-based eviction on read/write would suffice.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 4979121 and a1b6ecd.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (57)
  • .env.example (2 hunks)
  • .gitignore (1 hunks)
  • air.toml (1 hunks)
  • cmd/root.go (3 hunks)
  • cmd/version.go (2 hunks)
  • frontend/src/context/app-context.tsx (1 hunks)
  • frontend/src/context/user-context.tsx (1 hunks)
  • frontend/src/pages/login-page.tsx (1 hunks)
  • frontend/src/pages/logout-page.tsx (1 hunks)
  • frontend/src/pages/totp-page.tsx (1 hunks)
  • frontend/vite.config.ts (1 hunks)
  • go.mod (1 hunks)
  • internal/assets/assets.go (1 hunks)
  • internal/auth/auth_test.go (0 hunks)
  • internal/bootstrap/app_bootstrap.go (1 hunks)
  • internal/config/config.go (5 hunks)
  • internal/constants/constants.go (0 hunks)
  • internal/controller/context_controller.go (1 hunks)
  • internal/controller/health_controller.go (1 hunks)
  • internal/controller/oauth_controller.go (1 hunks)
  • internal/controller/proxy_controller.go (1 hunks)
  • internal/controller/resources_controller.go (1 hunks)
  • internal/controller/user_controller.go (1 hunks)
  • internal/handlers/context.go (0 hunks)
  • internal/handlers/handlers.go (0 hunks)
  • internal/handlers/handlers_test.go (0 hunks)
  • internal/handlers/oauth.go (0 hunks)
  • internal/handlers/proxy.go (0 hunks)
  • internal/handlers/user.go (0 hunks)
  • internal/hooks/hooks.go (0 hunks)
  • internal/middleware/context_middleware.go (1 hunks)
  • internal/middleware/ui_middleware.go (1 hunks)
  • internal/middleware/zerolog_middleware.go (1 hunks)
  • internal/oauth/oauth.go (0 hunks)
  • internal/providers/generic.go (0 hunks)
  • internal/providers/github.go (0 hunks)
  • internal/providers/google.go (0 hunks)
  • internal/providers/providers.go (0 hunks)
  • internal/server/server.go (0 hunks)
  • internal/service/auth_service.go (14 hunks)
  • internal/service/docker_service.go (3 hunks)
  • internal/service/generic_oauth_service.go (1 hunks)
  • internal/service/github_oauth_service.go (1 hunks)
  • internal/service/google_oauth_service.go (1 hunks)
  • internal/service/ldap_service.go (5 hunks)
  • internal/service/oauth_broker_service.go (1 hunks)
  • internal/types/api.go (0 hunks)
  • internal/types/types.go (0 hunks)
  • internal/utils/app_utils.go (1 hunks)
  • internal/utils/fs_utils.go (1 hunks)
  • internal/utils/label_utils.go (1 hunks)
  • internal/utils/security_utils.go (1 hunks)
  • internal/utils/string_utils.go (1 hunks)
  • internal/utils/user_utils.go (1 hunks)
  • internal/utils/utils.go (0 hunks)
  • internal/utils/utils_test.go (0 hunks)
  • main.go (1 hunks)
💤 Files with no reviewable changes (19)
  • internal/auth/auth_test.go
  • internal/constants/constants.go
  • internal/server/server.go
  • internal/oauth/oauth.go
  • internal/providers/google.go
  • internal/handlers/handlers.go
  • internal/providers/providers.go
  • internal/providers/generic.go
  • internal/handlers/context.go
  • internal/providers/github.go
  • internal/handlers/proxy.go
  • internal/handlers/user.go
  • internal/hooks/hooks.go
  • internal/handlers/oauth.go
  • internal/handlers/handlers_test.go
  • internal/types/api.go
  • internal/types/types.go
  • internal/utils/utils.go
  • internal/utils/utils_test.go
🧰 Additional context used
🪛 dotenv-linter (3.3.0)
.env.example

[warning] 8-8: [UnorderedKey] The SECURE_COOKIE key should go before the USERS key

(UnorderedKey)


[warning] 28-28: [UnorderedKey] The LOG_LEVEL key should go before the OAUTH_WHITELIST key

(UnorderedKey)

Comment thread internal/bootstrap/app_bootstrap.go
Comment thread internal/bootstrap/app_bootstrap.go
Comment thread internal/config/config.go
Comment thread internal/controller/user_controller.go
Comment thread internal/middleware/context_middleware.go
Comment thread internal/service/ldap_service.go
Comment thread internal/service/ldap_service.go
Comment thread internal/utils/security_utils.go
Comment thread internal/utils/security_utils.go
Comment thread internal/utils/user_utils.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants