From 7da3607032223795bbe2b1ddf1a64ae1886c9cb4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:43:38 +0200 Subject: [PATCH] feat(audit): emit the login event for the OAuth code exchange The new v2 OAuth token endpoint mints a fresh session without going through NewUserAuthTokenResponse, so those logins were missing from the audit trail. The refresh grant stays unaudited like the v1 refresh. --- pkg/modules/auth/oauth2server/token.go | 18 ++++++++++++++---- pkg/routes/api/v2/oauth.go | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/pkg/modules/auth/oauth2server/token.go b/pkg/modules/auth/oauth2server/token.go index 9d8d33a9a..11f85772e 100644 --- a/pkg/modules/auth/oauth2server/token.go +++ b/pkg/modules/auth/oauth2server/token.go @@ -17,10 +17,14 @@ package oauth2server import ( + "context" + "net/http" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/user" @@ -56,7 +60,7 @@ func HandleToken(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body") } - resp, err := ExchangeToken(&req, c.Request().UserAgent(), c.RealIP()) + resp, err := ExchangeToken(c.Request().Context(), &req, c.Request().UserAgent(), c.RealIP()) if err != nil { return err } @@ -69,10 +73,10 @@ func HandleToken(c *echo.Context) error { // token endpoint, independent of the HTTP layer. Callers own request binding and // the Cache-Control: no-store response header. deviceInfo/ipAddress are recorded // on the session created for the authorization_code grant. -func ExchangeToken(req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { +func ExchangeToken(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { switch req.GrantType { case "authorization_code": - return exchangeAuthorizationCode(req, deviceInfo, ipAddress) + return exchangeAuthorizationCode(ctx, req, deviceInfo, ipAddress) case "refresh_token": return exchangeRefreshToken(req) default: @@ -80,7 +84,7 @@ func ExchangeToken(req *TokenRequest, deviceInfo, ipAddress string) (*TokenRespo } } -func exchangeAuthorizationCode(req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { +func exchangeAuthorizationCode(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { s := db.NewSession() defer s.Close() @@ -133,6 +137,12 @@ func exchangeAuthorizationCode(req *TokenRequest, deviceInfo, ipAddress string) return nil, err } + // The code exchange mints a fresh session, so it is a login for the + // audit trail, same as NewUserAuthTokenResponse. + if err := events.DispatchWithContext(ctx, &user.LoginSucceededEvent{User: u}); err != nil { + log.Errorf("Could not dispatch login succeeded event: %s", err) + } + return &TokenResponse{ AccessToken: accessToken, TokenType: "bearer", diff --git a/pkg/routes/api/v2/oauth.go b/pkg/routes/api/v2/oauth.go index 9b13c7654..45d1efe57 100644 --- a/pkg/routes/api/v2/oauth.go +++ b/pkg/routes/api/v2/oauth.go @@ -76,7 +76,7 @@ func oauthToken(ctx context.Context, in *struct { Body oauth2server.TokenRequest `contentType:"application/x-www-form-urlencoded"` }) (*oauthTokenBody, error) { deviceInfo, ipAddress := requestClientInfo(ctx) - resp, err := oauth2server.ExchangeToken(&in.Body, deviceInfo, ipAddress) + resp, err := oauth2server.ExchangeToken(ctx, &in.Body, deviceInfo, ipAddress) if err != nil { return nil, translateDomainError(err) }