Skip to content

Set access token server side#1387

Open
tyler-hagen wants to merge 12 commits into
photoview:masterfrom
tyler-hagen:set-access-token-server-side
Open

Set access token server side#1387
tyler-hagen wants to merge 12 commits into
photoview:masterfrom
tyler-hagen:set-access-token-server-side

Conversation

@tyler-hagen

@tyler-hagen tyler-hagen commented Mar 4, 2026

Copy link
Copy Markdown
Contributor

Fix for: #184

Auth should now work even if the ui and api are hosted on separate domains.

Further improvement would be to not return the auth token in the graphql response, and also set the cookie to HttpOnly to avoid XSS access to the token. I figured that is out of scope for this PR.

Summary by CodeRabbit

  • New Features

    • Server now emits an auth cookie to manage sessions automatically.
  • Changes

    • Client login flow simplified: login no longer requires or passes tokens and runs on success only.
    • Middleware added to surface session tokens produced during requests as Set-Cookie responses.
  • Tests

    • Added tests validating the cookie-setting middleware behavior.

@coderabbitai

coderabbitai Bot commented Mar 4, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds middleware that sets an auth-token cookie from a resolver-populated context key; adds a context accessor used by resolvers to store the token; installs the middleware in the server pipeline; updates frontend login to stop passing/storing tokens.

Changes

Cohort / File(s) Summary
Auth middleware & context helpers
api/graphql/auth/auth.go, api/graphql/auth/auth_cookie_setter.go, api/graphql/auth/auth_cookie_setter_test.go
Adds ResolverCookieFromContext and userAccessTokenCtxKey; implements AuthCookieSetter(separateDomain bool) middleware that wraps ResponseWriter to set auth-token cookie from context on WriteHeader; includes tests.
Resolver updates
api/graphql/resolvers/user.go
Resolvers (AuthorizeUser, InitialSetupWizard) write generated token values into the resolver-scoped context key so middleware can set the cookie.
Server pipeline
api/server.go
Installs auth.AuthCookieSetter into the HTTP middleware chain (after LoggingMiddleware, before CORSMiddleware).
Frontend login flow
ui/src/Pages/LoginPage/LoginPage.tsx, ui/src/Pages/LoginPage/InitialSetupPage.tsx, ui/src/Pages/LoginPage/loginUtilities.tsx
Removes client-side token handling: login() signature no longer accepts or stores a token; mutation handlers no longer extract/use token from responses.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Browser
    participant Server as HTTP Server
    participant Middleware as AuthCookieSetter
    participant Resolver as GraphQL Resolver

    Client->>Server: POST /graphql (mutation)
    Server->>Middleware: pass request (ResponseWriter wrapped)
    Middleware->>Resolver: invoke resolver
    Resolver->>Resolver: generate token
    Resolver->>Resolver: store token in context key
    Resolver-->>Middleware: return response
    Middleware->>Middleware: on WriteHeader read token from context
    Middleware->>Client: add Set-Cookie: auth-token
    Middleware-->>Client: send response body
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

API, go, UI, javascript, security, improvement

Suggested reviewers

  • kkovaletp
  • googollee
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title 'Set access token server side' directly and clearly describes the main change: implementing server-side auth token setting via cookies instead of client-side token handling.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

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

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can make CodeRabbit's review stricter and more nitpicky using the `assertive` profile, if that's what you prefer.

Change the reviews.profile setting to assertive to make CodeRabbit's nitpick more issues in your PRs.

Comment thread api/graphql/auth/auth_cookie_setter.go Fixed
Comment thread api/graphql/auth/auth_cookie_setter.go Fixed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
api/graphql/auth/auth_cookie_setter_test.go (1)

38-80: Add coverage for WriteHeader-first handlers.

Current cases won’t catch regressions where headers are committed before Write. A dedicated test for that path would harden this middleware.

🧪 Example additional test case
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
 			req := httptest.NewRequest("GET", "/graphql", nil)
@@
 			assert.Equal(t, tc.responseAuthCookieVal, authTokenValue)
 		})
 	}
