Skip to content

RFC: SAML 2.0 Support #512

@lakhansamani

Description

@lakhansamani

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 audience restriction 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:

  1. If email domain matches an org with active SAML connection → show "Sign in with SSO" button
  2. Clicking "Sign in with SSO" → redirect to /saml/{org_slug}/login
  3. After SAML flow completes → tokens issued with org_id claim, 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

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions