GeistHaus
log in · sign up

https://feeds.feedburner.com/freelancing_gods

atom
10 posts
Polling state
Status active
Last polled May 19, 2026 01:17 UTC
Next poll May 20, 2026 00:10 UTC
Poll interval 86400s
Last-Modified Sun, 3 May 2026 02:02:08 GMT

Posts

MICF 2026 Recommendations
comedymicfmelbourne2026
Some recommendations for the 2026 edition of the Melbourne International Comedy Festival, as per the almost-yearly tradition.
Show full content

In a few weeks the Melbourne International Comedy Festival kicks off for another year. I’ve had a thorough look through the program, and there are so many superb shows to pick from - and that’s just based on the acts I know!

There are four shows I can strongly recommend directly, because I’ve seen these before at past festivals:

And then, there’s my extended list of other acts who are consistently excellent:

As always: if you come across any great shows, do let me know!

https://freelancing-gods.com/2026/03/02/micf-2026
AI and moral injury
aitechnologypoliticsworld
As generative AI and LLMs seem to take over the world, and as someone who has significant ethical concerns about these technologies, I've been feeling quite despondent lately.
Show full content

As generative AI and LLMs seem to take over the world, and as someone who has significant ethical concerns about these technologies, I’ve been feeling quite despondent lately. A colleague with similar values accurately described it as “existential depression” - and then earlier this week I read this excellent article by Krisztina Csapó, which provided the concept of moral injury:

the deep existential wound that arises from witnessing, participating in, or feeling complicit within systems that cause profound harm while betraying core values.

Yup. 100%.

Because of course, these emotions that I’m grappling with aren’t just about AI - so much of our world is struggling right now, including from climate change, fascism, and genocide - and it all feels intertwined. AI is just one of the more prominent components occupying my brain at the moment (which is, almost certainly, a very privileged position to be in).

Also, as Csapó discusses in their post: when looking at the world, grief is a totally rational response. I think some of what I’m grappling with is grief for both the world that was, but also at a smaller level, at what my industry, my working life was. Part of that is because AI is everywhere right now, and especially within the tech industry. It’s an invasive weed, a virus in a largely-unvaccinated world. Even in the moments where I ponder changing careers - where would I go that isn’t already overrun by this virus?


I wouldn’t blame the broader world for feeling a touch of schadenfreude towards the tech industry at the moment. Tech workers in the Global North - particularly those who lean into the Silicon Valley/venture capital mindset - are responsible for a great deal of tenuous working conditions (often known as ‘disruption’ 🙄) for so many others. And now, with mass layoffs in the tech industry, perhaps some are feeling it’s what is deserved?

That’s not true of course - no one deserves to be put in positions of financial or emotional distress, to be pushed into homelessness when a corporation decides you’re surplus to its requirements because an AI can do the work instead.

But for those of us who are now feeling shaken by the challenging job market and working conditions: can we take this industry hardship as a nudge to get over our exceptionalism? To embrace a class consciousness that’s long been missing in tech spaces?


In Henry Desroches’ recent (excellent) essay A Website to Destroy All Websites, he highlights Ivan Illich’s concept of radical monopoly:

that point where a technological tool is so dominant that people are excluded from society unless they become its users.

[…]

We can map fairly directly most technological developments in the last 100 (or even 200) years to this framework: a net lift, followed by a push to extract value and subsequent insistence upon the technology’s ubiquity.

The automobile and the Internet are both offered as examples - and while Desroches doesn’t call it out, my brain latched onto AI as another technology that is arguably following that trend, and at an accelerated rate.

And this eager adoption (and insistence on use) is happening despite the fact that LLMs are tools built through exploitation (of people, of the environment, of our digital commons), and are used for further exploitation.

Something seems to be deeply amiss in what we imagine our tools are for. […] I’ve watched as new technologies - particularly the most novel and ‘intelligent’ ones - are used to undermine and usurp human joy, security and even life itself. (Ways of Being - James Bridle)

There is a dream that is often offered to the altar of AI: that we can work less, for the same effort and pay, and have more spare time for actually living our lives. Perhaps this happens for some people, but the vastly more common situation from all of these AI-driven workplace changes seems to actually be: you will work more rather than less (if you’re lucky to have a job), and you will build more wealth and power for billionaires.

Of course, we live in a capitalist system - my idealism had me forgetting that this trend is nothing new. As Talking Heads have been singing for several decades: same as it ever was.


My friend Paul Campbell mused a while ago on Mastodon that AI has strong parallels to plastic - they’re both incredibly convenient, and both incredibly environmentally destructive.

This analogy feels pretty appropriate to me, though I think it can be taken further - perhaps LLMs are the cognitive equivalent to microplastics. We’re still learning of the full, negative effects on our health courtesy of the latter - the same could be said for the impact of AI and LLMs on our memory and learning skills.


I find myself saddened by how so many of my friends and peers have leant into using (and loudly promoting) AI/LLMs. Perhaps that sadness is stronger than I should be? I recognise that we all have our own boundaries, compromises and challenges to work through - as the saying goes, there’s no ethical consumption under capitalism - and we are all imperfect human beings. I am definitely no exception to that rule. And I can’t blame people for wanting shiny new technologies, for wanting things to be easier.

This sadness has latched onto me more tightly than others though…

Other systems of exploitation in our lives - such as those involving fossil fuels, or food supply chains that torture animals - these have existed for decades, if not centuries. But LLMs aren’t a long-standing technology. We’re talking a handful of years of mainstream use, maybe a decade at most - and the flaws of these technologies and the companies behind them have been clear for just as long. It’s a question that my friend Jan Lehnardt has considered as well - we’re getting in on the ground floor here, we don’t have generational baggage and long-standing societal bad habits. And yet, despite the widely documented problems, we are embracing LLMs so enthusiastically?

Ah, but capitalism is comfortable for the privileged if you ignore the exploitation (and for those without privilege, it’s just business as usual). I shouldn’t be surprised. Still, Larry Garfield’s post on hearing the constant refrain of “it is what it is” rings true - apathy from any one person is heartbreaking. To rub salt in the wound, this compounding, collective apathy erodes our individual agency.


Where do we go from here? Look, I’m not sure why you were expecting a random blog post to have any meaningful answers. I’m not writing this to absolve people, nor to make peace with the situation.

Part of me wants to be more understanding about those who choose to use LLMs. We’ve all got to pick and choose the battles we can take on, and if this isn’t one you can grapple with right now, that’s life, and I get it. Another part of me wants to maintain the rage, particularly at anyone who’s happily cheering for a future dominated by AI and LLMs. If you talk to me about such matters, I can’t promise that I’ll be patient enough to take the ‘high’ road.

For me - writing this out, connecting some dots, understanding my feelings a little more - it’s provided a good reminder that AI/LLMs aren’t the root cause here. Instead, we’re facing two long-standing, entangled systems - capitalism and colonialism - that reliably offer a carrot (convenience) and stick (exploitation) approach to many of us in the Global North, with the promise that ignorance is bliss.

If you’re still here and looking for ideas, here’s what I plan to do that pushes back against those systems (and if this resonates with you too, that’s just a nice bonus): express gratitude more often, appreciate art and support artists, show up for my friends and family, connect meaningfully with my colleagues, contribute mutual aid for those in need, and stand in solidarity across intersections.

More directly on that last point: Love and support for trans folks. Blak & Black lives matter. Free Palestine. Fuck fascists.

https://freelancing-gods.com/2026/02/14/ai-and-moral-injury
AI is Bad
aitechnologypoliticsworld
A vastly incomplete list of the many significant issues with artificial intelligence.
Show full content

A vastly incomplete list of many significant issues with artificial intelligence (AI) - particularly generative AI and/or large language models (LLMs), mostly so I have a single link to share when people enquire why I find AI so unethical.

This is inspired by a blog post I once saw (which was much longer and better, but I didn’t keep the link and have been unable to find it again). If you think you know the post I’m talking about, please send it my way.

  • The considerable amounts of energy required. [1] [2] [3] [4] [5]
  • The considerable amounts of water required. [1] [2]
  • The poor working conditions and trauma imposed on Global South workers. [1] [2] [3]
  • The exploitation of workers generally. [1] [2]
  • The issues with consent and abuse. [1] [2]
  • The mass layoffs because the work can supposedly be done by AI instead. [1]
  • The use of AI to increase the extraction of fossil fuels. [1] [2]
  • The use of AI by genocidal governments/militaries. [1]
  • The theft of copyrighted material for training. [1]
  • The overloading of public-good infrastructure. [1] [2]
  • The supercharging of surveillance. [1]
  • The impact on education systems. [1]
  • The reinforcing of fascism, racism, transphobia. [1] [2]
  • The enabling of disinformation and propaganda. [1]
  • The impact of hallucinations, especially in medical contexts. [1] [2]
  • The cognitive harms. [1]