+
+	t.Run("Handler calling WriteHeader before Write still sets auth cookie", func(t *testing.T) {
+		req := httptest.NewRequest("GET", "/graphql", nil)
+		handler := auth.AuthCookieSetter(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			cookie := auth.ResolverCookieFromContext(r.Context())
+			*cookie = "cookie"
+			w.WriteHeader(http.StatusOK)
+			_, _ = w.Write([]byte("ok"))
+		}))
+
+		recorder := &hijackableRecorder{httptest.NewRecorder()}
+		handler.ServeHTTP(recorder, req)
+
+		var authTokenValue string
+		for _, cookie := range recorder.Result().Cookies() {
+			if cookie.Name == "auth-token" {
+				authTokenValue = cookie.Value
+				break
+			}
+		}
+		assert.Equal(t, "cookie", authTokenValue)
+	})
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/graphql/auth/auth_cookie_setter_test.go` around lines 38 - 80, Add a test
case in TestAuthCookieSetterMiddleware that exercises handlers which call
WriteHeader (commit headers) before Write to ensure AuthCookieSetter still sets
cookies; create a new handler (e.g., writeHeaderFirstHandler) that calls
rw.WriteHeader(200) and then writes a body (and optionally sets a cookie value
via setResponseAuthCookieHandler logic), plug it into auth.AuthCookieSetter, use
the existing hijackableRecorder to capture recorder.Result().Cookies(), and
assert the "auth-token" cookie value matches the expected responseAuthCookieVal
just like the other cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@api/graphql/auth/auth_cookie_setter.go`:
- Around line 15-25: The Write method currently sets the cookie too late because
headers may already be committed; add an override for
authResponseWriter.WriteHeader that sets the auth-token cookie (using
authTokenFromResolver, SameSiteLaxMode, Path "/", Expires 14 days) before
delegating to w.ResponseWriter.WriteHeader(status), and ensure Write still sets
the cookie if WriteHeader was never called (guard with a boolean like cookieSet
or check header presence) so the Set-Cookie always happens before headers are
sent; reference authResponseWriter.Write, authResponseWriter.WriteHeader,
authTokenFromResolver, and w.ResponseWriter.WriteHeader.
- Around line 17-23: The cookie currently set in auth_cookie_setter.go
(http.SetCookie with Name "auth-token" and Value w.authTokenFromResolver) uses
SameSite: http.SameSiteLaxMode which prevents cross-site fetch/XHR; change the
cookie attributes to SameSite: http.SameSiteNoneMode and add Secure: true (and
keep Path, Expires); update the http.Cookie creation in the same http.SetCookie
call to use SameSiteNoneMode and set Secure to true so the auth cookie is sent
for cross-origin requests when UI and API are on different domains.

---

Nitpick comments:
In `@api/graphql/auth/auth_cookie_setter_test.go`:
- Around line 38-80: Add a test case in TestAuthCookieSetterMiddleware that
exercises handlers which call WriteHeader (commit headers) before Write to
ensure AuthCookieSetter still sets cookies; create a new handler (e.g.,
writeHeaderFirstHandler) that calls rw.WriteHeader(200) and then writes a body
(and optionally sets a cookie value via setResponseAuthCookieHandler logic),
plug it into auth.AuthCookieSetter, use the existing hijackableRecorder to
capture recorder.Result().Cookies(), and assert the "auth-token" cookie value
matches the expected responseAuthCookieVal just like the other cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5d7c64c1-e864-4601-995d-403001b32c77

📥 Commits

Reviewing files that changed from the base of the PR and between e0318d8 and 1d926ad.

