OAuth providers

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-11-20 17:14:49 +01:00
parent cff927d477
commit f6eb87fa2b
31 changed files with 2627 additions and 60 deletions
+15
View File
@@ -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).
+9
View File
@@ -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,
}
}
+4
View File
@@ -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},
}
}
+10
View File
@@ -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,
}
}
+374
View File
@@ -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)
}
+11
View File
@@ -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")
+55
View File
@@ -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
}
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+1
View File
@@ -266,6 +266,7 @@ func main() {
atomicLogger,
utils,
db,
conf,
)
// setup admin account
isInstalled, err := controllers.InitialSetup.IsInstalled(context.Background())
+9
View File
@@ -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
}
+179
View File
@@ -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
}
+55
View File
@@ -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
}
+16
View File
@@ -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,
+249
View File
@@ -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,
}
}
+98
View File
@@ -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)
}
+2
View File
@@ -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
+62 -5
View File
@@ -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())
+2
View File
@@ -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,
+649
View File
@@ -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
View File
@@ -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.
+2 -1
View File
@@ -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
+99
View File
@@ -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>
+11 -1
View File
@@ -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
]
}
];
+92 -36
View File
@@ -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>