Add SAML support

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-10-29 18:42:12 +01:00
parent 3018a3e691
commit 2766f8e423
97 changed files with 14824 additions and 2812 deletions

View File

@@ -59,6 +59,7 @@ type (
Auth *auth.Service
Authz *authz.Service
Trust *trust.Service
SAML *auth.SAMLService
ConsoleAuth ConsoleAuthConfig
TrustAuth TrustAuthConfig
ConnectorRegistry *connector.ConnectorRegistry
@@ -194,6 +195,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.cfg.ConnectorRegistry,
s.cfg.SafeRedirect,
s.cfg.CustomDomainCname,
s.cfg.SAML,
),
)

View File

@@ -60,11 +60,17 @@ type (
proboSvc *probo.Service
authSvc *auth.Service
authzSvc *authz.Service
samlSvc *auth.SAMLService
authCfg AuthConfig
customDomainCname string
}
ctxKey struct{ name string }
userTenantAccess struct {
tenantIDs []gid.TenantID
authErrors map[gid.TenantID]error
}
)
var (
@@ -92,6 +98,7 @@ func NewMux(
connectorRegistry *connector.ConnectorRegistry,
safeRedirect *saferedirect.SafeRedirect,
customDomainCname string,
samlSvc *auth.SAMLService,
) *chi.Mux {
r := chi.NewMux()
@@ -211,13 +218,6 @@ func NewMux(
},
)
r.Post("/auth/register", SignUpHandler(authSvc, authCfg))
r.Post("/auth/login", SignInHandler(authSvc, authCfg))
r.Delete("/auth/logout", SignOutHandler(authSvc, authCfg))
r.Post("/auth/signup-from-invitation", SignupFromInvitationHandler(authSvc, authCfg))
r.Post("/auth/forget-password", ForgetPasswordHandler(authSvc, authCfg))
r.Post("/auth/reset-password", ResetPasswordHandler(authSvc, authCfg))
r.Get("/connectors/initiate", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider")
if provider != "SLACK" {
@@ -295,12 +295,12 @@ func NewMux(
})
r.Get("/", playground.Handler("GraphQL", "/api/console/v1/query"))
r.Post("/query", graphqlHandler(logger, proboSvc, authSvc, authzSvc, authCfg, customDomainCname))
r.Post("/query", graphqlHandler(logger, proboSvc, authSvc, authzSvc, samlSvc, authCfg, customDomainCname))
return r
}
func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthConfig, customDomainCname string) http.HandlerFunc {
func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service, authzSvc *authz.Service, samlSvc *auth.SAMLService, authCfg AuthConfig, customDomainCname string) http.HandlerFunc {
var mb int64 = 1 << 20
es := schema.NewExecutableSchema(
@@ -309,6 +309,7 @@ func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.S
proboSvc: proboSvc,
authSvc: authSvc,
authzSvc: authzSvc,
samlSvc: samlSvc,
authCfg: authCfg,
customDomainCname: customDomainCname,
},
@@ -387,7 +388,10 @@ func WithSession(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthCon
ctx = context.WithValue(ctx, sessionContextKey, authResult.Session)
ctx = context.WithValue(ctx, userContextKey, authResult.User)
ctx = context.WithValue(ctx, userTenantContextKey, &authResult.TenantIDs)
ctx = context.WithValue(ctx, userTenantContextKey, &userTenantAccess{
tenantIDs: authResult.TenantIDs,
authErrors: authResult.AuthErrors,
})
next(w, r.WithContext(ctx))
@@ -425,13 +429,19 @@ func GetTenantAuthzService(ctx context.Context, authzSvc *authz.Service, tenantI
}
func validateTenantAccess(ctx context.Context, tenantID gid.TenantID) {
tenantIDs, _ := ctx.Value(userTenantContextKey).(*[]gid.TenantID)
access, _ := ctx.Value(userTenantContextKey).(*userTenantAccess)
if tenantIDs == nil {
if access == nil {
panic(fmt.Errorf("tenant not found"))
}
if !slices.Contains(*tenantIDs, tenantID) {
panic(fmt.Errorf("tenant not found"))
if !slices.Contains(access.tenantIDs, tenantID) {
if access.authErrors != nil {
if authErr := access.authErrors[tenantID]; authErr != nil {
panic(authErr)
}
}
panic(fmt.Errorf("access denied to tenant"))
}
}

View File

@@ -162,6 +162,34 @@ enum AuditState
@goEnum(value: "github.com/getprobo/probo/pkg/coredata.AuditStateOutdated")
}
enum SAMLEnforcementPolicy
@goModel(
model: "github.com/getprobo/probo/pkg/coredata.SAMLEnforcementPolicy"
) {
OFF
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.SAMLEnforcementPolicyOff"
)
OPTIONAL
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.SAMLEnforcementPolicyOptional"
)
REQUIRED
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.SAMLEnforcementPolicyRequired"
)
}
enum UserAuthMethod
@goModel(model: "github.com/getprobo/probo/pkg/coredata.UserAuthMethod") {
PASSWORD
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.UserAuthMethodPassword"
)
SAML
@goEnum(value: "github.com/getprobo/probo/pkg/coredata.UserAuthMethodSAML")
}
enum TrustCenterVisibility
@goModel(
model: "github.com/getprobo/probo/pkg/coredata.TrustCenterVisibility"
@@ -1791,6 +1819,8 @@ type Organization implements Node {
customDomain: CustomDomain @goField(forceResolver: true)
samlConfigurations: [SAMLConfiguration!]! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
@@ -1810,6 +1840,7 @@ type Membership implements Node {
role: String!
fullName: String!
emailAddress: String!
authMethod: UserAuthMethod! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
@@ -2693,7 +2724,6 @@ type VendorServiceEdge {
node: VendorService!
}
type VendorRiskAssessmentConnection {
edges: [VendorRiskAssessmentEdge!]!
pageInfo: PageInfo!
@@ -3174,6 +3204,28 @@ type Mutation {
deleteCustomDomain(
input: DeleteCustomDomainInput!
): DeleteCustomDomainPayload!
# SAML Configuration mutations (OWNER/ADMIN only)
# Step 1: Initiate domain verification (creates SAML config with unverified domain)
initiateDomainVerification(
input: InitiateDomainVerificationInput!
): InitiateDomainVerificationPayload!
# Step 2: Verify domain ownership via DNS TXT record
verifyDomain(input: VerifyDomainInput!): VerifyDomainPayload!
# Step 3: Configure SAML (only allowed after domain is verified)
createSAMLConfiguration(
input: CreateSAMLConfigurationInput!
): CreateSAMLConfigurationPayload!
updateSAMLConfiguration(
input: UpdateSAMLConfigurationInput!
): UpdateSAMLConfigurationPayload!
deleteSAMLConfiguration(
input: DeleteSAMLConfigurationInput!
): DeleteSAMLConfigurationPayload!
enableSAML(input: EnableSAMLInput!): EnableSAMLPayload!
disableSAML(input: DisableSAMLInput!): DisableSAMLPayload!
}
# Input Types
@@ -4794,3 +4846,167 @@ type CreateCustomDomainPayload {
type DeleteCustomDomainPayload {
deletedCustomDomainId: ID!
}
# ============================================
# SAML Configuration Types
# ============================================
type SAMLConfiguration implements Node {
id: ID!
organization: Organization! @goField(forceResolver: true)
emailDomain: String!
enabled: Boolean!
enforcementPolicy: SAMLEnforcementPolicy!
# Domain verification (required before SAML can be configured)
domainVerified: Boolean!
domainVerificationToken: String
domainVerifiedAt: Datetime
# Service Provider metadata (read-only, auto-generated)
spEntityId: String!
spAcsUrl: String!
spMetadataUrl: String! @goField(forceResolver: true)
# Identity Provider configuration
idpEntityId: String!
idpSsoUrl: String!
idpCertificate: String!
idpMetadataUrl: String
# Attribute mapping
attributeEmail: String!
attributeFirstname: String!
attributeLastname: String!
attributeRole: String!
# Default role for users when role attribute is missing or invalid
defaultRole: String!
# Auto-signup
autoSignupEnabled: Boolean!
# Test login URL for this configuration
testLoginUrl: String! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
# ============================================
# SAML Configuration Inputs
# ============================================
input CreateSAMLConfigurationInput {
organizationId: ID!
# Email domain this config applies to
emailDomain: String!
# Enforcement policy for this SAML configuration
enforcementPolicy: SAMLEnforcementPolicy!
# SP configuration (optional - auto-generated if not provided)
spCertificate: String
spPrivateKey: String
# IdP configuration - Option 1: Provide metadata XML (recommended for Google Workspace)
# This will automatically extract entityId, ssoUrl, and certificate from the metadata
idpMetadataXml: String
# IdP configuration - Option 2: Provide individual fields manually
# Required if idpMetadataXml is not provided
idpEntityId: String
idpSsoUrl: String
idpCertificate: String
idpMetadataUrl: String
# Attribute mapping (optional, defaults provided)
attributeEmail: String
attributeFirstname: String
attributeLastname: String
attributeRole: String
defaultRole: String
autoSignupEnabled: Boolean
}
input UpdateSAMLConfigurationInput {
id: ID!
enabled: Boolean
enforcementPolicy: SAMLEnforcementPolicy
spCertificate: String
spPrivateKey: String
idpEntityId: String
idpSsoUrl: String
idpCertificate: String
idpMetadataUrl: String
attributeEmail: String
attributeFirstname: String
attributeLastname: String
attributeRole: String
defaultRole: String
autoSignupEnabled: Boolean
}
# ============================================
# Domain Verification Inputs
# ============================================
input InitiateDomainVerificationInput {
organizationId: ID!
emailDomain: String!
}
input VerifyDomainInput {
id: ID!
}
input DeleteSAMLConfigurationInput {
id: ID!
}
input EnableSAMLInput {
id: ID!
}
input DisableSAMLInput {
id: ID!
}
# ============================================
# SAML Configuration Payloads
# ============================================
type InitiateDomainVerificationPayload {
samlConfiguration: SAMLConfiguration!
# The TXT record value that needs to be added to DNS
# Format: probo-verification={token}
dnsRecord: String!
}
type VerifyDomainPayload {
samlConfiguration: SAMLConfiguration!
verified: Boolean!
}
type CreateSAMLConfigurationPayload {
samlConfiguration: SAMLConfiguration!
}
type UpdateSAMLConfigurationPayload {
samlConfiguration: SAMLConfiguration!
}
type DeleteSAMLConfigurationPayload {
deletedSAMLConfigurationId: ID!
}
type EnableSAMLPayload {
samlConfiguration: SAMLConfiguration!
}
type DisableSAMLPayload {
samlConfiguration: SAMLConfiguration!
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"github.com/getprobo/probo/pkg/coredata"
)
func NewSAMLConfigurationWithURLs(c *coredata.SAMLConfiguration, spEntityID, spAcsURL string) *SAMLConfiguration {
return &SAMLConfiguration{
ID: c.ID,
EmailDomain: c.EmailDomain,
Enabled: c.Enabled,
EnforcementPolicy: c.EnforcementPolicy,
DomainVerified: c.DomainVerified,
DomainVerificationToken: c.DomainVerificationToken,
DomainVerifiedAt: c.DomainVerifiedAt,
SpEntityID: spEntityID,
SpAcsURL: spAcsURL,
IdpEntityID: c.IdPEntityID,
IdpSsoURL: c.IdPSsoURL,
IdpCertificate: c.IdPCertificate,
IdpMetadataURL: c.IdPMetadataURL,
AttributeEmail: c.AttributeEmail,
AttributeFirstname: c.AttributeFirstname,
AttributeLastname: c.AttributeLastname,
AttributeRole: c.AttributeRole,
DefaultRole: c.DefaultRole,
AutoSignupEnabled: c.AutoSignupEnabled,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}

View File

@@ -505,6 +505,29 @@ type CreateRiskPayload struct {
RiskEdge *RiskEdge `json:"riskEdge"`
}
type CreateSAMLConfigurationInput struct {
OrganizationID gid.GID `json:"organizationId"`
EmailDomain string `json:"emailDomain"`
EnforcementPolicy coredata.SAMLEnforcementPolicy `json:"enforcementPolicy"`
SpCertificate *string `json:"spCertificate,omitempty"`
SpPrivateKey *string `json:"spPrivateKey,omitempty"`
IdpMetadataXML *string `json:"idpMetadataXml,omitempty"`
IdpEntityID *string `json:"idpEntityId,omitempty"`
IdpSsoURL *string `json:"idpSsoUrl,omitempty"`
IdpCertificate *string `json:"idpCertificate,omitempty"`
IdpMetadataURL *string `json:"idpMetadataUrl,omitempty"`
AttributeEmail *string `json:"attributeEmail,omitempty"`
AttributeFirstname *string `json:"attributeFirstname,omitempty"`
AttributeLastname *string `json:"attributeLastname,omitempty"`
AttributeRole *string `json:"attributeRole,omitempty"`
DefaultRole *string `json:"defaultRole,omitempty"`
AutoSignupEnabled *bool `json:"autoSignupEnabled,omitempty"`
}
type CreateSAMLConfigurationPayload struct {
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
}
type CreateSnapshotInput struct {
OrganizationID gid.GID `json:"organizationId"`
Name string `json:"name"`
@@ -904,6 +927,14 @@ type DeleteRiskPayload struct {
DeletedRiskID gid.GID `json:"deletedRiskId"`
}
type DeleteSAMLConfigurationInput struct {
ID gid.GID `json:"id"`
}
type DeleteSAMLConfigurationPayload struct {
DeletedSAMLConfigurationID gid.GID `json:"deletedSAMLConfigurationId"`
}
type DeleteSnapshotInput struct {
SnapshotID gid.GID `json:"snapshotId"`
}
@@ -1000,6 +1031,14 @@ type DeleteVendorServicePayload struct {
DeletedVendorServiceID gid.GID `json:"deletedVendorServiceId"`
}
type DisableSAMLInput struct {
ID gid.GID `json:"id"`
}
type DisableSAMLPayload struct {
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
}
type Document struct {
ID gid.GID `json:"id"`
Title string `json:"title"`
@@ -1094,6 +1133,14 @@ type DocumentVersionSignatureOrder struct {
Direction page.OrderDirection `json:"direction"`
}
type EnableSAMLInput struct {
ID gid.GID `json:"id"`
}
type EnableSAMLPayload struct {
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
}
type Evidence struct {
ID gid.GID `json:"id"`
Size int `json:"size"`
@@ -1216,6 +1263,16 @@ type ImportMeasurePayload struct {
MeasureEdges []*MeasureEdge `json:"measureEdges"`
}
type InitiateDomainVerificationInput struct {
OrganizationID gid.GID `json:"organizationId"`
EmailDomain string `json:"emailDomain"`
}
type InitiateDomainVerificationPayload struct {
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
DNSRecord string `json:"dnsRecord"`
}
type Invitation struct {
ID gid.GID `json:"id"`
Email string `json:"email"`
@@ -1284,14 +1341,15 @@ type MeasureFilter struct {
}
type Membership struct {
ID gid.GID `json:"id"`
UserID gid.GID `json:"userID"`
OrganizationID gid.GID `json:"organizationID"`
Role string `json:"role"`
FullName string `json:"fullName"`
EmailAddress string `json:"emailAddress"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID gid.GID `json:"id"`
UserID gid.GID `json:"userID"`
OrganizationID gid.GID `json:"organizationID"`
Role string `json:"role"`
FullName string `json:"fullName"`
EmailAddress string `json:"emailAddress"`
AuthMethod coredata.UserAuthMethod `json:"authMethod"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Membership) IsNode() {}
@@ -1396,6 +1454,7 @@ type Organization struct {
TrustCenterFiles *TrustCenterFileConnection `json:"trustCenterFiles"`
TrustCenter *TrustCenter `json:"trustCenter,omitempty"`
CustomDomain *CustomDomain `json:"customDomain,omitempty"`
SamlConfigurations []*SAMLConfiguration `json:"samlConfigurations"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -1580,6 +1639,36 @@ type RiskFilter struct {
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
}
type SAMLConfiguration struct {
ID gid.GID `json:"id"`
Organization *Organization `json:"organization"`
EmailDomain string `json:"emailDomain"`
Enabled bool `json:"enabled"`
EnforcementPolicy coredata.SAMLEnforcementPolicy `json:"enforcementPolicy"`
DomainVerified bool `json:"domainVerified"`
DomainVerificationToken *string `json:"domainVerificationToken,omitempty"`
DomainVerifiedAt *time.Time `json:"domainVerifiedAt,omitempty"`
SpEntityID string `json:"spEntityId"`
SpAcsURL string `json:"spAcsUrl"`
SpMetadataURL string `json:"spMetadataUrl"`
IdpEntityID string `json:"idpEntityId"`
IdpSsoURL string `json:"idpSsoUrl"`
IdpCertificate string `json:"idpCertificate"`
IdpMetadataURL *string `json:"idpMetadataUrl,omitempty"`
AttributeEmail string `json:"attributeEmail"`
AttributeFirstname string `json:"attributeFirstname"`
AttributeLastname string `json:"attributeLastname"`
AttributeRole string `json:"attributeRole"`
DefaultRole string `json:"defaultRole"`
AutoSignupEnabled bool `json:"autoSignupEnabled"`
TestLoginURL string `json:"testLoginUrl"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SAMLConfiguration) IsNode() {}
func (this SAMLConfiguration) GetID() gid.GID { return this.ID }
type SendSigningNotificationsInput struct {
OrganizationID gid.GID `json:"organizationId"`
}
@@ -1973,6 +2062,28 @@ type UpdateRiskPayload struct {
Risk *Risk `json:"risk"`
}
type UpdateSAMLConfigurationInput struct {
ID gid.GID `json:"id"`
Enabled *bool `json:"enabled,omitempty"`
EnforcementPolicy *coredata.SAMLEnforcementPolicy `json:"enforcementPolicy,omitempty"`
SpCertificate *string `json:"spCertificate,omitempty"`
SpPrivateKey *string `json:"spPrivateKey,omitempty"`
IdpEntityID *string `json:"idpEntityId,omitempty"`
IdpSsoURL *string `json:"idpSsoUrl,omitempty"`
IdpCertificate *string `json:"idpCertificate,omitempty"`
IdpMetadataURL *string `json:"idpMetadataUrl,omitempty"`
AttributeEmail *string `json:"attributeEmail,omitempty"`
AttributeFirstname *string `json:"attributeFirstname,omitempty"`
AttributeLastname *string `json:"attributeLastname,omitempty"`
AttributeRole *string `json:"attributeRole,omitempty"`
DefaultRole *string `json:"defaultRole,omitempty"`
AutoSignupEnabled *bool `json:"autoSignupEnabled,omitempty"`
}
type UpdateSAMLConfigurationPayload struct {
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
}
type UpdateTaskInput struct {
TaskID gid.GID `json:"taskId"`
Name *string `json:"name,omitempty"`
@@ -2359,6 +2470,15 @@ type VendorServiceEdge struct {
Node *VendorService `json:"node"`
}
type VerifyDomainInput struct {
ID gid.GID `json:"id"`
}
type VerifyDomainPayload struct {
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
Verified bool `json:"verified"`
}
type Viewer struct {
ID gid.GID `json:"id"`
User *User `json:"user"`

View File

@@ -10,8 +10,10 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
@@ -1081,6 +1083,20 @@ func (r *measureConnectionResolver) TotalCount(ctx context.Context, obj *types.M
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// AuthMethod is the resolver for the authMethod field.
func (r *membershipResolver) AuthMethod(ctx context.Context, obj *types.Membership) (coredata.UserAuthMethod, error) {
session := SessionFromContext(ctx)
if session == nil {
return coredata.UserAuthMethodPassword, nil
}
authMethod, err := r.authSvc.GetUserAuthMethod(ctx, coredata.NewScope(obj.UserID.TenantID()), obj.UserID, obj.OrganizationID, session)
if err != nil {
return "", fmt.Errorf("cannot get user auth method: %w", err)
}
return authMethod, nil
}
// TotalCount is the resolver for the totalCount field.
func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *types.MembershipConnection) (int, error) {
switch obj.Resolver.(type) {
@@ -1098,6 +1114,8 @@ func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *type
// CreateOrganization is the resolver for the createOrganization field.
func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.CreateOrganizationInput) (*types.CreateOrganizationPayload, error) {
currentUser := UserFromContext(ctx)
prb := r.proboSvc.WithTenant(gid.NewTenantID())
organization, err := prb.Organizations.Create(
@@ -1112,7 +1130,7 @@ func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.C
err = r.authzSvc.AddUserToOrganization(
ctx,
UserFromContext(ctx).ID,
currentUser.ID,
organization.ID,
string(authz.RoleMember),
)
@@ -1129,8 +1147,8 @@ func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.C
ctx,
probo.CreatePeopleRequest{
OrganizationID: organization.ID,
FullName: UserFromContext(ctx).FullName,
PrimaryEmailAddress: UserFromContext(ctx).EmailAddress,
FullName: currentUser.FullName,
PrimaryEmailAddress: currentUser.EmailAddress,
AdditionalEmailAddresses: []string{},
Kind: coredata.PeopleKindEmployee,
},
@@ -3537,6 +3555,260 @@ func (r *mutationResolver) DeleteCustomDomain(ctx context.Context, input types.D
}, nil
}
// InitiateDomainVerification is the resolver for the initiateDomainVerification field.
func (r *mutationResolver) InitiateDomainVerification(ctx context.Context, input types.InitiateDomainVerificationInput) (*types.InitiateDomainVerificationPayload, error) {
user := UserFromContext(ctx)
if user == nil {
return nil, fmt.Errorf("user not authenticated")
}
organizationID := input.OrganizationID
tenantID := organizationID.TenantID()
config, err := r.authSvc.InitiateDomainVerification(ctx, tenantID, organizationID, input.EmailDomain)
if err != nil {
return nil, fmt.Errorf("failed to initiate domain verification: %w", err)
}
dnsRecord := auth.GetDomainVerificationRecord(*config.DomainVerificationToken)
return &types.InitiateDomainVerificationPayload{
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
config,
r.samlSvc.GetEntityID(),
r.samlSvc.GetAcsURL(),
),
DNSRecord: dnsRecord,
}, nil
}
// VerifyDomain is the resolver for the verifyDomain field.
func (r *mutationResolver) VerifyDomain(ctx context.Context, input types.VerifyDomainInput) (*types.VerifyDomainPayload, error) {
user := UserFromContext(ctx)
if user == nil {
return nil, fmt.Errorf("user not authenticated")
}
configID := input.ID
tenantID := configID.TenantID()
config, verified, err := r.authSvc.VerifyDomain(ctx, tenantID, configID)
if err != nil {
return nil, fmt.Errorf("failed to verify domain: %w", err)
}
return &types.VerifyDomainPayload{
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
config,
r.samlSvc.GetEntityID(),
r.samlSvc.GetAcsURL(),
),
Verified: verified,
}, nil
}
// CreateSAMLConfiguration is the resolver for the createSAMLConfiguration field.
func (r *mutationResolver) CreateSAMLConfiguration(ctx context.Context, input types.CreateSAMLConfigurationInput) (*types.CreateSAMLConfigurationPayload, error) {
user := UserFromContext(ctx)
if user == nil {
return nil, fmt.Errorf("user not authenticated")
}
organizationID := input.OrganizationID
tenantID := organizationID.TenantID()
var idpEntityID, idpSsoURL, idpCertificate string
var idpMetadataURL *string
if input.IdpMetadataXML != nil && *input.IdpMetadataXML != "" {
metadata, err := auth.ParseIdPMetadata(*input.IdpMetadataXML)
if err != nil {
return nil, fmt.Errorf("failed to parse IdP metadata XML: %w", err)
}
idpEntityID = metadata.EntityID
idpSsoURL = metadata.SsoURL
idpCertificate = metadata.Certificate
idpMetadataURL = metadata.MetadataURL
} else {
if input.IdpEntityID == nil || *input.IdpEntityID == "" {
return nil, fmt.Errorf("either idpMetadataXml or idpEntityId must be provided")
}
if input.IdpSsoURL == nil || *input.IdpSsoURL == "" {
return nil, fmt.Errorf("either idpMetadataXml or idpSsoUrl must be provided")
}
if input.IdpCertificate == nil || *input.IdpCertificate == "" {
return nil, fmt.Errorf("either idpMetadataXml or idpCertificate must be provided")
}
idpEntityID = *input.IdpEntityID
idpSsoURL = *input.IdpSsoURL
idpCertificate = *input.IdpCertificate
idpMetadataURL = input.IdpMetadataURL
}
attributeEmail := "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
if input.AttributeEmail != nil {
attributeEmail = *input.AttributeEmail
}
attributeFirstname := "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"
if input.AttributeFirstname != nil {
attributeFirstname = *input.AttributeFirstname
}
attributeLastname := "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"
if input.AttributeLastname != nil {
attributeLastname = *input.AttributeLastname
}
attributeRole := "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role"
if input.AttributeRole != nil {
attributeRole = *input.AttributeRole
}
defaultRole := "MEMBER"
if input.DefaultRole != nil {
defaultRole = *input.DefaultRole
}
autoSignupEnabled := false
if input.AutoSignupEnabled != nil {
autoSignupEnabled = *input.AutoSignupEnabled
}
config, err := r.authSvc.WithTenant(tenantID).CreateSAMLConfiguration(ctx, auth.CreateSAMLConfigurationRequest{
OrganizationID: organizationID,
EmailDomain: input.EmailDomain,
EnforcementPolicy: input.EnforcementPolicy,
IdPEntityID: idpEntityID,
IdPSsoURL: idpSsoURL,
IdPCertificate: idpCertificate,
IdPMetadataURL: idpMetadataURL,
AttributeEmail: attributeEmail,
AttributeFirstname: attributeFirstname,
AttributeLastname: attributeLastname,
AttributeRole: attributeRole,
DefaultRole: defaultRole,
AutoSignupEnabled: autoSignupEnabled,
})
if err != nil {
return nil, fmt.Errorf("failed to create SAML configuration: %w", err)
}
return &types.CreateSAMLConfigurationPayload{
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
config,
r.samlSvc.GetEntityID(),
r.samlSvc.GetAcsURL(),
),
}, nil
}
// UpdateSAMLConfiguration is the resolver for the updateSAMLConfiguration field.
func (r *mutationResolver) UpdateSAMLConfiguration(ctx context.Context, input types.UpdateSAMLConfigurationInput) (*types.UpdateSAMLConfigurationPayload, error) {
user := UserFromContext(ctx)
if user == nil {
return nil, fmt.Errorf("user not authenticated")
}
configID := input.ID
tenantID := configID.TenantID()
updatedConfig, err := r.authSvc.WithTenant(tenantID).UpdateSAMLConfiguration(ctx, auth.UpdateSAMLConfigurationRequest{
ID: configID,
Enabled: input.Enabled,
EnforcementPolicy: input.EnforcementPolicy,
IdPEntityID: input.IdpEntityID,
IdPSsoURL: input.IdpSsoURL,
IdPCertificate: input.IdpCertificate,
IdPMetadataURL: input.IdpMetadataURL,
AttributeEmail: input.AttributeEmail,
AttributeFirstname: input.AttributeFirstname,
AttributeLastname: input.AttributeLastname,
AttributeRole: input.AttributeRole,
DefaultRole: input.DefaultRole,
AutoSignupEnabled: input.AutoSignupEnabled,
})
if err != nil {
return nil, fmt.Errorf("failed to update SAML configuration: %w", err)
}
return &types.UpdateSAMLConfigurationPayload{
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
updatedConfig,
r.samlSvc.GetEntityID(),
r.samlSvc.GetAcsURL(),
),
}, nil
}
// DeleteSAMLConfiguration is the resolver for the deleteSAMLConfiguration field.
func (r *mutationResolver) DeleteSAMLConfiguration(ctx context.Context, input types.DeleteSAMLConfigurationInput) (*types.DeleteSAMLConfigurationPayload, error) {
user := UserFromContext(ctx)
if user == nil {
return nil, fmt.Errorf("user not authenticated")
}
configID := input.ID
tenantID := configID.TenantID()
err := r.authSvc.WithTenant(tenantID).DeleteSAMLConfiguration(ctx, configID)
if err != nil {
return nil, fmt.Errorf("failed to delete SAML configuration: %w", err)
}
return &types.DeleteSAMLConfigurationPayload{
DeletedSAMLConfigurationID: configID,
}, nil
}
// EnableSaml is the resolver for the enableSAML field.
func (r *mutationResolver) EnableSaml(ctx context.Context, input types.EnableSAMLInput) (*types.EnableSAMLPayload, error) {
user := UserFromContext(ctx)
if user == nil {
return nil, fmt.Errorf("user not authenticated")
}
configID := input.ID
tenantID := configID.TenantID()
enabledConfig, err := r.authSvc.WithTenant(tenantID).EnableSAMLConfiguration(ctx, configID)
if err != nil {
return nil, fmt.Errorf("failed to enable SAML: %w", err)
}
return &types.EnableSAMLPayload{
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
enabledConfig,
r.samlSvc.GetEntityID(),
r.samlSvc.GetAcsURL(),
),
}, nil
}
// DisableSaml is the resolver for the disableSAML field.
func (r *mutationResolver) DisableSaml(ctx context.Context, input types.DisableSAMLInput) (*types.DisableSAMLPayload, error) {
user := UserFromContext(ctx)
if user == nil {
return nil, fmt.Errorf("user not authenticated")
}
configID := input.ID
tenantID := configID.TenantID()
disabledConfig, err := r.authSvc.WithTenant(tenantID).DisableSAMLConfiguration(ctx, configID)
if err != nil {
return nil, fmt.Errorf("failed to disable SAML: %w", err)
}
return &types.DisableSAMLPayload{
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
disabledConfig,
r.samlSvc.GetEntityID(),
r.samlSvc.GetAcsURL(),
),
}, nil
}
// Organization is the resolver for the organization field.
func (r *nonconformityResolver) Organization(ctx context.Context, obj *types.Nonconformity) (*types.Organization, error) {
prb := r.ProboService(ctx, obj.ID.TenantID())
@@ -4282,6 +4554,27 @@ func (r *organizationResolver) CustomDomain(ctx context.Context, obj *types.Orga
return types.NewCustomDomain(domain, r.customDomainCname), nil
}
// SamlConfigurations is the resolver for the samlConfigurations field.
func (r *organizationResolver) SamlConfigurations(ctx context.Context, obj *types.Organization) ([]*types.SAMLConfiguration, error) {
tenantID := obj.ID.TenantID()
configs, err := r.authSvc.WithTenant(tenantID).GetSAMLConfigurationsByOrganizationID(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("failed to load SAML configurations: %w", err)
}
result := make([]*types.SAMLConfiguration, len(configs))
for i, config := range configs {
result[i] = types.NewSAMLConfigurationWithURLs(
config,
r.samlSvc.GetEntityID(),
r.samlSvc.GetAcsURL(),
)
}
return result, nil
}
// TotalCount is the resolver for the totalCount field.
func (r *peopleConnectionResolver) TotalCount(ctx context.Context, obj *types.PeopleConnection) (int, error) {
prb := r.ProboService(ctx, obj.ParentID.TenantID())
@@ -4727,6 +5020,41 @@ func (r *riskConnectionResolver) TotalCount(ctx context.Context, obj *types.Risk
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// Organization is the resolver for the organization field.
func (r *sAMLConfigurationResolver) Organization(ctx context.Context, obj *types.SAMLConfiguration) (*types.Organization, error) {
tenantID := obj.ID.TenantID()
prb := r.ProboService(ctx, tenantID)
config, err := r.authSvc.WithTenant(tenantID).GetSAMLConfigurationByID(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("failed to load SAML configuration: %w", err)
}
org, err := prb.Organizations.Get(ctx, config.OrganizationID)
if err != nil {
return nil, fmt.Errorf("failed to load organization: %w", err)
}
return types.NewOrganization(org), nil
}
// SpMetadataURL is the resolver for the spMetadataUrl field.
// Returns global Entity ID (same as spEntityId since metadata URL no longer needs config parameter)
func (r *sAMLConfigurationResolver) SpMetadataURL(ctx context.Context, obj *types.SAMLConfiguration) (string, error) {
return r.samlSvc.GetEntityID(), nil
}
// TestLoginURL is the resolver for the testLoginUrl field.
func (r *sAMLConfigurationResolver) TestLoginURL(ctx context.Context, obj *types.SAMLConfiguration) (string, error) {
entityID := r.samlSvc.GetEntityID()
parts := strings.Split(entityID, "/auth/saml/metadata")
if len(parts) != 2 {
return "", fmt.Errorf("invalid entity ID format")
}
return fmt.Sprintf("%s/auth/saml/login/%s", parts[0], obj.ID), nil
}
// Organization is the resolver for the organization field.
func (r *snapshotResolver) Organization(ctx context.Context, obj *types.Snapshot) (*types.Organization, error) {
prb := r.ProboService(ctx, obj.ID.TenantID())
@@ -5520,6 +5848,8 @@ func (r *viewerResolver) Organizations(ctx context.Context, obj *types.Viewer, f
panic(fmt.Errorf("failed to list organizations for user: %w", err))
}
// Show all organizations the user is a member of
// Authentication requirements will be enforced when switching to an organization
page := page.NewPage(organizations, cursor)
return types.NewOrganizationConnection(page), nil
@@ -5649,6 +5979,9 @@ func (r *Resolver) MeasureConnection() schema.MeasureConnectionResolver {
return &measureConnectionResolver{r}
}
// Membership returns schema.MembershipResolver implementation.
func (r *Resolver) Membership() schema.MembershipResolver { return &membershipResolver{r} }
// MembershipConnection returns schema.MembershipConnectionResolver implementation.
func (r *Resolver) MembershipConnection() schema.MembershipConnectionResolver {
return &membershipConnectionResolver{r}
@@ -5703,6 +6036,11 @@ func (r *Resolver) Risk() schema.RiskResolver { return &riskResolver{r} }
// RiskConnection returns schema.RiskConnectionResolver implementation.
func (r *Resolver) RiskConnection() schema.RiskConnectionResolver { return &riskConnectionResolver{r} }
// SAMLConfiguration returns schema.SAMLConfigurationResolver implementation.
func (r *Resolver) SAMLConfiguration() schema.SAMLConfigurationResolver {
return &sAMLConfigurationResolver{r}
}
// Snapshot returns schema.SnapshotResolver implementation.
func (r *Resolver) Snapshot() schema.SnapshotResolver { return &snapshotResolver{r} }
@@ -5818,6 +6156,7 @@ type invitationResolver struct{ *Resolver }
type invitationConnectionResolver struct{ *Resolver }
type measureResolver struct{ *Resolver }
type measureConnectionResolver struct{ *Resolver }
type membershipResolver struct{ *Resolver }
type membershipConnectionResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
type nonconformityResolver struct{ *Resolver }
@@ -5832,6 +6171,7 @@ type queryResolver struct{ *Resolver }
type reportResolver struct{ *Resolver }
type riskResolver struct{ *Resolver }
type riskConnectionResolver struct{ *Resolver }
type sAMLConfigurationResolver struct{ *Resolver }
type snapshotResolver struct{ *Resolver }
type snapshotConnectionResolver struct{ *Resolver }
type taskResolver struct{ *Resolver }

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"encoding/json"
"fmt"
"net/http"
authsvc "github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/server/session"
"go.gearno.de/kit/httpserver"
)
type (
AcceptInvitationRequest struct {
InvitationID gid.GID `json:"invitationId"`
}
AcceptInvitationResponse struct {
InvitationID gid.GID `json:"invitationId"`
}
)
func AcceptInvitationHandler(authSvc *authsvc.Service, authzSvc *authz.Service, authCfg RoutesConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sessionAuthCfg := session.AuthConfig{
CookieName: authCfg.CookieName,
CookieSecret: authCfg.CookieSecret,
}
errorHandler := session.ErrorHandler{
OnCookieError: func(err error) {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("session expired"))
},
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("user not found"))
},
OnTenantError: func(err error) {
panic(fmt.Errorf("failed to list tenants for user: %w", err))
},
}
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
if authResult == nil {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
return
}
// Parse request body
var req AcceptInvitationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("invalid request body"))
return
}
// Accept the invitation
_, err := authzSvc.AcceptInvitationByID(ctx, req.InvitationID, authResult.User.ID)
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, err)
return
}
response := AcceptInvitationResponse{
InvitationID: req.InvitationID,
}
httpserver.RenderJSON(w, http.StatusOK, response)
}
}

72
pkg/server/auth/auth.go Normal file
View File

@@ -0,0 +1,72 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"net/http"
"time"
authsvc "github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/filemanager"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
)
type Config struct {
Auth *authsvc.Service
Authz *authz.Service
SAML *authsvc.SAMLService
CookieName string
CookieDomain string
SessionDuration time.Duration
CookieSecret string
FileManager *filemanager.Service
PGClient *pg.Client
Logger *log.Logger
}
type Server struct {
router *chi.Mux
}
func NewServer(cfg Config) (*Server, error) {
router := chi.NewRouter()
MountRoutes(
router,
cfg.Auth,
cfg.Authz,
cfg.SAML,
RoutesConfig{
CookieName: cfg.CookieName,
CookieDomain: cfg.CookieDomain,
SessionDuration: cfg.SessionDuration,
CookieSecret: cfg.CookieSecret,
FileManager: cfg.FileManager,
PGClient: cfg.PGClient,
},
cfg.Logger,
)
return &Server{
router: router,
}, nil
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}

View File

@@ -12,14 +12,14 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package console_v1
package auth
import (
"encoding/json"
"fmt"
"net/http"
"github.com/getprobo/probo/pkg/auth"
authsvc "github.com/getprobo/probo/pkg/auth"
"go.gearno.de/kit/httpserver"
)
@@ -33,7 +33,7 @@ type (
}
)
func ForgetPasswordHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
func ForgetPasswordHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req ForgetPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"context"
"fmt"
"net/http"
authsvc "github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/getprobo/probo/pkg/server/session"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/pg"
)
type (
ListInvitationsResponse struct {
Invitations []InvitationResponse `json:"invitations"`
}
InvitationResponse struct {
ID gid.GID `json:"id"`
Email string `json:"email"`
FullName string `json:"fullName"`
Role string `json:"role"`
ExpiresAt string `json:"expiresAt"`
AcceptedAt *string `json:"acceptedAt,omitempty"`
CreatedAt string `json:"createdAt"`
Organization OrganizationSummary `json:"organization"`
}
OrganizationSummary struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
}
)
// loadOrganizationByID loads an organization by ID without tenant scope
func loadOrganizationByID(
ctx context.Context,
conn pg.Conn,
orgID gid.GID,
) (*coredata.Organization, error) {
query := `
SELECT
id,
tenant_id,
name,
logo_file_id,
horizontal_logo_file_id,
description,
website_url,
email,
headquarter_address,
custom_domain_id,
created_at,
updated_at
FROM
authz_organizations
WHERE
id = $1
`
row := conn.QueryRow(ctx, query, orgID)
var org coredata.Organization
err := row.Scan(
&org.ID,
&org.TenantID,
&org.Name,
&org.LogoFileID,
&org.HorizontalLogoFileID,
&org.Description,
&org.WebsiteURL,
&org.Email,
&org.HeadquarterAddress,
&org.CustomDomainID,
&org.CreatedAt,
&org.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("cannot load organization: %w", err)
}
return &org, nil
}
func ListInvitationsHandler(authSvc *authsvc.Service, authzSvc *authz.Service, authCfg RoutesConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sessionAuthCfg := session.AuthConfig{
CookieName: authCfg.CookieName,
CookieSecret: authCfg.CookieSecret,
}
errorHandler := session.ErrorHandler{
OnCookieError: func(err error) {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("session expired"))
},
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("user not found"))
},
OnTenantError: func(err error) {
panic(fmt.Errorf("failed to list tenants for user: %w", err))
},
}
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
if authResult == nil {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
return
}
// Get pending invitations for the user
cursor := page.NewCursor(
1000,
nil,
page.Head,
page.OrderBy[coredata.InvitationOrderField]{
Field: coredata.InvitationOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
},
)
invitationFilter := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
invitationsPage, err := authzSvc.GetUserInvitations(ctx, authResult.User.EmailAddress, cursor, invitationFilter)
if err != nil {
panic(fmt.Errorf("failed to list invitations for user: %w", err))
}
// Build response
response := ListInvitationsResponse{
Invitations: make([]InvitationResponse, 0, len(invitationsPage.Data)),
}
// Load organization data for each invitation
err = authCfg.PGClient.WithConn(ctx, func(conn pg.Conn) error {
for _, invitation := range invitationsPage.Data {
invitationResp := InvitationResponse{
ID: invitation.ID,
Email: invitation.Email,
FullName: invitation.FullName,
Role: invitation.Role,
ExpiresAt: invitation.ExpiresAt.Format("2006-01-02T15:04:05Z07:00"),
CreatedAt: invitation.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if invitation.AcceptedAt != nil {
acceptedAtStr := invitation.AcceptedAt.Format("2006-01-02T15:04:05Z07:00")
invitationResp.AcceptedAt = &acceptedAtStr
}
// Load organization details
org, err := loadOrganizationByID(ctx, conn, invitation.OrganizationID)
if err != nil {
// Log error but continue - organization might have been deleted
return nil
}
invitationResp.Organization = OrganizationSummary{
ID: org.ID,
Name: org.Name,
}
response.Invitations = append(response.Invitations, invitationResp)
}
return nil
})
if err != nil {
panic(fmt.Errorf("failed to load organization details: %w", err))
}
httpserver.RenderJSON(w, http.StatusOK, response)
}
}