⛔ Files ignored due to path filters (1)
  • ui/src/components/sidebar/__generated__/sidebarSetExpireShare.ts is excluded by !**/__generated__/**
📒 Files selected for processing (8)
  • api/graphql/auth/auth.go
  • api/graphql/auth/auth_cookie_setter.go
  • api/graphql/auth/auth_cookie_setter_test.go
  • api/graphql/resolvers/user.go
  • api/server.go
  • ui/src/Pages/LoginPage/InitialSetupPage.tsx
  • ui/src/Pages/LoginPage/LoginPage.tsx
  • ui/src/Pages/LoginPage/loginUtilities.tsx

Comment thread api/graphql/auth/auth_cookie_setter.go Outdated
Comment thread api/graphql/auth/auth_cookie_setter.go Outdated
@codecov

codecov Bot commented Mar 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.51020% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.42%. Comparing base (e0318d8) to head (9137e1d).
⚠️ Report is 30 commits behind head on master.

Files with missing lines Patch % Lines
api/graphql/resolvers/user.go 0.00% 4 Missing ⚠️
ui/src/Pages/LoginPage/LoginPage.tsx 0.00% 3 Missing ⚠️
api/graphql/auth/auth.go 60.00% 1 Missing and 1 partial ⚠️
ui/src/Pages/LoginPage/InitialSetupPage.tsx 0.00% 2 Missing ⚠️
api/server.go 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1387      +/-   ##
==========================================
+ Coverage   63.37%   63.42%   +0.05%     
==========================================
  Files         203      204       +1     
  Lines       16673    16715      +42     
  Branches      604      604              
==========================================
+ Hits        10567    10602      +35     
- Misses       5695     5701       +6     
- Partials      411      412       +1     
Flag Coverage Δ
api-mysql 46.66% <83.72%> (+0.26%) ⬆️
api-postgres 46.83% <83.72%> (+0.34%) ⬆️
api-sqlite 46.73% <83.72%> (+0.28%) ⬆️
ui 70.64% <16.66%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

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

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@api/graphql/auth/auth_cookie_setter.go`:
- Around line 9-13: The authResponseWriter currently embeds http.Hijacker and
performs an unconditional type assertion (w.(http.Hijacker)) which can panic;
change the struct to store a hijacker field (hijacker http.Hijacker) instead of
embedding, update any constructor or wrapper creation to set that field using a
safe type assertion (ok pattern), and add a Hijack() method on
authResponseWriter that checks the hijacker field and returns an error if nil
(rather than panicking) so callers can safely call Hijack(); ensure all
references to the old embedded type use the new Hijack() method or the hijacker
field.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 175bb7cb-5be7-47b4-b065-7711788769ae

📥 Commits

Reviewing files that changed from the base of the PR and between 1d926ad and 9b99c1b.

📒 Files selected for processing (3)
  • api/graphql/auth/auth_cookie_setter.go
  • api/graphql/auth/auth_cookie_setter_test.go
  • api/server.go

Comment thread api/graphql/auth/auth_cookie_setter.go Outdated
@tyler-hagen

Copy link
Copy Markdown
Contributor Author

This is ready for review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 (1)
api/graphql/auth/auth_cookie_setter_test.go (1)

25-39: Add a middleware success-path test that uses Write (implicit 200).

Current handlers only call WriteHeader(200). Add one case that writes a body without explicit WriteHeader to prevent regressions in cookie-setting on implicit status responses.

💡 Suggested coverage addition
 func noResponseAuthCookieHandler() http.HandlerFunc {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.WriteHeader(200)
 	})
 }
 
+func setResponseAuthCookieWriteHandler(token string) http.HandlerFunc {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		cookie := auth.ResolverCookieFromContext(r.Context())
+		*cookie = token
+		_, _ = w.Write([]byte("ok")) // implicit WriteHeader(200)
+	})
+}
+
 func TestAuthCookieSetterMiddleware(t *testing.T) {
@@
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
@@
 		})
 	}
+
+	t.Run("Login endpoint sets auth cookie on implicit 200 via Write", func(t *testing.T) {
+		req := httptest.NewRequest("GET", "/graphql", nil)
+		authHandler := auth.AuthCookieSetter(false)(setResponseAuthCookieWriteHandler("cookie"))
+		recorder := &hijackableRecorder{httptest.NewRecorder()}
+		authHandler.ServeHTTP(recorder, req)
+
+		var authToken *http.Cookie
+		for _, cookie := range recorder.Result().Cookies() {
+			if cookie.Name == "auth-token" {
+				authToken = cookie
+				break
+			}
+		}
+		if authToken == nil {
+			t.Fatalf("expected auth-token cookie to be set")
+		}
+		assert.Equal(t, "cookie", authToken.Value)
+	})
 }
Based on learnings: "When implementing an http.ResponseWriter wrapper for gqlgen (or similar HTTP handlers), ensure you override both WriteHeader and Write so headers/cookies are set consistently in all paths, including the successful response path."

Also applies to: 41-44

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/graphql/auth/auth_cookie_setter_test.go` around lines 25 - 39, Add a
success-path test that exercises implicit-200 responses by making one handler
write a body without calling WriteHeader: update or add a handler (e.g., change
noResponseAuthCookieHandler or add a new implicitWriteAuthCookieHandler) to call
w.Write([]byte("ok")) and not call WriteHeader, then add a test case that
invokes the middleware with that handler and asserts the cookie was set; ensure
you reference setResponseAuthCookieHandler and noResponseAuthCookieHandler when
locating existing tests to add the new implicit-write case.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@api/graphql/auth/auth_cookie_setter_test.go`:
- Around line 90-92: The test currently dereferences authToken.Value without
ensuring authToken is present; add an explicit presence check (e.g.,
assert.NotNil(t, authToken) or if authToken == nil { t.Fatalf(...) }) before
asserting on authToken.Value so a missing cookie fails clearly instead of
panicking; update the block around the existing check that uses
tc.responseAuthCookieVal and tc.uiOnSeparateDomain to first verify authToken is
non-nil, then compare authToken.Value to tc.responseAuthCookieVal and continue
the uiOnSeparateDomain logic.

---

Nitpick comments:
In `@api/graphql/auth/auth_cookie_setter_test.go`:
- Around line 25-39: Add a success-path test that exercises implicit-200
responses by making one handler write a body without calling WriteHeader: update
or add a handler (e.g., change noResponseAuthCookieHandler or add a new
implicitWriteAuthCookieHandler) to call w.Write([]byte("ok")) and not call
WriteHeader, then add a test case that invokes the middleware with that handler
and asserts the cookie was set; ensure you reference
setResponseAuthCookieHandler and noResponseAuthCookieHandler when locating
existing tests to add the new implicit-write case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c517ce67-115d-4eac-a0a0-d2ac1ecfaf55

📥 Commits

Reviewing files that changed from the base of the PR and between f8b0b18 and 2b6580f.

📒 Files selected for processing (1)
  • api/graphql/auth/auth_cookie_setter_test.go

Comment thread api/graphql/auth/auth_cookie_setter_test.go
@kkovaletp kkovaletp requested a review from googollee March 7, 2026 08:50
@kkovaletp kkovaletp added API Related to the backend api server written in Go UI Related to the frontend web ui written in Javascript high priority go Pull requests that update Go code javascript Pull requests that update Javascript code improvement Something, enhancing the product, but not a feature security Security related issues and PRs labels Mar 7, 2026
@kkovaletp kkovaletp added this to Roadmap Mar 7, 2026
@github-project-automation github-project-automation Bot moved this to In progress in Roadmap Mar 7, 2026
@kkovaletp

Copy link
Copy Markdown
Contributor

@tyler-hagen, thanks for the contribution! Fixing this issue is important from the security PoV, as the server should manage user sessions, not the clients.
As the fix touches critical parts of the code, I'd let @googollee review the PR 1st. Let us know when you're done with all planned steps for this PR (the implementation, tests, and reviews with the bot), and make sure that your solution covers all the supported cases, including hosting UI and API on different hosts (Docker containers).

@tyler-hagen

Copy link
Copy Markdown
Contributor Author

@kkovaletp everything is done and ready for review

Comment thread api/graphql/auth/auth.go Outdated

// Find the auth-token from the context. REQUIRES AuthCookieSetter to have run.
func ResolverCookieFromContext(ctx context.Context) *string {
raw, _ := ctx.Value(userAccessTokenCtxKey).(*string)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This may panic if userAccessTokenCtxKey isn't set correctly. How about this:

v := ctx.Value(userAccessTokenCtxKey)
if v == nil {
  return nil
}

return v.(*string)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If we return nil, then won't we just panic in the AuthorizeUseror InitialSetupWizard resolver anyway?
https://github.com/photoview/photoview/pull/1387/changes#diff-4c4a03d7186972110cd0c98c0f259cc92bee167d3fabff79277ebf45b9f20b2eR51

I'm sort of assuming if you're calling this function, then the AuthCookieSetter middleware has run. I see it as the same assumption UserFromContext makes just a couple lines up about the auth middleware having run.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

BTW, I'd be happy to change them both (ResolverCookieFromContext and UserFromContext)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since this function is far away from where the middleware is registered, it's hard to ensure the middleware exists. This detection can output more specific relevant logs when unexpected events occur.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated

Comment thread api/graphql/auth/auth_cookie_setter.go Outdated
// was provided by a resolver and the cookie has not already been written.
// For cross-origin deployments (separateDomain=true), it uses SameSite=None;Secure.
func (w *authResponseWriter) writeAuthCookieIfNeeded() {
if w.cookieWritten == false && w.authTokenFromResolver != "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It's better to shortcut the bad case first.

if w.cookieWritten || w.authTokenFromResolver == "" {
  return
}

// other code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated

Comment thread api/graphql/auth/auth_cookie_setter.go Outdated
// It also embeds http.Hijacker to support WebSocket upgrades.
type authResponseWriter struct {
http.ResponseWriter
http.Hijacker

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Well, actually, Hijacker is not enough. There are Pusher and Flusher, even deprecated CloseNotifier.

There is no good way to solve it. Could you use httpsnoop library to add hooks?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated. Thanks for pointing me to httpsnoop, I did not know that exists and seems like the best solution to the nasty problem of the underlying responsewriter implementing all sorts of different interfaces..

Comment on lines +35 to +42
http.SetCookie(w, &http.Cookie{
Name: "auth-token",
Value: w.authTokenFromResolver,
Path: "/",
SameSite: sameSite,
Secure: secure,
Expires: time.Now().Add(14 * 24 * time.Hour),
})

Check warning

Code scanning / CodeQL

Cookie 'HttpOnly' attribute is not set to true Medium

Sensitive cookie auth-token does not set HttpOnly attribute to true.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Out of scope of this PR - this is the current behavior when the cookie is set on the front end. The front end currently reads the cookie to tell if the user is logged in. This can be updated in a separate PR.

Comment on lines +35 to +42
http.SetCookie(w, &http.Cookie{
Name: "auth-token",
Value: w.authTokenFromResolver,
Path: "/",
SameSite: sameSite,
Secure: secure,
Expires: time.Now().Add(14 * 24 * time.Hour),
})

Check warning

Code scanning / CodeQL

Cookie 'Secure' attribute is not set to true Medium

Cookie does not set Secure attribute to true.

@tyler-hagen tyler-hagen Mar 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It is set to true if PHOTOVIEW_UI_ENDPOINT is set. In the case it is not, then the ui and api must be on the same domain. I do not want to break someones private network deployment that is maybe just served over http. I am assuming this is a pretty common setup.

Comment thread api/graphql/auth/auth_cookie_setter.go
Comment thread api/graphql/auth/auth_cookie_setter.go
@tyler-hagen tyler-hagen requested a review from googollee March 12, 2026 02:44

@googollee googollee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The backend part is good.

@kkovaletp do you have any other idea?

@googollee

Copy link
Copy Markdown
Contributor

@tyler-hagen I built an image based on your PR, ghcr.io/googollee/photoview:auth. After restarting, I can't open the page because of invalid authorization token. The access contains an invalid token in cookies, since I didn't clean the data in the browser.

The old behavior will redirect to the login page and remove the token from cookies. This PR breaks this behavior. Could you try to fix it? Thanks.

@googollee

Copy link
Copy Markdown
Contributor

@tyler-hagen after some checking, the unauthorized issue is not related with this pr. It still exists in the master code.

The backend is good.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@coderabbitai, this comment is related to all changes in this PR, not only in this file.
Please carefully analyze this PR in the context of the project code, configuration, and documentation available in this repo. Please take into account all possible and supported deployment types (including single-image Docker, separate images with API and UI running in different containers, direct deployment on the host, a deployment behind a proxy, etc.). Please make sure to resolve all assumptions based on the info from the repo and post your investigation results after you're confident in them. Please take this chance of an end-to-end PR review from different angles as the final check before the merge.
Based on your analysis, please answer:

  • Is this PR consistent with the product code?
  • Does it implement the functionality in the most efficient and reliable way?
  • Do you see any major (or higher) risks, bugs, or regressions preventing it from being merged?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

This is a thorough request — let me dig into the repository to gather all the context needed before giving you a confident answer.


🏁 Script executed:

#!/bin/bash
echo "=== api/server.go ==="
cat api/server.go

echo ""
echo "=== api/graphql/auth/auth.go ==="
cat api/graphql/auth/auth.go

echo ""
echo "=== api/graphql/auth/auth_cookie_setter.go ==="
cat api/graphql/auth/auth_cookie_setter.go

echo ""
echo "=== api/graphql/resolvers/user.go ==="
cat api/graphql/resolvers/user.go

echo ""
echo "=== ui/src/Pages/LoginPage/loginUtilities.tsx ==="
cat ui/src/Pages/LoginPage/loginUtilities.tsx

echo ""
echo "=== ui/src/Pages/LoginPage/LoginPage.tsx ==="
cat ui/src/Pages/LoginPage/LoginPage.tsx

echo ""
echo "=== ui/src/Pages/LoginPage/InitialSetupPage.tsx ==="
cat ui/src/Pages/LoginPage/InitialSetupPage.tsx

echo ""
echo "=== Environment variable definitions ==="
rg -rn "PHOTOVIEW_SERVE_UI|SEPARATE_DOMAIN|separateDomain|SeparateDomain|separate.domain|separate_domain" --type=go --type=ts --type=yaml --type=env -i

echo ""
echo "=== Existing cookie handling in api ==="
rg -rn "SetCookie\|auth.token\|auth-token\|saveTokenCookie\|token.*cookie\|cookie.*token" --type=go -i

echo ""
echo "=== CORS configuration ==="
rg -rn "CORS\|cors\|SameSite\|SameSiteNone" --type=go -C3

echo ""
echo "=== docker-compose files ==="
fd "docker-compose" --exec cat {}

echo ""
echo "=== Dockerfile(s) ==="
fd "Dockerfile" --exec cat {}

echo ""
echo "=== Environment variable loading in api ==="
fd "*.go" --exec rg -l "PHOTOVIEW" {} \; 2>/dev/null | head -30
rg -rn "PHOTOVIEW" --type=go -C1 | head -100

echo ""
echo "=== Proxy / nginx config ==="
fd "nginx" --exec cat {}

echo ""
echo "=== Existing saveTokenCookie and related UI ==="
rg -rn "saveTokenCookie\|auth.token\|auth-token" --type=ts -C3

echo ""
echo "=== ui/src/Pages/LoginPage ==="
ls ui/src/Pages/LoginPage/

echo ""
echo "=== Auth cookie setter test ==="
cat api/graphql/auth/auth_cookie_setter_test.go

Length of output: 61959


Let me gather a bit more context before giving the final answer.


🏁 Script executed:

#!/bin/bash
echo "=== api/utils/environment_variables.go ==="
cat api/utils/environment_variables.go

echo ""
echo "=== api/utils/urls.go or similar ==="
fd "url" api/utils/ --exec cat {}

echo ""
echo "=== ui/src/helpers/authentication.tsx ==="
cat ui/src/helpers/authentication.tsx

echo ""
echo "=== Access token model (expiry) ==="
fd "access_token" --type=f --exec cat {}

echo ""
echo "=== CORS middleware ==="
cat api/server/cors.go 2>/dev/null || fd "cors" --type=f --exec cat {}

echo ""
echo "=== server/logging.go (LoggingMiddleware) ==="
cat api/server/logging.go 2>/dev/null || fd "logging" api/server/ --exec cat {}

echo ""
echo "=== saveTokenCookie usages in UI ==="
rg -rn "saveTokenCookie\|authToken\|auth-token\|auth_token" --type=ts -C2

echo ""
echo "=== LoginPage tests ==="
cat ui/src/Pages/LoginPage/LoginPage.test.tsx
cat ui/src/Pages/LoginPage/InitialSetupPage.test.tsx

echo ""
echo "=== loginTestHelpers ==="
cat ui/src/Pages/LoginPage/loginTestHelpers.ts

echo ""
echo "=== httpsnoop in go.mod ==="
grep -i "httpsnoop\|felixge" api/go.mod api/go.sum | head -10

echo ""
echo "=== Check for nil deref - ResolverCookieFromContext usage ==="
rg -rn "ResolverCookieFromContext" --type=go -C3

echo ""
echo "=== PHOTOVIEW_UI_ENDPOINT(S) in docker-compose ==="
rg -rn "UI_ENDPOINT" --type=yaml -C2

echo ""
echo "=== PHOTOVIEW_UI_ENDPOINT(S) in .env example files ==="
fd ".env" --exec cat {} 2>/dev/null | head -100
fd "env.example\|env.sample\|\.env\.example" --exec cat {} 2>/dev/null | head -100

Length of output: 20160


Let me check the logout flow and the authentication helper before giving the final answer.


🏁 Script executed:

#!/bin/bash
echo "=== Logout resolver ==="
rg -rn "logout\|Logout\|SignOut\|signout" --type=go -C5 | head -80

echo ""
echo "=== ui/src/helpers/authentication.ts ==="
fd "authentication" ui/src/ --exec cat {}

echo ""
echo "=== Access token model ==="
fd "access_token\|AccessToken" api/ --type=f --exec cat {}

echo ""
echo "=== utils urls ==="
fd "url" api/utils/ --type=f --exec cat {}

echo ""
echo "=== What does UiEndpointUrl do ==="
rg -rn "UiEndpointUrl\|UIEndpoint\|ApiEndpointUrl\|ApiListenUrl" --type=go -C5 | head -60

echo ""
echo "=== Is there a logout cookie clear in any other handler ==="
rg -rn "MaxAge\|SetCookie\|Delete.*[Cc]ookie\|cookie.*[Dd]elete\|[Ee]xpire.*[Cc]ookie" --type=go -C3

echo ""
echo "=== Check PHOTOVIEW_UI_ENDPOINTS usage in entire codebase ==="
rg -rn "UI_ENDPOINTS\|UIEndpoints\|uiEndpoints" --type=go --type=ts -C3
rg -rn "UI_ENDPOINTS" -C3

echo ""
echo "=== Check how authToken is used in main app to check auth state ==="
rg -rn "authToken\|isAuthenticated\|auth.*[Cc]heck" --type=ts -C3 | head -80

Length of output: 6166


@kkovaletp — I've done a thorough end-to-end analysis of this PR, cross-referencing the middleware chain, deployment configurations, login/auth flows, and every changed file. Here are my findings.


End-to-End PR Review: #1387 "Set access token server side"

Is this PR consistent with the product code?

Mostly yes, with one concrete inconsistency that needs fixing:

  • The PR correctly reuses the existing PHOTOVIEW_UI_ENDPOINT env var (EnvUIEndpoint) — the same one already consulted by the CORS middleware and the WebSocket handler to determine whether cross-domain mode is active. The separateDomain flag is derived cleanly from utils.EnvUIEndpoint.GetValue() != "" in server.go.
  • The httpsnoop library (github.com/felixge/httpsnoop v1.0.4) was already a dependency in go.mod, so no new transitive dependency is introduced.
  • The middleware chain order is correct: dataloader.Middlewareauth.Middlewareserver.LoggingMiddlewareauth.AuthCookieSetterserver.CORSMiddleware. AuthCookieSetter must run after LoggingMiddleware (so that the statusResponseWriter wrapping is already in place) and before CORSMiddleware (so Access-Control-Allow-Credentials: true is in the response before the cookie is written) — this ordering is correct.
  • The 14-day cookie expiry matches the AUTH_TOKEN_MAX_AGE_IN_DAYS = 14 constant already defined in ui/src/helpers/authentication.ts, so both sides agree on the lifetime.
  • Inconsistency — orphaned import (compile/lint risk): loginUtilities.tsx still imports saveTokenCookie from authentication.ts but never calls it after the refactoring:
    // loginUtilities.tsx — this import is now dead
    import { saveTokenCookie } from '../../helpers/authentication'
    Depending on tsconfig/ESLint rules (no-unused-vars, @typescript-eslint/no-unused-vars), this will produce a build-time or CI lint error. It must be removed.

Does it implement the functionality in the most efficient and reliable way?

Efficient: yes. Reliable: not quite — one hard crash lurks.

The httpsnoop.Wrap approach (intercepting both WriteHeader and Write) is clean, idiomatic, and correctly handles both paths through which gqlgen can flush a response (explicit WriteHeader(200) vs. implicit on first Write). The test suite covers both paths. The pointer-in-context pattern (context.WithValue(ctx, key, &authToken)) is an unconventional but functional way to let a downstream resolver "write back" to the middleware.

However: both AuthorizeUser and InitialSetupWizard in user.go dereference the pointer without a nil guard:

cookie := auth.ResolverCookieFromContext(ctx)
*cookie = token.Value   // panics if cookie == nil

ResolverCookieFromContext explicitly returns nil when the context key is absent:

func ResolverCookieFromContext(ctx context.Context) *string {
    v := ctx.Value(userAccessTokenCtxKey)
    if v == nil {
        return nil   // ← returns nil
    }
    return v.(*string)
}

In production this is safe today because AuthCookieSetter is always registered. But any integration test, benchmark, or future code path that calls AuthorizeUser/InitialSetupWizard without going through the full server.go middleware chain will panic. The fix is a two-line guard in each resolver:

if cookie := auth.ResolverCookieFromContext(ctx); cookie != nil {
    *cookie = token.Value
}

Do you see any major risks, bugs, or regressions preventing merge?

🔴 Must-fix before merge

# File Issue
1 api/graphql/resolvers/user.go Nil pointer dereference*cookie = token.Value with no nil guard panics if middleware is absent (tests, future refactors). Fix: wrap in if cookie != nil. Both AuthorizeUser and InitialSetupWizard are affected.
2 ui/src/Pages/LoginPage/loginUtilities.tsx Dead importimport { saveTokenCookie } is never used after the refactor. Causes lint/TS error depending on project config.

🟡 Known trade-offs (acknowledged out-of-scope, but confirm understanding)

3. Cross-domain: authToken() returns undefined after login

authToken() in authentication.ts reads from document.cookie via js-cookie. In a cross-domain deployment (UI on ui.example.com, API on api.example.com), the Set-Cookie header from the API sets the token on api.example.com's cookie jar. JavaScript running on ui.example.com cannot read that cookie. Therefore:

  • After a successful login, login() redirects to the UI's base URL ✓
  • The home page's GraphQL requests succeed because the browser automatically sends the auth-token cookie to the API (SameSite=None; Secure ✓)
  • But if the user navigates back to /login, the if (authToken()) navigate('/') guard doesn't fire (returns undefined) — the user sees the login form again even though they're authenticated

This is a UX regression specifically for cross-domain setups, but not a functional auth failure. It was the pre-existing behavior for cross-domain anyway (nothing worked at all before this PR). Confirmed out-of-scope per the PR description.

4. Cross-domain: logout does not clear the API-domain cookie

The app has no server-side logout endpoint. Currently, logout is handled purely client-side by clearTokenCookie() in authentication.ts. For same-domain deployments this still works — the UI JS can remove a cookie on the same origin. For cross-domain deployments, clearTokenCookie() targets ui.example.com cookies and cannot reach the auth-token cookie set on api.example.com. After logout, the API-domain cookie persists until its 14-day expiry, meaning the browser will keep authenticating API requests after the user "logged out."

This is a security gap amplified (but not introduced) by this PR for cross-domain use. A proper server-side logout endpoint that responds with Set-Cookie: auth-token=; Max-Age=0; Path=/ is the correct fix. Recommend tracking as a follow-up issue.


Deployment matrix

Deployment type Login auth Post-login UI state Logout
Single-image Docker (API+UI same origin) ✅ works authToken() reads cookie clearTokenCookie() works
Separate containers, same domain/path via proxy ✅ works authToken() reads cookie clearTokenCookie() works
Separate containers, different domains (PHOTOVIEW_UI_ENDPOINT set) fixed by this PR ⚠️ authToken() returns null ⚠️ logout doesn't clear API cookie
Behind reverse proxy (nginx, Traefik, Caddy) ✅ works (headers pass through) same as above based on same/diff domain same as above
Direct host deployment ✅ works authToken() reads cookie clearTokenCookie() works

Summary

The PR correctly solves the core problem (issue #184) and is well-implemented. Two items must be fixed before merge: the nil-pointer dereference in both resolver functions and the dead saveTokenCookie import. The cross-domain authToken() and logout issues are real and worth tracking as follow-ups, but do not block the immediate fix.

Would you like me to open a follow-up GitHub issue for the server-side logout / cross-domain cookie clearing gap?


🧠 Learnings used
Learnt from: tyler-hagen
Repo: photoview/photoview PR: 1387
File: api/graphql/auth/auth_cookie_setter.go:9-13
Timestamp: 2026-03-05T08:02:59.889Z
Learning: In photoview/photoview (api/graphql/auth/auth_cookie_setter.go), the unconditional `w.(http.Hijacker)` type assertion is intentionally safe because `AuthCookieSetter` middleware is always registered after `LoggingMiddleware` in api/server.go. By the time `AuthCookieSetter` receives the ResponseWriter, it is already a `*statusResponseWriter` (from logging.go) which always implements `http.Hijacker`. Do not flag this assertion as a panic risk in future reviews of this middleware stack.

Learnt from: tyler-hagen
Repo: photoview/photoview PR: 1387
File: api/graphql/auth/auth_cookie_setter.go:0-0
Timestamp: 2026-03-05T07:19:38.996Z
Learning: When implementing an http.ResponseWriter wrapper for gqlgen (or similar HTTP handlers), ensure you override both WriteHeader and Write so headers/cookies are set consistently in all paths, including the successful response path. Do not rely on implicit WriteHeader(200) in the happy path; explicitly handle header/cookie transmission in both error and success cases to avoid subtle bugs. Apply this guideline to Go files involved in HTTP transport wrappers within the api/graphql directory (and nearby analogous HTTP wrapper implementations).

Learnt from: kkovaletp
Repo: photoview/photoview PR: 965
File: ui/src/helpers/testUtils.tsx:0-0
Timestamp: 2025-02-07T18:56:43.166Z
Learning: When reviewing code that was previously suggested by CodeRabbit, avoid suggesting changes unless there are critical issues. This prevents unnecessary iterations and maintains consistency in recommendations.

Learnt from: DanielOaks
Repo: photoview/photoview PR: 1016
File: ui/src/components/mapbox/mapboxHelperFunctions.tsx:101-115
Timestamp: 2025-04-13T05:38:45.180Z
Learning: Error handling improvements and code style changes should be addressed in dedicated PRs rather than bundled with dependency update PRs which should remain focused on minimal changes required for the updates.

Learnt from: DanielOaks
Repo: photoview/photoview PR: 1016
File: ui/src/components/mapbox/mapboxHelperFunctions.tsx:44-57
Timestamp: 2025-04-13T05:39:39.494Z
Learning: For Photoview PRs, feature enhancements like improved error logging should be suggested as separate PRs rather than being included in dependency update PRs, which should focus on minimal necessary code changes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@tyler-hagen, @googollee, please take a look at the bot's findings. Are they valid?
As for me, the missing logout issue is the most important thing, as it allows to steal a cookie and use it till the 14-day expiration is reached.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agree I actually think the logout issue is the biggest one despite it being listed under "trade offs"

But I think they are all important. I'll fix all 4 issues

@googollee

Copy link
Copy Markdown
Contributor

I agree on:

  • Guard when using ResolverCookieFromContext(), like:
if cookie := auth.ResolverCookieFromContext(ctx); cookie != nil {
    *cookie = token.Value
}
  • Clean cookies when logging out.

For authToken() returns undefined after login, I guess it's true, but I don't have enough experience with frontend part.

@googollee googollee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please guard the return when using ResolverCookieFromContext().

@tyler-hagen

Copy link
Copy Markdown
Contributor Author

Apologies for the late reply - I've been traveling but am back home now.

@tyler-hagen after some checking, the unauthorized issue is not related with this pr. It still exists in the master code.

The backend is good.

Agreed it seems pre-existing, I will try to narrow down the problem in a separate issue/PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

API Related to the backend api server written in Go go Pull requests that update Go code high priority improvement Something, enhancing the product, but not a feature javascript Pull requests that update Javascript code security Security related issues and PRs UI Related to the frontend web ui written in Javascript

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

4 participants