See also:

https://freelancing-gods.com/2026/02/13/ai-is-bad
SAML and Ruby: The Collection
rubyrailsssosaml
My colleagues and I have learnt a lot about supporting SAML in our Rails app. I've collected a lot of that into a handful of posts.
Show full content

Over the past year, my colleagues and I at Covidence have been rolling out SSO support for our Rails application - using SAML in particular. This has prompted a great deal of learning, as well as finding some neat solutions to a few challenges - so I’ve written up a handful of posts to share both some general concepts, and some of our solutions.

A lot of the content from these posts were first shared as a talk at the Melbourne Ruby Meet in October 2024 (and then again at Ruby Retreat NZ in May 2025). If taking in information via video is preferred, you can watch that here:

https://freelancing-gods.com/2025/05/11/saml-ruby-collection
SAML and Ruby: Automated request and feature tests
rubyrailsssosaml
Writing tests to confirm SAML authentication in Ruby isn't too daunting at a request level - but full feature tests are also possible!
Show full content

This post is part of the broader series around SAML and Ruby.

Most of the big pain points that have cropped up regularly for us at Covidence while building out support for SAML requests in our app were related to testing.

Manual testing in preview environments is something we’ve managed via bridging SAML requests through our staging server through to a legitimate (production) identity provider.

Automated testing is something we’ve figured out through both request and feature tests, particularly aided by a micro IdP service which we’ve wrapped up into the open source gem ssolo.

But manual testing in local environments is something we’ve mucked around with in various ways without finding something ideal… well, until we built ssolo. Because if a micro IdP can be running as a server for our tests, surely it can also be running as a server for our development environments too?

(Yes, yes it can)

The gem documentation does cover this, but let’s run through it here as well! Essentially, once you have the gem installed, you can fire it up alongside a set of environment variables:

bundle exec ssolo \
  SSOLO_PERSISTENCE=~/.ssolo.json \
  SSOLO_SP_CERTIFICATE="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" \
  SSOLO_HOST=127.0.0.1 \
  SSOLO_PORT=9292 \
  SSOLO_SILENT=true

SSOLO_PERSISTENCE is important - this tells ssolo where to save the generated private key and certificate. While in tests it’s (hopefully) fine for those values to change regularly, in our local environment we want these settings to stick around - they’re likely being cached somewhere.

SSOLO_SP_CERTIFICATE is also important - this is the service provider certificate that your local app is using for its SAML requests. The IdP server needs to know this, so it can both read the requests and send appropriately signed responses back.

SSOLO_HOST and SOLO_PORT are optional - these are the underlying Puma defaults, but you can customise them if needed.

And SSOLO_SILENT can hide the logging if that’s what you’d prefer. This is more useful in a test environment - in development situations, you probably want to know if something’s gone pear-shaped!

You can also specify SSOLO_NAME_ID to keep the supplied name ID as a fixed value. But otherwise, you will be prompted for a value when you’re going through the SAML flow.

Wrap this command up into your own script, or in a Procfile, so it’s easy to have running whenever you need it.


And once you’ve got it there and running, there’s two endpoints to be mindful of:

  • GET /metadata which returns the XML metadata
  • GET /saml which is the URL to initiate SAML requests

So, if you’re running the server with the default environment variables, you should be able to see the metadata via http://127.0.0.1:9292/metadata, and make SAML requests to http://127.0.0.1:9292/saml.

With all of this in place, you should be able to initiate a SAML request to this ssolo IdP, and quickly get a response back with your preferred name_id. No extra credentials required, no server wrangling with external third parties.

One last note: please keep in mind that ssolo is very minimal - we’ve built it out to be just enough for us. If you find some rough edges, we’d love to hear about them via the GitHub repo - and pull requests are of course welcome too!

https://freelancing-gods.com/2025/05/10/saml-ruby-development-idp
SAML and Ruby: Automated request and feature tests
rubyrailsssosaml
Writing tests to confirm SAML authentication in Ruby isn't too daunting at a request level - but full feature tests are also possible!
Show full content

This post is part of the broader series around SAML and Ruby.

When we started building out SAML support at Covidence, we looked around for examples of how to best write automated tests and didn’t find anything particularly compelling. Ideally, we wanted feature tests - the full flow of starting a sign-in process on our site, via an identity provider, and then having an active session - but a path through wasn’t clear.

So instead, we turned to request specs, and found that worked quite well! Our testing framework of choice is RSpec, but I’m sure these tests could be adapted to other tools.

Taking in the approach outlined in this post for the controller actions, we can test the endpoint which initiates a SAML request (the new action), where we confirm that the resulting redirect:

  • Has a SAMLRequest parameter
  • Has a RelayState parameter
  • And is going to the correct IdP URL
get "/sign_in/saml"
expect(response.status).to eq 302

redirect_uri = URI.parse(response.location)
queries = CGI.parse(redirect_uri.query)

# Confirm a SAMLRequest parameter is sent:
expect(queries["SAMLRequest"].length).to eq(1)
# Confirm a RelayState parameter is sent
# (perhaps with your preferred data):
expect(queries["RelayState"].length).to eq(1)

# Confirm we're redirecting to the IdP's SSO Service URL:
redirect_uri_without_query = redirect_uri.dup.tap {
  |uri| uri.query = nil
}.to_s
expect(redirect_uri_without_query).to eq(idp_sso_service_url)

Testing the receiving of a SAML response (the create action) is a bit trickier.

A reasonable approach is to stub out the response object - you don’t really care how the SAML response parameter is constructed, you’re just checking what happens when a valid response is passed in.

The end result of what the endpoint should do is up to you and your application. Maybe it’s just a redirect (as per below), maybe it’s reviewing certain cookies, or even parsing the session cookie to confirm its state.

saml_response = instance_double(
  "OneLogin::RubySaml::Response",
  is_valid?: true,
  name_id: "test@example.com",
  name_id_format: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
)

allow(OneLogin::RubySaml::Response)
  .to receive(:new)
  .and_return(saml_response_double)

post "/sign_in/saml",
  params: {
    RelayState: relay_state,
    SAMLResponse: "saml_reponse_string"
  }

expect(response).to redirect_to(logged_in_path)

These request tests have served us well - we’ve fleshed them out with more examples specific to our application: how failures are handled, how different customer states are managed, etc.

But the holy grail of a full feature test was still there, tempting us.

And we had a realisation, inspired by our work of managing requests from an IdP perspective with our bridging logic: what if we have our own tiny IdP server, running as a side service within our test suite? This removes any need to have an external service involved, keeping things controllable and reliable. (After all, you shouldn’t test what you can’t control!)

So we built a mini Rack app that operated in a separate thread, and it’s worked well. So well, in fact, that we’ve just extracted it out into a gem for others to use: ssolo!

It’s a bit more involved, so let’s break down the setup. Firstly, you’ll want to create a new ssolo controller to manage the service. When you start it, you’ll need to provide both the certificate for your service provider, and a name_id value. This value will be immediately returned by the IdP (rather than prompting the user for credentials).

controller = SSOlo::Controller.new
controller.start(
  sp_certificate: <<~CERT,
    -----BEGIN CERTIFICATE-----
    ...
    -----END CERTIFICATE-----
  CERT
  name_id: "test@example.com"
)

Then, you can use that controller to access the IdP’s settings to configure your SAML requests appropriately:

# connect up the appropriate SAML settings via an
# OneLogin::RubySaml::Settings instance:
controller.settings #=> OneLogin::RubySaml::Settings
controller.settings.idp_entity_id
controller.settings.idp_sso_service_url
controller.settings.idp_cert

# These details are also available via a URL:
controller.metadata_url

The core piece, though, is actually writing your tests to use this IdP.

# Click something that takes you off to the IdP:
click_on "Sign in via SSO"

# And then it immediately redirects you back, using the
# previously specified name_id:
expect(page).to have_content("test@example.com")

Once you’re done, make sure you then shut the IdP process down:

controller.stop

We hope it’s useful for others - please do give it a spin if you’re testing SAML in your own apps! And of course, questions and contributions are welcome via the ssolo GitHub repo.

Oh, and maybe you want to use ssolo for your development environment too? Onwards to the next post!

https://freelancing-gods.com/2025/05/09/saml-ruby-automated-tests
SAML and Ruby: Testing with ephemeral apps
rubyrailsssosaml
Identity providers require specific domains for testing - which is a challenge for preview/PR applications. We've found a way through this by building a bridging mechanism into our staging site.
Show full content