View File

@@ -0,0 +1,201 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"time"
authsvc "github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/filemanager"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/server/session"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/pg"
)
type (
AuthenticationStatus string
ListOrganizationsResponse struct {
Organizations []OrganizationResponse `json:"organizations"`
}
OrganizationResponse struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
LogoURL *string `json:"logoUrl,omitempty"`
AuthenticationMethod string `json:"authenticationMethod"` // "password", "saml", or "any"
AuthStatus AuthenticationStatus `json:"authStatus"` // "authenticated", "unauthenticated", "expired"
LoginURL string `json:"loginUrl"` // URL to login (SAML or password login page)
}
)
const (
AuthStatusAuthenticated AuthenticationStatus = "authenticated"
AuthStatusUnauthenticated AuthenticationStatus = "unauthenticated"
AuthStatusExpired AuthenticationStatus = "expired"
)
// generateLogoURL generates a presigned URL for an organization's logo
func generateLogoURL(
ctx context.Context,
fileManager *filemanager.Service,
conn pg.Conn,
logoFileID *gid.GID,
) (*string, error) {
if logoFileID == nil {
return nil, nil
}
var file coredata.File
// Load file without scope since we're in auth context (cross-tenant)
q := `SELECT bucket_name, file_key, file_name, mime_type, file_size FROM files WHERE id = $1`
err := conn.QueryRow(ctx, q, logoFileID).Scan(
&file.BucketName,
&file.FileKey,
&file.FileName,
&file.MimeType,
&file.FileSize,
)
if err != nil {
return nil, fmt.Errorf("cannot load file: %w", err)
}
presignedURL, err := fileManager.GenerateFileUrl(ctx, &file, 1*time.Hour)
if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err)
}
return &presignedURL, nil
}
func ListOrganizationsHandler(authSvc *authsvc.Service, authzSvc *authz.Service, authCfg RoutesConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sessionAuthCfg := session.AuthConfig{
CookieName: authCfg.CookieName,
CookieSecret: authCfg.CookieSecret,
}
errorHandler := session.ErrorHandler{
OnCookieError: func(err error) {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
},
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("session expired"))
},
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
session.ClearCookie(w, authCfg)
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("user not found"))
},
OnTenantError: func(err error) {
panic(fmt.Errorf("failed to list tenants for user: %w", err))
},
}
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
if authResult == nil {
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
return
}
// Get all organizations for the user (without filtering by authentication state)
organizations, err := authzSvc.GetAllUserOrganizations(ctx, authResult.User.ID)
if err != nil {
panic(fmt.Errorf("failed to list organizations for user: %w", err))
}
// Build response with authentication requirements for each organization
response := ListOrganizationsResponse{
Organizations: make([]OrganizationResponse, 0, len(organizations)),
}
for _, org := range organizations {
orgResponse := OrganizationResponse{
ID: org.ID,
Name: org.Name,
}
// Generate logo URL if available
if authCfg.FileManager != nil && authCfg.PGClient != nil {
err := authCfg.PGClient.WithConn(ctx, func(conn pg.Conn) error {
logoURL, err := generateLogoURL(ctx, authCfg.FileManager, conn, org.LogoFileID)
if err != nil {
// Log error but don't fail the request
return nil
}
orgResponse.LogoURL = logoURL
return nil
})
if err != nil {
// Log error but continue
}
}
// Check authentication requirements for this organization
err := authSvc.CheckOrganizationAccess(ctx, authResult.User, org.ID, authResult.Session)
if err != nil {
// User needs additional authentication
var errSAMLRequired authsvc.ErrSAMLAuthRequired
if errors.As(err, &errSAMLRequired) {
orgResponse.AuthenticationMethod = "saml"
orgResponse.AuthStatus = AuthStatusUnauthenticated
orgResponse.LoginURL = fmt.Sprintf("/auth/saml/login/%s", errSAMLRequired.ConfigID)
} else {
orgResponse.AuthenticationMethod = "password"
orgResponse.AuthStatus = AuthStatusUnauthenticated
orgResponse.LoginURL = "/authentication/login?method=password"
}
} else {
// User has proper authentication
orgResponse.AuthStatus = AuthStatusAuthenticated
// Determine which auth method they used
if authResult.Session.Data.PasswordAuthenticated {
orgResponse.AuthenticationMethod = "password"
orgResponse.LoginURL = "/authentication/login?method=password"
} else if len(authResult.Session.Data.SAMLAuthenticatedOrgs) > 0 {
// Find SAML config for this org
orgResponse.AuthenticationMethod = "saml"
// Try to find the SAML config ID for login URL
if samlInfo, ok := authResult.Session.Data.SAMLAuthenticatedOrgs[org.ID.String()]; ok {
orgResponse.LoginURL = fmt.Sprintf("/auth/saml/login/%s", samlInfo.SAMLConfigID)
} else {
orgResponse.LoginURL = "/authentication/login?method=password"
}
} else {
orgResponse.AuthenticationMethod = "any"
orgResponse.LoginURL = "/authentication/login?method=password"
}
}
response.Organizations = append(response.Organizations, orgResponse)
}
httpserver.RenderJSON(w, http.StatusOK, response)
}
}

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package console_v1
package auth
import (
"encoding/json"
@@ -21,7 +21,7 @@ import (
"errors"
"github.com/getprobo/probo/pkg/auth"
authsvc "github.com/getprobo/probo/pkg/auth"
"go.gearno.de/kit/httpserver"
)
@@ -36,7 +36,7 @@ type (
}
)
func ResetPasswordHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
func ResetPasswordHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req ResetPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -46,8 +46,8 @@ func ResetPasswordHandler(authSvc *auth.Service, authCfg AuthConfig) http.Handle
err := authSvc.ResetPassword(r.Context(), req.Token, req.Password)
if err != nil {
var invalidPasswordErr *auth.ErrInvalidPassword
var invalidTokenErr *auth.ErrInvalidTokenType
var invalidPasswordErr *authsvc.ErrInvalidPassword
var invalidTokenErr *authsvc.ErrInvalidTokenType
if errors.As(err, &invalidPasswordErr) {
httpserver.RenderError(w, http.StatusBadRequest, err)

60
pkg/server/auth/router.go Normal file
View File

@@ -0,0 +1,60 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"time"
authsvc "github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/filemanager"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
)
type RoutesConfig struct {
CookieName string
CookieDomain string
SessionDuration time.Duration
CookieSecret string
FileManager *filemanager.Service
PGClient *pg.Client
}
func MountRoutes(
r chi.Router,
authSvc *authsvc.Service,
authzSvc *authz.Service,
samlSvc *authsvc.SAMLService,
authCfg RoutesConfig,
logger *log.Logger,
) {
r.Post("/register", SignUpHandler(authSvc, authCfg))
r.Post("/login", SignInHandler(authSvc, authCfg))
r.Delete("/logout", SignOutHandler(authSvc, authCfg))
r.Post("/signup-from-invitation", SignupFromInvitationHandler(authSvc, authCfg))
r.Post("/forget-password", ForgetPasswordHandler(authSvc, authCfg))
r.Post("/reset-password", ResetPasswordHandler(authSvc, authCfg))
r.Post("/check-sso", SAMLCheckSSOHandler(authSvc, logger))
r.Get("/organizations", ListOrganizationsHandler(authSvc, authzSvc, authCfg))
r.Get("/invitations", ListInvitationsHandler(authSvc, authzSvc, authCfg))
r.Post("/invitations/accept", AcceptInvitationHandler(authSvc, authzSvc, authCfg))
// SAML routes
r.Get("/saml/login/{samlConfigID}", SAMLLoginHandler(samlSvc, authSvc, logger))
r.Post("/saml/consume", SAMLACSHandler(samlSvc, authSvc, authzSvc, authCfg, logger))
r.Get("/saml/metadata", SAMLMetadataHandler(samlSvc))
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"fmt"
"net/http"
"time"
authsvc "github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/securecookie"
"go.gearno.de/kit/log"
)
func getSessionIDFromCookie(r *http.Request, authCfg RoutesConfig) (gid.GID, error) {
cookieValue, err := securecookie.Get(r, securecookie.DefaultConfig(
authCfg.CookieName,
authCfg.CookieSecret,
))
if err != nil {
return gid.GID{}, err
}
return gid.ParseGID(cookieValue)
}
func SAMLACSHandler(samlSvc *authsvc.SAMLService, authSvc *authsvc.Service, authzSvc *authz.Service, authCfg RoutesConfig, logger *log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
logger.ErrorCtx(ctx, "failed to parse form", log.Error(err))
http.Error(w, "failed to parse form", http.StatusBadRequest)
return
}
if r.FormValue("SAMLResponse") == "" {
logger.WarnCtx(ctx, "missing SAMLResponse")
http.Error(w, "missing SAMLResponse", http.StatusBadRequest)
return
}
if r.FormValue("RelayState") == "" {
logger.WarnCtx(ctx, "missing RelayState")
http.Error(w, "missing RelayState", http.StatusBadRequest)
return
}
userInfo, err := samlSvc.HandleSAMLAssertion(ctx, r)
if err != nil {
logger.ErrorCtx(ctx, "SAML authentication failed", log.Error(err))
http.Error(w, "SAML authentication failed", http.StatusUnauthorized)
return
}
user, err := authSvc.CreateOrGetSAMLUser(ctx, userInfo.Email, userInfo.FullName, userInfo.SAMLSubject)
if err != nil {
logger.ErrorCtx(ctx, "cannot create or get SAML user", log.Error(err), log.String("email", userInfo.Email))
http.Error(w, "failed to create user", http.StatusInternalServerError)
return
}
err = authzSvc.EnsureSAMLMembership(ctx, userInfo.TenantID, user.ID, userInfo.OrganizationID, userInfo.Role)
if err != nil {
logger.ErrorCtx(ctx, "cannot ensure membership", log.Error(err), log.String("user_id", user.ID.String()), log.String("org_id", userInfo.OrganizationID.String()))
http.Error(w, "failed to create membership", http.StatusInternalServerError)
return
}
var session *coredata.Session
if existingSessionID, err := getSessionIDFromCookie(r, authCfg); err == nil {
if existingSession, err := authSvc.GetSession(ctx, existingSessionID); err == nil && existingSession.UserID == user.ID {
session = existingSession
}
}
if session == nil {
session, err = authSvc.CreateSessionForUser(ctx, user.ID, authCfg.SessionDuration)
if err != nil {
logger.ErrorCtx(ctx, "cannot create session", log.Error(err), log.String("user_id", user.ID.String()))
http.Error(w, "failed to create session", http.StatusInternalServerError)
return
}
}
if session.Data.SAMLAuthenticatedOrgs == nil {
session.Data.SAMLAuthenticatedOrgs = make(map[string]coredata.SAMLAuthInfo)
}
session.Data.SAMLAuthenticatedOrgs[userInfo.OrganizationID.String()] = coredata.SAMLAuthInfo{
AuthenticatedAt: time.Now(),
SAMLConfigID: userInfo.SAMLConfigID,
SAMLSubject: userInfo.SAMLSubject,
}
err = authSvc.UpdateSessionData(ctx, session.ID, session.Data)
if err != nil {
logger.ErrorCtx(ctx, "cannot update session data", log.Error(err), log.String("session_id", session.ID.String()))
http.Error(w, "failed to update session", http.StatusInternalServerError)
return
}
securecookie.Set(
w,
securecookie.DefaultConfig(
authCfg.CookieName,
authCfg.CookieSecret,
),
session.ID.String(),
)
logger.InfoCtx(ctx, "SAML login successful", log.String("user_id", user.ID.String()), log.String("org_id", userInfo.OrganizationID.String()))
redirectURL := fmt.Sprintf("/organizations/%s", userInfo.OrganizationID)
http.Redirect(w, r, redirectURL, http.StatusFound)
}
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"encoding/json"
"fmt"
"net/http"
authsvc "github.com/getprobo/probo/pkg/auth"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
)
type (
CheckSSORequest struct {
Email string `json:"email"`
}
CheckSSOResponse struct {
SSOAvailable bool `json:"ssoAvailable"`
SAMLConfigID *string `json:"samlConfigId,omitempty"`
OrganizationID *string `json:"organizationId,omitempty"`
EnforcementPolicy *string `json:"enforcementPolicy,omitempty"`
}
)
func SAMLCheckSSOHandler(authSvc *authsvc.Service, logger *log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req CheckSSORequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
return
}
if req.Email == "" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("email is required"))
return
}
configs, err := authSvc.CheckSSOAvailabilityByEmail(ctx, req.Email)
if err != nil {
logger.ErrorCtx(ctx, "cannot check SSO availability", log.Error(err), log.String("email", req.Email))
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("cannot check SSO availability"))
return
}
// No SAML configs found for this domain
if len(configs) == 0 {
httpserver.RenderJSON(w, http.StatusOK, CheckSSOResponse{
SSOAvailable: false,
})
return
}
// Multiple SAML configs found - ambiguous, user must use organization-specific SSO URL
if len(configs) > 1 {
logger.WarnCtx(ctx, "multiple SAML configurations found for domain", log.String("email", req.Email), log.Int("count", len(configs)))
httpserver.RenderError(w, http.StatusConflict, fmt.Errorf("multiple SSO configurations found for this domain. Please use your organization-specific SSO login URL"))
return
}
// Single SAML config found - return it
config := configs[0]
configIDStr := config.ID.String()
orgIDStr := config.OrganizationID.String()
enforcementPolicy := string(config.EnforcementPolicy)
httpserver.RenderJSON(w, http.StatusOK, CheckSSOResponse{
SSOAvailable: true,
SAMLConfigID: &configIDStr,
OrganizationID: &orgIDStr,
EnforcementPolicy: &enforcementPolicy,
})
}
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"fmt"
"net/http"
authsvc "github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/gid"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
)
func SAMLLoginHandler(samlSvc *authsvc.SAMLService, authSvc *authsvc.Service, logger *log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
samlConfigIDStr := chi.URLParam(r, "samlConfigID")
if samlConfigIDStr == "" {
logger.WarnCtx(ctx, "missing SAML config ID in URL")
http.Error(w, "missing SAML config ID", http.StatusBadRequest)
return
}
samlConfigID, err := gid.ParseGID(samlConfigIDStr)
if err != nil {
logger.ErrorCtx(ctx, "invalid SAML config ID", log.Error(err), log.String("saml_config_id", samlConfigIDStr))
http.Error(w, "invalid SAML config ID", http.StatusBadRequest)
return
}
tenantID := samlConfigID.TenantID()
config, err := authSvc.WithTenant(tenantID).GetSAMLConfigurationByID(ctx, samlConfigID)
if err != nil {
logger.ErrorCtx(ctx, "cannot load SAML configuration", log.Error(err), log.String("saml_config_id", samlConfigID.String()))
http.Error(w, "SAML configuration not found", http.StatusNotFound)
return
}
redirectURL, err := samlSvc.InitiateSAMLLogin(ctx, config.OrganizationID, tenantID, config.EmailDomain)
if err != nil {
logger.ErrorCtx(ctx, "cannot initiate SAML login", log.Error(err), log.String("saml_config_id", samlConfigID.String()), log.String("org_id", config.OrganizationID.String()), log.String("email_domain", config.EmailDomain))
http.Error(w, fmt.Sprintf("SAML login failed: %v", err), http.StatusInternalServerError)
return
}
logger.InfoCtx(ctx, "SAML login initiated", log.String("saml_config_id", samlConfigID.String()), log.String("org_id", config.OrganizationID.String()), log.String("email_domain", config.EmailDomain))
http.Redirect(w, r, redirectURL, http.StatusFound)
}
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
import (
"fmt"
"net/http"
authsvc "github.com/getprobo/probo/pkg/auth"
)
// SAMLMetadataHandler returns an HTTP handler that serves the SAML Service Provider metadata XML
// Uses global SP certificate configured at service startup
func SAMLMetadataHandler(samlSvc *authsvc.SAMLService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
metadataXML, err := samlSvc.GenerateMetadata()
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate metadata: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/samlmetadata+xml")
w.WriteHeader(http.StatusOK)
w.Write(metadataXML)
}
}

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package console_v1
package auth
import (
"encoding/json"
@@ -21,9 +21,10 @@ import (
"net/http"
"time"
authsvc "github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/securecookie"
"github.com/getprobo/probo/pkg/auth"
"go.gearno.de/kit/httpserver"
)
@@ -46,7 +47,7 @@ type (
}
)
func SignInHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
func SignInHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req SignInRequest
@@ -55,9 +56,16 @@ func SignInHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
return
}
session, user, err := authSvc.SignIn(r.Context(), req.Email, req.Password)
var existingSession *coredata.Session
if existingSessionID, err := getSessionIDFromCookie(r, authCfg); err == nil {
if session, err := authSvc.GetSession(r.Context(), existingSessionID); err == nil {
existingSession = session
}
}
session, user, err := authSvc.SignInWithExistingSession(r.Context(), req.Email, req.Password, existingSession)
if err != nil {
var ErrInvalidCredentials *auth.ErrInvalidCredentials
var ErrInvalidCredentials *authsvc.ErrInvalidCredentials
if errors.As(err, &ErrInvalidCredentials) {
httpserver.RenderError(w, http.StatusUnauthorized, err)
return

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package console_v1
package auth
import (
"fmt"
@@ -20,11 +20,11 @@ import (
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/securecookie"
"github.com/getprobo/probo/pkg/auth"
authsvc "github.com/getprobo/probo/pkg/auth"
"go.gearno.de/kit/httpserver"
)
func SignOutHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
func SignOutHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sessionID, err := securecookie.Get(r, securecookie.DefaultConfig(

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package console_v1
package auth
import (
"encoding/json"
@@ -20,7 +20,7 @@ import (
"fmt"
"net/http"
"github.com/getprobo/probo/pkg/auth"
authsvc "github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/securecookie"
"go.gearno.de/kit/httpserver"
)
@@ -37,7 +37,7 @@ type (
}
)
func SignUpHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
func SignUpHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req SignUpRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -52,13 +52,13 @@ func SignUpHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
req.FullName,
)
if err != nil {
var errUserAlreadyExists *auth.ErrUserAlreadyExists
var errUserAlreadyExists *authsvc.ErrUserAlreadyExists
if errors.As(err, &errUserAlreadyExists) {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
return
}
var errSignupDisabled *auth.ErrSignupDisabled
var errSignupDisabled *authsvc.ErrSignupDisabled
if errors.As(err, &errSignupDisabled) {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
return

View File

@@ -12,14 +12,14 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package console_v1
package auth
import (
"encoding/json"
"fmt"
"net/http"
"github.com/getprobo/probo/pkg/auth"
authsvc "github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/securecookie"
"go.gearno.de/kit/httpserver"
)
@@ -35,7 +35,7 @@ type (
}
)
func SignupFromInvitationHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
func SignupFromInvitationHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req SignupFromInvitationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {

View File

@@ -19,13 +19,53 @@ import (
"errors"
"runtime/debug"
"github.com/getprobo/probo/pkg/auth"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
)
func RecoverFunc(ctx context.Context, err any) error {
if gqlErr, ok := err.(*gqlerror.Error); ok {
return gqlErr
}
var errSAMLRequired auth.ErrSAMLAuthRequired
if errors.As(asError(err), &errSAMLRequired) {
return &gqlerror.Error{
Message: "Additional authentication required to access this organization",
Extensions: map[string]any{
"code": "AUTHENTICATION_REQUIRED",
"requiresSaml": true,
"redirectUrl": errSAMLRequired.RedirectURL,
"samlConfigId": errSAMLRequired.ConfigID.String(),
"organizationId": errSAMLRequired.OrganizationID.String(),
},
}
}
var errPasswordRequired auth.ErrPasswordAuthRequired
if errors.As(asError(err), &errPasswordRequired) {
return &gqlerror.Error{
Message: "Additional authentication required to access this organization",
Extensions: map[string]any{
"code": "AUTHENTICATION_REQUIRED",
"requiresSaml": false,
"redirectUrl": errPasswordRequired.RedirectURL,
"organizationId": errPasswordRequired.OrganizationID.String(),
},
}
}
logger := httpserver.LoggerFromContext(ctx)
logger.Error("resolver panic", log.Any("error", err), log.Any("stack", string(debug.Stack())))
return errors.New("internal server error")
}
func asError(err any) error {
if e, ok := err.(error); ok {
return e
}
return errors.New("unknown panic")
}

View File

@@ -24,10 +24,12 @@ import (
"github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/connector"
"github.com/getprobo/probo/pkg/filemanager"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/saferedirect"
"github.com/getprobo/probo/pkg/server/api"
auth_server "github.com/getprobo/probo/pkg/server/auth"
trust_v1 "github.com/getprobo/probo/pkg/server/api/trust/v1"
"github.com/getprobo/probo/pkg/server/trust"
"github.com/getprobo/probo/pkg/server/web"
@@ -35,6 +37,7 @@ import (
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
)
type Config struct {
@@ -44,12 +47,15 @@ type Config struct {
Auth *auth.Service
Authz *authz.Service
Trust *trust_pkg.Service
SAML *auth.SAMLService
ConsoleAuth api.ConsoleAuthConfig
TrustAuth api.TrustAuthConfig
ConnectorRegistry *connector.ConnectorRegistry
Agent *agents.Agent
SafeRedirect *saferedirect.SafeRedirect
CustomDomainCname string
FileManager *filemanager.Service
PGClient *pg.Client
Logger *log.Logger
}
@@ -57,6 +63,7 @@ type Server struct {
apiServer *api.Server
webServer *web.Server
trustServer *trust.Server
authServer *auth_server.Server
router *chi.Mux
extraHeaderFields map[string]string
proboService *probo.Service
@@ -70,6 +77,7 @@ func NewServer(cfg Config) (*Server, error) {
Auth: cfg.Auth,
Authz: cfg.Authz,
Trust: cfg.Trust,
SAML: cfg.SAML,
ConsoleAuth: cfg.ConsoleAuth,
TrustAuth: cfg.TrustAuth,
ConnectorRegistry: cfg.ConnectorRegistry,
@@ -92,12 +100,29 @@ func NewServer(cfg Config) (*Server, error) {
return nil, err
}
authServer, err := auth_server.NewServer(auth_server.Config{
Auth: cfg.Auth,
Authz: cfg.Authz,
SAML: cfg.SAML,
CookieName: cfg.ConsoleAuth.CookieName,
CookieDomain: cfg.ConsoleAuth.CookieDomain,
SessionDuration: cfg.ConsoleAuth.SessionDuration,
CookieSecret: cfg.ConsoleAuth.CookieSecret,
FileManager: cfg.FileManager,
PGClient: cfg.PGClient,
Logger: cfg.Logger.Named("auth"),
})
if err != nil {
return nil, err
}
router := chi.NewRouter()
server := &Server{
apiServer: apiServer,
webServer: webServer,
trustServer: trustServer,
authServer: authServer,
router: router,
extraHeaderFields: cfg.ExtraHeaderFields,
proboService: cfg.Probo,
@@ -111,6 +136,7 @@ func NewServer(cfg Config) (*Server, error) {
func (s *Server) setupRoutes() {
s.router.Mount("/api", s.apiServer)
s.router.Mount("/auth", s.authServer)
s.router.Route("/trust/{slugOrId}", func(r chi.Router) {
r.Use(s.loadTrustCenterBySlugOrID)

View File

@@ -32,9 +32,10 @@ type AuthConfig struct {
}
type AuthResult struct {
Session *coredata.Session
User *coredata.User
TenantIDs []gid.TenantID
Session *coredata.Session
User *coredata.User
TenantIDs []gid.TenantID
AuthErrors map[gid.TenantID]error // Maps tenant ID to authentication error
}
type ErrorHandler struct {
@@ -97,15 +98,28 @@ func TryAuth(
return nil
}
tenantIDs := make([]gid.TenantID, len(organizations))
for i, org := range organizations {
tenantIDs[i] = org.ID.TenantID()
// Validate organization access based on authentication requirements
// Only include organizations the user has proper authentication for
allowedTenantIDs := make([]gid.TenantID, 0, len(organizations))
authErrors := make(map[gid.TenantID]error)
for _, org := range organizations {
// Check if user has the required authentication for this organization
err := authSvc.CheckOrganizationAccess(ctx, user, org.ID, session)
if err == nil {
// User has proper authentication for this org
allowedTenantIDs = append(allowedTenantIDs, org.ID.TenantID())
} else {
// Store the authentication error for later use
authErrors[org.ID.TenantID()] = err
}
}
return &AuthResult{
Session: session,
User: user,
TenantIDs: tenantIDs,
TenantIDs: allowedTenantIDs,
AuthErrors: authErrors,
}
}