Commit Graph

11 Commits

Author SHA1 Message Date
kolaente 970f3c3733 fix(auth): build OIDC end-session URL with id_token_hint and post_logout_redirect_uri
On OIDC logout Vikunja redirected to the configured `logouturl` with no query
parameters, so it never sent `id_token_hint` or `post_logout_redirect_uri`.
RP-Initiated-Logout-compliant providers (e.g. PocketID) then ignored the
post-logout redirect and left the user on the IdP's own login page.

This builds the end-session URL server-side from the OpenID Connect
RP-Initiated Logout 1.0 spec:

- id_token_hint (§2, RECOMMENDED): the ID token previously issued to the
  session. It lets the OP skip the logout-confirmation prompt and is what makes
  the OP honor post_logout_redirect_uri (the OP MAY require it, §3).
- post_logout_redirect_uri (§2, OPTIONAL): where the OP redirects after logout.
  MUST be pre-registered with the OP. Defaults to service.publicurl so the user
  lands back on Vikunja.
- client_id (§2, OPTIONAL): the RP client id; the OP verifies it matches the
  id_token_hint.

The end_session_endpoint is discovered from the provider's discovery document
(§2.1, REQUIRED metadata) and falls back to the static `logouturl` config when
the provider does not publish one.

To replay id_token_hint, the raw ID token (and the provider key) are persisted
on the session at the OIDC callback (new migration adds oidc_id_token /
oidc_provider_key columns to the sessions table). At logout the server reads
them, builds the URL, deletes the session, and returns the URL in the logout
response so the frontend redirects to it.

Security note: the raw ID token is stored at rest in the sessions table
(json:"-", never exposed over the API) and removed when the session is deleted
on logout.

Spec: OpenID Connect RP-Initiated Logout 1.0
https://openid.net/specs/openid-connect-rpinitiated-1_0.html

Fixes #2820
2026-06-19 16:06:26 +02:00
kolaente 5b7924b1f6 fix(auth): return ErrAccountLocked for locked accounts on login
The login status check mapped a locked account to ErrAccountDisabled,
surfacing the disabled-account error code and message even though a
dedicated ErrAccountLocked exists (and the OIDC flow already uses it). Map
the locked status to ErrAccountLocked so credential login is consistent with
OIDC across both /api/v1 and /api/v2. Disabled accounts still return
ErrAccountDisabled.

This changes the v1 login error code for locked accounts on the wire (1020 ->
1026); the change is intentional and approved.
2026-06-17 19:43:41 +00:00
kolaente a32d8d6492 fix(auth): roll back on commit failure in DeleteSession
Restore the rollback-on-commit-failure that v1's Logout handler had before
this session-deletion logic was extracted, so a failed commit does not leave
the transaction open longer than the deferred Close.
2026-06-17 19:43:41 +00:00
kolaente 78f79accb5 refactor(auth): extract transport-agnostic login, logout and OIDC cores
Pull the credential/TOTP check, session deletion, user-token issuance and
OIDC callback flow out of the v1 echo handlers and into reusable helpers so
both /api/v1 and the upcoming /api/v2 share one implementation:

- auth.IssueUserToken + auth.WriteUserAuthCookies split the token/cookie
  machinery from the echo response; NewUserAuthTokenResponse now wraps them.
- auth.SessionIDFromContext reads the sid claim for logout.
- shared.AuthenticateUserCredentials, shared.DeleteSession hold the login
  and logout cores.
- openid.AuthenticateCallback holds the OIDC exchange/getOrCreate/TOTP/team
  sync, returning the user; HandleCallback issues the token as before.

v1 behaviour is unchanged on the wire.
2026-06-17 19:43:41 +00:00
kolaente 5555950f03 refactor(testing): extract e2e fixture reset/truncate into shared package
Pull the HTTP-agnostic table reset and truncate-all logic out of the v1
testing handlers into pkg/routes/api/shared so /api/v2 can reuse it. v1's
wire behavior is unchanged; it now delegates to the shared functions.
2026-06-17 12:13:50 +00:00
kolaente b86710903b fix: dispatch pending events after user creation commits
The register handler, local/LDAP login and the OIDC callback all queue
the user.created event via DispatchOnCommit but never called
DispatchPending, so the event was silently dropped and its queue entry
leaked. Flush after commit and discard on rollback.
2026-06-12 08:56:08 +00:00
kolaente eac1fa2726 refactor(auth): extract shared auth/token business logic for v2 reuse
Pull the HTTP-independent core out of the v1 auth handlers so both
/api/v1 and the upcoming /api/v2 routes share one implementation:

