diff --git a/backend/app/administration.go b/backend/app/administration.go index 447d84f..5fc3f6e 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -188,6 +188,12 @@ const ( ROUTE_V1_WEBHOOK_ID_TEST = "/api/v1/webhook/:id/test" // identifiers ROUTE_V1_IDENTIFIER = "/api/v1/identifier" + // oauth providers + ROUTE_V1_OAUTH_PROVIDER = "/api/v1/oauth-provider" + ROUTE_V1_OAUTH_PROVIDER_ID = "/api/v1/oauth-provider/:id" + ROUTE_V1_OAUTH_PROVIDER_REMOVE_AUTH = "/api/v1/oauth-provider/:id/remove-authorization" + ROUTE_V1_OAUTH_AUTHORIZE = "/api/v1/oauth-authorize/:id" + ROUTE_V1_OAUTH_CALLBACK = "/api/v1/oauth-callback" // license ROUTE_V1_LICENSE = "/api/v1/license" // version @@ -362,6 +368,15 @@ func setupRoutes( // smtp configuration headers PATCH(ROUTE_V1_SMTP_CONFIGURATION_HEADERS, middleware.SessionHandler, controllers.SMTPConfiguration.AddHeader). DELETE(ROUTE_V1_SMTP_HEADER_ID, middleware.SessionHandler, controllers.SMTPConfiguration.RemoveHeader). + // oauth providers + GET(ROUTE_V1_OAUTH_PROVIDER, middleware.SessionHandler, controllers.OAuthProvider.GetAll). + GET(ROUTE_V1_OAUTH_PROVIDER_ID, middleware.SessionHandler, controllers.OAuthProvider.GetByID). + POST(ROUTE_V1_OAUTH_PROVIDER, middleware.SessionHandler, controllers.OAuthProvider.Create). + PATCH(ROUTE_V1_OAUTH_PROVIDER_ID, middleware.SessionHandler, controllers.OAuthProvider.UpdateByID). + DELETE(ROUTE_V1_OAUTH_PROVIDER_ID, middleware.SessionHandler, controllers.OAuthProvider.DeleteByID). + POST(ROUTE_V1_OAUTH_PROVIDER_REMOVE_AUTH, middleware.SessionHandler, controllers.OAuthProvider.RemoveAuthorization). + GET(ROUTE_V1_OAUTH_AUTHORIZE, middleware.SessionHandler, controllers.OAuthProvider.GetAuthorizationURL). + GET(ROUTE_V1_OAUTH_CALLBACK, controllers.OAuthProvider.HandleCallback). // emails GET(ROUTE_V1_EMAIL, middleware.SessionHandler, controllers.Email.GetAll). GET(ROUTE_V1_EMAIL_OVERVIEW, middleware.SessionHandler, controllers.Email.GetOverviews). diff --git a/backend/app/controllers.go b/backend/app/controllers.go index a63aa67..48919ce 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -1,6 +1,7 @@ package app import ( + "github.com/phishingclub/phishingclub/config" "github.com/phishingclub/phishingclub/controller" "go.uber.org/zap" "gorm.io/gorm" @@ -38,6 +39,7 @@ type Controllers struct { Import *controller.Import Backup *controller.Backup IPAllowList *controller.IPAllowList + OAuthProvider *controller.OAuthProvider } // NewControllers creates a collection of controllers @@ -50,6 +52,7 @@ func NewControllers( atomLogger *zap.AtomicLevel, utillities *Utilities, db *gorm.DB, + conf *config.Config, ) *Controllers { common := controller.Common{ SessionService: services.Session, @@ -188,6 +191,11 @@ func NewControllers( geoIP := &controller.GeoIP{ Common: common, } + oauthProvider := &controller.OAuthProvider{ + Common: common, + OAuthProviderService: services.OAuthProvider, + Config: conf, + } return &Controllers{ Asset: asset, @@ -220,5 +228,6 @@ func NewControllers( Import: importController, Backup: backup, IPAllowList: ipAllowList, + OAuthProvider: oauthProvider, } } diff --git a/backend/app/repositories.go b/backend/app/repositories.go index 7139eff..5259796 100644 --- a/backend/app/repositories.go +++ b/backend/app/repositories.go @@ -28,6 +28,8 @@ type Repositories struct { AllowDeny *repository.AllowDeny Webhook *repository.Webhook Identifier *repository.Identifier + OAuthProvider *repository.OAuthProvider + OAuthState *repository.OAuthState } // NewRepositories creates a collection of repositories @@ -57,5 +59,7 @@ func NewRepositories( AllowDeny: &repository.AllowDeny{DB: db}, Webhook: &repository.Webhook{DB: db}, Identifier: &repository.Identifier{DB: db}, + OAuthProvider: &repository.OAuthProvider{DB: db}, + OAuthState: &repository.OAuthState{DB: db}, } } diff --git a/backend/app/services.go b/backend/app/services.go index 373da4e..7d8f59e 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -38,6 +38,7 @@ type Services struct { Backup *service.Backup IPAllowList *service.IPAllowListService ProxySessionManager *service.ProxySessionManager + OAuthProvider *service.OAuthProvider } // NewServices creates a collection of services @@ -248,6 +249,14 @@ func NewServices( EmailRepository: repositories.Email, PageRepository: repositories.Page, } + oauthProvider := &service.OAuthProvider{ + Common: common, + OAuthProviderRepository: repositories.OAuthProvider, + OAuthStateRepository: repositories.OAuthState, + } + + // inject oauth provider service into api sender + apiSender.OAuthProviderService = oauthProvider return &Services{ Asset: asset, @@ -279,5 +288,6 @@ func NewServices( Backup: backupService, IPAllowList: ipAllowListService, ProxySessionManager: proxySessionManager, + OAuthProvider: oauthProvider, } } diff --git a/backend/controller/oauthProvider.go b/backend/controller/oauthProvider.go new file mode 100644 index 0000000..1da770a --- /dev/null +++ b/backend/controller/oauthProvider.go @@ -0,0 +1,374 @@ +package controller + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/phishingclub/phishingclub/build" + "github.com/phishingclub/phishingclub/config" + "github.com/phishingclub/phishingclub/database" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/repository" + "github.com/phishingclub/phishingclub/service" +) + +// OAuthProviderColumnsMap is a map between the frontend and the backend +var OAuthProviderColumnsMap = map[string]string{ + "created_at": repository.TableColumn(database.OAUTH_PROVIDER_TABLE, "created_at"), + "updated_at": repository.TableColumn(database.OAUTH_PROVIDER_TABLE, "updated_at"), + "name": repository.TableColumn(database.OAUTH_PROVIDER_TABLE, "name"), + "is_authorized": repository.TableColumn(database.OAUTH_PROVIDER_TABLE, "is_authorized"), +} + +// OAuthProvider is a controller +type OAuthProvider struct { + Common + OAuthProviderService *service.OAuthProvider + Config *config.Config +} + +// Create creates a new oauth provider +func (c *OAuthProvider) Create(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + // parse request + var req model.OAuthProvider + if ok := c.handleParseRequest(g, &req); !ok { + return + } + // save oauth provider + id, err := c.OAuthProviderService.Create(g, session, &req) + // handle response + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK( + g, + gin.H{ + "id": id.String(), + }, + ) +} + +// GetAll gets oauth providers +func (c *OAuthProvider) GetAll(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + // parse request + queryArgs, ok := c.handleQueryArgs(g) + if !ok { + return + } + queryArgs.DefaultSortByUpdatedAt() + queryArgs.RemapOrderBy(OAuthProviderColumnsMap) + companyID := companyIDFromRequestQuery(g) + // get + providers, err := c.OAuthProviderService.GetAll( + g.Request.Context(), + session, + companyID, + repository.OAuthProviderOption{ + Limit: &queryArgs.Limit, + Offset: &queryArgs.Offset, + Search: &queryArgs.Search, + }, + ) + // handle response + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, providers) +} + +// GetByID gets an oauth provider by id +func (c *OAuthProvider) GetByID(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + // parse id + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + // get + provider, err := c.OAuthProviderService.GetByID( + g.Request.Context(), + session, + id, + ) + // handle response + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, provider) +} + +// UpdateByID updates an oauth provider by id +func (c *OAuthProvider) UpdateByID(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + // parse id + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + // parse request + var req model.OAuthProvider + if ok := c.handleParseRequest(g, &req); !ok { + return + } + // update + err := c.OAuthProviderService.UpdateByID( + g.Request.Context(), + session, + id, + &req, + ) + // handle response + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{"message": "updated"}) +} + +// DeleteByID deletes an oauth provider by id +func (c *OAuthProvider) DeleteByID(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + // parse id + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + // delete + err := c.OAuthProviderService.DeleteByID( + g.Request.Context(), + session, + id, + ) + // handle response + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{"message": "deleted"}) +} + +// RemoveAuthorization removes authorization tokens from an oauth provider +func (c *OAuthProvider) RemoveAuthorization(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + // parse id + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + // remove authorization + err := c.OAuthProviderService.RemoveAuthorization( + g.Request.Context(), + session, + id, + ) + // handle response + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{"message": "authorization removed"}) +} + +// GetAuthorizationURL generates the oauth authorization url for user to visit +func (c *OAuthProvider) GetAuthorizationURL(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + // parse id + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + // construct redirect uri from config (secure - not user-controllable) + var host string + if build.Flags.Production { + host = c.Config.TLSHost() + } else { + host = "localhost" + } + + adminPort := c.Config.AdminNetAddressPort() + + var redirectURI string + if adminPort == 443 || adminPort == 0 { + // standard https port or ephemeral port, no need to include in url + redirectURI = fmt.Sprintf("https://%s/api/v1/oauth-callback", host) + } else { + // non-standard port, include it + redirectURI = fmt.Sprintf("https://%s:%d/api/v1/oauth-callback", host, adminPort) + } + + // get authorization url + authURL, err := c.OAuthProviderService.GetAuthorizationURL( + g.Request.Context(), + session, + id, + redirectURI, + ) + // handle response + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{"authorizationURL": authURL}) +} + +// HandleCallback handles the oauth callback from the provider +// note: this endpoint is PUBLIC (no session required) because oauth providers call it from cross-site context +func (c *OAuthProvider) HandleCallback(g *gin.Context) { + code := g.Query("code") + state := g.Query("state") + errorParam := g.Query("error") + + // handle oauth errors from provider (don't expose error details to user) + if errorParam != "" { + errorDesc := g.Query("error_description") + c.Logger.Warnw("oauth provider returned error", "error", errorParam, "description", errorDesc) + c.renderCallbackPage(g, false, "provider_error") + return + } + + // validate parameters + if code == "" || state == "" { + c.Logger.Warnw("oauth callback missing required parameters") + c.renderCallbackPage(g, false, "invalid_request") + return + } + + // construct redirect uri from config (must match the one used in authorization) + var host string + if build.Flags.Production { + host = c.Config.TLSHost() + } else { + host = "localhost" + } + + adminPort := c.Config.AdminNetAddressPort() + + var redirectURI string + if adminPort == 443 || adminPort == 0 { + // standard https port or ephemeral port, no need to include in url + redirectURI = fmt.Sprintf("https://%s/api/v1/oauth-callback", host) + } else { + // non-standard port, include it + redirectURI = fmt.Sprintf("https://%s:%d/api/v1/oauth-callback", host, adminPort) + } + + // exchange code for tokens + // session is nil because callback is public (cross-site context doesn't send cookies) + // validation happens through state token lookup (bound to initiating session) + if err := c.OAuthProviderService.ExchangeCodeForTokens( + g.Request.Context(), + nil, // no session - callback is public + state, + code, + redirectURI, + ); err != nil { + // log detailed error internally, show generic error to user + c.Logger.Warnw("failed to exchange code for tokens", "reason", err) + c.renderCallbackPage(g, false, "token_exchange_failed") + return + } + + // success - render success page + c.renderCallbackPage(g, true, "") +} + +// renderCallbackPage renders a plain text page for oauth callback result +// this page is shown in the popup window, notifies the parent, and instructs user to close it +func (c *OAuthProvider) renderCallbackPage(g *gin.Context, success bool, errorCode string) { + var text string + var status string + + // define allowed error codes and their user-friendly messages + allowedErrors := map[string]string{ + "provider_error": "The OAuth provider returned an error", + "invalid_request": "Invalid authorization request", + "token_exchange_failed": "Failed to exchange authorization code for tokens", + } + + if success { + text = "OAuth authorization successful!\n\nYou can close this window now." + status = "success" + } else { + // validate error code - only use allowed values + userMessage, ok := allowedErrors[errorCode] + if !ok { + // if error code is not in allowed list, use generic message + c.Logger.Warnw("invalid error code passed to renderCallbackPage", "errorCode", errorCode) + userMessage = "An unexpected error occurred" + errorCode = "unknown_error" + } + text = fmt.Sprintf("OAuth authorization failed.\n\n%s\n\nYou can close this window now.", userMessage) + status = "error" + } + + // determine target origin for postMessage + // in dev, use wildcard for localhost to handle vite proxy (frontend on :8003, backend on :8002) + // in production, use specific origin for security + var targetOrigin string + if build.Flags.Production { + targetOrigin = "window.location.origin" + } else { + // in dev, check if we're on localhost and use wildcard + targetOrigin = "(window.location.hostname === 'localhost' ? '*' : window.location.origin)" + } + + // html with script to notify parent window and plain text display + html := fmt.Sprintf(` + +
+ +%s+ + +`, text, status, targetOrigin, status) + + g.Header("Content-Type", "text/html; charset=utf-8") + g.String(http.StatusOK, html) +} diff --git a/backend/database/apiSender.go b/backend/database/apiSender.go index 5390471..9f089aa 100644 --- a/backend/database/apiSender.go +++ b/backend/database/apiSender.go @@ -25,6 +25,10 @@ type APISender struct { CustomField3 string CustomField4 string + // oauth provider for token-based authentication + OAuthProviderID *uuid.UUID `gorm:"type:uuid;index;"` + OAuthProvider *OAuthProvider `gorm:"foreignKey:OAuthProviderID"` + // Request fields RequestMethod string RequestURL string @@ -38,6 +42,13 @@ type APISender struct { } func (e *APISender) Migrate(db *gorm.DB) error { + // add o_auth_provider_id column if it doesn't exist + if !db.Migrator().HasColumn(&APISender{}, "o_auth_provider_id") { + if err := db.Migrator().AddColumn(&APISender{}, "OAuthProviderID"); err != nil { + return err + } + } + // SQLITE // ensure name + null company id is unique return UniqueIndexNameAndNullCompanyID(db, "api_senders") diff --git a/backend/database/oauthProvider.go b/backend/database/oauthProvider.go new file mode 100644 index 0000000..289d86a --- /dev/null +++ b/backend/database/oauthProvider.go @@ -0,0 +1,55 @@ +package database + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + OAUTH_PROVIDER_TABLE = "oauth_providers" +) + +// OAuthProvider is the gorm data model for oauth providers +type OAuthProvider struct { + ID uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"` + CreatedAt *time.Time `gorm:"not null;index;"` + UpdatedAt *time.Time `gorm:"not null;index;"` + + Name string `gorm:"not null;uniqueIndex:idx_oauth_providers_unique_name_and_company_id;"` + + // oauth endpoints (user configurable) + AuthURL string `gorm:"not null;type:varchar(512);"` + TokenURL string `gorm:"not null;type:varchar(512);"` + Scopes string `gorm:"not null;type:varchar(512);"` + + // user's oauth app credentials (stored as plain text like smtp passwords) + ClientID string `gorm:"not null;type:varchar(255);"` + ClientSecret string `gorm:"not null;type:varchar(255);"` + + // current token state (stored as plain text) + AccessToken string `gorm:"type:varchar(4096);"` + RefreshToken string `gorm:"type:varchar(4096);"` + TokenExpiresAt *time.Time `gorm:"index;"` + + // authorization metadata + AuthorizedEmail string `gorm:"type:varchar(255);"` + AuthorizedAt *time.Time `gorm:"index;"` + + // status + IsAuthorized bool `gorm:"not null;default:false;"` + + // can belong-to + CompanyID *uuid.UUID `gorm:"uniqueIndex:idx_oauth_providers_unique_name_and_company_id;"` + Company *Company `gorm:"foreignkey:CompanyID;"` +} + +func (o *OAuthProvider) Migrate(db *gorm.DB) error { + // ensure name + company id is unique + return UniqueIndexNameAndNullCompanyID(db, "oauth_providers") +} + +func (OAuthProvider) TableName() string { + return OAUTH_PROVIDER_TABLE +} diff --git a/backend/database/oauthState.go b/backend/database/oauthState.go new file mode 100644 index 0000000..4e68c33 --- /dev/null +++ b/backend/database/oauthState.go @@ -0,0 +1,36 @@ +package database + +import ( + "time" + + "github.com/google/uuid" +) + +const ( + OAUTH_STATE_TABLE = "oauth_states" +) + +// OAuthState stores temporary state tokens for oauth flows +// used for csrf protection +type OAuthState struct { + ID uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"` + CreatedAt *time.Time `gorm:"not null;index;"` + + // the state token sent to oauth provider (random cryptographic token) + StateToken string `gorm:"not null;uniqueIndex;type:varchar(255);"` + + // the oauth provider this state is for + OAuthProviderID uuid.UUID `gorm:"not null;index;type:uuid"` + OAuthProvider *OAuthProvider `gorm:"foreignkey:OAuthProviderID;"` + + // expiration (state tokens expire after 10 minutes) + ExpiresAt *time.Time `gorm:"not null;index;"` + + // whether this state token has been used (prevent replay attacks) + Used bool `gorm:"not null;default:false;index;"` + UsedAt *time.Time `gorm:"index;"` +} + +func (OAuthState) TableName() string { + return OAUTH_STATE_TABLE +} diff --git a/backend/go.mod b/backend/go.mod index ace0e51..3bcd89b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,6 +5,7 @@ go 1.25.1 require ( github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 github.com/PuerkitoBio/goquery v1.8.1 + github.com/andybalholm/brotli v1.2.0 github.com/boombuler/barcode v1.0.1 github.com/brianvoe/gofakeit/v7 v7.0.4 github.com/caddyserver/certmagic v0.19.2 @@ -18,6 +19,7 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/go-errors/errors v1.5.1 github.com/google/uuid v1.3.1 + github.com/klauspost/compress v1.18.1 github.com/oapi-codegen/nullable v1.1.0 github.com/pquerna/otp v1.4.0 github.com/stretchr/testify v1.9.0 @@ -27,6 +29,7 @@ require ( golang.org/x/crypto v0.43.0 golang.org/x/mod v0.29.0 golang.org/x/net v0.46.0 + golang.org/x/sync v0.18.0 golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/sqlite v1.6.0 @@ -35,7 +38,6 @@ require ( require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/cascadia v1.3.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -71,7 +73,6 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.1 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -106,7 +107,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.9.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect golang.org/x/tools v0.38.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 1eaeaab..7e1839b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -270,8 +270,8 @@ golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/backend/main.go b/backend/main.go index 89fb623..e2be1ba 100644 --- a/backend/main.go +++ b/backend/main.go @@ -266,6 +266,7 @@ func main() { atomicLogger, utils, db, + conf, ) // setup admin account isInstalled, err := controllers.InitialSetup.IsInstalled(context.Background()) diff --git a/backend/model/apiSender.go b/backend/model/apiSender.go index 8988a01..2968cc8 100644 --- a/backend/model/apiSender.go +++ b/backend/model/apiSender.go @@ -26,6 +26,8 @@ type APISender struct { CustomField2 nullable.Nullable[vo.OptionalString255] `json:"customField2"` CustomField3 nullable.Nullable[vo.OptionalString255] `json:"customField3"` CustomField4 nullable.Nullable[vo.OptionalString255] `json:"customField4"` + OAuthProviderID nullable.Nullable[uuid.UUID] `json:"oauthProviderID"` + OAuthProvider *OAuthProvider `json:"oauthProvider"` RequestMethod nullable.Nullable[vo.HTTPMethod] `json:"requestMethod"` RequestURL nullable.Nullable[vo.String255] `json:"requestURL"` RequestHeaders nullable.Nullable[APISenderHeaders] `json:"requestHeaders"` @@ -149,6 +151,13 @@ func (a *APISender) ToDBMap() map[string]interface{} { m["expected_response_body"] = expectedResponseBody.String() } } + if a.OAuthProviderID.IsSpecified() { + if a.OAuthProviderID.IsNull() { + m["o_auth_provider_id"] = nil + } else { + m["o_auth_provider_id"] = a.OAuthProviderID.MustGet() + } + } return m } diff --git a/backend/model/oauthProvider.go b/backend/model/oauthProvider.go new file mode 100644 index 0000000..55be355 --- /dev/null +++ b/backend/model/oauthProvider.go @@ -0,0 +1,179 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/validate" + "github.com/phishingclub/phishingclub/vo" +) + +// OAuthProvider is a user-configured OAuth 2.0 provider +type OAuthProvider struct { + ID nullable.Nullable[uuid.UUID] `json:"id"` + CreatedAt *time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` + + Name nullable.Nullable[vo.String127] `json:"name"` + + // oauth endpoints (user configurable) + AuthURL nullable.Nullable[vo.String512] `json:"authURL"` + TokenURL nullable.Nullable[vo.String512] `json:"tokenURL"` + Scopes nullable.Nullable[vo.String512] `json:"scopes"` + + // user's oauth app credentials + ClientID nullable.Nullable[vo.String255] `json:"clientID"` + ClientSecret nullable.Nullable[vo.OptionalString255] `json:"clientSecret"` // write-only, never returned + + // current token state (stored as plain text like smtp passwords) + AccessToken nullable.Nullable[vo.OptionalString1MB] `json:"-"` // never returned in api + RefreshToken nullable.Nullable[vo.OptionalString1MB] `json:"-"` // never returned in api + TokenExpiresAt *time.Time `json:"tokenExpiresAt"` + + // authorization metadata + AuthorizedEmail nullable.Nullable[vo.OptionalString255] `json:"authorizedEmail"` // email of the account that authorized + AuthorizedAt *time.Time `json:"authorizedAt"` + + // status + IsAuthorized nullable.Nullable[bool] `json:"isAuthorized"` // whether oauth flow completed + + CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"` + Company *Company `json:"company"` +} + +// Validate checks if the oauth provider has a valid state +func (o *OAuthProvider) Validate() error { + if err := validate.NullableFieldRequired("name", o.Name); err != nil { + return err + } + if err := validate.NullableFieldRequired("authURL", o.AuthURL); err != nil { + return err + } + if err := validate.NullableFieldRequired("tokenURL", o.TokenURL); err != nil { + return err + } + if err := validate.NullableFieldRequired("scopes", o.Scopes); err != nil { + return err + } + if err := validate.NullableFieldRequired("clientID", o.ClientID); err != nil { + return err + } + if err := validate.NullableFieldRequired("clientSecret", o.ClientSecret); err != nil { + return err + } + return nil +} + +// ToDBMap converts the fields that can be stored or updated to a map +func (o *OAuthProvider) ToDBMap() map[string]any { + m := map[string]any{} + + if o.Name.IsSpecified() { + m["name"] = nil + if name, err := o.Name.Get(); err == nil { + m["name"] = name.String() + } + } + + if o.AuthURL.IsSpecified() { + m["auth_url"] = nil + if authURL, err := o.AuthURL.Get(); err == nil { + m["auth_url"] = authURL.String() + } + } + + if o.TokenURL.IsSpecified() { + m["token_url"] = nil + if tokenURL, err := o.TokenURL.Get(); err == nil { + m["token_url"] = tokenURL.String() + } + } + + if o.Scopes.IsSpecified() { + m["scopes"] = nil + if scopes, err := o.Scopes.Get(); err == nil { + m["scopes"] = scopes.String() + } + } + + if o.ClientID.IsSpecified() { + m["client_id"] = nil + if clientID, err := o.ClientID.Get(); err == nil { + m["client_id"] = clientID.String() + } + } + + if o.ClientSecret.IsSpecified() { + if o.ClientSecret.IsNull() { + // don't update client secret if null + } else { + if v, err := o.ClientSecret.Get(); err == nil { + // only update if non-empty + if v.String() != "" { + m["client_secret"] = v.String() + } + } + } + } + + if o.AccessToken.IsSpecified() { + if o.AccessToken.IsNull() { + m["access_token"] = "" + } else { + if v, err := o.AccessToken.Get(); err == nil { + m["access_token"] = v.String() + } else { + m["access_token"] = "" + } + } + } + + if o.RefreshToken.IsSpecified() { + if o.RefreshToken.IsNull() { + m["refresh_token"] = "" + } else { + if v, err := o.RefreshToken.Get(); err == nil { + m["refresh_token"] = v.String() + } else { + m["refresh_token"] = "" + } + } + } + if o.TokenExpiresAt != nil { + m["token_expires_at"] = o.TokenExpiresAt + } + + if o.AuthorizedEmail.IsSpecified() { + if o.AuthorizedEmail.IsNull() { + m["authorized_email"] = "" + } else { + if v, err := o.AuthorizedEmail.Get(); err == nil { + m["authorized_email"] = v.String() + } else { + m["authorized_email"] = "" + } + } + } + + if o.AuthorizedAt != nil { + m["authorized_at"] = o.AuthorizedAt + } + + if o.IsAuthorized.IsSpecified() { + m["is_authorized"] = nil + if isAuthorized, err := o.IsAuthorized.Get(); err == nil { + m["is_authorized"] = isAuthorized + } + } + + if o.CompanyID.IsSpecified() { + if o.CompanyID.IsNull() { + m["company_id"] = nil + } else { + m["company_id"] = o.CompanyID.MustGet() + } + } + + return m +} diff --git a/backend/model/oauthState.go b/backend/model/oauthState.go new file mode 100644 index 0000000..b6dbcae --- /dev/null +++ b/backend/model/oauthState.go @@ -0,0 +1,55 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/database" + "github.com/phishingclub/phishingclub/vo" +) + +// OAuthState represents a temporary state token for oauth flow +type OAuthState struct { + ID nullable.Nullable[uuid.UUID] `json:"id"` + CreatedAt *time.Time `json:"createdAt"` + + // the state token sent to oauth provider + StateToken nullable.Nullable[vo.String255] `json:"stateToken"` + + // the oauth provider this state is for + OAuthProviderID nullable.Nullable[uuid.UUID] `json:"oauthProviderID"` + OAuthProvider *OAuthProvider `json:"oauthProvider"` + + // expiration + ExpiresAt *time.Time `json:"expiresAt"` + + // whether this state token has been used + Used bool `json:"used"` + UsedAt *time.Time `json:"usedAt"` +} + +// OAuthStateFromDB converts database model to model +func OAuthStateFromDB(db *database.OAuthState) *OAuthState { + if db == nil { + return nil + } + + stateToken, err := vo.NewString255(db.StateToken) + if err != nil { + // fallback to empty if token is invalid (should not happen) + stateToken = vo.NewString255Must("") + } + + state := &OAuthState{ + ID: nullable.NewNullableWithValue(db.ID), + CreatedAt: db.CreatedAt, + StateToken: nullable.NewNullableWithValue(*stateToken), + OAuthProviderID: nullable.NewNullableWithValue(db.OAuthProviderID), + ExpiresAt: db.ExpiresAt, + Used: db.Used, + UsedAt: db.UsedAt, + } + + return state +} diff --git a/backend/repository/apiSender.go b/backend/repository/apiSender.go index 227a34f..10e3242 100644 --- a/backend/repository/apiSender.go +++ b/backend/repository/apiSender.go @@ -24,6 +24,7 @@ type APISenderOption struct { WithRequestHeaders bool WithResponseHeaders bool + WithOAuthProvider bool } // APISender is a API sender repository @@ -36,6 +37,9 @@ func (a *APISender) preload(o *APISenderOption, db *gorm.DB) *gorm.DB { if o == nil { return db } + if o.WithOAuthProvider { + db = db.Preload("OAuthProvider") + } return db } @@ -318,6 +322,16 @@ func ToAPISender(row *database.APISender) (*model.APISender, error) { expectedResponseHeaders.SetUnspecified() } + oauthProviderID := nullable.NewNullNullable[uuid.UUID]() + if row.OAuthProviderID != nil { + oauthProviderID.Set(*row.OAuthProviderID) + } + + var oauthProvider *model.OAuthProvider + if row.OAuthProvider != nil { + oauthProvider = ToOAuthProvider(row.OAuthProvider) + } + return &model.APISender{ ID: id, CreatedAt: row.CreatedAt, @@ -329,6 +343,8 @@ func ToAPISender(row *database.APISender) (*model.APISender, error) { CustomField2: customField2, CustomField3: customField3, CustomField4: customField4, + OAuthProviderID: oauthProviderID, + OAuthProvider: oauthProvider, RequestMethod: requestMethod, RequestURL: requestURL, RequestHeaders: requestHeaders, diff --git a/backend/repository/oauthProvider.go b/backend/repository/oauthProvider.go new file mode 100644 index 0000000..e40b6b3 --- /dev/null +++ b/backend/repository/oauthProvider.go @@ -0,0 +1,249 @@ +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/database" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/vo" + "gorm.io/gorm" +) + +// OAuthProvider is the repository for oauth providers +type OAuthProvider struct { + DB *gorm.DB +} + +// OAuthProviderOption is the option for getting oauth providers +type OAuthProviderOption struct { + Limit *int + Offset *int + Search *string +} + +// Insert inserts a new oauth provider +func (o *OAuthProvider) Insert(ctx context.Context, provider *model.OAuthProvider) (*uuid.UUID, error) { + m := provider.ToDBMap() + now := time.Now() + m["created_at"] = now + m["updated_at"] = now + id := uuid.New() + m["id"] = id + + if err := o.DB.WithContext(ctx).Table("oauth_providers").Create(m).Error; err != nil { + return nil, err + } + + return &id, nil +} + +// GetAll gets all oauth providers with pagination +func (o *OAuthProvider) GetAll( + ctx context.Context, + companyID *uuid.UUID, + option *OAuthProviderOption, +) (*model.Result[model.OAuthProvider], error) { + var dbProviders []database.OAuthProvider + var totalCount int64 + + query := o.DB.WithContext(ctx).Table("oauth_providers") + + if companyID != nil { + query = query.Where("company_id = ? OR company_id IS NULL", companyID) + } else { + query = query.Where("company_id IS NULL") + } + + if option.Search != nil && *option.Search != "" { + search := "%" + *option.Search + "%" + query = query.Where("name ILIKE ?", search) + } + + if err := query.Count(&totalCount).Error; err != nil { + return nil, err + } + + query = query.Order("created_at DESC") + + if option.Limit != nil { + query = query.Limit(*option.Limit) + } + + if option.Offset != nil { + query = query.Offset(*option.Offset) + } + + if err := query.Find(&dbProviders).Error; err != nil { + return nil, err + } + + // convert database types to model types + providers := make([]*model.OAuthProvider, len(dbProviders)) + for i := range dbProviders { + providers[i] = ToOAuthProvider(&dbProviders[i]) + } + + hasNextPage := false + if option.Limit != nil && option.Offset != nil { + hasNextPage = int64(*option.Offset+*option.Limit) < totalCount + } + + return &model.Result[model.OAuthProvider]{ + Rows: providers, + HasNextPage: hasNextPage, + }, nil +} + +// GetByID gets an oauth provider by id +func (o *OAuthProvider) GetByID( + ctx context.Context, + id uuid.UUID, +) (*model.OAuthProvider, error) { + var dbProvider database.OAuthProvider + + if err := o.DB.WithContext(ctx). + Table("oauth_providers"). + Where("id = ?", id). + First(&dbProvider).Error; err != nil { + return nil, err + } + + return ToOAuthProvider(&dbProvider), nil +} + +// GetByNameAndCompanyID gets an oauth provider by name and company id +func (o *OAuthProvider) GetByNameAndCompanyID( + ctx context.Context, + name string, + companyID *uuid.UUID, +) (*model.OAuthProvider, error) { + var dbProvider database.OAuthProvider + + query := o.DB.WithContext(ctx). + Table("oauth_providers"). + Where("name = ?", name) + + if companyID != nil { + query = query.Where("company_id = ?", companyID) + } else { + query = query.Where("company_id IS NULL") + } + + if err := query.First(&dbProvider).Error; err != nil { + return nil, err + } + + return ToOAuthProvider(&dbProvider), nil +} + +// UpdateByID updates an oauth provider by id +func (o *OAuthProvider) UpdateByID( + ctx context.Context, + id uuid.UUID, + provider *model.OAuthProvider, +) error { + m := provider.ToDBMap() + m["updated_at"] = time.Now() + + return o.DB.WithContext(ctx). + Table("oauth_providers"). + Where("id = ?", id). + Updates(m).Error +} + +// UpdateTokens updates the oauth tokens for a provider +func (o *OAuthProvider) UpdateTokens( + ctx context.Context, + id uuid.UUID, + accessToken string, + refreshToken string, + expiresAt time.Time, +) error { + updates := map[string]interface{}{ + "access_token": accessToken, + "refresh_token": refreshToken, + "token_expires_at": expiresAt, + "is_authorized": true, + "authorized_at": time.Now(), + "updated_at": time.Now(), + } + + return o.DB.WithContext(ctx). + Table("oauth_providers"). + Where("id = ?", id). + Updates(updates).Error +} + +// RemoveAuthorization removes authorization tokens from a provider +func (o *OAuthProvider) RemoveAuthorization( + ctx context.Context, + id uuid.UUID, +) error { + updates := map[string]interface{}{ + "access_token": nil, + "refresh_token": nil, + "token_expires_at": nil, + "is_authorized": false, + "authorized_at": nil, + "authorized_email": nil, + "updated_at": time.Now(), + } + + return o.DB.WithContext(ctx). + Table("oauth_providers"). + Where("id = ?", id). + Updates(updates).Error +} + +// DeleteByID deletes an oauth provider by id +func (o *OAuthProvider) DeleteByID( + ctx context.Context, + id uuid.UUID, +) error { + return o.DB.WithContext(ctx). + Table("oauth_providers"). + Where("id = ?", id). + Delete(&model.OAuthProvider{}).Error +} + +// ToOAuthProvider converts database type to model type +func ToOAuthProvider(row *database.OAuthProvider) *model.OAuthProvider { + id := nullable.NewNullableWithValue(row.ID) + companyID := nullable.NewNullNullable[uuid.UUID]() + if row.CompanyID != nil { + companyID.Set(*row.CompanyID) + } + name := nullable.NewNullableWithValue(*vo.NewString127Must(row.Name)) + authURL := nullable.NewNullableWithValue(*vo.NewString512Must(row.AuthURL)) + tokenURL := nullable.NewNullableWithValue(*vo.NewString512Must(row.TokenURL)) + scopes := nullable.NewNullableWithValue(*vo.NewString512Must(row.Scopes)) + clientID := nullable.NewNullableWithValue(*vo.NewString255Must(row.ClientID)) + clientSecret := nullable.NewNullableWithValue(*vo.NewOptionalString255Must(row.ClientSecret)) + accessToken := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.AccessToken)) + refreshToken := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.RefreshToken)) + authorizedEmail := nullable.NewNullableWithValue(*vo.NewOptionalString255Must(row.AuthorizedEmail)) + isAuthorized := nullable.NewNullableWithValue(row.IsAuthorized) + + return &model.OAuthProvider{ + ID: id, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + CompanyID: companyID, + Name: name, + AuthURL: authURL, + TokenURL: tokenURL, + Scopes: scopes, + ClientID: clientID, + ClientSecret: clientSecret, + AccessToken: accessToken, + RefreshToken: refreshToken, + TokenExpiresAt: row.TokenExpiresAt, + AuthorizedEmail: authorizedEmail, + AuthorizedAt: row.AuthorizedAt, + IsAuthorized: isAuthorized, + Company: nil, + } +} diff --git a/backend/repository/oauthState.go b/backend/repository/oauthState.go new file mode 100644 index 0000000..7dd2f5f --- /dev/null +++ b/backend/repository/oauthState.go @@ -0,0 +1,98 @@ +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/phishingclub/phishingclub/database" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "gorm.io/gorm" +) + +// OAuthState repository +type OAuthState struct { + DB *gorm.DB +} + +// Insert inserts a new oauth state token +func (r *OAuthState) Insert( + ctx context.Context, + state *model.OAuthState, +) (*uuid.UUID, error) { + id := uuid.New() + now := time.Now() + dbState := &database.OAuthState{ + ID: id, + CreatedAt: &now, + StateToken: state.StateToken.MustGet().String(), + OAuthProviderID: state.OAuthProviderID.MustGet(), + ExpiresAt: state.ExpiresAt, + Used: false, + } + + result := r.DB.WithContext(ctx).Create(dbState) + if result.Error != nil { + return nil, errs.Wrap(result.Error) + } + + return &id, nil +} + +// GetByStateToken retrieves an oauth state by state token +func (r *OAuthState) GetByStateToken( + ctx context.Context, + stateToken string, +) (*model.OAuthState, error) { + var dbState database.OAuthState + result := r.DB.WithContext(ctx). + Where("state_token = ?", stateToken). + First(&dbState) + + if result.Error != nil { + return nil, errs.Wrap(result.Error) + } + + return r.toModel(&dbState), nil +} + +// MarkAsUsed marks a state token as used +func (r *OAuthState) MarkAsUsed( + ctx context.Context, + id uuid.UUID, +) error { + now := time.Now() + result := r.DB.WithContext(ctx). + Model(&database.OAuthState{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "used": true, + "used_at": &now, + }) + + if result.Error != nil { + return errs.Wrap(result.Error) + } + + return nil +} + +// DeleteExpired deletes expired oauth state tokens +func (r *OAuthState) DeleteExpired(ctx context.Context) error { + now := time.Now() + result := r.DB.WithContext(ctx). + Where("expires_at < ?", now). + Delete(&database.OAuthState{}) + + if result.Error != nil { + return errs.Wrap(result.Error) + } + + return nil +} + +// toModel converts database model to domain model +func (r *OAuthState) toModel(dbState *database.OAuthState) *model.OAuthState { + return model.OAuthStateFromDB(dbState) +} diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go index 25f43df..1eb9137 100644 --- a/backend/seed/migrate.go +++ b/backend/seed/migrate.go @@ -53,6 +53,8 @@ func initialInstallAndSeed( &database.Webhook{}, &database.Identifier{}, &database.CampaignStats{}, + &database.OAuthProvider{}, + &database.OAuthState{}, } // disable foreign key constraints temporarily for sqlite to allow table recreation diff --git a/backend/service/apiSender.go b/backend/service/apiSender.go index 9c18c5d..0b7ca7f 100644 --- a/backend/service/apiSender.go +++ b/backend/service/apiSender.go @@ -31,6 +31,7 @@ type APISender struct { TemplateService *Template CampaignTemplateService *CampaignTemplate APISenderRepository *repository.APISender + OAuthProviderService *OAuthProvider } // APISenderTestResponse is a response for testing API sender @@ -285,6 +286,13 @@ func (a *APISender) UpdateByID( if v, err := incoming.Name.Get(); err == nil { current.Name.Set(v) } + if incoming.CompanyID.IsSpecified() { + if v, err := incoming.CompanyID.Get(); err == nil { + current.CompanyID.Set(v) + } else { + current.CompanyID.SetNull() + } + } if v, err := incoming.APIKey.Get(); err == nil { current.APIKey.Set(v) } @@ -325,6 +333,13 @@ func (a *APISender) UpdateByID( if v, err := incoming.ExpectedResponseBody.Get(); err == nil { current.ExpectedResponseBody.Set(v) } + if incoming.OAuthProviderID.IsSpecified() { + if v, err := incoming.OAuthProviderID.Get(); err == nil { + current.OAuthProviderID.Set(v) + } else { + current.OAuthProviderID.SetNull() + } + } if err := current.Validate(); err != nil { a.Logger.Errorw("failed to validate API sender", "error", err) return err @@ -399,12 +414,28 @@ func (a *APISender) SendTest( return nil, errs.ErrAuthorizationFailed } a.Logger.Debugw("sending test request to API sender", "id", id.String()) - // get the API sender - apiSender, err := a.APISenderRepository.GetByID(ctx, id, &repository.APISenderOption{}) + // get the API sender with oauth provider + apiSender, err := a.APISenderRepository.GetByID(ctx, id, &repository.APISenderOption{ + WithOAuthProvider: true, + }) if err != nil { a.Logger.Errorw("failed to get API sender by ID", "error", err) return nil, errs.Wrap(err) } + + // get oauth access token if oauth provider is configured on the api sender + var oauthAccessToken string + oauthProviderID, err := apiSender.OAuthProviderID.Get() + if err == nil && a.OAuthProviderService != nil { + // oauth provider is configured for this api sender + token, tokenErr := a.OAuthProviderService.GetValidAccessToken(ctx, oauthProviderID) + if tokenErr != nil { + a.Logger.Errorw("failed to get oauth access token for test", "error", tokenErr, "oauthProviderID", oauthProviderID) + return nil, errs.Wrap(tokenErr) + } + oauthAccessToken = token + a.Logger.Debugw("got oauth access token for api test request", "oauthProviderID", oauthProviderID) + } emailRaw := "bob@enterprise.test" email := *vo.NewEmailMust(emailRaw) cid := nullable.NewNullableWithValue(uuid.New()) @@ -469,13 +500,15 @@ func (a *APISender) SendTest( }, }, } - url, headers, body, err := a.buildRequest( + url, headers, body, err := a.buildRequestWithCustomURL( apiSender, "api-sender-test.test", "id", "foo/bar", testCampaignRecipient, testEmail, + "", + oauthAccessToken, ) if err != nil { a.Logger.Errorw("failed to build test request", "error", err) @@ -556,7 +589,9 @@ func (a *APISender) SendWithCustomURL( ctx, session, &apiSenderID, - &repository.APISenderOption{}, + &repository.APISenderOption{ + WithOAuthProvider: true, + }, ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("api sender did not load: %s", err) @@ -565,6 +600,20 @@ func (a *APISender) SendWithCustomURL( return errors.New("api sender did not load") } + // get oauth access token if oauth provider is configured on the api sender + var oauthAccessToken string + oauthProviderID, err := apiSender.OAuthProviderID.Get() + if err == nil && a.OAuthProviderService != nil { + // oauth provider is configured for this api sender + token, err := a.OAuthProviderService.GetValidAccessToken(ctx, oauthProviderID) + if err != nil { + a.Logger.Errorw("failed to get oauth access token", "error", err, "oauthProviderID", oauthProviderID) + return fmt.Errorf("failed to get oauth access token: %w", err) + } + oauthAccessToken = token + a.Logger.Debugw("got oauth access token for api request", "oauthProviderID", oauthProviderID) + } + domainName := domain.Name.MustGet() urlIdentifier := cTemplate.URLIdentifier if urlIdentifier == nil { @@ -579,7 +628,9 @@ func (a *APISender) SendWithCustomURL( campaignRecipient, email, customCampaignURL, + oauthAccessToken, ) + resp, respBodyClose, err := a.sendRequest( context.Background(), apiSender, @@ -740,7 +791,7 @@ func (a *APISender) buildRequest( campaignRecipient *model.CampaignRecipient, email *model.Email, // todo is this superfluous? it should be in the campaign recipient? ) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) { - return a.buildRequestWithCustomURL(apiSender, domainName, urlKey, urlPath, campaignRecipient, email, "") + return a.buildRequestWithCustomURL(apiSender, domainName, urlKey, urlPath, campaignRecipient, email, "", "") } // buildRequestWithCustomURL builds an API request with optional custom campaign URL @@ -752,6 +803,7 @@ func (a *APISender) buildRequestWithCustomURL( campaignRecipient *model.CampaignRecipient, email *model.Email, customCampaignURL string, + oauthAccessToken string, ) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) { // create template data first so it can be used in headers, url, and body t := a.TemplateService.CreateMail( @@ -763,6 +815,11 @@ func (a *APISender) buildRequestWithCustomURL( apiSender, ) + // add oauth access token to template data if available + if oauthAccessToken != "" { + (*t)["OAuthAccessToken"] = oauthAccessToken + } + // override campaign URL if custom one is provided if customCampaignURL != "" { templateURL := fmt.Sprintf("https://%s%s?%s=%s", domainName, urlPath, urlKey, campaignRecipient.ID.MustGet().String()) diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 52c1665..062e5c1 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -1692,6 +1692,7 @@ func (c *Campaign) sendCampaignMessages( &repository.CampaignTemplateOption{ WithDomain: true, WithSMTPConfiguration: true, + WithAPISender: true, WithIdentifier: true, WithBeforeLandingProxy: true, WithLandingProxy: true, @@ -3368,6 +3369,7 @@ func (c *Campaign) sendSingleCampaignMessage( &repository.CampaignTemplateOption{ WithDomain: true, WithSMTPConfiguration: true, + WithAPISender: true, WithIdentifier: true, WithBeforeLandingProxy: true, WithLandingProxy: true, diff --git a/backend/service/oauthProvider.go b/backend/service/oauthProvider.go new file mode 100644 index 0000000..8a98700 --- /dev/null +++ b/backend/service/oauthProvider.go @@ -0,0 +1,649 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-errors/errors" + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/data" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/random" + "github.com/phishingclub/phishingclub/repository" + "github.com/phishingclub/phishingclub/validate" + "github.com/phishingclub/phishingclub/vo" + "golang.org/x/sync/singleflight" + "gorm.io/gorm" +) + +// OAuthProvider service handles oauth provider operations +type OAuthProvider struct { + Common + OAuthProviderRepository *repository.OAuthProvider + OAuthStateRepository *repository.OAuthState + + // refreshGroup ensures only one token refresh happens per provider at a time + // even if multiple goroutines request simultaneous token refreshes + refreshGroup singleflight.Group +} + +// TokenResponse represents the response from oauth token endpoints +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` +} + +// Create creates a new oauth provider +func (o *OAuthProvider) Create( + ctx context.Context, + session *model.Session, + provider *model.OAuthProvider, +) (*uuid.UUID, error) { + ae := NewAuditEvent("OAuthProvider.Create", session) + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + + // validate + if err := provider.Validate(); err != nil { + o.Logger.Errorw("failed to validate oauth provider", "error", err) + return nil, errs.Wrap(err) + } + + var companyID *uuid.UUID + if cid, err := provider.CompanyID.Get(); err == nil { + companyID = &cid + } + + // check uniqueness + name := provider.Name.MustGet() + isOK, err := repository.CheckNameIsUnique( + ctx, + o.OAuthProviderRepository.DB, + "oauth_providers", + name.String(), + companyID, + nil, + ) + if err != nil { + o.Logger.Errorw("failed to check oauth provider uniqueness", "error", err) + return nil, errs.Wrap(err) + } + if !isOK { + o.Logger.Debugw("oauth provider name is already used", "name", name.String()) + return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name") + } + + // set initial authorization state + provider.IsAuthorized = nullable.NewNullableWithValue(false) + + // save + id, err := o.OAuthProviderRepository.Insert(ctx, provider) + if err != nil { + o.Logger.Errorw("failed to insert oauth provider", "error", err) + return nil, errs.Wrap(err) + } + + ae.Details["id"] = id.String() + o.AuditLogAuthorized(ae) + + return id, nil +} + +// GetAll gets all oauth providers with pagination +func (o *OAuthProvider) GetAll( + ctx context.Context, + session *model.Session, + companyID *uuid.UUID, + option repository.OAuthProviderOption, +) (*model.Result[model.OAuthProvider], error) { + ae := NewAuditEvent("OAuthProvider.GetAll", session) + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + + // get all oauth providers + result, err := o.OAuthProviderRepository.GetAll(ctx, companyID, &option) + if err != nil { + o.Logger.Errorw("failed to get all oauth providers", "error", err) + return nil, errs.Wrap(err) + } + + // clear sensitive fields before returning + for i := range result.Rows { + result.Rows[i].ClientSecret = nullable.NewNullNullable[vo.OptionalString255]() + } + + return result, nil +} + +// GetByID gets an oauth provider by id +func (o *OAuthProvider) GetByID( + ctx context.Context, + session *model.Session, + id *uuid.UUID, +) (*model.OAuthProvider, error) { + ae := NewAuditEvent("OAuthProvider.GetByID", session) + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + + provider, err := o.OAuthProviderRepository.GetByID(ctx, *id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.Wrap(err) + } + o.Logger.Errorw("failed to get oauth provider by id", "error", err) + return nil, errs.Wrap(err) + } + + // clear sensitive fields + provider.ClientSecret = nullable.NewNullNullable[vo.OptionalString255]() + + return provider, nil +} + +// UpdateByID updates an oauth provider by id +func (o *OAuthProvider) UpdateByID( + ctx context.Context, + session *model.Session, + id *uuid.UUID, + provider *model.OAuthProvider, +) error { + ae := NewAuditEvent("OAuthProvider.UpdateByID", session) + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + + // validate + if err := provider.Validate(); err != nil { + o.Logger.Errorw("failed to validate oauth provider", "error", err) + return errs.Wrap(err) + } + + // get existing provider + existing, err := o.OAuthProviderRepository.GetByID(ctx, *id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.Wrap(err) + } + return errs.Wrap(err) + } + + var companyID *uuid.UUID + if cid, err := existing.CompanyID.Get(); err == nil { + companyID = &cid + } + + // check uniqueness + name := provider.Name.MustGet() + isOK, err := repository.CheckNameIsUnique( + ctx, + o.OAuthProviderRepository.DB, + "oauth_providers", + name.String(), + companyID, + id, + ) + if err != nil { + o.Logger.Errorw("failed to check oauth provider uniqueness", "error", err) + return errs.Wrap(err) + } + if !isOK { + o.Logger.Debugw("oauth provider name is already used", "name", name.String()) + return validate.WrapErrorWithField(errors.New("is not unique"), "name") + } + + // if client secret is being updated with a non-empty value, invalidate authorization + if provider.ClientSecret.IsSpecified() && !provider.ClientSecret.IsNull() { + if secret, err := provider.ClientSecret.Get(); err == nil && secret.String() != "" { + provider.IsAuthorized = nullable.NewNullableWithValue(false) + } + } + + // update + if err := o.OAuthProviderRepository.UpdateByID(ctx, *id, provider); err != nil { + o.Logger.Errorw("failed to update oauth provider", "error", err) + return errs.Wrap(err) + } + + ae.Details["id"] = id.String() + o.AuditLogAuthorized(ae) + + return nil +} + +// DeleteByID deletes an oauth provider by id +func (o *OAuthProvider) DeleteByID( + ctx context.Context, + session *model.Session, + id *uuid.UUID, +) error { + ae := NewAuditEvent("OAuthProvider.DeleteByID", session) + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + + // check if provider exists + _, err = o.OAuthProviderRepository.GetByID(ctx, *id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.Wrap(err) + } + return errs.Wrap(err) + } + + // delete + if err := o.OAuthProviderRepository.DeleteByID(ctx, *id); err != nil { + o.Logger.Errorw("failed to delete oauth provider", "error", err) + return errs.Wrap(err) + } + + ae.Details["id"] = id.String() + o.AuditLogAuthorized(ae) + + return nil +} + +// RemoveAuthorization removes authorization tokens from an oauth provider +func (o *OAuthProvider) RemoveAuthorization( + ctx context.Context, + session *model.Session, + id *uuid.UUID, +) error { + ae := NewAuditEvent("OAuthProvider.RemoveAuthorization", session) + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + + // check if provider exists + provider, err := o.OAuthProviderRepository.GetByID(ctx, *id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.Wrap(err) + } + return errs.Wrap(err) + } + + // remove authorization + if err := o.OAuthProviderRepository.RemoveAuthorization(ctx, *id); err != nil { + o.Logger.Errorw("failed to remove authorization from oauth provider", "error", err) + return errs.Wrap(err) + } + + name, _ := provider.Name.Get() + ae.Details["id"] = id.String() + ae.Details["name"] = name.String() + o.AuditLogAuthorized(ae) + + return nil +} + +// GetAuthorizationURL creates the oauth authorization url for the user to visit +func (o *OAuthProvider) GetAuthorizationURL( + ctx context.Context, + session *model.Session, + providerID *uuid.UUID, + redirectURI string, +) (string, error) { + ae := NewAuditEvent("OAuthProvider.GetAuthorizationURL", session) + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + o.LogAuthError(err) + return "", errs.Wrap(err) + } + if !isAuthorized { + o.AuditLogNotAuthorized(ae) + return "", errs.ErrAuthorizationFailed + } + + // get provider + provider, err := o.OAuthProviderRepository.GetByID(ctx, *providerID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", errs.Wrap(err) + } + return "", errs.Wrap(err) + } + + // generate cryptographically random state token (32 bytes base64-encoded) + stateToken, err := random.GenerateRandomURLBase64Encoded(32) + if err != nil { + o.Logger.Errorw("failed to generate state token", "error", err) + return "", errs.Wrap(err) + } + + // store state token (expires in 10 minutes) + expiresAt := time.Now().Add(10 * time.Minute) + + // create state token vo + stateTokenVO, err := vo.NewString255(stateToken) + if err != nil { + o.Logger.Errorw("failed to create state token vo", "error", err) + return "", errs.Wrap(err) + } + + oauthState := &model.OAuthState{ + StateToken: nullable.NewNullableWithValue(*stateTokenVO), + OAuthProviderID: nullable.NewNullableWithValue(*providerID), + ExpiresAt: &expiresAt, + } + + _, err = o.OAuthStateRepository.Insert(ctx, oauthState) + if err != nil { + o.Logger.Errorw("failed to store oauth state token", "error", err) + return "", errs.Wrap(err) + } + + // build authorization url + authURL := provider.AuthURL.MustGet() + clientID := provider.ClientID.MustGet() + scopes := provider.Scopes.MustGet() + + params := url.Values{ + "client_id": {clientID.String()}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {scopes.String()}, + "state": {stateToken}, + "access_type": {"offline"}, // request refresh token + "prompt": {"consent"}, // force consent to get refresh token + } + + authorizationURL := authURL.String() + "?" + params.Encode() + + o.AuditLogAuthorized(ae) + + return authorizationURL, nil +} + +// ExchangeCodeForTokens exchanges authorization code for access and refresh tokens +// session can be nil when called from public callback endpoint +// security is enforced through state token validation (one-time-use, expires) +func (o *OAuthProvider) ExchangeCodeForTokens( + ctx context.Context, + session *model.Session, + stateToken string, + code string, + redirectURI string, +) error { + // retrieve state token from database + oauthState, err := o.OAuthStateRepository.GetByStateToken(ctx, stateToken) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + o.Logger.Warnw("invalid or expired state token", "stateToken", stateToken) + return errors.New("invalid or expired state token") + } + o.Logger.Errorw("failed to retrieve state token", "error", err) + return errs.Wrap(err) + } + + // validate state token hasn't been used (prevent replay attacks) + if oauthState.Used { + o.Logger.Warnw("state token already used", "stateToken", stateToken) + return errors.New("state token already used") + } + + // validate state token hasn't expired + if oauthState.ExpiresAt != nil && time.Now().After(*oauthState.ExpiresAt) { + o.Logger.Warnw("state token expired", "stateToken", stateToken, "expiresAt", oauthState.ExpiresAt) + return errors.New("state token expired") + } + + // get provider from state + providerID := oauthState.OAuthProviderID.MustGet() + provider, err := o.OAuthProviderRepository.GetByID(ctx, providerID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.Wrap(err) + } + return errs.Wrap(err) + } + + // mark state token as used + stateID := oauthState.ID.MustGet() + if err := o.OAuthStateRepository.MarkAsUsed(ctx, stateID); err != nil { + o.Logger.Errorw("failed to mark state token as used", "error", err) + // continue anyway - token exchange is more important + } + + // get client secret + clientSecret := provider.ClientSecret.MustGet().String() + + // exchange code for tokens + tokenURL := provider.TokenURL.MustGet() + clientID := provider.ClientID.MustGet() + + data := url.Values{ + "code": {code}, + "client_id": {clientID.String()}, + "client_secret": {clientSecret}, + "redirect_uri": {redirectURI}, + "grant_type": {"authorization_code"}, + } + + tokens, err := o.requestTokens(tokenURL.String(), data) + if err != nil { + o.Logger.Errorw("failed to exchange code for tokens", "error", err) + return errs.Wrap(err) + } + + // store tokens + expiresAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second) + if err := o.OAuthProviderRepository.UpdateTokens( + ctx, + providerID, + tokens.AccessToken, + tokens.RefreshToken, + expiresAt, + ); err != nil { + o.Logger.Errorw("failed to update tokens", "error", err) + return errs.Wrap(err) + } + + // log successful token exchange + o.Logger.Infow("oauth token exchange successful", + "providerID", providerID.String(), + ) + + return nil +} + +// GetValidAccessToken returns a valid access token, refreshing if needed +// this is the key method used by other services +// uses singleflight to deduplicate concurrent refresh requests for the same provider +func (o *OAuthProvider) GetValidAccessToken( + ctx context.Context, + providerID uuid.UUID, +) (string, error) { + // use singleflight to ensure only one refresh per provider at a time + // key is the provider id - all concurrent calls with same provider will share the same work + val, err, shared := o.refreshGroup.Do(providerID.String(), func() (interface{}, error) { + return o.getValidAccessTokenInternal(ctx, providerID) + }) + + if shared { + o.Logger.Debugw("oauth token request shared with concurrent call", + "providerID", providerID.String(), + ) + } + + if err != nil { + return "", err + } + + return val.(string), nil +} + +// getValidAccessTokenInternal is the actual implementation that fetches/refreshes tokens +// this is wrapped by GetValidAccessToken with singleflight for concurrency safety +func (o *OAuthProvider) getValidAccessTokenInternal( + ctx context.Context, + providerID uuid.UUID, +) (string, error) { + // get provider + provider, err := o.OAuthProviderRepository.GetByID(ctx, providerID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", errs.Wrap(err) + } + return "", errs.Wrap(err) + } + + // check if authorized + if provider.IsAuthorized.MustGet() == false { + return "", errors.New("oauth provider not authorized - user must complete authorization flow") + } + + // check if token needs refresh (5 minute buffer) + if provider.TokenExpiresAt != nil && time.Now().Add(5*time.Minute).Before(*provider.TokenExpiresAt) { + // token still valid, return as-is + accessToken, _ := provider.AccessToken.Get() + return accessToken.String(), nil + } + + // token expired or about to expire, refresh it + o.Logger.Infow("refreshing oauth token", "providerID", providerID.String()) + + // get client secret and refresh token (stored as plain text) + clientSecret := provider.ClientSecret.MustGet().String() + refreshToken, _ := provider.RefreshToken.Get() + + // refresh tokens + tokenURL := provider.TokenURL.MustGet() + clientID := provider.ClientID.MustGet() + + data := url.Values{ + "client_id": {clientID.String()}, + "client_secret": {clientSecret}, + "refresh_token": {refreshToken.String()}, + "grant_type": {"refresh_token"}, + } + + newTokens, err := o.requestTokens(tokenURL.String(), data) + if err != nil { + o.Logger.Errorw("failed to refresh tokens", "error", err) + return "", errs.Wrap(err) + } + + // some providers return new refresh token, some don't + newRefreshToken := newTokens.RefreshToken + if newRefreshToken == "" { + // keep the old refresh token + oldRefresh, _ := provider.RefreshToken.Get() + newRefreshToken = oldRefresh.String() + } + + // update stored + expiresAt := time.Now().Add(time.Duration(newTokens.ExpiresIn) * time.Second) + if err := o.OAuthProviderRepository.UpdateTokens( + ctx, + providerID, + newTokens.AccessToken, + newRefreshToken, + expiresAt, + ); err != nil { + o.Logger.Errorw("failed to update refreshed tokens", "error", err) + return "", errs.Wrap(err) + } + + return newTokens.AccessToken, nil +} + +// requestTokens makes a request to the token endpoint +func (o *OAuthProvider) requestTokens(tokenURL string, data url.Values) (*TokenResponse, error) { + resp, err := http.PostForm(tokenURL, data) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("token request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var tokens TokenResponse + if err := json.Unmarshal(body, &tokens); err != nil { + return nil, err + } + + return &tokens, nil +} + +/* @TODO the logic is here, but i dont think we really need to implement it +// CleanupExpiredStates removes expired oauth state tokens from database +// should be called periodically (e.g., daily) +func (o *OAuthProvider) CleanupExpiredStates(ctx context.Context) error { + err := o.OAuthStateRepository.DeleteExpired(ctx) + if err != nil { + o.Logger.Errorw("failed to cleanup expired oauth states", "error", err) + return errs.Wrap(err) + } + return nil +} +**/ diff --git a/backend/vendor/golang.org/x/sync/errgroup/errgroup.go b/backend/vendor/golang.org/x/sync/errgroup/errgroup.go index 1d8cffa..2f45dbc 100644 --- a/backend/vendor/golang.org/x/sync/errgroup/errgroup.go +++ b/backend/vendor/golang.org/x/sync/errgroup/errgroup.go @@ -3,7 +3,7 @@ // license that can be found in the LICENSE file. // Package errgroup provides synchronization, error propagation, and Context -// cancelation for groups of goroutines working on subtasks of a common task. +// cancellation for groups of goroutines working on subtasks of a common task. // // [errgroup.Group] is related to [sync.WaitGroup] but adds handling of tasks // returning errors. diff --git a/backend/vendor/modules.txt b/backend/vendor/modules.txt index c07a7f2..c981b4b 100644 --- a/backend/vendor/modules.txt +++ b/backend/vendor/modules.txt @@ -571,9 +571,10 @@ golang.org/x/net/ipv4 golang.org/x/net/ipv6 golang.org/x/net/proxy golang.org/x/net/publicsuffix -# golang.org/x/sync v0.17.0 +# golang.org/x/sync v0.18.0 ## explicit; go 1.24.0 golang.org/x/sync/errgroup +golang.org/x/sync/singleflight # golang.org/x/sys v0.37.0 ## explicit; go 1.24.0 golang.org/x/sys/cpu diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 7b9d384..847e894 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -1815,6 +1815,99 @@ export class API { } }; + /** + * oauthProvider is the API for OAuth Provider related operations. + */ + oauthProvider = { + /** + * Get all OAuth Providers using pagination. + * + * @param {TableURLParams} options + * @param {string|null} companyID + * @returns {Promise
- Are you sure you want to delete
- {#if name?.length > 30}
-
+ {#if actionMessage}
+ {actionMessage}
+ {#if name?.length > 30}
+
+ {/if}
+ "{name}"?
+ {:else}
+ Are you sure you want to delete
+ {#if name?.length > 30}
+
+ {/if}
+ "{name}"?
{/if}
- "{name}"?
{headersToString(
- testResponse.request?.headers
- ) || 'No headers'}
+ {testResponse.request
- ?.body || 'Empty body'}
+ {headersToString(
- testResponse.response?.headers
- ) || 'No headers'}
+ {testResponse
- .apiSender?.expectedResponseHeaders || 'No validation specified'}
+ {testResponse
- .response?.body || 'Empty response'}
+ {testResponse
- .apiSender?.expectedResponseBody || 'No validation specified'}
+