From 7827ff64b9e419b3d6febc840937b7141f21b909 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 26 Mar 2026 16:32:10 +0100 Subject: [PATCH] feat: add OAuth 2.0 token endpoint 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. --- pkg/modules/auth/oauth2server/token.go | 144 +++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 pkg/modules/auth/oauth2server/token.go diff --git a/pkg/modules/auth/oauth2server/token.go b/pkg/modules/auth/oauth2server/token.go new file mode 100644 index 000000000..2725b988d --- /dev/null +++ b/pkg/modules/auth/oauth2server/token.go @@ -0,0 +1,144 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package oauth2server + +import ( + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/user" + + "github.com/labstack/echo/v5" +) + +// TokenResponse is the OAuth 2.0 token response. +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` +} + +// tokenRequest holds the JSON body of a POST /oauth/token request. +type tokenRequest struct { + GrantType string `json:"grant_type"` + Code string `json:"code"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + CodeVerifier string `json:"code_verifier"` + RefreshToken string `json:"refresh_token"` +} + +// HandleToken handles POST /oauth/token. +// Supports grant_type=authorization_code and grant_type=refresh_token. +func HandleToken(c *echo.Context) error { + var req tokenRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body") + } + + switch req.GrantType { + case "authorization_code": + return handleAuthorizationCodeGrant(c, &req) + case "refresh_token": + return handleRefreshTokenGrant(c, &req) + default: + return &models.ErrOAuthInvalidGrantType{} + } +} + +func handleAuthorizationCodeGrant(c *echo.Context, req *tokenRequest) error { + s := db.NewSession() + defer s.Close() + + // Look up and delete the authorization code (single-use) + oauthCode, err := models.GetAndDeleteOAuthCode(s, req.Code) + if err != nil { + _ = s.Rollback() + return err + } + + // Validate client_id matches + if oauthCode.ClientID != req.ClientID { + _ = s.Rollback() + return &models.ErrOAuthClientNotFound{} + } + + // Validate redirect_uri matches + if oauthCode.RedirectURI != req.RedirectURI { + _ = s.Rollback() + return &models.ErrOAuthInvalidRedirectURI{} + } + + // Verify PKCE + if !VerifyPKCE(req.CodeVerifier, oauthCode.CodeChallenge, oauthCode.CodeChallengeMethod) { + _ = s.Rollback() + return &models.ErrOAuthPKCEVerifyFailed{} + } + + // Create a session (reuses existing session infrastructure) + deviceInfo := c.Request().UserAgent() + ipAddress := c.RealIP() + session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false) + if err != nil { + _ = s.Rollback() + return err + } + + u, err := user.GetUserByID(s, oauthCode.UserID) + if err != nil { + _ = s.Rollback() + return err + } + + // Generate JWT + accessToken, err := auth.NewUserJWTAuthtoken(u, session.ID) + if err != nil { + _ = s.Rollback() + return err + } + + if err := s.Commit(); err != nil { + return err + } + + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, TokenResponse{ + AccessToken: accessToken, + TokenType: "bearer", + ExpiresIn: config.ServiceJWTTTLShort.GetInt64(), + RefreshToken: session.RefreshToken, + }) +} + +func handleRefreshTokenGrant(c *echo.Context, req *tokenRequest) error { + result, err := auth.RefreshSession(req.RefreshToken) + if err != nil { + return err + } + + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, TokenResponse{ + AccessToken: result.AccessToken, + TokenType: "bearer", + ExpiresIn: result.ExpiresIn, + RefreshToken: result.NewRefreshToken, + }) +}