Commit Graph
214 Commits
Author SHA1 Message Date
kolaenteandTink b2c1686d11 feat(auth): preview verified OIDC accounts and reject unavailable identities 2026-11-05 12:44:50 +01:00
kolaenteandTink 859b5c5255 feat(user): notify users when an OIDC identity is linked 2026-11-05 12:44:50 +01:00
kolaenteandTink f0625e6d1c feat(api): add reauthenticated OIDC identity management endpoints 2026-11-05 12:44:50 +01:00
kolaenteandTink 68feffefc3 test(openid): count code exchanges in the mock IdP 2026-11-05 12:44:50 +01:00
kolaenteandTink 934ced938e feat(auth): persist linked OIDC identities and authenticate local accounts 2026-11-05 12:44:49 +01:00
kolaenteandTink 5a3ef8e565 refactor(openid): extract issuer to provider lookup from the admin user view
FindProviderByIssuer is the lookup linked identities will use to resolve
their provider; the admin user view switches to it.
2026-11-05 12:44:49 +01:00
kolaenteandTink 833feb90ed test(openid): pin callback login behaviour with a signing mock IdP
Adds a mock OpenID provider issuing signed ID tokens so AuthenticateCallback
can be tested end to end, and pins the current behaviour for OIDC-created
users (email/name/avatar/team sync, disabled user refused). The proxy test
reuses the same mock instead of its inline copy.
2026-11-05 12:44:49 +01:00
kolaenteandTink 61f8af603f refactor(user): share password and TOTP reauthentication 2026-11-05 12:44:49 +01:00
kolaente f44e605075 feat(auth): warn once per provider about enabled fallback options 2026-11-04 16:02:57 +01:00
kolaente 45f06adc27 feat(auth): log a deprecation warning on openid fallback matches
Every login matched through emailfallback or usernamefallback now warns that both options will be removed and the user has to link the provider from their account settings.
2026-11-04 16:02:57 +01:00
s-seidelandGitHub ded000f11c feat(auth): sync teams from oidc groups claim (#4311) 2026-11-03 17:13:41 +00:00
kolaente 01c3884bb1 feat(auth): sync provider avatars in the background after login
Address review decision: the post-commit avatar sync still blocked the
LDAP/OIDC login response for the download and up to 30s of decode
queueing. It now runs in a goroutine on a context that outlives the
request, with panics recovered and failures logged.
2026-10-20 23:05:30 +02:00
kolaente 98abca499a fix(auth): sync OIDC and LDAP avatars after the login commits
Address review decision: the provider avatar was decoded inside the login
transaction after the user row was written, holding it while queued for
decode capacity, and a later rollback (TOTP, disabled user) discarded the
work. Logins now commit first; the avatar is decoded without a session
and saved in its own short transaction.
2026-10-20 23:05:30 +02:00
kolaente 9467ad5787 fix(avatar): decode avatars before opening a database session
Address review decision: a decode queued for capacity held the request's
transaction for up to 30s, so on SQLite a concurrent write made the
upload fail with database is locked. Uploads and avatar reads now decode
and encode first; only the encoded PNG enters the transaction.
2026-10-20 23:05:30 +02:00
kolaente b764cf77eb fix(avatar): sync LDAP and OIDC avatars through one decode
Address pr-swarm finding: both logins copied crop, store and flush, had
diverged, decoded every avatar twice, and imaging.Fill cloned images
thinner than 100 px at full resolution outside the decode budget.
2026-10-20 23:05:30 +02:00
kolaente d879cb5c19 fix(ldap): keep the login when storing the synced avatar fails
Address pr-swarm finding: a busy decode budget made StoreAvatarFile fail
the whole LDAP login with 503, while OIDC skips the avatar.
2026-10-20 23:05:30 +02:00
kolaente 1ce3df2d60 refactor(imageutils): queue decodes on the caller's context
Address pr-swarm finding: avatar, background and OIDC/LDAP decodes passed
context.Background(), so a disconnected client stayed queued for up to 30s.
2026-10-20 23:05:30 +02:00
kolaente eaecaa53fe fix(oidc): bound avatar decode memory
Identity-provider avatars are cropped through imageutils.Decode. When the
server is busy the avatar is skipped and logged, like any other avatar
sync error, so the login still succeeds.
2026-10-20 23:05:30 +02:00
kolaente da41604b64 fix(auth): reject user JWTs whose session was revoked on the HTTP path 2026-10-20 15:13:02 +02:00
kolaente a0454051cb fix(auth): refuse email-unconfirmed accounts on every credential path 2026-10-20 15:13:02 +02:00
kolaente c9b6d29383 fix(openid): copy oauth2 config before setting per-request redirect URL
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.
2026-10-19 17:51:16 +02:00
kolaente 13f7a89a09 fix(openid): reject non-http(s) end_session_endpoint
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.
2026-10-19 16:58:05 +02:00
kolaenteandTink 33bd1c9afe feat(ldap): match users and groups by an immutable directory id
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.
2026-10-19 13:42:19 +02:00
kolaenteandTink a2875739e9 fix(ldap): match legacy group members against the ldap username
Address pr-swarm finding.
2026-10-19 13:42:19 +02:00
kolaenteandTink eff486337f feat(ldap): query only the user's groups during group sync
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.
2026-10-19 13:42:19 +02:00
kolaenteandTink 413b5bead2 refactor(ldap): use go-ldap filter escaping for the user filter
Address pr-swarm finding.
2026-10-19 13:42:19 +02:00
kolaente f99110c7d2 fix(auth): send the refresh cookie with SameSite=Lax
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.
2026-10-02 19:08:02 +02:00
kolaente 46ae983554 fix(websocket): check the session and close sockets on revocation
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.
2026-10-02 19:08:02 +02:00
kolaenteandTink 1bf93c58aa refactor(utils): rename NewHTTPClient to NewUnguardedHTTPClient
Address review comment: make the missing SSRF guard obvious at the call site
2026-09-30 18:26:56 +02:00
kolaenteandTink bff05f2377 fix(utils): stop exempting direct requests to the proxy address from the SSRF guard
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
2026-09-30 18:26:56 +02:00
kolaenteandTink 3ddc89e2c5 feat(auth): route OIDC requests through the outgoing proxy 2026-09-30 18:26:56 +02:00
kolaenteandTink e61cf15617 refactor(auth): migrate session operations to the v2 SDK 2026-09-30 10:42:33 +02:00
kolaente cf92339788 fix(auth): add an iat claim to issued JWTs 2026-09-29 17:12:42 +02:00
kolaente 739d364f40 test(auth): require an iat claim on issued JWTs 2026-09-29 17:12:42 +02:00
06c451da40 fix(auth): link OIDC users by email when username fallback misses (#4012)
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>
2026-09-24 06:27:43 +00:00
kolaenteandTink 92f7edf0a0 refactor(humabridge): export the echo-context accessor
Five packages carried their own copy of the EchoContextKey lookup.
2026-09-15 11:41:34 +02:00
a59dbc2842 docs: add swagger annotations for the OAuth 2.0 endpoints (#3837)
`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>
2026-09-10 18:05:20 +00:00
kolaente fe712620f7 perf(db): read handlers and token auth run without a transaction
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.
2026-09-08 21:09:26 +02:00
kolaente 912e899c10 perf(auth): reuse the api token owner from the request context
GetAuthFromClaims opened a session and loaded the token owner by id on
every call, although the token middleware had already put the user into
the context. With the metrics middleware and the handler both resolving
the auth, that was two extra transactions and user lookups per request
authenticated with an API token.
2026-09-06 16:40:57 +02:00
kolaenteandkolaente b042d8c77a Update auth.go 2026-09-04 11:02:36 +02:00
kolaente 3be5bf8c4f fix(metrics): don't log an error for every unauthenticated request
The metrics middleware called GetAuthFromClaims on every request and logged
its error when no JWT was in context. Unauthenticated routes (login, token
refresh, /info, websocket) hit that path constantly, flooding the log at
WARNING level and burying real errors.

Skip the active-user update when the request carries no credentials.
2026-09-04 11:02:36 +02:00
kolaente 48322d5c08 fix(auth): reject bot accounts during OIDC fallback 2026-08-29 23:29:46 +02:00
kolaenteandTink 771ee93ae5 fix(auth): clear the refresh cookie only when the token is unusable
Only an expired session or a disabled account invalidates the cookie. A transient failure must not clear it, and neither must a token rotated away by a concurrent refresh: that would delete the cookie the winning refresh just set.
2026-08-29 21:47:07 +02:00
kolaenteandTink 7a7a2f030b refactor(auth): give RefreshSession typed domain errors
It returned *echo.HTTPError from a transport-agnostic module, which v2 had
to shim back into a huma error. Domain errors render on both APIs and let
callers tell the failure modes apart.

address pr-swarm finding
2026-08-29 21:47:07 +02:00
kolaenteandTink 39694d479c fix(auth): send the refresh token cookie to the v2 refresh endpoint
The cookie was path-scoped to /api/v1/user/token/refresh only, so browsers never sent it to the v2 endpoint and every v2 refresh returned 401. One cookie per API version path now, with the route literals shared as consts.
2026-08-29 21:47:07 +02:00
8656f03aae fix(openid): pin token endpoint auth method instead of probing (#3607)
Vikunja leaves `oauth2.Config.Endpoint.AuthStyle` at
`AuthStyleAutoDetect`, so `golang.org/x/oauth2` tries
`client_secret_basic`, retries with `client_secret_post` on *any* error,
and reports only the second error — turning every token endpoint failure
(a wrong client secret, say) into a bogus "the client registration does
not allow client_secret_post" complaint.

Style now comes from the provider's
`token_endpoint_auth_methods_supported` discovery value. Provider
advertises neither: keep autodetect, so nothing changes for providers
with a thin discovery document.

Side effect: one token request per login instead of two, no more
spurious failed-auth entry in provider logs on every successful login.

Reported at
https://community.vikunja.io/t/authelia-could-not-authenticate-against-third-party/4770

## How to verify

1. Register a Vikunja client in an OIDC provider that only accepts
`client_secret_basic` (Authelia: `token_endpoint_auth_method:
'client_secret_basic'`).
2. Configure that provider under `auth.openid.providers` in Vikunja, but
put a deliberately wrong value in `clientsecret`.
3. Log in through that provider and read the Vikunja log.
4. **Expected:** the error names the real problem — an `invalid_client`
/ "client secret did not match" message from the provider.
**Before this PR:** the error claimed the client registration does not
allow `client_secret_post`, which was never configured on either side.
5. Fix `clientsecret` to the correct value and log in again.
6. **Expected:** login succeeds, and the provider's access log shows a
single `POST /token` per login rather than a rejected one followed by an
accepted one.

---------

Co-authored-by: kolaente <k@knt.li>
2026-08-27 12:43:11 +00:00
kolaente dbe664dec5 fix(security): reject non-user tokens in GetUserFromClaims
GetUserFromClaims read the id claim straight into User.ID without checking the
token type. A link share JWT carries the raw, positive share id in that claim
and reaches every authenticated route, so the only thing preventing
impersonation was the incidental absence of a username claim in link share
tokens. Adding one would have reintroduced the confusion with a positive id,
bypassing the GetID negation entirely.

AuthTypeUser moves to pkg/user, which parses the claims and cannot import
pkg/modules/auth, so the value exists once.
2026-08-04 13:40:11 +02:00
Mauandkolaente 1790727981 fix(openid): stop logging raw token-endpoint response bodies
Three Debug-level log calls in the token-exchange path logged the
raw response body/full oauth2.Token on error. The full token struct
has no String() override, so %v printed AccessToken/RefreshToken in
clear text; the raw response body could similarly carry sensitive
request/response context. Log the RFC 6749 error/error_description
fields (successful-exchange-but-missing-id_token path: the token
type) instead - the intended human-readable diagnostic.
2026-07-29 22:53:46 +02:00
TinkandGitHub 584ddd99a2 fix(oauth2): burn authorization code even when validation fails (#3350) 2026-07-28 17:13:15 +02:00
kolaente 4ae2e09301 fix(auth): reject API tokens at the OAuth authorize endpoint (GHSA-v3p6-34mc-hj7v) 2026-07-19 18:59:34 +02:00