The memory keyvalue store only shallow-copies Provider, so Oauth2Config
was shared between the cache and all requests. Concurrent callbacks
overwrote each other's RedirectURL, causing intermittent login failures.
A provider's discovery document could advertise a javascript: URL as
end_session_endpoint, which the frontend then navigated to on logout,
running script on Vikunja's origin. Only absolute http(s) URLs are now
accepted, both when caching the discovered endpoint and when building
the logout URL. Invalid endpoints are dropped so logout stays local.
Add auth.ldap.attribute.id. When set (e.g. objectGUID on AD, entryUUID
on OpenLDAP), users and teams are matched by that attribute instead of
the login name and the group DN, so renaming or moving them in the
directory no longer creates duplicate accounts or new teams without the
old shares.
AD's binary objectGUID is formatted as a canonical GUID string; other
attributes must be printable text. Existing accounts and teams are
switched to the new id on the next login. An empty option keeps the old
behaviour.
Support {userdn} and {username} placeholders in auth.ldap.groupsyncfilter.
When present, every matching group is a membership and the member
attribute is not fetched, so the cost scales with the user's own
memberships and nested AD groups can be resolved with
LDAP_MATCHING_RULE_IN_CHAIN. Filters without a placeholder keep the
previous client-side member matching.
The group search now uses paged results in both modes so directories
with more groups than the server's size limit no longer fail the login.
A CSV saved as UTF-16 (Excel's "Unicode Text") was read as UTF-8, leaving
a NUL between every character. MySQL and SQLite store that, Postgres text
columns reject it with SQLSTATE 22021 and the whole import fails.
The file is now decoded to UTF-8 when read: a UTF-16 BOM switches to
UTF-16 decoding, remaining NUL bytes are dropped and invalid UTF-8 becomes
U+FFFD, matching what encoding/json does for the JSON importers.
Fixes API-CLOUD-4G
Detect, preview and migrate each repeated the size checks and file read.
Extracting readCSVFile gives the raw bytes a single entry point, and
projectsFromCSV makes the migrate conversion testable without a database.
Over https the refresh cookie used SameSite=None. CORS allows localhost
origins by default for the desktop app, so a page on any localhost port
could refresh a session with the user's cookie and read the new token.
The cookie is now always SameSite=Lax. SameSite=None existed for the
desktop app's cookie refresh, which it replaced with OAuth over IPC in
v2.3.0.
Changelog:
- Desktop v2.2.x has to log in again more often; upgrade to v2.3.0 or later.
- Deployments with the frontend and API on different registrable domains
lose cookie refresh, as before v2.2.0.
The socket only checked the token's signature and expiry. A token of a
deleted session or of a disabled user still authenticated, and an open
socket kept receiving notifications after logout, session revocation,
password change or account deletion.
Auth now requires the token's session to exist for that user and the
user to be active; the connection registers first and is checked once,
so a concurrent revocation can't slip in between. Transient errors close
with try-again-later instead of invalid_token. Each socket closes when
its token expires, which bounds exposure even on nodes that miss a
revocation event.
Deleting sessions dispatches SessionsRevokedEvent on commit, every caller
flushes it after committing, and the hub closes the matching sockets.
Disabling or locking a user and deleting a user revoke their sessions,
also from the CLI.
The proxy dial exemption was keyed on addresses learned lazily from earlier
proxied requests, so a direct request (loopback, NO_PROXY or an unproxied
scheme) or a redirect to the proxy's host:port was dialed unguarded. Derive
the exempt addresses once from the configured or environment proxy URLs and
reject direct requests to them with ssrf.ErrProhibitedIP.
Address pr-swarm finding: SSRF guard bypass via the proxy address
Both `usernamefallback` + `emailfallback` on: lookup required username
AND email match, so local user with different username never linked,
duplicate account created. Now verified-email-only lookup always tried
last.
Fixes#2022
## How to verify
1. Create a local user `localuser` with email `me@example.com`.
2. Configure an OIDC provider with `usernamefallback: true` and
`emailfallback: true`. In the IdP, create a user whose email is
`me@example.com` (sent with `email_verified: true`) and whose
username/sub is different from `localuser`.
3. Log in to Vikunja through that OIDC provider.
4. **Expected:** You are logged in as `localuser`, and `vikunja user
list` shows no new user for the provider's issuer.
**Before this PR:** a second user with a random or IdP-provided username
was created.
---------
Co-authored-by: kolaente <k@knt.li>
The files row is inserted before the blob is written, and the local backend creates the file before copying into it, so a failed copy left a partial blob behind once the row was rolled back.
The vikunja-file import checks the user's stored files against
migration.vikunjafile.maxuserstorage. The upload is now one of those
files, so an import counted its own zip against the quota. Exclude files
referenced as a migration upload.
The upload was spooled to the receiving instance's temp dir and only its
name travelled with the event. That holds while events stay in-process,
but it ties the job to the instance that accepted the upload.
Store it through the files package on the configured backend and record
its id on the migration status, so the event carries only the status id
and any instance can run the import. The job copies it to a local temp
file first: the zip importer needs random access, which an S3 object body
cannot give.
The upload is removed once the import is over. An hourly cron deletes
leftovers from events lost on restart or instances that died mid-import.
The config was unmarshalled up front purely to produce a 400, then parsed
again by SetOptions, and a third time in the background job. Drop the
throwaway parse and give SetOptions a domain error that maps to 400 on
its own.
A file import ran inside the upload request. A big export takes minutes,
far longer than a reverse proxy holds a request open, so the client got a
502 while the import kept running and kept the user's migration slot, and
there was no way to cancel or retry it.
The upload request now only validates what is cheap to validate, claims
the slot, spools the upload into a per-process temp directory and queues a
FileMigrationRequestedEvent. FileMigrationListener runs the import through
the lifecycle it now shares with the OAuth migrations: stale event skip,
panic recovery, heartbeat, outcome and notifications. The vikunja-file
migrator rejects a non-zip or structurally broken export before queueing;
ticktick, wekan and csv would have to parse the whole upload, so they fail
in the job and report through the status and failure mail. The csv
migrator receives its column mapping as options carried with the event.
Split opening the zip and indexing its entries out of Migrate, so every
structural check that doesn't need entry contents runs in one place before
anything is imported. The missing VERSION check moves into that scan.
A finished migration_status row looked the same whether the import worked
or failed, so the client could only guess. Rows now carry error_kind, a
closed set the client translates (reported, interrupted, credentials,
queue, upload, detail), and error_message, set only for "detail": the
error caused by the user's own data, the same text the failure mail sends.
A failure we report to Sentry stores just the kind, so server internals
never reach the status.
The migration was already finished and its claim released when the done
notification failed, yet the error was returned and the listener then sent
a failure mail and overwrote the finished row. Log it instead. The failure
mail's own log line also printed the migration error instead of the
notification error.
The claim timeout was measured from started_at, so a long import either
kept a dead claim for a day (the old 24h default) or had its slot taken
over while still running. A running migration now refreshes heartbeat_at
at a tenth of the timeout, clamped to 1s..30s, and a claim only counts as
stale once its last beat is older than the timeout. Rows that never beat
fall back to started_at. The default timeout drops to 5m accordingly.
Also bind the stale cutoff in the configured timezone: xorm writes times in
the engine's zone but binds query args as-is, which broke the comparison on
SQLite.
A second import sent while the first claim was still being written could
fail the insert with whatever the driver reports under contention, which
surfaced as a 500. The claim now reads the running row before writing so a
losing request stays read-only, re-reads the live claim whenever the insert
fails, retries lock collisions a few times, and probes for a stale claim
before taking a write lock to release it.
SQLite dumps can contain empty strings in json columns (e.g. project_views.filter),
which PostgreSQL rejects with "invalid input syntax for type json".
Fixes#3895
mcp.New builds the tool index from the v2 document and serves a stateless
per-request server: JWTs are rejected, mcp:access is required, tools/list is
filtered by the caller's token, and cross-origin browser requests are
refused unless the CORS configuration trusts the origin.
Tools are built from the finished document: path and query parameters plus
the JSON body become one input schema, update tools take their schema from
the sibling PUT because AutoPatch strips $ref, and an explicit allow-list
decides which operations are typed tools, catalog actions, or hidden.
Top-level and related tasks are collected first and created in one batch
so preset indexes survive, related references resolve to the created
struct instead of a mutable id lookup, untitled relation stubs are skipped,
and positions and buckets remap through one old-id map.
After a wipe the init-schema path marks every migration as applied, so
the backfill never runs on the restored rows and every project would be
invisible.
`POST /api/v1/oauth/authorize` and `POST /api/v1/oauth/token` are served
but missing from the generated OpenAPI docs: the handlers in
`pkg/modules/auth/oauth2server` carry no swagger annotations, so neither
path is in `swagger.json` — 126 paths, no `oauth` among them.
Found while building a third-party client. The API answers fine; the
docs say the endpoints don't exist, so a client that validates its calls
against them can't use OAuth at all.
Adds annotations for both handlers and regenerates `pkg/swagger`. No
behaviour change — comments only, plus generated output. `mage
generate:swagger-docs` produces 521 insertions and 0 deletions, so
nothing else in the spec moved.
Checked against a 2.5.0 instance: `OPTIONS` on both paths returns
`Allow: OPTIONS, POST`, and `POST /api/v1/oauth/token` with no grant
type returns code 17007 naming `authorization_code` and `refresh_token`.
One judgement call worth a look: I annotated the token endpoint `@Accept
json`, matching the comment on `TokenRequest` that v1 binds JSON and v2
takes the form-encoded body. In practice v1 binds a form body too —
`Content-Type: application/x-www-form-urlencoded` with `grant_type=nope`
returns the same 17007 — so if you'd rather document both, say the word
and I'll widen it.
The `/api/v2` routes are untouched — happy to cover those too if you'd
like them in.
AI-assisted: annotations drafted with Claude, reviewed and tested by me.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
db.NewSession begins a transaction, so every request holds one pooled
connection for its whole life: across the API's own work and every round
trip in between. At 20,000 simulated users that filled a 192-connection
pool with 170 backends idle in transaction while 13 were active, and the
database sat at 40 % CPU with p99 at 94 ms.
NewReadSession attaches the session memo but does not Begin; each statement
borrows a connection only for itself. Commit and Rollback are no-ops on it,
so DoReadOne, DoReadAll and ValidateAPITokenString keep their shape. A read
path that writes (position healing) now commits per statement.