2025-11-10 00:19:30 +00:00
package auth
import (
"errors"
2025-12-03 00:07:39 +00:00
"log/slog"
2025-11-10 00:19:30 +00:00
2025-11-30 19:19:33 +00:00
"github.com/get-drexa/drexa/internal/httperr"
2026-01-02 15:57:35 +00:00
"github.com/get-drexa/drexa/internal/organization"
2025-11-26 01:09:42 +00:00
"github.com/get-drexa/drexa/internal/user"
2025-11-10 00:19:30 +00:00
"github.com/gofiber/fiber/v2"
2025-11-29 20:51:56 +00:00
"github.com/uptrace/bun"
2025-11-10 00:19:30 +00:00
)
2025-12-04 00:26:20 +00:00
const (
2025-12-16 00:41:30 +00:00
TokenDeliveryCookie = "cookie"
TokenDeliveryBody = "body"
2025-12-04 00:26:20 +00:00
)
const (
cookieKeyAccessToken = "access_token"
cookieKeyRefreshToken = "refresh_token"
)
2025-12-13 22:44:37 +00:00
// loginRequest represents the login credentials
// @Description Login request with email, password, and token delivery preference
2025-11-10 00:19:30 +00:00
type loginRequest struct {
2025-12-13 22:44:37 +00:00
// User's email address
Email string ` json:"email" example:"user@example.com" `
// User's password
Password string ` json:"password" example:"secretpassword123" `
// How to deliver tokens: "cookie" (set HTTP-only cookies) or "body" (include in response)
TokenDelivery string ` json:"tokenDelivery" example:"body" enums:"cookie,body" `
2025-11-10 00:19:30 +00:00
}
2025-12-13 22:44:37 +00:00
// loginResponse represents a successful login response
// @Description Login response containing user info and optionally tokens
2025-11-10 00:19:30 +00:00
type loginResponse struct {
2025-12-13 22:44:37 +00:00
// Authenticated user information
User user . User ` json:"user" `
2026-01-02 15:57:35 +00:00
// Organizations the user is a member of
Organizations [ ] organization . Organization ` json:"organizations" `
2025-12-13 22:44:37 +00:00
// JWT access token (only included when tokenDelivery is "body")
AccessToken string ` json:"accessToken,omitempty" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature" `
// Base64 URL encoded refresh token (only included when tokenDelivery is "body")
RefreshToken string ` json:"refreshToken,omitempty" example:"dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi" `
2025-11-10 00:19:30 +00:00
}
2025-12-13 22:44:37 +00:00
// refreshAccessTokenRequest represents a token refresh request
// @Description Request to exchange a refresh token for new tokens
2025-12-03 00:07:39 +00:00
type refreshAccessTokenRequest struct {
2025-12-13 22:44:37 +00:00
// Base64 URL encoded refresh token
RefreshToken string ` json:"refreshToken" example:"dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi" `
2025-12-03 00:07:39 +00:00
}
2025-12-13 22:44:37 +00:00
// tokenResponse represents new access and refresh tokens
// @Description Response containing new access token and refresh token
2025-12-03 00:07:39 +00:00
type tokenResponse struct {
2025-12-13 22:44:37 +00:00
// New JWT access token
AccessToken string ` json:"accessToken" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature" `
// New base64 URL encoded refresh token
RefreshToken string ` json:"refreshToken" example:"xK9mPqRsTuVwXyZ0AbCdEfGhIjKlMnOpQrStUvWxYz1234567890abcdefgh" `
2025-12-03 00:07:39 +00:00
}
2025-11-29 20:51:56 +00:00
type HTTPHandler struct {
2026-01-02 15:57:35 +00:00
service * Service
organizationService * organization . Service
db * bun . DB
cookieConfig CookieConfig
2025-11-10 00:19:30 +00:00
}
2026-01-02 15:57:35 +00:00
func NewHTTPHandler ( s * Service , organizationService * organization . Service , db * bun . DB , cookieConfig CookieConfig ) * HTTPHandler {
return & HTTPHandler { service : s , organizationService : organizationService , db : db , cookieConfig : cookieConfig }
2025-11-10 00:19:30 +00:00
}
2025-11-29 20:51:56 +00:00
func ( h * HTTPHandler ) RegisterRoutes ( api fiber . Router ) {
auth := api . Group ( "/auth" )
auth . Post ( "/login" , h . Login )
2025-12-03 00:07:39 +00:00
auth . Post ( "/tokens" , h . refreshAccessToken )
2025-11-29 20:51:56 +00:00
}
2025-11-10 00:19:30 +00:00
2025-12-13 22:44:37 +00:00
// Login authenticates a user with email and password
// @Summary User login
// @Description Authenticate with email and password to receive JWT tokens. Tokens can be delivered via HTTP-only cookies or in the response body based on the tokenDelivery field.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body loginRequest true "Login credentials"
// @Success 200 {object} loginResponse "Successful authentication"
// @Failure 400 {object} map[string]string "Invalid request body or token delivery method"
// @Failure 401 {object} map[string]string "Invalid email or password"
// @Router /auth/login [post]
2025-11-29 20:51:56 +00:00
func ( h * HTTPHandler ) Login ( c * fiber . Ctx ) error {
2025-11-10 00:19:30 +00:00
req := new ( loginRequest )
if err := c . BodyParser ( req ) ; err != nil {
return c . Status ( fiber . StatusBadRequest ) . JSON ( fiber . Map { "error" : "Invalid request" } )
}
2026-01-02 15:57:35 +00:00
if req . TokenDelivery != TokenDeliveryCookie && req . TokenDelivery != TokenDeliveryBody {
return c . Status ( fiber . StatusBadRequest ) . JSON ( fiber . Map { "error" : "invalid token delivery method" } )
}
2025-11-29 20:51:56 +00:00
tx , err := h . db . BeginTx ( c . Context ( ) , nil )
if err != nil {
2025-11-30 19:19:33 +00:00
return httperr . Internal ( err )
2025-11-29 20:51:56 +00:00
}
defer tx . Rollback ( )
2025-12-03 00:07:39 +00:00
result , err := h . service . LoginWithEmailAndPassword ( c . Context ( ) , tx , req . Email , req . Password )
2025-11-10 00:19:30 +00:00
if err != nil {
if errors . Is ( err , ErrInvalidCredentials ) {
return c . Status ( fiber . StatusUnauthorized ) . JSON ( fiber . Map { "error" : "Invalid credentials" } )
}
2025-11-30 19:19:33 +00:00
return httperr . Internal ( err )
2025-11-10 00:19:30 +00:00
}
2026-01-02 15:57:35 +00:00
orgs , err := h . organizationService . ListOrganizationsForUser ( c . Context ( ) , tx , result . User . ID )
if err != nil {
return httperr . Internal ( err )
}
2025-11-29 20:51:56 +00:00
if err := tx . Commit ( ) ; err != nil {
2025-11-30 19:19:33 +00:00
return httperr . Internal ( err )
2025-11-29 20:51:56 +00:00
}
2025-12-04 00:26:20 +00:00
switch req . TokenDelivery {
2025-12-16 00:41:30 +00:00
case TokenDeliveryCookie :
SetAuthCookies ( c , result . AccessToken , result . RefreshToken , h . cookieConfig )
2025-12-04 00:26:20 +00:00
return c . JSON ( loginResponse {
2026-01-02 15:57:35 +00:00
User : * result . User ,
Organizations : orgs ,
2025-12-04 00:26:20 +00:00
} )
2025-12-16 00:41:30 +00:00
case TokenDeliveryBody :
2025-12-04 00:26:20 +00:00
return c . JSON ( loginResponse {
2026-01-02 15:57:35 +00:00
User : * result . User ,
Organizations : orgs ,
AccessToken : result . AccessToken ,
RefreshToken : result . RefreshToken ,
2025-12-04 00:26:20 +00:00
} )
}
2026-01-02 15:57:35 +00:00
return httperr . Internal ( errors . New ( "unreachable token delivery" ) )
2025-11-10 00:19:30 +00:00
}
2025-12-03 00:07:39 +00:00
2025-12-13 22:44:37 +00:00
// refreshAccessToken exchanges a refresh token for new access and refresh tokens
// @Summary Refresh access token
// @Description Exchange a valid refresh token for a new pair of access and refresh tokens. The old refresh token is invalidated (rotation).
// @Tags auth
// @Accept json
// @Produce json
// @Param request body refreshAccessTokenRequest true "Refresh token"
// @Success 200 {object} tokenResponse "New tokens"
// @Failure 400 {object} map[string]string "Invalid request body"
// @Failure 401 {object} map[string]string "Invalid, expired, or reused refresh token"
// @Router /auth/tokens [post]
2025-12-03 00:07:39 +00:00
func ( h * HTTPHandler ) refreshAccessToken ( c * fiber . Ctx ) error {
req := new ( refreshAccessTokenRequest )
if err := c . BodyParser ( req ) ; err != nil {
return c . Status ( fiber . StatusBadRequest ) . JSON ( fiber . Map { "error" : "Invalid request" } )
}
tx , err := h . db . BeginTx ( c . Context ( ) , nil )
if err != nil {
return httperr . Internal ( err )
}
defer tx . Rollback ( )
result , err := h . service . RefreshAccessToken ( c . Context ( ) , tx , req . RefreshToken )
if err != nil {
if errors . Is ( err , ErrInvalidRefreshToken ) ||
errors . Is ( err , ErrRefreshTokenExpired ) ||
errors . Is ( err , ErrRefreshTokenReused ) {
_ = tx . Commit ( )
slog . Info ( "invalid refresh token" , "error" , err )
return c . Status ( fiber . StatusUnauthorized ) . JSON ( fiber . Map { "error" : "invalid refresh token" } )
}
return httperr . Internal ( err )
}
if err := tx . Commit ( ) ; err != nil {
return httperr . Internal ( err )
}
return c . JSON ( tokenResponse {
AccessToken : result . AccessToken ,
RefreshToken : result . RefreshToken ,
} )
}