Early Access Preview
Back to blog
engineeringsecurityidentitysamlenterprisearchitecture

We Built a SAML Identity Provider from Scratch — And Got GitHub SSO Working in One Session

March 7, 202616 min readAitherium
Share

At 2 AM on a Thursday, I stared at a green checkmark on GitHub's SAML configuration page:

Passed: Successfully authenticated your SAML SSO identity

That checkmark meant something. It meant our AI agent operating system — its full microservice fleet — was now authenticating GitHub organization members through our own identity provider. Not Okta. Not Entra ID. Not Auth0. Our own IdP, running in a Docker container, issuing signed SAML 2.0 assertions, tunneled through Cloudflare to the public internet.

This is the story of how we built a complete SAML Identity Provider into AitherIdentity, wired it up to GitHub Enterprise SSO, and shipped it in a single session — including every bug we hit along the way.

GitHub Single sign-on to Aitherium — the SSO gate that greets every org member, powered entirely by AitherIdentity
GitHub Single sign-on to Aitherium — the SSO gate that greets every org member, powered entirely by AitherIdentity

Why Build Your Own IdP?

Most organizations buy an identity provider. Okta, PingIdentity, Microsoft Entra ID — they're battle-tested, SOC 2 certified, and somebody else's problem to maintain.

But AitherOS isn't most organizations.

We're building an AI agent operating system. Dozens of microservices across 12 architectural layers. Agents that spawn subagents, manage infrastructure, and make decisions autonomously. Our identity layer already handles:

  • RBAC with hierarchical roles and resource-level permissions
  • WebAuthn/FIDO2 passwordless authentication with hardware security keys
  • TOTP/HOTP two-factor authentication with QR code provisioning
  • SCIM 2.0 for automated user lifecycle management
  • SAML SP mode for consuming assertions from external IdPs
  • OAuth 2.0 / OpenID Connect with JWT tokens

Adding IdP mode was the missing piece. We needed to be the authority, not just a consumer. When GitHub asks "who is this user?", we wanted AitherIdentity to answer with a cryptographically signed assertion — not redirect to some third-party service.

The architecture already supported it. AitherIdentity runs as part of our security compound service, sitting in the security layer of the AitherOS stack. It has direct access to the RBAC database, the secret store, and the event bus. All the pieces were there.

The Architecture

Here's what we built:

GitHub (SP)                        AitherIdentity (IdP)
    │                                      │
    │  1. User clicks "Sign in with SSO"   │
    │─────────────────────────────────────▶│
    │     SAMLRequest (AuthnRequest)        │
    │                                      │
    │  2. IdP shows login form             │
    │◀─────────────────────────────────────│
    │     HTML login page                   │
    │                                      │
    │  3. User submits credentials          │
    │─────────────────────────────────────▶│
    │     POST /identity/idp/saml/login     │
    │                                      │
    │  4. IdP validates against RBAC        │
    │     Signs SAML assertion (RSA-SHA256) │
    │     Returns auto-submit form          │
    │◀─────────────────────────────────────│
    │     SAMLResponse (signed assertion)   │
    │                                      │
    │  5. Browser POSTs to GitHub ACS       │
    │─────────────────────────────────────▶│
    │     https://github.com/orgs/         │
    │     Aitherium/saml/consume            │
    │                                      │
    │  6. GitHub validates signature +      │
    │     assertion → user authenticated   │
    └──────────────────────────────────────┘

The Stack

  • SAML Identity Provider module — The core IdP engine. Parses AuthnRequests, generates signed SAML 2.0 assertions with RSA-SHA256, exposes metadata, and serves the login flow.
  • AitherIdentity — The HTTP layer. Mounts IdP routes, handles credential validation against RBAC, maps user objects to SAML attributes.
  • Security compound service — Hosts AitherIdentity at the /identity prefix inside the Docker container.
  • Cloudflare Tunnel — Routes idp.aitherium.com from the public internet to the container on our internal Docker network.
  • RSA Key Pair — 2048-bit RSA key, auto-generated on first boot, stored in the container's data volume. Signs every assertion.

Key Endpoints

The IdP exposes five endpoints under the /identity/idp/saml/ path:

  • Metadata — SAML 2.0 metadata XML that GitHub reads to discover our IdP capabilities
  • SSO (GET and POST) — receives AuthnRequest via redirect or POST binding
  • Login — form submission handler that validates credentials and issues assertions
  • Single Logout — handles logout propagation across service providers

The Implementation

Service Provider Registration

The IdP needs to know about GitHub as a service provider. We register SPs with their entity ID, assertion consumer service URL, and an optional signing certificate:

# Register GitHub as a Service Provider
await idp.register_sp(
    sp_entity_id="https://github.com/orgs/Aitherium",
    sp_name="github-Aitherium",
    acs_url="https://github.com/orgs/Aitherium/saml/consume",
    slo_url=None,
    name_id_format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
)

SAML Assertion Generation

The heart of an IdP is assertion generation. Every attribute, every namespace, every XML element must be precisely correct — or the SP will reject it. Here's a simplified view of what happens when a user successfully authenticates:

# 1. Create the SAML Response envelope
response = etree.SubElement(root, f"{{{SAMLP_NS}}}Response", nsmap=NSMAP)

# 2. Create the Assertion
assertion = etree.SubElement(response, f"{{{SAML_NS}}}Assertion")

# 3. Add Subject with NameID
subject = etree.SubElement(assertion, f"{{{SAML_NS}}}Subject")
name_id = etree.SubElement(subject, f"{{{SAML_NS}}}NameID",
    Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent")
name_id.text = user_email

# 4. Add Conditions (timing, audience restriction)
conditions = etree.SubElement(assertion, f"{{{SAML_NS}}}Conditions",
    NotBefore=not_before.isoformat() + "Z",
    NotOnOrAfter=not_on_or_after.isoformat() + "Z")

# 5. Add Attributes (name, email, roles)
attr_statement = etree.SubElement(assertion, f"{{{SAML_NS}}}AttributeStatement")

# 6. Sign with RSA-SHA256
signed_xml = XMLSigner(method=methods.enveloped,
    signature_algorithm="rsa-sha256",
    digest_algorithm="sha256"
).sign(response, key=private_key, cert=[certificate])

The Login Form

When the IdP receives an AuthnRequest from GitHub, it renders a login form. The form is clean, minimal, and branded:

<form method="POST" action="/identity/idp/saml/login">
    <input type="hidden" name="flow_id" value="{flow_id}" />
    <input type="text" name="username" placeholder="Username or email" />
    <input type="password" name="password" placeholder="Password" />
    <button type="submit">Sign In</button>
</form>

The flow_id links the login attempt back to the original AuthnRequest, so we know which SP to send the assertion to after successful authentication.

Connecting to the Public Internet

Running an IdP inside Docker is great for development. But GitHub needs to reach it from the public internet. We use Cloudflare Tunnel — zero exposed ports, zero firewall rules.

The tunnel runs as a container alongside our other services, connecting outbound to Cloudflare's edge. In the Cloudflare dashboard, we configured a public hostname:

  • Subdomain: idp
  • Domain: aitherium.com
  • Backend: Our security compound service

That's it. idp.aitherium.com now routes directly to our security service container. No nginx, no reverse proxy, no port forwarding. Cloudflare handles TLS termination, DDoS protection, and the tunnel encryption.

The base URL is configured via environment variable:

x-common-env: &common-env
  AITHER_IDP_BASE_URL: "https://idp.aitherium.com"

This ensures all SAML metadata, SSO URLs, and assertion recipient URLs use the public domain name instead of localhost.

GitHub Enterprise SAML Configuration

On the GitHub side, the setup is straightforward. In the Aitherium org settings → Authentication security:

FieldValue
Sign on URLhttps://idp.aitherium.com/identity/idp/saml/sso
Issuerhttps://idp.aitherium.com/identity/idp/saml/metadata
Public certificatePEM-encoded X.509 certificate from our IdP metadata
Signature methodRSA-SHA256
Digest methodSHA256

Click "Test SAML configuration." GitHub redirects to our IdP. We log in. The assertion flies back. Green checkmark.

Every Bug We Hit (And How We Fixed Them)

Building an IdP that issues valid assertions is one thing. Getting GitHub to actually accept them is another. Here's every issue we encountered, in order:

Bug 1: The Environment Variable That Wouldn't Stick

Symptom: AITHER_IDP_BASE_URL wasn't appearing in the container despite being in the compose file.

Root Cause: Docker Compose V2's YAML merge key (<<: *common-env) has specific behavior around how service-level environment variables merge with anchored blocks. Variables added at the service level were being silently dropped.

Fix: Added AITHER_IDP_BASE_URL directly to the x-common-env: &common-env anchor block, so it propagates to every service automatically. Verified with docker compose config | grep AITHER_IDP.

Bug 2: The 404 That Made No Sense

Symptom: GET /idp/saml/sso returned 404, even though the route was clearly registered.

Root Cause: AitherIdentity runs inside SecurityCore, which mounts it at the /identity prefix. The IdP was generating URLs without the prefix: /idp/saml/sso instead of /identity/idp/saml/sso.

Fix: Added a route_prefix parameter to SAMLIdentityProvider.__init__(). All URL generation now includes the prefix:

self._sso_url = f"{base_url}{route_prefix}/idp/saml/sso"
self._slo_url = f"{base_url}{route_prefix}/idp/saml/slo"
self._metadata_url = f"{base_url}{route_prefix}/idp/saml/metadata"

Bug 3: The Login Form That Pointed Nowhere

Symptom: Login form rendered, but submitting it hit a 404.

Root Cause: The HTML template had action="{base_url}/idp/saml/login" — missing the /identity prefix.

Fix: Replaced with a {login_url} template variable that includes the full path:

login_url = f"{self._base_url}{self._route_prefix}/idp/saml/login"

Bug 4: The Authentication Function That Was Completely Wrong

Symptom: Login form submission returned a 500 error.

Root Cause: Three compounding issues in one function call:

  1. identity_manager.authenticate(username, password) was called with positional args, but it expects a LoginRequest object
  2. The call wasn't awaited (it's async)
  3. It returns a TokenResponse, not a User object

