-
-
Notifications
You must be signed in to change notification settings - Fork 204
Description
RFC: SAML 2.0 Support
Phase: 3 — Enterprise SSO & Federation
Priority: P1 — High
Estimated Effort: High
Depends on: Organizations (#511)
Problem Statement
Authorizer has zero SAML support. Enterprise customers using Okta, Azure AD, OneLogin, PingFederate, or ADFS require SAML 2.0 SSO. WorkOS and Keycloak both support SAML as a core feature. Without SAML, Authorizer is excluded from any enterprise deal where the customer's identity provider only supports SAML (still common in large enterprises, healthcare, government).
Current Architecture Context
- OAuth/OIDC provider system in
internal/oauth/handles social login providers - OAuth flow:
/oauth_login/:provider→ redirect to IdP →/oauth_callback/:provider→ session creation - Provider interface:
GetOAuthConfig(ctx, provider) (*oauth2.Config, error) - Per-organization auth config planned in Organizations RFC (RFC: Organization & Multi-Tenancy Enhancements #511)
- No SAML library in
go.mod
Proposed Solution
1. Architecture: Authorizer as SAML Service Provider (SP)
Authorizer acts as the Service Provider (SP). Enterprise IdPs (Okta, Azure AD, etc.) act as the Identity Provider (IdP). This is the standard pattern — enterprise customers configure their IdP to trust Authorizer as an SP.
Library: crewjam/saml — mature, well-maintained Go SAML library with SP support, assertion validation, and XML signature verification.
2. SAML Connection Schema
New schema: internal/storage/schemas/saml_connection.go
type SAMLConnection struct {
ID string `json:"id" gorm:"primaryKey;type:char(36)"`
OrganizationID string `json:"organization_id" gorm:"type:char(36);uniqueIndex:idx_saml_org"`
// IdP Configuration
IdPEntityID string `json:"idp_entity_id" gorm:"type:varchar(512)"`
IdPSSOURL string `json:"idp_sso_url" gorm:"type:text"` // IdP's Single Sign-On URL
IdPSLOURL string `json:"idp_slo_url" gorm:"type:text"` // IdP's Single Logout URL (optional)
IdPCertificate string `json:"idp_certificate" gorm:"type:text"` // PEM-encoded X.509 certificate
IdPMetadataURL string `json:"idp_metadata_url" gorm:"type:text"` // Auto-fetch IdP metadata
// SP Configuration (generated)
SPEntityID string `json:"sp_entity_id" gorm:"type:varchar(512)"` // e.g., "https://auth.example.com/saml/{org_slug}"
SPACSURL string `json:"sp_acs_url" gorm:"type:varchar(512)"` // Assertion Consumer Service URL
// Attribute Mapping
AttributeMapping string `json:"attribute_mapping" gorm:"type:text"` // JSON: IdP attribute → Authorizer field
// Settings
IsActive bool `json:"is_active" gorm:"type:bool;default:false"`
SignRequests bool `json:"sign_requests" gorm:"type:bool;default:true"`
AllowIdPInitiated bool `json:"allow_idp_initiated" gorm:"type:bool;default:false"`
DefaultRole string `json:"default_role" gorm:"type:varchar(100)"` // Role assigned to SAML-provisioned users
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime"`
}Default attribute mapping:
{
"email": "urn:oid:0.9.2342.19200300.100.1.3",
"given_name": "urn:oid:2.5.4.42",
"family_name": "urn:oid:2.5.4.4",
"name": "urn:oid:2.16.840.1.113730.3.1.241",
"groups": "memberOf"
}Common IdP attribute names are also supported as aliases (e.g., "email" maps from both urn:oid:0.9.2342.19200300.100.1.3 and http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress).
3. SAML Endpoints
New routes in internal/server/http_routes.go:
| Endpoint | Method | Purpose |
|---|---|---|
/saml/:org_slug/metadata |
GET | SP metadata XML for the organization |
/saml/:org_slug/acs |
POST | Assertion Consumer Service — receives SAML response from IdP |
/saml/:org_slug/login |
GET | Initiate SP-initiated SSO (redirect to IdP) |
/saml/:org_slug/slo |
GET/POST | Single Logout (optional) |
New handler package: internal/http_handlers/saml.go
4. SP-Initiated SSO Flow
User → Authorizer /saml/{org_slug}/login
→ Generate SAML AuthnRequest (signed with SP private key)
→ HTTP-Redirect to IdP SSO URL with SAMLRequest parameter
→ User authenticates at IdP
→ IdP POST SAML Response to /saml/{org_slug}/acs
→ Authorizer validates:
1. XML signature using IdP certificate
2. Assertion conditions (audience, NotBefore, NotOnOrAfter)
3. InResponseTo matches original request ID
4. Destination matches ACS URL
→ Extract user attributes via configured mapping
→ Find or create user in Authorizer
→ Create session, issue JWT tokens
→ Redirect to application callback URL
5. IdP-Initiated SSO Flow
When allow_idp_initiated=true:
- IdP sends unsolicited SAML Response to ACS URL
- No InResponseTo validation (since there was no request)
- Higher security risk — disabled by default
- Additional validation: check
audiencerestriction matches SP entity ID
6. SP Metadata Generation
GET /saml/{org_slug}/metadata returns standard SAML SP metadata XML:
<EntityDescriptor entityID="https://auth.example.com/saml/acme-corp" xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<SPSSODescriptor AuthnRequestsSigned="true" WantAssertionsSigned="true"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<KeyInfo><X509Data><X509Certificate>...</X509Certificate></X509Data></KeyInfo>
</KeyDescriptor>
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://auth.example.com/saml/acme-corp/acs" index="1"/>
</SPSSODescriptor>
</EntityDescriptor>This XML is what enterprise IT admins upload to their IdP (Okta, Azure AD, etc.) to configure the trust relationship.
7. User Provisioning from SAML
When a SAML assertion is received for a user:
func handleSAMLAssertion(ctx context.Context, assertion *saml.Assertion, conn *schemas.SAMLConnection) {
// Extract attributes using configured mapping
attrs := extractAttributes(assertion, conn.AttributeMapping)
email := attrs["email"]
// Find existing user
user, err := store.GetUserByEmail(ctx, email)
if user == nil {
// JIT (Just-In-Time) provisioning — create user
user = &schemas.User{
Email: email,
GivenName: attrs["given_name"],
FamilyName: attrs["family_name"],
EmailVerifiedAt: time.Now().Unix(), // SAML = verified by IdP
SignupMethods: "saml",
Roles: conn.DefaultRole,
}
user, _ = store.AddUser(ctx, user)
}
// Ensure user is member of the organization
member, _ := store.GetOrganizationMember(ctx, conn.OrganizationID, user.ID)
if member == nil {
store.AddOrganizationMember(ctx, &schemas.OrganizationMember{
OrganizationID: conn.OrganizationID,
UserID: user.ID,
Role: conn.DefaultRole,
})
}
// Map SAML groups to roles (if groups attribute present)
if groups, ok := attrs["groups"]; ok {
mapGroupsToRoles(user, groups, conn)
}
// Create session and issue tokens (same as OAuth callback)
createSessionAndRedirect(ctx, user, conn.OrganizationID)
}8. Storage Interface Methods
AddSAMLConnection(ctx context.Context, conn *schemas.SAMLConnection) (*schemas.SAMLConnection, error)
UpdateSAMLConnection(ctx context.Context, conn *schemas.SAMLConnection) (*schemas.SAMLConnection, error)
DeleteSAMLConnection(ctx context.Context, id string) error
GetSAMLConnectionByID(ctx context.Context, id string) (*schemas.SAMLConnection, error)
GetSAMLConnectionByOrgID(ctx context.Context, orgID string) (*schemas.SAMLConnection, error)
GetSAMLConnectionByOrgSlug(ctx context.Context, orgSlug string) (*schemas.SAMLConnection, error)
ListSAMLConnections(ctx context.Context, pagination *model.Pagination) ([]*schemas.SAMLConnection, *model.Pagination, error)9. GraphQL Admin API
type SAMLConnection {
id: ID!
organization_id: String!
idp_entity_id: String
idp_sso_url: String
sp_entity_id: String!
sp_acs_url: String!
sp_metadata_url: String! # URL to download SP metadata XML
attribute_mapping: Map
is_active: Boolean!
sign_requests: Boolean!
allow_idp_initiated: Boolean!
default_role: String
created_at: Int64!
}
type Mutation {
_create_saml_connection(params: CreateSAMLConnectionInput!): SAMLConnection!
_update_saml_connection(params: UpdateSAMLConnectionInput!): SAMLConnection!
_delete_saml_connection(id: ID!): Response!
}
type Query {
_saml_connections(params: PaginatedInput): SAMLConnections!
_saml_connection(id: ID!): SAMLConnection!
}
input CreateSAMLConnectionInput {
organization_id: ID!
idp_metadata_url: String # Auto-fetch from URL (preferred)
idp_entity_id: String # Or configure manually
idp_sso_url: String
idp_certificate: String # PEM-encoded
attribute_mapping: Map
default_role: String
allow_idp_initiated: Boolean
}Integration with Login Flow
When a user navigates to login:
- If email domain matches an org with active SAML connection → show "Sign in with SSO" button
- Clicking "Sign in with SSO" → redirect to
/saml/{org_slug}/login - After SAML flow completes → tokens issued with
org_idclaim, redirect to app
meta query enhancement:
type Meta {
# ... existing fields
saml_enabled: Boolean # true if any active SAML connection exists
}Security Considerations
- All SAML assertions validated: signature, conditions, timestamps, audience
- Replay attack prevention: track assertion IDs, reject duplicates within validity window
- Clock skew tolerance: configurable (default ±5 minutes)
- IdP certificate pinning: only trust configured certificates
- SP request signing: sign AuthnRequests with SP private key
- IdP-initiated SSO disabled by default (higher CSRF risk)
CLI Configuration Flags
--saml-sp-private-key= # PEM private key for SP request signing
--saml-sp-certificate= # PEM certificate for SP metadata
--saml-clock-skew-tolerance=5m # Allowed clock skew for assertion validation
Testing Plan
- Unit tests for SAML assertion parsing and validation
- Unit tests for attribute mapping
- Integration test: full SP-initiated SSO flow with mock IdP
- Integration test: IdP-initiated SSO flow
- Test replay attack prevention
- Test expired assertion rejection
- Test JIT user provisioning from SAML attributes
- Test group-to-role mapping