This post is part of the broader series around SAML and Ruby.

When it comes to testing our work manually (alongside our automated test suite), we make use of Heroku’s preview apps linked to GitHub pull requests.

And largely, that works well for us - but when it comes to testing our SAML integration, we’ve hit a challenge: identity providers (IdPs) require service providers to be accessed by a fixed route, but our preview apps are on a range of subdomains.

For example: a production site may be available at app.example.com, and the staging site at staging.example.com. But each preview app will be at preview-1.example.com, preview-2.example.com, and so on - the domains are constantly changing.

The IdPs we’ve been testing with are resolute about the endpoints being fixed - the domain and the path. Patterns are not allowed either. And they’re an external service, not something we can control… so, we were feeling a bit stuck!

Then, a moment of realisation: let’s build something we can control - and this has ended up being a SAML bridging service via our staging site.

  • The preview app initiates a SAML request and sends it to the staging site (operating as an IdP).
  • The staging site then starts a second SAML request, forwarding the user onto the true IdP.
  • The IdP verifies the user and sends them back to the staging site (operating as a service provider), finishing the second SAML flow.
  • The staging site then immediately passes the identity through to the preview app, to finish the initial SAML flow.

Using our staging site means we don’t have to deploy a whole other app elsewhere - though we of course make sure this functionality is not available in production.

From a Rails perspective, we’ve done this in a new controller, with a pair of actions (again new and create, just like in our main SAML controller).

def new
  # Save original request details
  save_identity_cache

  redirect_to(
    OneLogin::RubySaml::Authrequest.new.create(
      # Settings for the actual IdP:
      service_settings,
      # The original request's ID:
      RelayState: identity_request.request_id
    )
  )
end

private

# The details of the initial SAML request (sent from the preview
# site to the staging site).
def identity_request
  @identity_request ||= SamlIdp::Request.from_deflated_request(
    params[:SAMLRequest]
  )
end

# And save those initial details to the cache, to re-use on the
# return journey:
def save_identity_cache
  Rails.cache.write(
    identity_request.request_id,
    {
      relay_state: params[:RelayState],
      issuer: identity_request.issuer,
      acs_url: identity_request.acs_url
    }
  )
end

This new action is the endpoint on our staging site that accepts the original SAML request, and initiates a new SAML request to the ‘true’ identity provider.

As part of this, it saves the essential details from the original request in the Rails cache and uses the RelayState in the new request to keep that identifier. Using a cache here rather than a session is important, as session cookies are not passed along when you’re redirecting between sites.

And then, we need to handle the request coming back from the true identity provider:

def create
  @identity_acs_url = identity_cache[:acs_url]
  @identity_relay_state = identity_cache[:relay_state]

  # `encode_response` comes from the saml_idp gem
  @identity_response = encode_response(
    service_response,
    audience_uri: identity_cache[:issuer],
    acs_url: identity_cache[:acs_url],
    encryption: {
      # Both SP and IdP have certificates. This should
      # be the certificate for the original service provider
      # (i.e. the preview site).
      #
      # An instance of OpenSSL::X509::Certificate is expected
      cert: saml_certificate,
      block_encryption: "aes256-cbc",
      key_transport: "rsa-oaep-mgf1p"
    }
  )
end

private

def identity_cache
  @identity_cache ||= Rails.cache.read(identity_cache_key)
end

def identity_cache_key
  params[:SAMLRequest] ? identity_request.request_id : params[:RelayState]
end

def service_response
  @service_response ||= OneLogin::RubySaml::Response.new(
    params[:SAMLResponse],
    settings: service_settings
  ).tap do |response|
    unless response.is_valid?
      raise ArgumentError, response.errors.join(",")
    end
  end
end

And the corresponding view:

<%= form_tag(@identity_acs_url, style: "visibility: hidden") do %>
  <%= hidden_field_tag("SAMLResponse", @identity_response) %>
  <%= hidden_field_tag("RelayState", @identity_relay_state) %>
  <%= submit_tag "Submit" %>
<% end %>

<script type="text/javascript">
  document.forms[0].submit();
</script>

We need to render a form that automatically submits, because SAML responses are sent via POST requests - so we can’t rely on a standard HTTP redirect, which is sent as a GET.

For this action, we’re making use of the saml_idp gem, which we configure as follows:

# config/initializers/saml_idp.rb
SamlIdp.configure do |config|
  config.base_saml_location = "https://staging.example.com/saml"
  # This is the certificate and private key for the staging site when
  # operating as an IdP.
  config.x509_certificate = saml_certificate.to_pem
  config.secret_key = saml_private_key.private_to_pem
  config.algorithm = :sha256
  # This block defines how we convert a 'principal' object to a name_id.
  # In our case, the principal is already a SAML response, so we can
  # just extract the name_id directly from it.
  config.name_id.formats = {
    email_address: ->(principal) { principal.name_id }
  }
end

You may have noted in the code samples above that there’s a couple of references to certificates and private keys. These certificates are ones you can generate yourself, and this can be done within Ruby code:

name = OpenSSL::X509::Name.parse "/CN=nobody/DC=example"
private_key = OpenSSL::PKey::RSA.new 2048

certificate = OpenSSL::X509::Certificate.new
certificate.version = 2
certificate.serial = 0
certificate.not_before = Time.now
certificate.not_after = Time.now + (10 * 365 * 24 * 60 * 60)
certificate.public_key = private_key.public_key
certificate.subject = name
certificate.issuer = name
certificate.sign(private_key, OpenSSL::Digest.new("SHA256"))

certificate

There are distinct certificates for the preview sites acting as service providers, the staging site acting as an identity provider, and the staging site acting as a service provider. It’s easy to get tripped up when attempting to use the right certificate in the right moment - so you may want to use a single certificate for all of these scenarios, given this is for internal testing.

https://freelancing-gods.com/2025/05/08/saml-ruby-bridging
SAML and Ruby: Parsing federation metadata
rubyrailsssosaml
When working with multiple IdPs, parsing metadata files is not performant via the ruby-saml gem - but there is a better way with Nokogiri.
Show full content

This post is part of the broader series around SAML and Ruby.

As part of rolling out SSO for our customers at Covidence, we were quickly made aware of various federations that exist for research-related institutions (such as AAF and eduGAIN) - and these federations collect both SAML identity and service provider metadata into central locations.

There’s a great advantage in this for us - instead of needing to ask each of our customers individually for their SAML IdP metadata, we can instead just refer to these aggregated files. The catch? We need to parse those files just for what’s relevant to us - and the files can get quite large!

Still, the ruby-saml gem gives us a way to do this:

def saml_settings
  parser = OneLogin::RubySaml::IdpMetadataParser.new

  settings = parser.parse_remote(
    # The aggregate metadata URL:
    "https://example.com/auth/saml2/idp/metadata",
    # The specific IdP we're looking for:
    entity_id: "http//example.com/target/entity"
  )

  # You've got the IdP settings, but you still need to add
  # the content of your service provider:
  settings.assertion_consumer_service_url =
    "http://#{request.host}/saml_sessions"

  settings
end

Now, the above example would involve downloading the metadata file every time you’re generating settings for a SAML request - which definitely not a wise move. I recommend caching the settings for each specific identity provider you care about.

But also: parsing large files is very slow, and very memory hungry. We dug into the source code of the ruby-saml gem and found it’s using rexml for the parsing. After some experimentation, we found a faster way with Nokogiri:

def saml_settings_hash(metadata_file_contents, entity_id)
  entire_document = Nokogiri::XML(
    metadata_file_contents
  )

  # Use Nokogiri to find the appropriate XML node:
  node = entire_document.xpath(
    "//md:EntityDescriptor[@entityID=\"#{entity_id}\"]/md:IDPSSODescriptor",
    "md" => "urn:oasis:names:tc:SAML:2.0:metadata"
  ).first
  return nil if node.nil?

  # Convert the IdP element into a standalone XML document,
  # so rexml can parse it:
  idp_document = Nokogiri::XML(node.to_xml).tap do |sub_document|
    entire_document.namespaces.each do |prefix, url|
      sub_document.root.add_namespace(normalised_prefix(prefix), url)
    end
  end

  # Return a hash that can be ingested by OneLogin::RubySaml::Settings
  OneLogin::RubySaml::IdpMetadataParser::IdpMetadata.new(
    REXML::Document.new(idp_document.to_xml).root,
    entity_id
  ).to_hash(
    sso_binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
  )
end

def normalised_prefix(prefix)
  return nil if prefix == "xmlns"

  prefix.gsub("xmlns:", "")
