Fix OIDC login-gate cross-file wiring bugs from whole-branch review

Four bugs slipped through per-task review since each task only saw its
own diff:

- Logout was a plain <a href> GET against a POST-only backend route, so
  it 405'd and never cleared the session cookie or hit Keycloak's
  end-session redirect. Now a <form method="post"> with a submit button
  styled to match the old link (still a real full-page navigation, not
  a fetch, so the Keycloak redirect chain still works).
- Login/logout used origin-relative paths, unreachable from the Vite
  dev server (:5173) against the backend (:8080) with no proxy
  configured. Both now build their URL from client.ts's now-exported
  BASE_URL.
- handleSessionCallback's four failure paths redirected to
  /?auth_error=failed with no logging, making a real OIDC failure
  undiagnosable in production. Added log.Printf on each failure site.
- handleSessionLogout passed a bare "/" to EndSessionURL; Keycloak
  requires post_logout_redirect_uri to be an absolute, registered URL.
  Added SessionConfig.PublicBaseURL, wired from cfg.PublicBaseURL in
  main.go, and used to build an absolute redirect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 22:03:02 +02:00
parent eeff1e9b40
commit 502e61e7b4
7 changed files with 39 additions and 17 deletions

View File

@@ -94,9 +94,14 @@ body {
}
.logout-link {
background: none;
border: none;
padding: 0;
font: inherit;
color: #9aa0ab;
font-size: 0.85rem;
text-decoration: none;
cursor: pointer;
}
.logout-link:hover {

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { api } from "./api/client";
import { api, BASE_URL } from "./api/client";
import "./App.css";
import { Dashboard } from "./pages/Dashboard";
import { Plan } from "./pages/Plan";
@@ -56,9 +56,11 @@ function App({ session }: { session: SessionInfo }) {
>
{profileName ?? "Profile"}
</button>
<a className="logout-link" href="/api/session/logout" title={session.email}>
Log out
</a>
<form method="post" action={`${BASE_URL}/api/session/logout`} title={session.email}>
<button type="submit" className="logout-link">
Log out
</button>
</form>
</div>
</header>
<main>{showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { api } from "./api/client";
import { api, BASE_URL } from "./api/client";
import "./LoginGate.css";
import App from "./App";
import type { SessionInfo } from "./types/api";
@@ -39,7 +39,7 @@ export function LoginGate() {
<div className="login-gate">
<h1>🧞 geniusrun</h1>
{authError && <p className="login-gate-error">{AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."}</p>}
<a className="login-gate-button" href="/api/session/login">
<a className="login-gate-button" href={`${BASE_URL}/api/session/login`}>
Log in
</a>
</div>

View File

@@ -13,7 +13,7 @@ import type {
WorkoutKind,
} from "../types/api";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
export const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
@@ -39,9 +39,12 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = {
// Session (app login via OIDC -- distinct from the Garmin credential
// login below). No logout()/login() methods: those are plain <a href>
// full-page navigations (see LoginGate.tsx), not fetches, since the OIDC
// flow and Keycloak's own logout redirect need real browser navigation.
// login below). No logout()/login() methods: login is a plain <a href>
// and logout is a <form method="post"> submit button (see App.tsx /
// LoginGate.tsx) -- both are real full-page navigations, not fetches,
// since the OIDC flow and Keycloak's own logout redirect need real
// browser navigation. Logout must POST (see server.go's route), which an
// <a href> can't do, hence the form.
getSessionInfo: () => request<SessionInfo>("/api/session/me"),
// Auth