Skip to content

Instantly share code, notes, and snippets.

@ukdave
Created June 9, 2023 15:51
Show Gist options
  • Save ukdave/b26d366e8ab5eef3d61c6cc51e01bf6c to your computer and use it in GitHub Desktop.
Save ukdave/b26d366e8ab5eef3d61c6cc51e01bf6c to your computer and use it in GitHub Desktop.
Ruby Sharepoint SAML federated authentication
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<s:Header>
<a:Action s:mustUnderstand="1">http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/Issue</a:Action>
<a:ReplyTo>
<a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address>
</a:ReplyTo>
<a:To s:mustUnderstand="1"><%= to %></a:To>
<o:Security s:mustUnderstand="1" xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
<o:UsernameToken u:Id="uuid-7b105801-44ac-4da7-aa69-a87f9db37299-1">
<o:Username><%= username.encode(xml: :text) %></o:Username>
<o:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText"><%= password.encode(xml: :text) %></o:Password>
</o:UsernameToken>
</o:Security>
</s:Header>
<s:Body>
<trust:RequestSecurityToken xmlns:trust="http://docs.oasis-open.org/ws-sx/ws-trust/200512">
<wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy">
<wsa:EndpointReference xmlns:wsa="http://www.w3.org/2005/08/addressing">
<wsa:Address><%= relying_party %></wsa:Address>
</wsa:EndpointReference>
</wsp:AppliesTo>
<trust:KeyType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer</trust:KeyType>
<trust:RequestType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue</trust:RequestType>
</trust:RequestSecurityToken>
</s:Body>
</s:Envelope>
# frozen_string_literal: true
class AuthenticationError < StandardError
attr_reader :response
def initialize(message, response)
super(message)
@response = response
end
end
# frozen_string_literal: true
# Heavily based on: https://github.com/s-KaiNet/node-sp-auth
#
# The 5 steps are:
#
# 1. get user realm details from microsoft
# 2. get the assertion from your SAML IDP (identity provider)
# 3. trade the assertion for a STS (Security Token Service) token
# 4. trade the STS token for cookies
# 5. use the cookies to do your REST calls
#
# Usage:
# Authenticator.call("https://example.sharepoint.com", "user@example.com", "Pa55w0rd")
# => { "rtFa" => "xxxxx", "FedAuth" => "xxxxx" }
#
# See also:
# * https://stackoverflow.com/questions/11295953/claim-auth-from-adfs
# * https://github.com/Plaristote/sharepoint-ruby
class Authenticator
CONTENT_TYPES = {
soap: "application/soap+xml; charset=utf-8",
form: "application/x-www-form-urlencoded"
}.freeze
class << self
def call(site, username, password)
user_realm = fetch_user_realm(username)
unless user_realm["NameSpaceType"] == "Federated"
raise build_auth_error("Unsupported realm type: #{user_realm['NameSpaceType']}", nil)
end
saml_assertion = fetch_adfs_saml_assertion(username, password, user_realm["AuthURL"],
user_realm["CloudInstanceIssuerUri"])
sts_token = fetch_sts_token(saml_assertion, site)
fetch_cookies(site, sts_token)
end
private
def fetch_user_realm(username)
response = HTTParty.post("https://login.microsoftonline.com/GetUserRealm.srf", body: { login: username })
raise build_auth_error("Failed to fetch user realm", response) unless response.success?
response.parsed_response
end
def fetch_adfs_saml_assertion(username, password, adfs_url, relying_party)
adfs_host = URI.parse(adfs_url).host
username_mixed_url = "https://#{adfs_host}/adfs/services/trust/13/usernamemixed"
body = render_adfs_saml_request_template(username_mixed_url, username, password, relying_party)
response = perform_http_request(username_mixed_url, body, :soap)
raise build_auth_error("Authentication failed (ADFS)", response, "s:Text") unless response.success?
token = get_string_in_xml_tag(response.body, "trust:RequestedSecurityToken")
raise build_auth_error("Authentication failed (ADFS) - SAML assertion not found", response) if token.blank?
token
end
def fetch_sts_token(saml_assertion, site)
body = render_sts_token_request_template(saml_assertion, site)
response = perform_http_request("https://login.microsoftonline.com/extSTS.srf", body, :soap)
raise build_auth_error("Authentication failed (STS)", response, "psf:text") unless response.success?
token = get_string_in_xml_tag(response.body, "wsse:BinarySecurityToken")
raise build_auth_error("Authentication failed (STS) - security token not found", response) if token.blank?
token
end
def fetch_cookies(site, sts_token)
response = perform_http_request("#{site}/_forms/default.aspx?wa=wsignin1.0", sts_token, :form)
unless response.success?
raise build_auth_error("Authentication failed (cookies) - response code: #{response.code}", response)
end
header = response.headers["set-cookie"]
cookies = { "rtFa" => get_cookie_from_header(header, "rtFa"),
"FedAuth" => get_cookie_from_header(header, "FedAuth") }
return cookies if cookies["rtFa"].present? && cookies["FedAuth"].present?
raise build_auth_error("Authentication failed (cookies) - rtFa and/or FedAuth cookies not found", response)
end
def perform_http_request(url, body, content_type)
HTTParty.post(url, body:, headers: { "Content-Length" => body.length.to_s,
"Content-Type" => CONTENT_TYPES.fetch(content_type) })
end
def render_adfs_saml_request_template(to, username, password, relying_party)
load_template("adfs_saml_request").result_with_hash(to:, username:, password:, relying_party:)
end
def render_sts_token_request_template(saml_assertion, endpoint)
load_template("sts_token_request").result_with_hash(saml_assertion:, endpoint:)
end
def load_template(name)
ERB.new(File.read(File.expand_path("#{name}.xml.erb", __dir__)))
end
def get_string_in_xml_tag(str, tag)
result = str.match(%r{<#{Regexp.escape(tag)}[^>]*>(.*)</#{Regexp.escape(tag)}>})
return nil unless result
result.captures.first
end
def get_cookie_from_header(header, cookie_name)
result = header.match(/#{Regexp.escape(cookie_name)}=([^;]+);/)
return nil unless result
result.captures.first
end
def build_auth_error(message, response, xml_error_message_tag = nil)
full_message = message.dup
full_message.concat(" - response code: #{response.code}") if response.present? && !response.success?
full_message.concat(": #{get_string_in_xml_tag(response.body, xml_error_message_tag)}") if xml_error_message_tag
AuthenticationError.new(full_message, response)
end
end
end
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<s:Header>
<a:Action s:mustUnderstand="1">http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue</a:Action>
<a:ReplyTo>
<a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address>
</a:ReplyTo>
<a:To s:mustUnderstand="1">https://login.microsoftonline.com/extSTS.srf</a:To>
<o:Security s:mustUnderstand="1" xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"><%= saml_assertion %></o:Security>
</s:Header>
<s:Body>
<t:RequestSecurityToken xmlns:t="http://schemas.xmlsoap.org/ws/2005/02/trust">
<wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy">
<a:EndpointReference>
<a:Address><%= endpoint %></a:Address>
</a:EndpointReference>
</wsp:AppliesTo>
<t:KeyType>http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey</t:KeyType>
<t:RequestType>http://schemas.xmlsoap.org/ws/2005/02/trust/Issue</t:RequestType>
<t:TokenType>urn:oasis:names:tc:SAML:1.0:assertion</t:TokenType>
</t:RequestSecurityToken>
</s:Body>
</s:Envelope>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment