mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-05-21 07:26:50 +02:00
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OAuth Callback</title>
|
||||
</head>
|
||||
<body>
|
||||
<pre>%s</pre>
|
||||
<script>
|
||||
console.log('OAuth callback page loaded with status: %s');
|
||||
if (window.opener) {
|
||||
console.log('Sending message to parent window');
|
||||
try {
|
||||
var targetOrigin = %s;
|
||||
console.log('Target origin:', targetOrigin);
|
||||
window.opener.postMessage({
|
||||
type: 'oauth-callback',
|
||||
status: '%s'
|
||||
}, targetOrigin);
|
||||
console.log('Message sent successfully');
|
||||
} catch (e) {
|
||||
console.error('Failed to send message:', e);
|
||||
}
|
||||
} else {
|
||||
console.log('No window.opener found');
|
||||
}
|
||||
|
||||
// auto-close popup after 5 seconds
|
||||
setTimeout(function() {
|
||||
console.log('Auto-closing popup window');
|
||||
window.close();
|
||||
}, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`, text, status, targetOrigin, status)
|
||||
|
||||
g.Header("Content-Type", "text/html; charset=utf-8")
|
||||
g.String(http.StatusOK, html)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+3
-3
@@ -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
|
||||
|
||||
+2
-2
@@ -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=
|
||||
|
||||
@@ -266,6 +266,7 @@ func main() {
|
||||
atomicLogger,
|
||||
utils,
|
||||
db,
|
||||
conf,
|
||||
)
|
||||
// setup admin account
|
||||
isInstalled, err := controllers.InitialSetup.IsInstalled(context.Background())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
**/
|
||||
+1
-1
@@ -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.
|
||||
|
||||
Vendored
+2
-1
@@ -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
|
||||
|
||||
@@ -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<ApiResponse>}
|
||||
*/
|
||||
getAll: async (options, companyID) => {
|
||||
return await getJSON(
|
||||
this.getPath(`/oauth-provider?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get an OAuth Provider by its ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getByID: async (id) => {
|
||||
return await getJSON(this.getPath(`/oauth-provider/${id}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new OAuth Provider.
|
||||
*
|
||||
* @param {Object} provider
|
||||
* @param {string} provider.name
|
||||
* @param {string} provider.clientID
|
||||
* @param {string} provider.clientSecret
|
||||
* @param {string} provider.authURL
|
||||
* @param {string} provider.tokenURL
|
||||
* @param {string} provider.scopes
|
||||
* @param {string} provider.companyID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
create: async (provider) => {
|
||||
return await postJSON(this.getPath('/oauth-provider'), provider);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an OAuth Provider.
|
||||
*
|
||||
* @param {Object} provider
|
||||
* @param {string} provider.id
|
||||
* @param {string} provider.name
|
||||
* @param {string} provider.clientID
|
||||
* @param {string} provider.clientSecret
|
||||
* @param {string} provider.authURL
|
||||
* @param {string} provider.tokenURL
|
||||
* @param {string} provider.scopes
|
||||
* @param {string} provider.companyID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
update: async (provider) => {
|
||||
return await patchJSON(this.getPath(`/oauth-provider/${provider.id}`), provider);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an OAuth Provider.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
delete: async (id) => {
|
||||
return await deleteJSON(this.getPath(`/oauth-provider/${id}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the authorization URL for an OAuth Provider.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getAuthorizationURL: async (id) => {
|
||||
return await getJSON(this.getPath(`/oauth-authorize/${id}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove authorization tokens from an OAuth Provider.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
removeAuthorization: async (id) => {
|
||||
return await postJSON(this.getPath(`/oauth-provider/${id}/remove-authorization`), {});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* user is the API for user related operations - these actions also affect the user's sessions
|
||||
*/
|
||||
@@ -2501,6 +2594,7 @@ export class API {
|
||||
* @param {string} sender.customField2
|
||||
* @param {string} sender.customField3
|
||||
* @param {string} sender.customField4
|
||||
* @param {string} sender.oauthProviderID
|
||||
* @param {string} sender.requestMethod
|
||||
* @param {string} sender.requestURL
|
||||
* @param {APISenderHeader[]} sender.requestHeaders
|
||||
@@ -2518,6 +2612,7 @@ export class API {
|
||||
customField2,
|
||||
customField3,
|
||||
customField4,
|
||||
oauthProviderID,
|
||||
requestMethod,
|
||||
requestURL,
|
||||
requestHeaders,
|
||||
@@ -2539,6 +2634,7 @@ export class API {
|
||||
customField2: customField2,
|
||||
customField3: customField3,
|
||||
customField4: customField4,
|
||||
oauthProviderID: oauthProviderID,
|
||||
requestMethod: requestMethod,
|
||||
requestURL: requestURL,
|
||||
requestHeaders: requestHeaders,
|
||||
@@ -2561,6 +2657,7 @@ export class API {
|
||||
* @param {string} sender.customField2
|
||||
* @param {string} sender.customField3
|
||||
* @param {string} sender.customField4
|
||||
* @param {string} sender.oauthProviderID
|
||||
* @param {string} sender.requestMethod
|
||||
* @param {string} sender.requestURL
|
||||
* @param {APISenderHeader[]} sender.requestHeaders
|
||||
@@ -2578,6 +2675,7 @@ export class API {
|
||||
customField2,
|
||||
customField3,
|
||||
customField4,
|
||||
oauthProviderID,
|
||||
requestMethod,
|
||||
requestURL,
|
||||
requestHeaders,
|
||||
@@ -2602,6 +2700,7 @@ export class API {
|
||||
customField2: customField2,
|
||||
customField3: customField3,
|
||||
customField4: customField4,
|
||||
oauthProviderID: oauthProviderID,
|
||||
requestMethod: requestMethod,
|
||||
requestURL: requestURL,
|
||||
requestHeaders: requestHeaders,
|
||||
|
||||
@@ -125,6 +125,11 @@
|
||||
api_senders: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||||
</svg>
|
||||
`,
|
||||
|
||||
oauth_providers: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
|
||||
</svg>
|
||||
`,
|
||||
|
||||
proxy: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
@@ -149,7 +154,8 @@
|
||||
'/email/': 'emails_overview',
|
||||
'/attachment/': 'attachments',
|
||||
'/smtp-configuration/': 'smtp_configurations',
|
||||
'/api-sender/': 'api_senders'
|
||||
'/api-sender/': 'api_senders',
|
||||
'/oauth-provider/': 'oauth_providers'
|
||||
};
|
||||
|
||||
return icons[iconMap[route] || 'dashboard']; // fallback to dashboard if route not found
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||||
</svg>`,
|
||||
|
||||
oauth_providers: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
|
||||
</svg>`,
|
||||
|
||||
proxy: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>`,
|
||||
@@ -123,6 +127,7 @@
|
||||
'/attachment/': 'attachments',
|
||||
'/smtp-configuration/': 'smtp_configurations',
|
||||
'/api-sender/': 'api_senders',
|
||||
'/oauth-provider/': 'oauth_providers',
|
||||
'/profile/': 'profile',
|
||||
'/sessions/': 'sessions',
|
||||
'/user/': 'users',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
export let confirm = false;
|
||||
export let confirmWord = 'confirm';
|
||||
export let permanent = true;
|
||||
export let actionMessage = '';
|
||||
|
||||
let confirmText = '';
|
||||
|
||||
@@ -47,11 +48,19 @@
|
||||
<h3 class="text-lg font-medium text-gray-900">Delete {type}</h3>
|
||||
-->
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Are you sure you want to delete
|
||||
{#if name?.length > 30}
|
||||
<br />
|
||||
{#if actionMessage}
|
||||
{actionMessage}
|
||||
{#if name?.length > 30}
|
||||
<br />
|
||||
{/if}
|
||||
<span class="font-medium text-gray-900 dark:text-gray-300">"{name}"</span>?
|
||||
{:else}
|
||||
Are you sure you want to delete
|
||||
{#if name?.length > 30}
|
||||
<br />
|
||||
{/if}
|
||||
<span class="font-medium text-gray-900 dark:text-gray-300">"{name}"</span>?
|
||||
{/if}
|
||||
<span class="font-medium text-gray-900 dark:text-gray-300">"{name}"</span>?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,6 +81,10 @@ export const route = {
|
||||
label: 'API Senders',
|
||||
route: '/api-sender/'
|
||||
},
|
||||
oauthProviders: {
|
||||
label: 'OAuth',
|
||||
route: '/oauth-provider/'
|
||||
},
|
||||
allowDeny: {
|
||||
label: 'Filters',
|
||||
route: '/filter/'
|
||||
@@ -122,7 +126,13 @@ export const menu = [
|
||||
{
|
||||
label: 'Emails',
|
||||
type: 'submenu',
|
||||
items: [route.emails, route.attachments, route.smtpConfigurations, route.apiSenders]
|
||||
items: [
|
||||
route.emails,
|
||||
route.attachments,
|
||||
route.smtpConfigurations,
|
||||
route.apiSenders,
|
||||
route.oauthProviders
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -11,27 +11,30 @@
|
||||
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
|
||||
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
|
||||
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import TextareaField from '$lib/components/TextareaField.svelte';
|
||||
import TableCellScope from '$lib/components/table/TableCellScope.svelte';
|
||||
import BigButton from '$lib/components/BigButton.svelte';
|
||||
import FormColumn from '$lib/components/FormColumn.svelte';
|
||||
import FormColumns from '$lib/components/FormColumns.svelte';
|
||||
import FormColumn from '$lib/components/FormColumn.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import Table from '$lib/components/table/Table.svelte';
|
||||
import HeadTitle from '$lib/components/HeadTitle.svelte';
|
||||
import TableViewButton from '$lib/components/table/TableViewButton.svelte';
|
||||
import { getModalText } from '$lib/utils/common';
|
||||
import TableCopyButton from '$lib/components/table/TableCopyButton.svelte';
|
||||
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
|
||||
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import TextareaField from '$lib/components/TextareaField.svelte';
|
||||
import SimpleCodeEditor from '$lib/components/editor/SimpleCodeEditor.svelte';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import TableCellScope from '$lib/components/table/TableCellScope.svelte';
|
||||
import TableViewButton from '$lib/components/table/TableViewButton.svelte';
|
||||
import { BiMap } from '$lib/utils/maps';
|
||||
import { fetchAllRows } from '$lib/utils/api-utils';
|
||||
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
|
||||
import VimToggle from '$lib/components/editor/VimToggle.svelte';
|
||||
|
||||
// services
|
||||
@@ -40,6 +43,7 @@
|
||||
// data
|
||||
let form = null;
|
||||
let contextCompanyID = null;
|
||||
let oauthProviderMap = new BiMap({});
|
||||
let formValues = {
|
||||
name: '',
|
||||
companyID: '',
|
||||
@@ -48,6 +52,7 @@
|
||||
customField2: '',
|
||||
customField3: '',
|
||||
customField4: '',
|
||||
oauthProvider: null,
|
||||
requestMethod: '',
|
||||
requestURL: '',
|
||||
requestHeaders: '',
|
||||
@@ -87,6 +92,15 @@
|
||||
contextCompanyID = appStateService.getContext().companyID;
|
||||
formValues.companyID = contextCompanyID;
|
||||
}
|
||||
|
||||
// load oauth providers
|
||||
(async () => {
|
||||
const oauthProviders = await fetchAllRows((options) => {
|
||||
return api.oauthProvider.getAll(options, contextCompanyID);
|
||||
});
|
||||
oauthProviderMap = BiMap.FromArrayOfObjects(oauthProviders);
|
||||
})();
|
||||
|
||||
refreshConfigurations();
|
||||
tableURLParams.onChange(refreshConfigurations);
|
||||
(async () => {
|
||||
@@ -148,7 +162,10 @@
|
||||
|
||||
const onClickCreate = async () => {
|
||||
try {
|
||||
const res = await api.apiSender.create(formValues);
|
||||
const res = await api.apiSender.create({
|
||||
...formValues,
|
||||
oauthProviderID: oauthProviderMap.byValueOrNull(formValues.oauthProvider)
|
||||
});
|
||||
if (!res.success) {
|
||||
modalError = res.error;
|
||||
return;
|
||||
@@ -165,7 +182,10 @@
|
||||
|
||||
const onClickUpdate = async (saveOnly = false) => {
|
||||
try {
|
||||
const res = await api.apiSender.update(formValues);
|
||||
const res = await api.apiSender.update({
|
||||
...formValues,
|
||||
oauthProviderID: oauthProviderMap.byValueOrNull(formValues.oauthProvider)
|
||||
});
|
||||
if (!res.success) {
|
||||
modalError = res.error;
|
||||
throw res.error;
|
||||
@@ -177,6 +197,7 @@
|
||||
}
|
||||
refreshConfigurations();
|
||||
} catch (err) {
|
||||
addToast('Failed to update API sender', 'Error');
|
||||
console.error('failed to update API sender:', err);
|
||||
}
|
||||
};
|
||||
@@ -259,6 +280,7 @@
|
||||
|
||||
const assignAPISender = (apiSender) => {
|
||||
formValues = apiSender;
|
||||
formValues.oauthProvider = oauthProviderMap.byKey(apiSender.oauthProviderID);
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
@@ -418,7 +440,17 @@
|
||||
maxLength={255}
|
||||
optional={true}
|
||||
toolTipText="Use as {'{{.APIKey}}'}"
|
||||
placeholder="S3C-R37-AP1-K3Y">API Key</TextField
|
||||
placeholder="your-api-key">API Key</TextField
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<TextFieldSelect
|
||||
id="oauthProvider"
|
||||
bind:value={formValues.oauthProvider}
|
||||
options={oauthProviderMap.values()}
|
||||
optional={true}
|
||||
toolTipText="OAuth provider for token-based authentication. Access token available as {'{{.OAuthAccessToken}}'}"
|
||||
>OAuth Provider (Optional)</TextFieldSelect
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -589,7 +621,9 @@ X-Custom-Header: Hello Friend"
|
||||
</Modal>
|
||||
|
||||
<Modal headerText="API Sender Test Results" visible={isTestModalVisible} onClose={closeTestModal}>
|
||||
<div class="col-span-3 w-full overflow-y-auto px-6 py-4 space-y-6 select-text">
|
||||
<div
|
||||
class="col-span-3 w-full overflow-y-auto px-6 py-4 space-y-6 select-text overflow-x-hidden"
|
||||
>
|
||||
{#if !testResponse.error}
|
||||
<!-- Successful Test -->
|
||||
<div class="mb-6 pt-4 pb-2 border-b border-gray-200 dark:border-gray-600 w-full">
|
||||
@@ -599,7 +633,7 @@ X-Custom-Header: Hello Friend"
|
||||
<div
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<div class="font-medium">
|
||||
<div class="font-medium break-all">
|
||||
{testResponse.apiSender?.requestMethod}
|
||||
{testResponse?.request?.url}
|
||||
</div>
|
||||
@@ -611,21 +645,28 @@ X-Custom-Header: Hello Friend"
|
||||
Request Headers
|
||||
</h3>
|
||||
<div
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
|
||||
>
|
||||
<pre class="text-xs whitespace-pre-wrap overflow-x-auto max-h-60">{headersToString(
|
||||
testResponse.request?.headers
|
||||
) || 'No headers'}</pre>
|
||||
<div
|
||||
class="text-xs whitespace-pre-wrap break-all max-h-60 overflow-y-auto font-mono"
|
||||
style="word-break: break-all; overflow-wrap: anywhere;"
|
||||
>
|
||||
{headersToString(testResponse.request?.headers) || 'No headers'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 pb-2 w-full">
|
||||
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">Request Body</h3>
|
||||
<div
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
|
||||
>
|
||||
<pre class="text-xs whitespace-pre-wrap overflow-x-auto max-h-80">{testResponse.request
|
||||
?.body || 'Empty body'}</pre>
|
||||
<div
|
||||
class="text-xs whitespace-pre-wrap break-all max-h-80 overflow-y-auto font-mono"
|
||||
style="word-break: break-all; overflow-wrap: anywhere;"
|
||||
>
|
||||
{testResponse.request?.body || 'Empty body'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- response -->
|
||||
@@ -635,7 +676,7 @@ X-Custom-Header: Hello Friend"
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
|
||||
>
|
||||
<div class="text-sm font-medium mb-1">Received:</div>
|
||||
<div
|
||||
@@ -648,7 +689,7 @@ X-Custom-Header: Hello Friend"
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
|
||||
>
|
||||
<div class="text-sm font-medium mb-1">Expected:</div>
|
||||
<div>{testResponse.apiSender?.expectedResponseStatusCode}</div>
|
||||
@@ -662,19 +703,26 @@ X-Custom-Header: Hello Friend"
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
|
||||
>
|
||||
<div class="text-sm font-medium mb-1">Received:</div>
|
||||
<pre class="text-xs whitespace-pre-wrap overflow-x-auto max-h-60">{headersToString(
|
||||
testResponse.response?.headers
|
||||
) || 'No headers'}</pre>
|
||||
<div
|
||||
class="text-xs whitespace-pre-wrap break-all max-h-60 overflow-y-auto font-mono"
|
||||
style="word-break: break-all; overflow-wrap: anywhere;"
|
||||
>
|
||||
{headersToString(testResponse.response?.headers) || 'No headers'}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
|
||||
>
|
||||
<div class="text-sm font-medium mb-1">Expected to contain:</div>
|
||||
<pre class="text-xs whitespace-pre-wrap overflow-x-auto max-h-60">{testResponse
|
||||
.apiSender?.expectedResponseHeaders || 'No validation specified'}</pre>
|
||||
<div
|
||||
class="text-xs whitespace-pre-wrap break-all max-h-60 overflow-y-auto font-mono"
|
||||
style="word-break: break-all; overflow-wrap: anywhere;"
|
||||
>
|
||||
{testResponse.apiSender?.expectedResponseHeaders || 'No validation specified'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -683,18 +731,26 @@ X-Custom-Header: Hello Friend"
|
||||
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">Response Body</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
|
||||
>
|
||||
<div class="text-sm font-medium mb-1">Received:</div>
|
||||
<pre class="text-xs whitespace-pre-wrap overflow-x-auto max-h-80">{testResponse
|
||||
.response?.body || 'Empty response'}</pre>
|
||||
<div
|
||||
class="text-xs whitespace-pre-wrap break-all max-h-80 overflow-y-auto font-mono"
|
||||
style="word-break: break-all; overflow-wrap: anywhere;"
|
||||
>
|
||||
{testResponse.response?.body || 'Empty response'}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
|
||||
>
|
||||
<div class="text-sm font-medium mb-1">Expected to contain:</div>
|
||||
<pre class="text-xs whitespace-pre-wrap overflow-x-auto max-h-80">{testResponse
|
||||
.apiSender?.expectedResponseBody || 'No validation specified'}</pre>
|
||||
<div
|
||||
class="text-xs whitespace-pre-wrap break-all max-h-80 overflow-y-auto font-mono"
|
||||
style="word-break: break-all; overflow-wrap: anywhere;"
|
||||
>
|
||||
{testResponse.apiSender?.expectedResponseBody || 'No validation specified'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -705,9 +761,9 @@ X-Custom-Header: Hello Friend"
|
||||
Request Details
|
||||
</h3>
|
||||
<div
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-600 overflow-hidden"
|
||||
>
|
||||
<div class="font-medium">
|
||||
<div class="font-medium break-all">
|
||||
{testResponse.apiSender?.requestMethod}
|
||||
{testResponse.apiSender?.requestURL}
|
||||
</div>
|
||||
@@ -715,8 +771,8 @@ X-Custom-Header: Hello Friend"
|
||||
</div>
|
||||
<div class="pt-4 pb-2 w-full">
|
||||
<h3 class="text-base font-medium text-red-600 mb-3">Error</h3>
|
||||
<div class="p-4 bg-red-50 rounded-md border border-red-200">
|
||||
<div class="text-red-600 whitespace-pre-wrap">{testResponse.error}</div>
|
||||
<div class="p-4 bg-red-50 rounded-md border border-red-200 overflow-hidden">
|
||||
<div class="text-red-600 whitespace-pre-wrap break-words">{testResponse.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -117,8 +117,8 @@
|
||||
refreshApiSenders(),
|
||||
refreshPages(),
|
||||
refreshProxies(),
|
||||
getCampaignTemplates(),
|
||||
refreshIdentifiers()
|
||||
refreshIdentifiers(),
|
||||
getCampaignTemplates()
|
||||
]);
|
||||
tableURLParams.onChange(refreshCampaignTemplates);
|
||||
const editID = $page.url.searchParams.get('edit');
|
||||
@@ -395,7 +395,7 @@
|
||||
|
||||
const closeModal = () => {
|
||||
isModalVisible = false;
|
||||
form.reset();
|
||||
form?.reset();
|
||||
formValues = {
|
||||
id: null,
|
||||
templateType: 'Email',
|
||||
@@ -413,8 +413,6 @@
|
||||
stateIdentifier: 'session',
|
||||
urlPath: ''
|
||||
};
|
||||
modalError = '';
|
||||
showAdvancedOptions = false;
|
||||
};
|
||||
|
||||
/** @param {string} id */
|
||||
@@ -463,7 +461,7 @@
|
||||
if (template.smtpConfigurationID) {
|
||||
formValues.templateType = 'Email';
|
||||
} else {
|
||||
formValues.templateType = 'API Sender';
|
||||
formValues.templateType = 'External API';
|
||||
}
|
||||
formValues.domain = domainMap.byKey(template.domainID);
|
||||
formValues.email = emailMap.byKey(template.emailID);
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { newTableURLParams } from '$lib/service/tableURLParams.js';
|
||||
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
|
||||
import Headline from '$lib/components/Headline.svelte';
|
||||
import TextField from '$lib/components/TextField.svelte';
|
||||
import TableRow from '$lib/components/table/TableRow.svelte';
|
||||
import TableCell from '$lib/components/table/TableCell.svelte';
|
||||
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
|
||||
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import PasswordField from '$lib/components/PasswordField.svelte';
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
|
||||
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import BigButton from '$lib/components/BigButton.svelte';
|
||||
import FormColumns from '$lib/components/FormColumns.svelte';
|
||||
import FormColumn from '$lib/components/FormColumn.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import Table from '$lib/components/table/Table.svelte';
|
||||
import HeadTitle from '$lib/components/HeadTitle.svelte';
|
||||
import { getModalText } from '$lib/utils/common';
|
||||
import TableCopyButton from '$lib/components/table/TableCopyButton.svelte';
|
||||
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
|
||||
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import SelectSquare from '$lib/components/SelectSquare.svelte';
|
||||
import TableCellScope from '$lib/components/table/TableCellScope.svelte';
|
||||
import TextareaField from '$lib/components/TextareaField.svelte';
|
||||
|
||||
// services
|
||||
const appStateService = AppStateService.instance;
|
||||
|
||||
// data
|
||||
let form = null;
|
||||
let formValues = {
|
||||
id: null,
|
||||
name: null,
|
||||
|
||||
clientID: null,
|
||||
clientSecret: null,
|
||||
authURL: null,
|
||||
tokenURL: null,
|
||||
scopes: null
|
||||
};
|
||||
let providers = [];
|
||||
let providersHasNextPage = false;
|
||||
let formError = '';
|
||||
let contextCompanyID = null;
|
||||
const tableURLParams = newTableURLParams();
|
||||
let isModalVisible = false;
|
||||
let isProviderTableLoading = false;
|
||||
let isSubmitting = false;
|
||||
let modalMode = null;
|
||||
let modalText = '';
|
||||
let isDeleteAlertVisible = false;
|
||||
let deleteValues = {
|
||||
id: null,
|
||||
name: null
|
||||
};
|
||||
let isRemoveAuthAlertVisible = false;
|
||||
let removeAuthValues = {
|
||||
id: null,
|
||||
name: null
|
||||
};
|
||||
|
||||
$: {
|
||||
modalText = getModalText('OAuth', modalMode);
|
||||
}
|
||||
|
||||
// hooks
|
||||
onMount(() => {
|
||||
if (appStateService.getContext()) {
|
||||
contextCompanyID = appStateService.getContext().companyID;
|
||||
}
|
||||
refreshProviders();
|
||||
tableURLParams.onChange(refreshProviders);
|
||||
|
||||
// listen for oauth callback messages from popup window
|
||||
const handleMessage = (event) => {
|
||||
console.log('received message:', event.data, 'from origin:', event.origin);
|
||||
// verify message is from our origin or localhost (for dev with vite proxy)
|
||||
const isValidOrigin =
|
||||
event.origin === window.location.origin ||
|
||||
(window.location.hostname === 'localhost' &&
|
||||
new URL(event.origin).hostname === 'localhost');
|
||||
if (!isValidOrigin) {
|
||||
console.log('message origin does not match, ignoring');
|
||||
return;
|
||||
}
|
||||
// handle oauth callback result
|
||||
if (event.data && event.data.type === 'oauth-callback') {
|
||||
console.log('oauth callback message received with status:', event.data.status);
|
||||
if (event.data.status === 'success') {
|
||||
addToast('OAuth authorization successful!', 'Success');
|
||||
refreshProviders();
|
||||
} else if (event.data.status === 'error') {
|
||||
addToast('OAuth authorization failed', 'Error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
console.log('message listener added for oauth callbacks');
|
||||
|
||||
(async () => {
|
||||
const editID = $page.url.searchParams.get('edit');
|
||||
if (editID) {
|
||||
await openUpdateModal(editID);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
tableURLParams.unsubscribe();
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
});
|
||||
|
||||
// component logic
|
||||
const refreshProviders = async () => {
|
||||
try {
|
||||
isProviderTableLoading = true;
|
||||
const data = await getProviders();
|
||||
providers = data.rows;
|
||||
providersHasNextPage = data.hasNextPage;
|
||||
} catch (e) {
|
||||
addToast('Failed to get OAuth providers', 'Error');
|
||||
console.error(e);
|
||||
} finally {
|
||||
isProviderTableLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a provider by ID
|
||||
* @param {string} id
|
||||
*/
|
||||
const getProvider = async (id) => {
|
||||
try {
|
||||
showIsLoading();
|
||||
const res = await api.oauthProvider.getByID(id);
|
||||
if (res.success) {
|
||||
return res.data;
|
||||
} else {
|
||||
throw res.error;
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to get OAuth provider', 'Error');
|
||||
console.error('failed to get OAuth provider', e);
|
||||
} finally {
|
||||
hideIsLoading();
|
||||
}
|
||||
};
|
||||
|
||||
const getProviders = async () => {
|
||||
try {
|
||||
const res = await api.oauthProvider.getAll(tableURLParams, contextCompanyID);
|
||||
if (!res.success) {
|
||||
throw res.error;
|
||||
}
|
||||
return res.data;
|
||||
} catch (e) {
|
||||
addToast('Failed to get OAuth providers', 'Error');
|
||||
console.error('failed to get OAuth providers', e);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const onClickSubmit = async () => {
|
||||
try {
|
||||
isSubmitting = true;
|
||||
if (modalMode === 'create' || modalMode === 'copy') {
|
||||
await create();
|
||||
return;
|
||||
} else {
|
||||
await update();
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
formError = '';
|
||||
try {
|
||||
const res = await api.oauthProvider.create({
|
||||
name: formValues.name,
|
||||
clientID: formValues.clientID,
|
||||
clientSecret: formValues.clientSecret,
|
||||
authURL: formValues.authURL,
|
||||
tokenURL: formValues.tokenURL,
|
||||
scopes: formValues.scopes,
|
||||
companyID: contextCompanyID
|
||||
});
|
||||
if (!res.success) {
|
||||
formError = res.error;
|
||||
return;
|
||||
}
|
||||
addToast('Created OAuth provider', 'Success');
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
addToast('Failed to create OAuth provider', 'Error');
|
||||
console.error('failed to create OAuth provider:', err);
|
||||
}
|
||||
refreshProviders();
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
formError = '';
|
||||
try {
|
||||
const res = await api.oauthProvider.update({
|
||||
id: formValues.id,
|
||||
name: formValues.name,
|
||||
clientID: formValues.clientID,
|
||||
clientSecret: formValues.clientSecret,
|
||||
authURL: formValues.authURL,
|
||||
tokenURL: formValues.tokenURL,
|
||||
scopes: formValues.scopes,
|
||||
companyID: formValues.companyID
|
||||
});
|
||||
if (res.success) {
|
||||
addToast('Updated OAuth provider', 'Success');
|
||||
closeModal();
|
||||
} else {
|
||||
formError = res.error;
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to update OAuth provider', 'Error');
|
||||
console.error('failed to update OAuth provider', e);
|
||||
}
|
||||
refreshProviders();
|
||||
};
|
||||
|
||||
const openDeleteAlert = async (provider) => {
|
||||
isDeleteAlertVisible = true;
|
||||
deleteValues.id = provider.id;
|
||||
deleteValues.name = provider.name;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes an OAuth provider
|
||||
* @param {string} id
|
||||
*/
|
||||
const onClickDelete = async (id) => {
|
||||
const action = api.oauthProvider.delete(id);
|
||||
|
||||
action
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
refreshProviders();
|
||||
return;
|
||||
}
|
||||
throw res.error;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('failed to delete oauth provider', e);
|
||||
});
|
||||
return action;
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
modalMode = 'create';
|
||||
formError = '';
|
||||
formValues = {
|
||||
id: null,
|
||||
name: null,
|
||||
clientID: null,
|
||||
clientSecret: null,
|
||||
authURL: null,
|
||||
tokenURL: null,
|
||||
scopes: null
|
||||
};
|
||||
isModalVisible = true;
|
||||
};
|
||||
|
||||
const openUpdateModal = async (id) => {
|
||||
modalMode = 'update';
|
||||
formError = '';
|
||||
const provider = await getProvider(id);
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
formValues = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
clientID: provider.clientID,
|
||||
clientSecret: provider.clientSecret,
|
||||
authURL: provider.authURL,
|
||||
tokenURL: provider.tokenURL,
|
||||
scopes: provider.scopes,
|
||||
companyID: provider.companyID
|
||||
};
|
||||
isModalVisible = true;
|
||||
};
|
||||
|
||||
const openCopyModal = async (id) => {
|
||||
modalMode = 'copy';
|
||||
formError = '';
|
||||
const provider = await getProvider(id);
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
formValues = {
|
||||
id: null,
|
||||
name: provider.name + ' (copy)',
|
||||
clientID: provider.clientID,
|
||||
clientSecret: provider.clientSecret,
|
||||
authURL: provider.authURL,
|
||||
tokenURL: provider.tokenURL,
|
||||
scopes: provider.scopes
|
||||
};
|
||||
isModalVisible = true;
|
||||
};
|
||||
|
||||
const openRemoveAuthAlert = async (provider) => {
|
||||
isRemoveAuthAlertVisible = true;
|
||||
removeAuthValues.id = provider.id;
|
||||
removeAuthValues.name = provider.name;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes authorization from an OAuth provider
|
||||
* @param {string} id
|
||||
*/
|
||||
const onClickRemoveAuth = async (id) => {
|
||||
const action = api.oauthProvider.removeAuthorization(id);
|
||||
|
||||
action
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
addToast('Removed authorization from OAuth provider', 'Success');
|
||||
refreshProviders();
|
||||
return;
|
||||
}
|
||||
throw res.error;
|
||||
})
|
||||
.catch((e) => {
|
||||
addToast('Failed to remove authorization', 'Error');
|
||||
console.error('failed to remove authorization from oauth provider', e);
|
||||
});
|
||||
return action;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalVisible = false;
|
||||
form?.reset();
|
||||
};
|
||||
|
||||
const onClickAuthorize = async (id) => {
|
||||
try {
|
||||
showIsLoading();
|
||||
const res = await api.oauthProvider.getAuthorizationURL(id);
|
||||
if (res.success && res.data.authorizationURL) {
|
||||
// open authorization url in popup window
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = window.screenX + (window.outerWidth - width) / 2;
|
||||
const top = window.screenY + (window.outerHeight - height) / 2;
|
||||
const popup = window.open(
|
||||
res.data.authorizationURL,
|
||||
'OAuth Authorization',
|
||||
`width=${width},height=${height},left=${left},top=${top},toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes`
|
||||
);
|
||||
if (!popup) {
|
||||
addToast('Failed to open authorization window. Please allow popups.', 'Error');
|
||||
}
|
||||
} else {
|
||||
throw res.error || 'No authorization URL returned';
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to get authorization URL', 'Error');
|
||||
console.error('failed to get authorization URL', e);
|
||||
} finally {
|
||||
hideIsLoading();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<HeadTitle title="OAuth" />
|
||||
<main>
|
||||
<Headline>OAuth</Headline>
|
||||
<BigButton on:click={openCreateModal}>New OAuth</BigButton>
|
||||
<Table
|
||||
columns={['Name', 'Status']}
|
||||
sortable={['name', 'is_authorized']}
|
||||
pagination={tableURLParams}
|
||||
hasData={isProviderTableLoading || providers.length > 0}
|
||||
hasNextPage={providersHasNextPage}
|
||||
plural="OAuth providers"
|
||||
isGhost={isProviderTableLoading}
|
||||
hasActions
|
||||
>
|
||||
{#each providers as provider}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<button
|
||||
on:click={() => {
|
||||
openUpdateModal(provider.id);
|
||||
}}
|
||||
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
||||
title={provider.name}
|
||||
class="block w-full py-1 text-left"
|
||||
>
|
||||
{provider.name}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{#if provider.isAuthorized}
|
||||
<span class="text-green-600 dark:text-green-400">Authorized</span>
|
||||
{:else}
|
||||
<span class="text-yellow-600 dark:text-yellow-400">Not Authorized</span>
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<TableDropDownEllipsis>
|
||||
<TableCopyButton
|
||||
title="Copy"
|
||||
on:click={() => openCopyModal(provider.id)}
|
||||
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
||||
/>
|
||||
<TableUpdateButton
|
||||
on:click={() => openUpdateModal(provider.id)}
|
||||
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
||||
/>
|
||||
{#if !provider.isAuthorized}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => onClickAuthorize(provider.id)}
|
||||
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||
>
|
||||
<p class="ml-2 text-left">Authorize</p>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => onClickAuthorize(provider.id)}
|
||||
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||
>
|
||||
<p class="ml-2 text-left">Re-authorize</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => openRemoveAuthAlert(provider)}
|
||||
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||
>
|
||||
<p class="ml-2 text-left">Remove Authorization</p>
|
||||
</button>
|
||||
{/if}
|
||||
<TableDeleteButton
|
||||
on:click={() => openDeleteAlert(provider)}
|
||||
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
||||
/>
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</Table>
|
||||
|
||||
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
|
||||
<FormGrid on:submit={onClickSubmit} bind:bindTo={form} {isSubmitting}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={formValues.name}
|
||||
placeholder="My OAuth Provider"
|
||||
>
|
||||
Name
|
||||
</TextField>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={255}
|
||||
bind:value={formValues.clientID}
|
||||
placeholder="your-client-id"
|
||||
>
|
||||
Client ID
|
||||
</TextField>
|
||||
<PasswordField
|
||||
required={modalMode === 'create' || modalMode === 'copy'}
|
||||
minLength={modalMode === 'update' ? 0 : 1}
|
||||
maxLength={255}
|
||||
bind:value={formValues.clientSecret}
|
||||
placeholder={modalMode === 'update'
|
||||
? 'Leave empty to keep existing secret'
|
||||
: 'your-client-secret'}
|
||||
>
|
||||
Client Secret
|
||||
</PasswordField>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={512}
|
||||
bind:value={formValues.authURL}
|
||||
placeholder="https://example.com/oauth2/v2/auth"
|
||||
>
|
||||
Authorization URL
|
||||
</TextField>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={512}
|
||||
bind:value={formValues.tokenURL}
|
||||
placeholder="https://example.com/oauth2/token"
|
||||
>
|
||||
Token URL
|
||||
</TextField>
|
||||
<TextareaField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={512}
|
||||
bind:value={formValues.scopes}
|
||||
placeholder="https://example.com/auth/mail.send"
|
||||
height="small"
|
||||
toolTipText="Space-separated list of OAuth scopes">Scopes</TextareaField
|
||||
>
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
|
||||
<FormError message={formError} />
|
||||
<FormFooter {closeModal} {isSubmitting} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
|
||||
<DeleteAlert
|
||||
name={deleteValues.name}
|
||||
onClick={() => onClickDelete(deleteValues.id)}
|
||||
bind:isVisible={isDeleteAlertVisible}
|
||||
/>
|
||||
|
||||
<DeleteAlert
|
||||
name={removeAuthValues.name}
|
||||
onClick={() => onClickRemoveAuth(removeAuthValues.id)}
|
||||
bind:isVisible={isRemoveAuthAlertVisible}
|
||||
title="Remove Authorization"
|
||||
actionMessage="Are you sure you want to remove authorization from"
|
||||
list={[
|
||||
'Access and refresh tokens will be deleted',
|
||||
'You will need to re-authorize to use this provider for sending'
|
||||
]}
|
||||
permanent={false}
|
||||
/>
|
||||
</main>
|
||||
Reference in New Issue
Block a user