Anonymous avatar Anonymous committed 47ddf0f

OpenID 2.0 Relying Party support

Comments (0)

Files changed (7)

framework/env/auth/openid/_curl.lua

+function auth.openid._curl(url, curl_options)
+  -- NOTE: Don't accept URLs starting with file:// or other nasty protocols
+  if not string.find(url, "^[Hh][Tt][Tt][Pp][Ss]?://") then
+    return nil
+  end
+  local options = table.new(curl_options)
+  options[#options+1] = "-i"
+  options[#options+1] = url
+  local stdout, errmsg, status = os.pfilter(nil, "curl", unpack(options))
+  if not stdout then
+    error("Error while executing curl: " .. errmsg)
+  end
+  if status ~= 0 then
+    return nil
+  end
+  local status  = tonumber(string.match(stdout, "^[^ ]+ *([0-9]*)"))
+  local headers = string.match(stdout, "(\r?\n.-\r?\n)\r?\n")
+  local body    = string.match(stdout, "\r?\n\r?\n(.*)")
+  return status, headers, body
+end

framework/env/auth/openid/_normalize_url.lua

+--[[--
+url,                         -- normalized URL or nil
+auth.openid._normalize_url(
+  url                        -- unnormalized URL
+)
+
+This function normalizes an URL, and returns nil if the given URL is not a
+valid absolute URL. For security reasons only a restricted set of URLs is
+valid.
+
+--]]--
+
+function auth.openid._normalize_url(url)
+  local url = string.match(url, "^(.-)??$")  -- remove "?" at end
+  local proto, host, path = string.match(
+    url,
+    "([A-Za-z]+)://([0-9A-Za-z.:_-]+)/?([0-9A-Za-z%%/._~-]*)$"
+  )
+  if not proto then
+    return nil
+  end
+  proto = string.lower(proto)
+  host  = string.lower(host)
+  local port = string.match(host, ":(.*)")
+  if port then
+    if string.find(port, "^[0-9]+$") then
+      port = tonumber(port)
+      host = string.match(host, "^(.-):")
+      if port < 1 or port > 65535 then
+        return nil
+      end
+    else
+      return nil
+    end
+  end
+  if proto == "http" then
+    if port == 80 then port = nil end
+  elseif proto == "https" then
+    if port == 443 then port = nil end
+  else
+    return nil
+  end
+  if
+    string.find(host, "^%.") or
+    string.find(host, "%.$") or
+    string.find(host, "%.%.")
+  then
+    return nil
+  end
+  for part in string.gmatch(host, "[^.]+") do
+    if not string.find(part, "[A-Za-z]") then
+      return nil
+    end
+  end
+  local path_parts = {}
+  for part in string.gmatch(path, "[^/]+") do
+    if part == "." then
+      -- do nothing
+    elseif part == ".." then
+      path_parts[#path_parts] = nil
+    else
+      local fail = false
+      local part = string.gsub(
+        part,
+        "%%([0-9A-Fa-f]?[0-9A-Fa-f]?)",
+        function (hex)
+          if #hex ~= 2 then
+            fail = true
+            return
+          end
+          local char = string.char(tonumber("0x" .. hex))
+          if string.find(char, "[0-9A-Za-z._~-]") then
+            return char
+          else
+            return "%" .. string.upper(hex)
+          end
+        end
+      )
+      if fail then
+        return nil
+      end
+      path_parts[#path_parts+1] = part
+    end
+  end
+  if string.find(path, "/$") then
+    path_parts[#path_parts+1] = ""
+  end
+  path = table.concat(path_parts, "/")
+  if port then
+    host = host .. ":" .. tostring(port)
+  end
+  return proto .. "://" .. host .. "/" .. path
+end

framework/env/auth/openid/discover.lua

+--[[--
+discovery_data,                                         -- table containing "claimed_identifier", "op_endpoint" and "op_local_identifier"
+errmsg,                                                 -- error message in case of failure
+errcode =                                               -- error code in case of failure (TODO: not implemented yet)
+auth.openid.discover{
+  user_supplied_identifier = user_supplied_identifier,  -- string given by user
+  https_as_default         = https_as_default,          -- default to https
+  curl_options             = curl_options               -- options passed to "curl" binary, when performing discovery
+}
+
+--]]--
+
+-- helper function
+local function decode_entities(str)
+  local str = str
+  str = string.gsub(value, "&lt;", '<')
+  str = string.gsub(value, "&gt;", '>')
+  str = string.gsub(value, "&quot;", '"')
+  str = string.gsub(value, "&amp;", '&amp;')
+  return str
+end
+
+-- helper function
+local function get_tag_value(
+  str,          -- HTML document or document snippet
+  match_tag,    -- tag name
+  match_key,    -- attribute key to match
+  match_value,  -- attribute value to match
+  result_key    -- attribute key of value to return
+)
+  -- NOTE: The following parameters are case insensitive
+  local match_tag   = string.lower(match_tag)
+  local match_key   = string.lower(match_key)
+  local match_value = string.lower(match_value)
+  local result_key  = string.lower(result_key)
+  for tag, attributes in
+    string.gmatch(str, "<([0-9A-Za-z_-]+) ([^>]*)>")
+  do
+    local tag = string.lower(tag)
+    if tag == match_tag then
+      local matching = false
+      for key, value in
+        string.gmatch(attributes, '([0-9A-Za-z_-]+)="([^"<>]*)"')
+      do
+        local key = string.lower(key)
+        local value = decode_entities(value)
+        if key == match_key then
+          -- NOTE: match_key must only match one key of space seperated list
+          for value in string.gmatch(value, "[^ ]+") do
+            if string.lower(value) == match_value then
+              matching = true
+              break
+            end
+          end
+        end
+        if key == result_key then
+          result_value = value
+        end
+      end
+      if matching then
+        return result_value
+      end
+    end
+  end
+  return nil
+end
+
+-- helper function
+local function tag_contents(str, match_tag)
+  local pos = 0
+  local tagpos, closing, tag
+  local function next_tag()
+    local prefix
+    tagpos, prefix, tag, pos = string.match(
+      str,
+      "()<(/?)([0-9A-Za-z:_-]+)[^>]*>()",
+      pos
+    )
+    closing = (prefix == "/")
+  end
+  return function()
+    repeat
+      next_tag()
+      if not tagpos then return nil end
+      local stripped_tag
+      if string.find(tag, ":") then
+        stripped_tag = string.match(tag, ":([^:]*)$")
+      else
+        stripped_tag = tag
+      end
+    until stripped_tag == match_tag and not closing
+    local content_start = pos
+    local used_tag = tag
+    local counter = 0
+    while true do
+      repeat
+        next_tag()
+        if not tagpos then return nil end
+      until tag == used_tag
+      if closing then
+        if counter > 0 then
+          counter = counter - 1
+        else
+          return string.sub(str, content_start, tagpos-1)
+        end
+      else
+        counter = counter + 1
+      end
+    end
+    local content = string.sub(rest, 1, startpos-1)
+    str = string.sub(rest, endpos+1)
+    return content
+  end
+end
+
+local function strip(str)
+  local str = str
+  string.gsub(str, "^[ \t\r\n]+", "")
+  string.gsub(str, "[ \t\r\n]+$", "")
+  return str
+end
+
+function auth.openid.discover(args)
+  local url = string.match(args.user_supplied_identifier, "[^#]*")
+  -- NOTE: XRIs are not supported
+  if
+    string.find(url, "^[Xx][Rr][Ii]://") or
+    string.find(url, "^[=@+$!(]")
+  then
+    return nil, "XRI identifiers are not supported."
+  end
+  -- Prepend http:// or https://, if neccessary:
+  if not string.find(url, "://") then
+    if args.default_to_https then
+      url = "https://" .. url
+    else
+      url = "http://" .. url
+    end
+  end
+  -- Either an xrds_document or an html_document will be fetched
+  local xrds_document, html_document
+  -- Repeat max 10 times to avoid endless redirection loops
+  local redirects = 0
+  while true do
+    local status, headers, body = auth.openid._curl(url, args.curl_options)
+    if not status then
+      return nil, "Error while locating XRDS or HTML file for discovery."
+    end
+    -- Check, if we are redirected:
+    local location = string.match(
+      headers,
+      "\r?\n[Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:[ \t]*([^\r\n]+)"
+    )
+    if location then
+      -- If we are redirected too often, then return an error.
+      if redirects >= 10 then
+        return nil, "Too many redirects."
+      end
+      -- Otherwise follow the redirection by changing the variable "url"
+      -- and by incrementing the redirect counter.
+      url = location
+      redirects = redirects + 1
+    else
+      -- Check, if there is an X-XRDS-Location header
+      -- pointing to an XRDS document:
+      local xrds_location = string.match(
+        headers,
+        "\r?\n[Xx]%-[Xx][Rr][Dd][Ss]%-[Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:[ \t]*([^\r\n]+)"
+      )
+      -- If there is no X-XRDS-Location header, there might be an
+      -- http-equiv meta tag serving the same purpose:
+      if not xrds_location and status == 200 then
+        xrds_location = get_tag_value(body, "meta", "http-equiv", "X-XRDS-Location", "content")
+      end
+      if xrds_location then
+        -- If we know the XRDS-Location, we can fetch the XRDS document
+        -- from that location:
+        local status, headers, body = auth.openid._curl(xrds_location, args.curl_options)
+        if not status then
+          return nil, "XRDS document could not be loaded."
+        end
+        if status ~= 200 then
+          return nil, "XRDS document not found where expected."
+        end
+        xrds_document = body
+        break
+      elseif
+        -- If the Content-Type header is set accordingly, then we already
+        -- should have received an XRDS document:
+        string.find(
+          headers,
+          "\r?\n[Cc][Oo][Nn][Tt][Ee][Nn][Tt]%-[Tt][Yy][Pp][Ee]:[ \t]*application/xrds%+xml\r?\n"
+        )
+      then
+        if status ~= 200 then
+          return nil, "XRDS document announced but not found."
+        end
+        xrds_document = body
+        break
+      else
+        -- Otherwise we should have received an HTML document:
+        if status ~= 200 then
+          return nil, "No XRDS or HTML document found for discovery."
+        end
+        html_document = body
+        break;
+      end
+    end
+  end
+  local claimed_identifier   -- OpenID identifier the user claims to own
+  local op_endpoint          -- OpenID provider endpoint URL
+  local op_local_identifier  -- optional user identifier, local to the OpenID provider
+  if xrds_document then
+    -- If we got an XRDS document, we look for a matching <Service> entry:
+    for content in tag_contents(xrds_document, "Service") do
+      local service_uri, service_localid
+      for content in tag_contents(content, "URI") do
+        if not string.find(content, "[<>]") then
+          service_uri = strip(content)
+          break
+        end
+      end
+      for content in tag_contents(content, "LocalID") do
+        if not string.find(content, "[<>]") then
+          service_localid = strip(content)
+          break
+        end
+      end
+      for content in tag_contents(content, "Type") do
+        if not string.find(content, "[<>]") then
+          local content = strip(content)
+          if content == "http://specs.openid.net/auth/2.0/server" then
+            -- The user entered a provider identifier, thus claimed_identifier
+            -- and op_local_identifier will be set to nil.
+            op_endpoint = service_uri
+            break
+          elseif content == "http://specs.openid.net/auth/2.0/signon" then
+            -- The user entered his/her own identifier.
+            claimed_identifier  = url
+            op_endpoint         = service_uri
+            op_local_identifier = service_localid
+            break
+          end
+        end
+      end
+    end
+  elseif html_document then
+    -- If we got an HTML document, we look for matching <link .../> tags:
+    claimed_identifier = url
+    op_endpoint = get_tag_value(
+      html_document,
+      "link", "rel", "openid2.provider", "href"
+    )
+    op_local_identifier = get_tag_value(
+      html_document,
+      "link", "rel", "openid2.local_id", "href"
+    )
+  else
+    error("Assertion failed")  -- should not happen
+  end
+  if not op_endpoint then
+    return nil, "No OpenID endpoint found."
+  end
+  if claimed_identifier then
+    claimed_identifier = auth.openid._normalize_url(claimed_identifier)
+    if not claimed_identifier then
+      return nil, "Claimed identifier could not be normalized."
+    end
+  end
+  return {
+    claimed_identifier  = claimed_identifier,
+    op_endpoint         = op_endpoint,
+    op_local_identifier = op_local_identifier
+  }
+end

framework/env/auth/openid/initiate.lua

+--[[--
+success,                                                -- boolean indicating success or failure
+errmsg =                                                -- error message in case of failure (TODO: not implemented yet)
+auth.openid.initiate{
+  user_supplied_identifier = user_supplied_identifier,  -- string given by user
+  https_as_default         = https_as_default,          -- default to https
+  curl_options             = curl_options,              -- additional options passed to "curl" binary, when performing discovery
+  return_to_module         = return_to_module,          -- module of the verifying view, the user shall return to after authentication
+  return_to_view           = return_to_view,            -- verifying view, the user shall return to after authentication
+  realm                    = realm                      -- URL the user should authenticate for, defaults to application base
+}
+
+In order to authenticate using OpenID the user should enter an identifier.
+It is recommended that the form field element for this identifier is named
+"openid_identifier", so that User-Agents can automatically determine the
+given field should contain an OpenID identifier. The entered identifier is
+then passed as "user_supplied_identifier" argument to this function. It
+returns false on error and currently never returns on success. However in
+future this function shall return true on success. After the user has
+authenticated successfully, he/she is forwarded to the URL given by the
+"return_to" argument. Under this URL the application has to verify the
+result by calling auth.openid.verify{...}.
+
+--]]--
+
+function auth.openid.initiate(args)
+  local dd, errmsg, errcode = auth.openid.discover(args)
+  if not dd then
+    return nil, errmsg, errcode
+  end
+  -- TODO: Use request.redirect once it supports external URLs
+  cgi.set_status("303 See Other")
+  cgi.add_header(
+    "Location: " ..
+    encode.url{
+      external = dd.op_endpoint,
+      params = {
+        ["openid.ns"]         = "http://specs.openid.net/auth/2.0",
+        ["openid.mode"]       = "checkid_setup",
+        ["openid.claimed_id"] = dd.claimed_identifier or
+                                "http://specs.openid.net/auth/2.0/identifier_select",
+        ["openid.identity"]   = dd.op_local_identifier or dd.claimed_identifier or
+                                "http://specs.openid.net/auth/2.0/identifier_select",
+        ["openid.return_to"]  = encode.url{
+                                  base   = request.get_absolute_baseurl(),
+                                  module = args.return_to_module,
+                                  view   = args.return_to_view
+                                },
+        ["openid.realm"]      = args.realm or request.get_absolute_baseurl()
+      }
+    }
+  )
+  cgi.send_data()
+  exit()
+end

framework/env/auth/openid/verify.lua

+--[[--
+claimed_identifier,                        -- identifier owned by the user
+errmsg,                                    -- error message in case of failure
+errcode =                                  -- error code in case of failure (TODO: not implemented yet)
+auth.openid.verify(
+  force_https              = force_https,  -- only allow https
+  curl_options             = curl_options  -- options passed to "curl" binary, when performing discovery
+)
+
+--]]--
+
+function auth.openid.verify(args)
+  local args = args or {}
+  if cgi.params["openid.ns"] ~= "http://specs.openid.net/auth/2.0" then
+    return nil, "No indirect OpenID 2.0 message received."
+  end
+  local mode = cgi.params["openid.mode"]
+  if mode == "id_res" then
+    local return_to_url = cgi.params["openid.return_to"]
+    if not return_to_url then
+      return nil, "No return_to URL received in answer."
+    end
+    if return_to_url ~= encode.url{
+      base   = request.get_absolute_baseurl(),
+      module = request.get_module(),
+      view   = request.get_view()
+    } then
+      return nil, "return_to URL not matching."
+    end
+    local discovery_args = table.new(args)
+    local claimed_identifier = cgi.params["openid.claimed_id"]
+    if not claimed_identifier then
+      return nil, "No claimed identifier received."
+    end
+    local cropped_identifier = string.match(claimed_identifier, "[^#]*")
+    local normalized_identifier = auth.openid._normalize_url(
+      cropped_identifier
+    )
+    if not normalized_identifier then
+      return nil, "Claimed identifier could not be normalized."
+    end
+    if normalized_identifier ~= cropped_identifier then
+      return nil, "Claimed identifier was not normalized."
+    end
+    discovery_args.user_supplied_identifier = cropped_identifier
+    local dd, errmsg, errcode = auth.openid.discover(discovery_args)
+    if not dd then
+      return nil, errmsg, errcode
+    end
+    if not dd.claimed_identifier then
+      return nil, "Identifier is an OpenID Provider."
+    end
+    if dd.claimed_identifier ~= cropped_identifier then
+      return nil, "Claimed identifier does not match."
+    end
+    local nonce = cgi.params["openid.response_nonce"]
+    if not nonce then
+      return nil, "Did not receive a response nonce."
+    end 
+    local year, month, day, hour, minute, second = string.match(
+      nonce,
+      "^([0-9][0-9][0-9][0-9])%-([0-9][0-9])%-([0-9][0-9])T([0-9][0-9]):([0-9][0-9]):([0-9][0-9])Z"
+    )
+    if not year then
+      return nil, "Response nonce did not contain a parsable date/time."
+    end
+    local ts = atom.timestamp{
+      year   = tonumber(year),
+      month  = tonumber(month),
+      day    = tonumber(day),
+      hour   = tonumber(hour),
+      minute = tonumber(minute),
+      second = tonumber(second)
+    }
+    -- NOTE: 50 hours margin allows us to ignore time zone issues here:
+    if math.abs(ts - atom.timestamp:get_current()) > 3600 * 50 then
+      return nil, "Response nonce contains wrong time or local time is wrong."
+    end
+    local params = {}
+    for key, value in pairs(cgi.params) do
+      local trimmed_key = string.match(key, "^openid%.(.+)")
+      if trimmed_key then
+        params[key] = value
+      end
+    end
+    params["openid.mode"] = "check_authentication"
+    local options = table.new(args.curl_options)
+    for key, value in pairs(params) do
+      options[#options+1] = "--data-urlencode"
+      options[#options+1] = key .. "=" .. value
+    end
+    local status, headers, body = auth.openid._curl(dd.op_endpoint, options)
+    if status ~= 200 then
+      return nil, "Authorization could not be verified."
+    end
+    local result = {}
+    for key, value in string.gmatch(body, "([^\n:]+):([^\n]*)") do
+      result[key] = value
+    end
+    if result.ns ~= "http://specs.openid.net/auth/2.0" then
+      return nil, "No OpenID 2.0 message replied."
+    end
+    if result.is_valid == "true" then
+      return claimed_identifier
+    else
+      return nil, "Signature invalid."
+    end
+  elseif mode == "cancel" then
+    return nil, "Authorization failed according to OpenID provider."
+  else
+    return nil, "Unexpected OpenID mode."
+  end
+end

framework/env/auth/openid/xrds_document.lua

+--[[--
+auth.openid.xrds_document{
+  return_to_module = return_to_module,
+  return_to_view   = return_to_view
+}
+
+This function returns an XRDS document with Content-Type
+application/xrds+xml. For more information see documentation on
+auth.openid.xrds_document{...}.
+
+--]]--
+
+function auth.openid.xrds_document(args)
+  slot.set_layout(nil, "application/xrds+xml")
+  slot.put_into("data",
+    "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
+    "<xrds:XRDS xmlns:xrds=\"xri://$xrds\" xmlns=\"xri://$xrd*($v*2.0)\">\n",
+    "  <XRD>\n",
+    "    <Service>\n",                                                   
+    "      <Type>http://specs.openid.net/auth/2.0/return_to</Type>\n",
+    "      <URI>",
+    encode.url{
+      base   = request.get_absolute_baseurl(),
+      module = args.return_to_module,
+      view   = args.return_to_view
+    },
+    "</URI>\n",
+    "    </Service>\n",
+    "  </XRD>\n",
+    "</xrds:XRDS>\n"
+  )
+end

framework/env/auth/openid/xrds_header.lua

+--[[--
+auth.openid.xrds_header{
+  ...                     -- arguments as used for encode.url{...}, pointing to an XRDS document as explained below
+}
+
+According to the OpenID specification, providers should verify, that
+return_to URLs are an OpenID relying party endpoint. To use OpenID
+providers following this recommendation, the relying parties can send a
+X-XRDS-Location header by calling this function. Its arguments must refer
+to an URL returning a document as follows:
+
+<?xml version="1.0" encoding="UTF-8"?>
+<xrds:XRDS xmlns:xrds="xri://$xrds" xmlns="xri://$xrd*($v*2.0)">
+  <XRD>                                                         
+    <Service>                                                   
+      <Type>http://specs.openid.net/auth/2.0/return_to</Type>   
+      <URI>RETURN_TO_URL</URI>                                  
+    </Service>                                                  
+  </XRD>                                                        
+</xrds:XRDS>
+
+The placeholder RETURN_TO_URL has to be replaced by the absolute URL of the
+given return_to_module and return_to_view.
+
+
+Example application-wide filter, assuming the document above is saved in
+"static/openid.xrds":
+
+auth.openid.xrds_header{ static = "openid.xrds" }
+execute.inner()
+
+
+Example applications-wide filter, assuming
+- the return_to_module is "openid"
+- the return_to_view is "return"
+- the module for returning the xrds document is "openid"
+- the view for returning the xrds document is "xrds"
+
+auth.openid.xrds_header{ module = "openid", view = "xrds" }
+execute.inner()
+
+
+In the last example the "xrds" view in module "openid" has to make the
+following call:
+
+auth.openid.xrds_document{
+  return_to_module = "openid",
+  return_to_view   = "return"
+}
+
+--]]--
+function auth.openid.xrds_header(args)
+  cgi.add_header("X-XRDS-Location: " .. encode.url(args))
+end
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.