local-openid.git  about / heads / tags
Single User, Ephemeral OpenID Provider
blob 0edbf37a04d643e9d37c789f552e8d0b300ffa9b 11323 bytes (raw)
$ git show HEAD:lib/local_openid.rb	# shows this blob on the CLI

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
 
# A personal OpenID identity provider, authentication is done by editing
# a YAML file on the server where this application runs
# (~/.local-openid/config.yml by default) instead of via HTTP/HTTPS
# form authentication in the browser.
#:stopdoc:
require 'tempfile'
require 'time'
require 'yaml'

require 'sinatra/base'
require 'openid'
require 'openid/extensions/sreg'
require 'openid/extensions/pape'
require 'openid/store/filesystem'

class LocalOpenID < Sinatra::Base
  set :static, false
  set :sessions, true
  set :environment, :production
  set :logging, false # load Rack::CommonLogger in config.ru instead

  @@dir ||= File.expand_path(ENV['LOCAL_OPENID_DIR'] || '~/.local-openid')
  Dir.mkdir(@@dir) unless File.directory?(@@dir)

  # all the sinatra endpoints:
  get('/xrds') { big_lock { render_identity_xrds(true) } }
  get('/provider/xrds') { big_lock { render_provider_xrds(true) } }
  get('/provider') { big_lock { get_or_post_provider } }
  post('/provider') { big_lock { get_or_post_provider } }
  get('/') { big_lock { render_identity_xrds } }
  post('/') { big_lock { render_identity_xrds } }

  private

  # yes, I use gsub for templating because I find it easier than erb :P
  PROMPT = %q!<html>
  <head><title>OpenID login: %s</title></head>
  <body><h1>reload this page when approved: %s</h1></body>
  </html>!

  PROVIDER_XRDS_HTML = %q!<html><head>
  <meta http-equiv="X-XRDS-Location" content="%sprovider/xrds" />
  <title>OpenID server endpoint</title>
  </head><body>OpenID server endpoint</body></html>!

  IDENTITY_XRDS_HTML = %q!<html><head>
  <link rel="openid.server" href="%sprovider" />
  <link rel="openid2.provider" href="%sprovider" />
  <link rel="openid2.local_id" href="%s" />
  <link rel="openid.delegate"  href="%s" />
  <meta http-equiv="X-XRDS-Location" content="%sxrds" />
  <title>OpenID identity</title>
  </head><body>OpenID identity</body></html>!

  PROVIDER_XRDS_XML = %q!<?xml version="1.0" encoding="UTF-8"?>
  <xrds:XRDS
    xmlns:xrds="xri://$xrds"
    xmlns:openid="http://openid.net/xmlns/1.0"
    xmlns="xri://$xrd*($v*2.0)">
  <XRD version="2.0">
    <Service priority="0">
      %types
      <URI>%sprovider</URI>
    </Service>
  </XRD>
  </xrds:XRDS>!

  IDENTITY_XRDS_XML = %q!<?xml version="1.0" encoding="UTF-8"?>
  <xrds:XRDS
    xmlns:xrds="xri://$xrds"
    xmlns:openid="http://openid.net/xmlns/1.0"
    xmlns="xri://$xrd*($v*2.0)">
  <XRD version="2.0">
    <Service priority="0">
      %types
      <URI>%sprovider</URI>
      <LocalID>%s</LocalID>
      <openid:Delegate>%s</openid:Delegate>
    </Service>
  </XRD>
  </xrds:XRDS>!

  CONFIG_HEADER = %!
  This file may be changed by #{__FILE__} or your favorite $EDITOR
  comments will be deleted when modified by #{__FILE__}.  See the
  comments end of this file for help on the format.
  !.lstrip!

  CONFIG_TRAILER = %!
  Configuration file description.

  * allowed_ips     An array of strings representing IPs that may
                    authenticate through local-openid.  Only put
                    IP addresses that you trust in here.

  Each OpenID consumer trust root will have its own hash keyed by
  the trust root URL.  Keys in this hash are:

    - expires       The time at which this login will expire.
                    This is generally the only entry you need to edit
                    to approve a site.  You may also delete this line
                    and rename the "expires1m" to this.
    - expires1m     The time 1 minute from when this entry was updated.
                    This is provided as a convenience for replacing
                    the default "expires" entry.  This key may be safely
                    removed by a user editing it.
    - updated       Time this entry was updated, strictly informational.
    - session_id    Unique identifier in your session cookie to prevent
                    other users from hijacking your session.  You may
                    delete this if you have changed browsers or computers.
    - assoc_handle  See the OpenID specs, may be empty.  Do not edit this.

  SReg keys supported by the Ruby OpenID implementation should be
  supported, they include (but are not limited to):
  ! << OpenID::SReg::DATA_FIELDS.map do |key, value|
    "   - #{key}: #{value}"
  end.join("\n") << %!
  SReg keys may be global at the top-level or private to each trust root.
  Per-trust root SReg entries override the global settings.
  !

  include OpenID::Server

  # this is the heart of our provider logic, adapted from the
  # Ruby OpenID gem Rails example
  def get_or_post_provider
    oidreq = begin
      server.decode_request(params)
    rescue ProtocolError => err
      halt(500, err.to_s)
    end

    oidreq or return render_provider_xrds

    oidresp = case oidreq
    when CheckIDRequest
      if oidreq.id_select && oidreq.immediate
        oidreq.answer(false)
      elsif is_authorized?(oidreq)
        resp = oidreq.answer(true, nil, server_root)
        add_sreg(oidreq, resp)
        add_pape(oidreq, resp)
        resp
      elsif oidreq.immediate
        oidreq.answer(false, server_root + "provider")
      else
        session[:id] ||= "#{Time.now.to_i}.#$$.#{rand}"
        session[:ip] = request.ip
        merge_config(oidreq)
        write_config

        # here we allow our user to open $EDITOR and edit the appropriate
        # 'expires' field in config.yml corresponding to oidreq.trust_root
        return PROMPT.gsub(/%s/, oidreq.trust_root)
      end
    else
      server.handle_request(oidreq)
    end

    finalize_response(oidresp)
  end

  def server_root
    "#{request.base_url}/"
  end

  def server
    @server ||= Server.new(
        OpenID::Store::Filesystem.new("#@@dir/store"),
        server_root + "provider")
  end

  # support the simple registration extension if possible,
  # allow per-site overrides of various data points
  def add_sreg(oidreq, oidresp)
    sregreq = OpenID::SReg::Request.from_openid_request(oidreq) or return
    per_site = config[oidreq.trust_root] || {}

    sreg_data = {}
    sregreq.all_requested_fields.each do |field|
      sreg_data[field] = per_site[field] || config[field]
    end

    sregresp = OpenID::SReg::Response.extract_response(sregreq, sreg_data)
    oidresp.add_extension(sregresp)
  end

  def add_pape(oidreq, oidresp)
    papereq = OpenID::PAPE::Request.from_openid_request(oidreq) or return
    paperesp = OpenID::PAPE::Response.new(papereq.preferred_auth_policies,
                                          papereq.max_auth_age)
    # since this implementation requires shell/filesystem access to the
    # OpenID server to authenticate, we can say we're at the highest
    # auth level possible...
    paperesp.add_policy_uri(OpenID::PAPE::AUTH_MULTI_FACTOR_PHYSICAL)
    paperesp.auth_time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
    paperesp.nist_auth_level = 4
    oidresp.add_extension(paperesp)
  end

  def err(msg)
    env['rack.errors'].write("#{msg}\n")
    false
  end

  def finalize_response(oidresp)
    server.signatory.sign(oidresp) if oidresp.needs_signing
    web_response = server.encode_response(oidresp)

    case web_response.code
    when HTTP_OK
      web_response.body
    when HTTP_REDIRECT
      location = web_response.headers['location']
      err("redirecting to: #{location} ...")
      redirect(location)
    else
      halt(500, web_response.body)
    end
  end

  # the heart of our custom authentication logic
  def is_authorized?(oidreq)
    (config['allowed_ips'] ||= []).include?(request.ip) or
      return err("Not allowed: #{request.ip}\n" \
                 "You need to put this IP in the 'allowed_ips' array "\
                 "in:\n #@@dir/config.yml")

    request.ip == session[:ip] or
      return err("session IP mismatch: " \
                 "#{request.ip.inspect} != #{session[:ip].inspect}")

    trust_root = oidreq.trust_root
    per_site = config[trust_root] or
      return err("trust_root unknown: #{trust_root}")

    session_id = session[:id] or return err("no session ID")

    assoc_handle = per_site['assoc_handle'] # this may be nil
    expires = per_site['expires'] or
      return err("no expires (trust_root=#{trust_root})")

    assoc_handle == oidreq.assoc_handle or
      return err("assoc_handle mismatch: " \
                 "#{assoc_handle.inspect} != #{oidreq.assoc_handle.inspect}" \
                 " (trust_root=#{trust_root})")

    per_site['session_id'] == session_id or
      return err("session ID mismatch: " \
                 "#{per_site['session_id'].inspect} != #{session_id.inspect}" \
                 " (trust_root=#{trust_root})")

    expires > Time.now or
      return err("Expired: #{expires.inspect} (trust_root=#{trust_root})")

    true
  end

  def config
    @config ||= begin
      YAML.load(File.read("#@@dir/config.yml"))
    rescue Errno::ENOENT
      {}
    end
  end

  def merge_config(oidreq)
    per_site = config[oidreq.trust_root] ||= {}
    per_site.merge!({
        'assoc_handle' => oidreq.assoc_handle,
        'expires' => Time.at(0).utc,
        'updated' => Time.now.utc,
        'expires1m' => Time.now.utc + 60, # easy edit to "expires" in $EDITOR
        'session_id' => session[:id],
      })
  end

  def write_config
    path = "#@@dir/config.yml"
    tmp = Tempfile.new('config.yml', File.dirname(path))
    tmp.syswrite(CONFIG_HEADER.gsub(/^/m, "# "))
    tmp.syswrite(config.to_yaml)
    tmp.syswrite(CONFIG_TRAILER.gsub(/^/m, "# "))
    tmp.fsync
    File.rename(tmp.path, path)
    tmp.close!
  end

  # this output is designed to be parsed by OpenID consumers
  def render_provider_xrds(force = false)
    if force || request.accept.include?('application/xrds+xml')

      # this seems to work...
      types = [ OpenID::OPENID_IDP_2_0_TYPE ]

      headers['Content-Type'] = 'application/xrds+xml'
      types = types.map { |uri| "<Type>#{uri}</Type>" }.join("\n")
      PROVIDER_XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
    else # render a browser-friendly page with an XRDS pointer
      headers['X-XRDS-Location'] = "#{server_root}provider/xrds"
      PROVIDER_XRDS_HTML.gsub(/%s/, server_root)
    end
  end

  def render_identity_xrds(force = false)
    if force || request.accept.include?('application/xrds+xml')

      # this seems to work...
      types = [ OpenID::OPENID_2_0_TYPE,
                OpenID::OPENID_1_0_TYPE,
                OpenID::SREG_URI ]

      headers['Content-Type'] = 'application/xrds+xml'
      types = types.map { |uri| "<Type>#{uri}</Type>" }.join("\n")
      IDENTITY_XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
    else # render a browser-friendly page with an XRDS pointer
      headers['X-XRDS-Location'] = "#{server_root}xrds"
      IDENTITY_XRDS_HTML.gsub(/%s/, server_root)
    end
  end

  # if a single-user OpenID provider like us is being hit by multiple
  # clients at once, then something is seriously wrong.  Can't use
  # Mutexes here since somebody could be running this as a CGI script
  def big_lock(&block)
    lock = "#@@dir/lock"
    File.open(lock, File::WRONLY|File::CREAT|File::EXCL, 0600) do |fp|
      begin
        yield
      ensure
        File.unlink(lock)
      end
    end
    rescue Errno::EEXIST
      err("Lock: #{lock} exists! Possible hijacking attempt") rescue nil
  end
end
#:startdoc:

git clone https://yhbt.net/local-openid.git