end

There’s still room for improvement here - it’d be nice to avoid parsing the entire document - but it’s not been a priority for us. The Nokogiri approach works well enough for now.

And I’m afraid we don’t have any benchmarks on hand, so you’ll just have to take my word for it! Granted, if you’re digging into this and do have some numbers, please send them my way.

https://freelancing-gods.com/2025/05/07/saml-ruby-federations
SAML and Ruby: Building a Service Provider
rubyrailsssosaml
Building SAML service provider logic in a Rails app isn't actually as daunting as I first feared.
Show full content

This post is part of the broader series around SAML and Ruby.

If you’re building a Rails site that needs to act as a SAML service provider, you’ve got two key options: you can use a third party service to manage the integration with identity providers, or you can build out the logic yourself.

There are a great many third party services to consider, including Auth0, Shibboleth, Firebase, Kinde, KeyCloak, and FusionAuth. Some of these are closed-source paid services, others are open source - often with a paid option for managed hosting. There’s value in these options, so you may want to investigate further.

However, building support directly in your Ruby or Rails app isn’t actually as daunting as I first feared - and as a bonus, it lets you retain control of the customer/user data, rather than being beholden to the limitations and terms of a separate service.

ruby-saml and Osso

The two key things that greatly helped us with building out service provider support into our app at Covidence are:

  • The ruby-saml gem, which has existed for many years, and so has been extensively tested against a wide variety of identity providers;
  • And a blog post by Osso on how to use that gem in a Rails application.

Osso was once a third party option, and while they don’t exist as a business any more, I’m very glad for their generous spirit in sharing a solid starting point for Ruby developers diving into SAML. You’ll be well-served by reading their post, but in case a shorter summary is useful, here’s our take.

The way out

There are two key endpoints required to behave as a SAML service provider: one that redirects your visitors out to the identity provider, and one that accepts the resulting response when they’re sent back to your site with a verified identity.

For me, these work well as new and create actions in a single controller. Let's take them one at a time.

class SAMLController < ApplicationController
  def new
    # Generate a new SAML request
    saml_request = OneLogin::RubySaml::Authrequest.new

    # Send the current visitor away to the IdP:
    redirect_to(
      saml_request.create(
        # These are settings for the specific IdP:
        saml_settings,
        # This is your own context/state, which the IdP does not
        # care about but it will send it back to you:
        RelayState: "new-user-request"
      ),
      # Ensure Rails is okay with you redirecting people away to
      # a different site:
      allow_other_host: true
    )
  end
end

In this action, we’re generating a new SAML request, and then using it to build a redirect URL for a specific identity provider (the saml_settings method) and our own app’s context or state (the RelayState parameter). Relay states will be sent back to us by the IdP - the value of it is entirely up to you, but should be a maximum of 80 bytes. The IdP will not parse it, so it’s purely for your own app’s use.

The saml_settings method could look something like the following:

def saml_settings
  settings = OneLogin::RubySaml::Settings.new

  # Where the IdP sends users back to on our site:
  settings.assertion_consumer_service_url =
    "http://#{request.host}/saml_sessions"

  # A unique identifier of our service, sometimes requested by
  # the IdP:
  settings.sp_entity_id =
    "http://#{request.host}/saml/metadata"

  # A unique identifier of the IdP:
  settings.idp_entity_id =
    "https://google.com/..."

  # The IdP URL our `new` action redirects users to:
  settings.idp_sso_service_url =
    "https://google.com/saml/..."

  # The X.509 certificate used to sign requests for the IdP:
  settings.idp_cert = <<~CERT
    -----BEGIN CERTIFICATE-----
    ...
    -----END CERTIFICATE-----
  CERT

  settings
end

This method is generating an object that contains all the relevant details for interacting with a given identity provider. The examples in the code are referring to Google, but you’ll want to update it for the IdP you’re actually talking to.

  • assertion_consumer_service_url is a URL is where visitors will be redirected back to on a successful authentication. This should point to the create action in this controller we’re working on.
  • sp_entity_id is an identifier for your service, and should be unique from the perspective of the identity provider.
  • idp_entity_id is an identifier for the identity provider, supplied by them, and should also be considered unique.
  • idp_sso_service_url is supplied by the identity provider, and is a live URL where we redirect visitors to.
  • idp_cert is a X509 certificate supplied by the identity provider, used for signing requests.

The entity IDs for both service providers and identity providers are usually URLs. This is not a hard requirement for the SAML specification, but seems to have become a de-facto standard.

These URLs don’t need to be functional - it doesn’t matter if they return a 404 - but it is recommended that they return SAML metadata outlining the provider’s details as an XML document (though this is beyond the scope of this post).

There are other settings that your IdP may require - these can be specified as per the ruby-saml documentation, or parsed via their XML metadata document:

parser = OneLogin::RubySaml::IdpMetadataParser.new
settings = parser.parse_remote("https://example.com/idp/metadata")

It is very strongly recommended that these settings are cached regularly, rather than requested for every new SAML request, so your site isn’t beholden to Internet connectivity glitches or failures on the IdP site.

The way back in

The above new action sends visitors off to the IdP - but then you’ll want an endpoint for their return. It could look something like the following:

class SAMLController < Application Controller
  # Disable CSRF checks for our create action:
  skip_before_action(
    :verify_authenticity_token, only: [:create]
  )

  def new
    # ... as above
  end

  def create
    # Parse the given SAML response
    saml_response = OneLogin::RubySaml::Response.new(
      params[:SAMLResponse]
    )
    # And apply the same IdP configuration settings
    saml_response.settings = saml_settings

    # If it's a valid response, then we have a confirmed identity
    # and can log the visitor in:
    if saml_response.is_valid?
      session[:userid] = saml_response.nameid
    else
      # Otherwise, the response is invalid - you'll probably want
      # to provide some feedback and ask people to try logging in
      # again.
      # ...
    end
  end

  private

  def saml_settings
    # ... as above
  end
end

When the HTTP request comes in, we want to verify the SAML response with the same IdP settings as before. If the SAML response is valid, then we know we have a confirmed identity, and can use that to log them into our site.

The supplied nameid (or name_id) from the IdP might be an email address, or a persistent unique identifier for the user/identity, or even a more ephemeral reference. It varies for each identity provider, and sometimes can be configured - so it’s best to ensure you know ahead of time what you’re dealing with here. If you need to handle a variety of name IDs, then looking at saml_response.name_id_format could be helpful.

As for the logic of actually logging someone into your site with this identity - well, that’s going to depend on how you’ve implemented authentication, whether it’s via Devise or Clearance or another gem, or something you’re rolling yourself. At this point, the SAML flow is complete, so the rest is up to you.

But perhaps you want to read some of the other posts, to get a sense of how to test all of this!

https://freelancing-gods.com/2025/05/06/saml-ruby-service-provider
SAML and Ruby: The Terminology
rubyrailsssosaml
...
Show full content

This post is part of the broader series around SAML and Ruby.

When working on SSO, there’s a lot of terminology that crops up, and it can get rather overwhelming at times, especially when you’re new to it all. Here’s a rough and ready list of common terms that you may come across.

SSO

SSO stands for “Single Sign-On”, which is the process of delegating authentication to a third party service.

You’ve likely come across it at some point - you’re viewing a website, you need to sign in, and it gives you the option of authenticating via a different site such as Google or Facebook or (once upon a time) Twitter. You click the link, you’re taken to that third party site to sign in, and then you’re sent back to the original site and you’ve been logged in there too.

That process? That’s SSO.

Authentication vs Authorisation

Authentication is the process of confirming who someone is. Commonly, this is done via a username and password.

Authorisation, however, is checking whether someone has access to do certain things.

For example: there’s a distinction between being logged into a site (authentication), and having administrator access to manage others’ accounts (authorisation).

SAML

SAML is a standardised protocol for SSO. It’s a particular way of going about asking a third party who a person is.

There are other SSO protocols you may have heard of. OAuth is quite common. OIDC is another. I’m sure there’s more, but thankfully I’ve not had to deal with them.

This series of posts are focused on SAML, though some of the concepts I’m sure apply more broadly.

Identity Providers (IdPs)

When it comes to the back and forth between sites, we have identity providers, or IdPs. These are the services which confirm the identity of the user - i.e. where a password is entered. A very common example when you sign in to a site via Google: Google is the IdP.

Service Providers (SPs)

On the other side of fence is the Service Provider, or SP. This is the site that directs you off to the IdP - the one that has the button that says “Sign in with Google” or similar, and handles the response from the IdP when an identity has been confirmed.

https://freelancing-gods.com/2025/05/05/saml-ruby-terminology