mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-30 08:56:59 +00:00
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.
145 lines
4.0 KiB
Go
145 lines
4.0 KiB
Go
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
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,
|
|
})
|
|
}
|