Fix: Replaced the entire authentication flow with direct RBAC lookup and password verification. The new flow looks up the user by username or email, then verifies the password hash directly. This also added proper handling for OAuth users who have no password set — they get a clear error message instead of a cryptic 500.

Bug 5: The XML Namespace That Broke Everything

Symptom: GitHub rejected the SAML assertion with: "The QName value 'xs:string' has no corresponding namespace declaration in scope."

Root Cause: Our SAML assertions used xsi:type="xs:string" on attribute values (standard practice), but the XML namespace map only declared saml and samlp prefixes. The xs (XMLSchema) and xsi (XMLSchema-instance) prefixes were never declared.

Fix: Added both namespaces to the NSMAP constant:

NSMAP = {
    "saml":  "urn:oasis:names:tc:SAML:2.0:assertion",
    "samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
    "xs":    "http://www.w3.org/2001/XMLSchema",
    "xsi":   "http://www.w3.org/2001/XMLSchema-instance",
}

This was the final fix. After this, GitHub accepted the assertion.

What the Assertion Looks Like

For the curious, here's a simplified version of the SAML assertion that GitHub accepted:

<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                xmlns:xs="http://www.w3.org/2001/XMLSchema"
                xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                ID="_response_abc123"
                InResponseTo="_github_request_id"
                Destination="https://github.com/orgs/Aitherium/saml/consume">

  <samlp:Status>
    <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
  </samlp:Status>

  <saml:Assertion ID="_assertion_def456" IssueInstant="2026-03-07T06:00:00Z">
    <saml:Issuer>https://idp.aitherium.com/identity/idp/saml/metadata</saml:Issuer>

    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
      <!-- RSA-SHA256 enveloped signature -->
    </ds:Signature>

    <saml:Subject>
      <saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">
        hello@aitherium.com
      </saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData
          InResponseTo="_github_request_id"
          Recipient="https://github.com/orgs/Aitherium/saml/consume"
          NotOnOrAfter="2026-03-07T06:05:00Z"/>
      </saml:SubjectConfirmation>
    </saml:Subject>

    <saml:Conditions NotBefore="2026-03-07T05:59:00Z"
                     NotOnOrAfter="2026-03-07T06:05:00Z">
      <saml:AudienceRestriction>
        <saml:Audience>https://github.com/orgs/Aitherium</saml:Audience>
      </saml:AudienceRestriction>
    </saml:Conditions>

    <saml:AuthnStatement AuthnInstant="2026-03-07T06:00:00Z">
      <saml:AuthnContext>
        <saml:AuthnContextClassRef>
          urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
        </saml:AuthnContextClassRef>
      </saml:AuthnContext>
    </saml:AuthnStatement>

    <saml:AttributeStatement>
      <saml:Attribute Name="email">
        <saml:AttributeValue xsi:type="xs:string">
          hello@aitherium.com
        </saml:AttributeValue>
      </saml:Attribute>
      <saml:Attribute Name="name">
        <saml:AttributeValue xsi:type="xs:string">
          Aitherium
        </saml:AttributeValue>
      </saml:Attribute>
      <saml:Attribute Name="roles">
        <saml:AttributeValue xsi:type="xs:string">
          admin
        </saml:AttributeValue>
      </saml:Attribute>
    </saml:AttributeStatement>
  </saml:Assertion>
</samlp:Response>

Every element matters. Miss a namespace? Rejected. Wrong Destination? Rejected. Signature covers wrong element? Rejected. Timing off by more than the allowed skew? Rejected. SAML is unforgiving.

The Security Model

Running your own IdP means you own the security story end-to-end. Here's what's in place:

Cryptographic Controls

  • RSA-SHA256 signatures on every assertion — GitHub verifies against the certificate in our metadata
  • 2048-bit RSA keys auto-generated on first boot, rotatable via API
  • Assertion expiry — 5-minute validity window with 60-second clock skew allowance
  • InResponseTo binding — assertions are bound to the original request ID, preventing replay attacks

Authentication Controls

  • RBAC-backed credential verification — passwords hashed with bcrypt, stored in the RBAC database
  • WebAuthn ready — the IdP can require hardware key verification before issuing assertions (future)
  • 2FA ready — TOTP verification can gate assertion issuance (future)
  • OAuth user detection — users who authenticated via OAuth (no password) get a clear error, not a silent failure

Network Controls

  • Cloudflare Tunnel — zero exposed ports, encrypted tunnel from our Docker network to Cloudflare's edge
  • No direct internet exposure — the container has no published ports to the host machine's public interface
  • DDoS protection — Cloudflare's edge handles volumetric attacks before they reach our infrastructure
  • TLS termination — HTTPS handled by Cloudflare with their managed certificates

Audit Trail

  • Every SAML response logged — SP name, user email, request ID, timestamp
  • Event bus integration — authentication events propagate through the event bus for real-time monitoring
  • Chronicle integration — all identity events are durably logged in our centralized logging system

What This Unlocks

With AitherIdentity as our IdP, we now control:

  1. GitHub Organization SSO — Every member of the Aitherium org authenticates through our IdP. We can enforce password policies, require 2FA, and revoke access instantly.

  2. Future SP Integrations — Any SAML-compatible service can be added as an SP. Slack, Notion, AWS, GCP — one IdP to rule them all. Register the SP, configure the metadata, done.

  3. Agent Identity — Our AI agents can be issued SAML assertions too. An agent acting on behalf of a user can present a signed assertion to any integrated service. This is how we'll handle delegated access in the agent mesh.

  4. SCIM Provisioning — The SCIM 2.0 module is already built. When we onboard a new team member, SCIM automatically provisions their account across all connected SPs. When they leave, one deactivation propagates everywhere.

  5. Compliance Stories — For enterprise customers who ask "who's your IdP?", the answer is "we are, and here's the audit trail." No third-party dependency. No data leaving our infrastructure for authentication.

The Numbers

MetricValue
Time to build IdP module~4 hours
Time to debug GitHub integration~3 hours
Bugs encountered5
Lines of code (IdP engine)~1,100
Lines of code (IdP HTTP routes)~200
SAML namespaces required4 (saml, samlp, xs, xsi)
Assertion validity window5 minutes
Key sizeRSA 2048-bit
Signature algorithmRSA-SHA256
Total enterprise security modules6 (RBAC, WebAuthn, SAML SP+IdP, SCIM, 2FA)

What's Next

We're not done with the identity stack. On the roadmap:

  • LDAP/Active Directory connector — For enterprises that live in AD, we need to sync users and groups bidirectionally. This is what makes AitherIdentity viable as a drop-in replacement for legacy IdPs.

  • Step-up authentication — High-risk actions (deploying to production, accessing secrets) should trigger additional verification. The 2FA and WebAuthn modules are built; we just need to wire them into the assertion flow.

  • SP-initiated SLO — Single Logout is registered but not fully implemented. When a user logs out of GitHub, we should kill their session across all connected SPs.

  • SAML assertion encryption — Currently we sign but don't encrypt. For SPs that require confidentiality of assertion contents (not just integrity), we'll add XML encryption support.

  • Admin dashboard — AitherVeil (our Next.js dashboard) needs an enterprise admin panel for managing SPs, viewing authentication logs, and configuring policies.

Conclusion

Building your own SAML Identity Provider is not for the faint of heart. The spec is sprawling, XML namespaces are punishing, and every SP has slightly different expectations. But if you're building an operating system for AI agents — one that needs to be the authority for identity across dozens of services — owning the IdP is non-negotiable.

AitherIdentity now sits at the center of our security architecture. It authenticates humans, will soon authenticate agents, and speaks every protocol the enterprise expects: SAML, OAuth, OIDC, WebAuthn, SCIM.

One identity. One authority. Zero third-party dependencies.

The green checkmark on GitHub's SAML test page wasn't just a validation of our XML. It was a validation of the architecture — that a small team can build enterprise-grade identity infrastructure inside an AI operating system, and have it accepted by one of the largest developer platforms in the world.


AitherOS is an AI agent operating system. AitherIdentity is the enterprise security module within AitherOS, handling authentication, authorization, and identity federation across the full microservice fleet.

Enjoyed this post?
Share