Fix broken login on omsorgWeb: restore refreshToken in auth responses
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 14s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Successful in 5s
Docker-Images bauen und veröffentlichen / build (, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 17s

The cookie-only refresh-token migration earlier this session broke both
mitarbeiter-app and mitarbeiter-app-legacy: they're server-to-server PHP
clients (cURL/Guzzle calling omsorgCore directly) with no browser cookie
jar, so dropping refreshToken from the login/refresh response body left
them with nothing to store - login appeared to succeed, redirected to
the dashboard, but the very next page's session check failed silently
(mitarbeiter-app's _ensure_fresh_token() bails out whenever
$_SESSION['omsorgcore_refresh_token'] is empty), bouncing the user back
to the login form every time.

Fix: dual-mode refresh token transport instead of cookie-only.
- LoginResponse includes refreshToken again (restores the pre-migration
  contract PHP already expected) alongside the HttpOnly cookie.
- AuthController.Refresh/Logout accept an optional body-carried
  RefreshRequest/LogoutRequest as a fallback: cookie is checked first
  (browser/omsorgapp), body second (server-to-server clients).
- omsorgapp keeps ignoring the body's refreshToken and relies solely on
  the cookie (XSS-safe) - only its authApi.js needed a small update since
  the regenerated client now requires an explicit (empty) parameter
  object for refresh/logout.
- Regenerated omsorgcore-client-ts; api-client-php's lib/ was already
  consistent (never regenerated during the original migration, so it
  still expected refreshToken all along - only the backend had stopped
  providing it).

Verified end-to-end against a live instance: PHP login+refresh via
omsorgcore_login()/omsorgcore_refresh(), and the browser cookie-only
flow via curl with Origin/credentials headers - both work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-08-10 19:02:16 +02:00
co-authored by Claude Sonnet 5
parent dca0349e8c
commit 09dce2ab98
14 changed files with 226 additions and 24 deletions
@@ -1,3 +1,8 @@
namespace OmsorgCore.Api.Contracts;
public record LoginResponse(string AccessToken, DateTime ExpiresAt, bool MustChangePassword);
// RefreshToken steht hier UND als HttpOnly-Cookie (siehe AuthController.SetRefreshTokenCookie) -
// zwei Konsumenten mit unterschiedlichem Transport: omsorgapp (Browser) ignoriert dieses Feld
// bewusst und verlässt sich nur auf die Cookie (XSS-sicher, siehe omsorgapp/src/api/authApi.js).
// omsorgWeb (PHP, server-seitiger Aufrufer ohne Browser-Cookie-Jar) MUSS den Wert hier lesen und
// selbst in der PHP-Session verwalten, siehe omsorgWeb/mitarbeiter-app/lib/auth.php.
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt, bool MustChangePassword);
@@ -0,0 +1,5 @@
namespace OmsorgCore.Api.Contracts;
// Optional - nur für Aufrufer ohne Browser-Cookie-Jar (server-seitige API-Clients wie omsorgWeb),
// siehe AuthController.Logout. Ein Browser schickt keinen Body, die HttpOnly-Cookie reicht.
public record LogoutRequest(string? RefreshToken);
@@ -0,0 +1,5 @@
namespace OmsorgCore.Api.Contracts;
// Optional - nur für Aufrufer ohne Browser-Cookie-Jar (server-seitige API-Clients wie omsorgWeb),
// siehe AuthController.Refresh. Ein Browser schickt keinen Body, die HttpOnly-Cookie reicht.
public record RefreshRequest(string? RefreshToken);
@@ -82,13 +82,26 @@ public class AuthController : ControllerBase
await _dispatcher.DispatchAsync(new AuditEvent(result.UserId, result.Username, ipAddress, "Login"), cancellationToken);
SetRefreshTokenCookie(result.RefreshToken);
return Ok(new LoginResponse(result.Token, result.ExpiresAt.Value, result.MustChangePassword));
return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
}
// Zwei Transportwege für den vorgelegten Refresh-Token, in dieser Reihenfolge geprüft:
// 1. HttpOnly-Cookie (Browser, omsorgapp) - schickt bewusst keinen Body.
// 2. Body (server-seitige API-Clients ohne Cookie-Jar, z. B. omsorgWeb per PHP-cURL/Guzzle).
private string? ResolveRefreshToken(string? bodyRefreshToken)
{
if (Request.Cookies.TryGetValue(RefreshTokenCookieName, out var cookieToken) && !string.IsNullOrEmpty(cookieToken))
{
return cookieToken;
}
return string.IsNullOrEmpty(bodyRefreshToken) ? null : bodyRefreshToken;
}
[HttpPost("refresh")]
public async Task<ActionResult<LoginResponse>> Refresh(CancellationToken cancellationToken)
public async Task<ActionResult<LoginResponse>> Refresh(RefreshRequest? request, CancellationToken cancellationToken)
{
if (!Request.Cookies.TryGetValue(RefreshTokenCookieName, out var refreshToken) || string.IsNullOrEmpty(refreshToken))
var refreshToken = ResolveRefreshToken(request?.RefreshToken);
if (refreshToken is null)
{
return Unauthorized();
}
@@ -99,19 +112,28 @@ public class AuthController : ControllerBase
return Unauthorized();
}
SetRefreshTokenCookie(result.RefreshToken);
return Ok(new LoginResponse(result.Token, result.ExpiresAt.Value, result.MustChangePassword));
// Cookie nur erneuern, wenn der Aufrufer auch eine Cookie vorgelegt hat - ein reiner
// API-Client (Body-basiert) soll keine Cookie untergeschoben bekommen, die er nie abfragt.
if (Request.Cookies.ContainsKey(RefreshTokenCookieName))
{
SetRefreshTokenCookie(result.RefreshToken);
}
return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
}
[HttpPost("logout")]
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
public async Task<IActionResult> Logout(LogoutRequest? request, CancellationToken cancellationToken)
{
if (Request.Cookies.TryGetValue(RefreshTokenCookieName, out var refreshToken) && !string.IsNullOrEmpty(refreshToken))
var refreshToken = ResolveRefreshToken(request?.RefreshToken);
if (refreshToken is not null)
{
await _authService.RevokeAsync(refreshToken, cancellationToken);
}
Response.Cookies.Delete(RefreshTokenCookieName, new CookieOptions { Path = "/api/auth" });
if (Request.Cookies.ContainsKey(RefreshTokenCookieName))
{
Response.Cookies.Delete(RefreshTokenCookieName, new CookieOptions { Path = "/api/auth" });
}
await _dispatcher.DispatchAsync(
new AuditEvent(_currentUserService.UserId, _currentUserService.Username, _currentUserService.IpAddress, "Logout"),
cancellationToken);