The four boolean OIDC provider fields (emailfallback, usernamefallback,
forceuserinfo, requireavailability) were parsed with a strict .(bool)
type assertion. That works for YAML/JSON config where leaves are native
bools, but fails for every other input path: env vars always arrive as
strings, and GetConfigValueFromFile (used by the *.file Docker secret
convention) also always returns strings. The assertion would silently
zero the field for emailfallback and usernamefallback, and log an error
and zero the field for forceuserinfo and requireavailability, which is
what #2599 reports.
Extract a small parseBoolField helper that accepts both native bools and
strings (via strconv.ParseBool) and logs a parse error from each call
site. This also fixes the previously-silent drop of stringified
emailfallback / usernamefallback values — those now log an error if the
input is garbage, matching the behaviour of the other two fields.
Fixes#2599
Regression test for #2599. Exercises getProviderFromMap with native
bools and with stringified booleans ("true"/"false"/"1"/"0") for all
four boolean provider fields — emailfallback, usernamefallback,
forceuserinfo, requireavailability. From env vars and from the
GetConfigValueFromFile path every leaf arrives as a string, so the
current .(bool) assertion silently zeros these fields.
The OIDC callback handler previously issued a JWT without ever
checking TOTP state. For installations with EmailFallback (or
UsernameFallback) enabled, this allowed an attacker who could
authenticate at the IdP with a matching email to log in as a local
user with TOTP enrolled, bypassing the second factor entirely.
HandleCallback now runs enforceTOTPIfRequired after resolving the
user and before any team sync writes, returning 412/1017 when the
passcode is missing or invalid. Clients resubmit the OIDC flow with
the totp_passcode field populated.
Fixes GHSA-8jvc-mcx6-r4cg
Extracts a TOTP gate that the OIDC callback will use to enforce 2FA
for users with TOTP enabled. Mirrors the local-login TOTP flow in
pkg/routes/api/v1/login.go. Not yet wired into HandleCallback.
Refs GHSA-8jvc-mcx6-r4cg
Covers the four states the OIDC TOTP gate must handle: user without
TOTP, TOTP enabled with missing passcode, invalid passcode, and
valid passcode. The helper function under test does not exist yet,
so the package currently fails to compile.
Refs GHSA-8jvc-mcx6-r4cg
Prepares the OIDC callback struct to carry a TOTP passcode so the
handler can enforce 2FA for users with TOTP enabled. No behaviour
change yet.
Refs GHSA-8jvc-mcx6-r4cg
Previously GetLinkShareFromClaims built a *LinkSharing entirely from JWT
claims with no DB interaction, so deleted shares and permission downgrades
took up to 72h (the JWT TTL) to take effect. The permission and sharedByID
claims were trusted blindly.
GetLinkShareFromClaims now takes an *xorm.Session, looks up the share via
GetLinkShareByID, verifies the hash claim against the DB row, and returns
ErrLinkShareTokenInvalid when the row is missing or the hash mismatches.
The permission and sharedByID claims are discarded; the DB row is
authoritative. GetAuthFromClaims opens a read session for the link-share
branch, mirroring the existing API-token branch.
Token creation and the JWT format are unchanged, so already-issued tokens
keep working except when the underlying share has been deleted or its hash
no longer matches.
Fixes GHSA-96q5-xm3p-7m84 / CVE-2026-35594.
Move JWT parsing (GetUserIDFromToken) and API token validation
(ValidateAPITokenString) into pkg/modules/auth so both HTTP middleware
and WebSocket auth use the same logic. This ensures consistent token
validity checks including expiry and user status (disabled/locked).
The HTTP API token middleware now delegates to the shared function,
removing duplicated lookup/expiry logic.
Detect when two configured OIDC providers resolve to the same issuer URL
at startup and halt with a fatal error, preventing team sync data
corruption caused by ambiguous (external_id, issuer) matching.
Also adds duplicate issuer detection to the doctor service diagnostics
and comprehensive tests with mock OIDC discovery servers.
Add web tests covering the authorize endpoint, token exchange, PKCE
verification, single-use codes, and refresh token rotation. Add unit
tests for redirect URI validation and PKCE. Add E2E test for the full
browser-based authorization code flow with login redirect.
Extract setupApiUrl helper for E2E tests to avoid duplication.
Add POST /api/v1/oauth/token supporting authorization_code and
refresh_token grant types. Validates PKCE, exchanges codes for
JWT access tokens with refresh token rotation. Uses the shared
RefreshSession helper for the refresh grant.
Add POST /api/v1/oauth/authorize behind auth middleware. Validates
OAuth parameters (response_type, redirect_uri, PKCE), fetches the
authenticated user, creates an authorization code, and returns it
as JSON for the frontend to handle the redirect.
Add redirect URI validation that allowlists vikunja-* custom protocol
schemes, rejecting http/https and dangerous schemes like javascript:.
Add PKCE S256 verification following RFC 7636.
The cookie-based /user/token/refresh handler had session refresh logic
(lookup, expiry check, token rotation, user fetch, JWT generation)
that will be reused by the OAuth token endpoint. Extract it into
auth.RefreshSession() and rewrite RefreshToken to use it.
Teams synced from OpenID Connect providers were always named with "(OIDC)"
suffix (e.g., "DevTeam (OIDC)"). This changes it to use the configured
provider name instead (e.g., "DevTeam (Keycloak)"), making it easier to
identify which provider a team came from when multiple OIDC providers are
configured. Existing team names will be updated automatically on next user
login.
https://claude.ai/code/session_012LXXPvYe6i27WTcha1PL7A
When a disabled/locked LDAP user authenticates, return early from
getOrCreateLdapUser without updating their profile info or syncing
avatar. The login handler already rejects them, but this avoids
unnecessary database writes.
Ref: GHSA-94xm-jj8x-3cr4
When `forceuserinfo: true`, `mergeClaims` discards `vikunja_groups`
and `extra_settings_links` claims fetched from the userinfo endpoint,
failing team sync for opaque tokens.
Fixes team sync for OIDC providers using opaque tokens.
Return ErrAccountLocked for locked users instead of ErrAccountDisabled.
Also skip profile updates and avatar sync for disabled/locked users
found during OIDC login — HandleCallback rejects the auth anyway.
Make isErrUserStatusError public and replace all verbose
!IsErrAccountDisabled(err) && !IsErrAccountLocked(err) checks
with the shorter IsErrUserStatusError(err) call.
SameSite=None requires Secure=true per browser spec. When running over
plain HTTP (local dev, e2e tests), browsers reject or downgrade the
cookie, breaking session refresh. Fall back to SameSite=Lax for HTTP
while keeping SameSite=None for HTTPS (needed for the Electron desktop
app cross-origin scenario).
SameSite=Strict prevents the browser from sending the HttpOnly refresh
token cookie in cross-origin contexts like the Electron desktop app,
where the page runs on localhost but the API is on a remote host. This
caused sessions to expire quickly because refresh requests never
included the cookie.
SameSite=None allows cross-origin sending while HttpOnly still prevents
JavaScript from reading the cookie value (XSS protection).
Resolves#2309
syncUserGroups created its own db.NewSession() internally while being
called from AuthenticateUserInLDAP which already has an active session
with writes. In SQLite shared-cache mode this causes a lock conflict.
Pass the caller's session through instead, and add s.Commit() before
db.AssertExists calls in LDAP tests.
Refactor functions that created their own sessions when called from
within existing transactions, which caused "database table is locked"
errors in SQLite's shared-cache mode.
Changes:
- Add files.CreateWithSession() to reuse caller's session
- Refactor DeleteBackgroundFileIfExists() to accept session parameter
- Add variadic session parameter to notifications.Notify() and
Notifiable.ShouldNotify() interface
- Update all Notify callers (~17 sites) to pass their session through
- Use files.CreateWithSession in SaveBackgroundFile and NewAttachment
- Fix test code to commit sessions before assertions
Add defer s.Close() to sessions that were never closed:
- auth.GetAuthFromClaims inline session
- models.deleteUsers cron function
- notifications.notify database insert
- Login creates a server-side session and sets an HttpOnly refresh
token cookie alongside the short-lived JWT
- POST /user/token/refresh exchanges the cookie for a new JWT and
rotates the refresh token atomically
- POST /user/logout destroys the session and clears the cookie
- POST /user/token restricted to link share tokens only
- Session list (GET) and delete (DELETE) routes for /user/sessions
- All user sessions invalidated on password change and reset
- CORS configured to allow credentials for cross-origin cookies
- JWT 401 responses use structured error code 11 for client detection
- Refresh token cookie name constants annotated for gosec G101
As discussed on Matrix, Vikunja currently prevents users from using LDAP
authentication if the server allows anonymous binds (common in local
environments like YunoHost). The application would previously trigger a
`log.Fatal` if `AuthLdapBindDN` or `AuthLdapBindPassword` were left
empty in the configuration.
#### **How this fixes the problem:**
* **Validation:** Removed the strict requirement for Bind credentials in
`InitializeLDAPConnection`.
* **Connection Logic:** Updated `ConnectAndBindToLDAPDirectory` to
attempt an `UnauthenticatedBind` from the `go-ldap` library when no
credentials are provided.
* **Safety:** If a Bind DN is provided, the behavior remains unchanged
(authenticated bind).
#### **Testing:**
* Tested manually on a **YunoHost** instance by replacing the binary.
* Confirmed that Vikunja now successfully starts and authenticates users
via the local LDAP (localhost) without requiring a service account.
* Added a basic unit test in `pkg/modules/auth/ldap/ldap_test.go` to
ensure the initialization logic doesn't crash with empty credentials.
*Note: This is my first contribution to a Go project (assisted by an LLM
for syntax). Feedback on code style is more than welcome!*
Remove email, name, emailRemindersEnabled, and isLocalUser from user JWT
claims, and isLocalUser from link share JWT claims. These fields are never
used from the token - the backend always fetches the full user from the
database by ID, and the frontend fetches user data from the /user API
endpoint immediately after login.
Also simplify GetUserFromClaims to only extract id and username, and
remove the now-unnecessary email override in the frontend's
refreshUserInfo.
This changes the error handling to a centralized HTTP error handler in `pkg/routes/error_handler.go` that converts all error types to proper HTTP responses. This simplifies the overall error handling because http handler now only need to return the error instead of calling HandleHTTPError as previously.
It also removes the duplication between handling errors with and without Sentry.
🐰 Hop along, dear errors, no more wrapping today!
We've centralized handlers in a shiny new way,
From scattered to unified, the code flows so clean,
ValidationHTTPError marshals JSON supreme!
Direct propagation hops forward with glee,
A refactor so grand—what a sight to see! 🎉