- oauth2server: ExchangeToken and Authorize take plain inputs and return
  typed responses; HandleToken/HandleAuthorize keep binding + headers.
- pkg/routes/api/shared: AuthenticateLinkShare, RegisterUser,
  ResetPassword (+ session clear), RequestPasswordResetToken and
  ConfirmEmail, plus the shared UserRegister and LinkShareToken types.

v1 handlers now delegate to these; their wire output is unchanged.
2026-06-12 07:58:17 +00:00
kolaente 56b1ba47ec feat(api/v2): add public instance info endpoint
Add GET /api/v2/info (public — no auth). Extract the /info response type and
its assembly out of the v1 handler into pkg/routes/api/shared.BuildInfo() so
both API versions return byte-identical info; refactor v1's handler onto it.
Add the v2 path to unauthenticatedAPIPaths.
2026-06-11 20:07:43 +00:00
kolaente 5579daa452 feat(api/v2): add admin actions on /api/v2
Port the admin action endpoints to the Huma-backed /api/v2:

- GET    /admin/overview            instance counts + license snapshot
- POST   /admin/users               create a user (201)
- PATCH  /admin/users/{id}/admin    promote/demote (*bool, nil = unchanged)
- PATCH  /admin/users/{id}/status   set status (*Status, nil = unchanged)
- DELETE /admin/users/{id}          delete (mode=now|scheduled, 204)
- PATCH  /admin/projects/{id}/owner reassign project owner

All sit behind the existing gateV2AdminRoutes path middleware (admin + license
gate, 404 on failure), so no per-handler permission checks are added. The
hand-registered PATCH routes carry genuine partial semantics, which AutoPatch
does not synthesise. The admin user response reuses the existing
pkg/routes/api/shared package.
2026-06-11 19:32:42 +00:00
kolaente e25f997281 refactor(admin): extract shared admin overview, user-create and user-view helpers
Move the admin overview computation and struct into models.BuildOverview /
models.Overview, the admin create-user flow into models.CreateUserAsAdmin /
models.CreateUserBody, and the admin user response view into a new
pkg/routes/api/shared package (shared.AdminUser / shared.NewAdminUser) so both
the v1 and v2 admin routes call the same code. The v1 handlers are refactored
onto these helpers and stay byte-identical on the wire.
2026-06-11 19:32:42 +00:00
kolaente 46b07a019c refactor(user): extract shared account orchestration into models/user/shared for v1+v2
Pull the business logic out of the v1 current-user account/settings handlers
into reusable functions so both v1 and the upcoming v2 handlers call one
implementation. No behavior change — the v1 handlers keep their HTTP-layer
quirks (input binding, validation, error mapping); only orchestration moves.

Homes are forced by the import graph:
- shared.GetAuthProviderName  (new pkg/routes/api/shared, above openid+user so it
                              can combine both without a cycle; routes-only helper)
- user.ChangeUserEmail        (CheckUserCredentials + UpdateEmail, both in user)
- models.ChangeUserPassword   (needs models.DeleteAllUserSessions; user can't import models)
- models.UpdateUserGeneralSettings / UpdateUserAvatarProvider
                              (need avatar.FlushAllCaches; user can't import avatar)

The general settings get a single shared wire struct, models.UserGeneralSettings
(tagged for both swaggo/govalidator and Huma): it is the update request body and
the nested settings on GET /user for v1 (replacing v1's UserSettings) and v2.
ExtraSettingsLinks is readOnly — populated from the user on read, ignored on
write. A dedicated struct is required because user.User's settings fields are
json:"-" so they don't leak when it is embedded in other responses.
2026-06-11 07:02:31 +00:00