1 # A personal OpenID identity provider, authentication is done by editing
2 # a YAML file on the server where this application runs
3 # (~/.local-openid/config.yml by default) instead of via HTTP/HTTPS
4 # form authentication in the browser.
10 require 'sinatra/base'
12 require 'openid/extensions/sreg'
13 require 'openid/extensions/pape'
14 require 'openid/store/filesystem'
16 class LocalOpenID < Sinatra::Base
19 set :environment, :production
20 set :logging, false # load Rack::CommonLogger in config.ru instead
22 @@dir ||= File.expand_path(ENV['LOCAL_OPENID_DIR'] || '~/.local-openid')
23 Dir.mkdir(@@dir) unless File.directory?(@@dir)
25 # all the sinatra endpoints:
26 get('/xrds') { big_lock { render_identity_xrds(true) } }
27 get('/provider/xrds') { big_lock { render_provider_xrds(true) } }
28 get('/provider') { big_lock { get_or_post_provider } }
29 post('/provider') { big_lock { get_or_post_provider } }
30 get('/') { big_lock { render_identity_xrds } }
31 post('/') { big_lock { render_identity_xrds } }
35 # yes, I use gsub for templating because I find it easier than erb :P
37 <head><title>OpenID login: %s</title></head>
38 <body><h1>reload this page when approved: %s</h1></body>
41 PROVIDER_XRDS_HTML = %q!<html><head>
42 <meta http-equiv="X-XRDS-Location" content="%sprovider/xrds" />
43 <title>OpenID server endpoint</title>
44 </head><body>OpenID server endpoint</body></html>!
46 IDENTITY_XRDS_HTML = %q!<html><head>
47 <link rel="openid.server" href="%sprovider" />
48 <link rel="openid2.provider" href="%sprovider" />
49 <link rel="openid2.local_id" href="%s" />
50 <link rel="openid.delegate" href="%s" />
51 <meta http-equiv="X-XRDS-Location" content="%sxrds" />
52 <title>OpenID identity</title>
53 </head><body>OpenID identity</body></html>!
55 PROVIDER_XRDS_XML = %q!<?xml version="1.0" encoding="UTF-8"?>
57 xmlns:xrds="xri://$xrds"
58 xmlns:openid="http://openid.net/xmlns/1.0"
59 xmlns="xri://$xrd*($v*2.0)">
61 <Service priority="0">
68 IDENTITY_XRDS_XML = %q!<?xml version="1.0" encoding="UTF-8"?>
70 xmlns:xrds="xri://$xrds"
71 xmlns:openid="http://openid.net/xmlns/1.0"
72 xmlns="xri://$xrd*($v*2.0)">
74 <Service priority="0">
78 <openid:Delegate>%s</openid:Delegate>
84 This file may be changed by #{__FILE__} or your favorite $EDITOR
85 comments will be deleted when modified by #{__FILE__}. See the
86 comments end of this file for help on the format.
90 Configuration file description.
92 * allowed_ips An array of strings representing IPs that may
93 authenticate through local-openid. Only put
94 IP addresses that you trust in here.
96 Each OpenID consumer trust root will have its own hash keyed by
97 the trust root URL. Keys in this hash are:
99 - expires The time at which this login will expire.
100 This is generally the only entry you need to edit
101 to approve a site. You may also delete this line
102 and rename the "expires1m" to this.
103 - expires1m The time 1 minute from when this entry was updated.
104 This is provided as a convenience for replacing
105 the default "expires" entry. This key may be safely
106 removed by a user editing it.
107 - updated Time this entry was updated, strictly informational.
108 - session_id Unique identifier in your session cookie to prevent
109 other users from hijacking your session. You may
110 delete this if you have changed browsers or computers.
111 - assoc_handle See the OpenID specs, may be empty. Do not edit this.
113 SReg keys supported by the Ruby OpenID implementation should be
114 supported, they include (but are not limited to):
115 ! << OpenID::SReg::DATA_FIELDS.map do |key, value|
116 " - #{key}: #{value}"
118 SReg keys may be global at the top-level or private to each trust root.
119 Per-trust root SReg entries override the global settings.
122 include OpenID::Server
124 # this is the heart of our provider logic, adapted from the
125 # Ruby OpenID gem Rails example
126 def get_or_post_provider
128 server.decode_request(params)
129 rescue ProtocolError => err
133 oidreq or return render_provider_xrds
135 oidresp = case oidreq
137 if oidreq.id_select && oidreq.immediate
139 elsif is_authorized?(oidreq)
140 resp = oidreq.answer(true, nil, server_root)
141 add_sreg(oidreq, resp)
142 add_pape(oidreq, resp)
144 elsif oidreq.immediate
145 oidreq.answer(false, server_root + "provider")
147 session[:id] ||= "#{Time.now.to_i}.#$$.#{rand}"
148 session[:ip] = request.ip
152 # here we allow our user to open $EDITOR and edit the appropriate
153 # 'expires' field in config.yml corresponding to oidreq.trust_root
154 return PROMPT.gsub(/%s/, oidreq.trust_root)
157 server.handle_request(oidreq)
160 finalize_response(oidresp)
163 # we're the provider for exactly one identity. However, we do rely on
164 # being proxied and being hit with an appropriate HTTP Host: header.
165 # Don't expect OpenID consumers to handle port != 80.
167 "http://#{request.host}/"
171 @server ||= Server.new(
172 OpenID::Store::Filesystem.new("#@@dir/store"),
173 server_root + "provider")
176 # support the simple registration extension if possible,
177 # allow per-site overrides of various data points
178 def add_sreg(oidreq, oidresp)
179 sregreq = OpenID::SReg::Request.from_openid_request(oidreq) or return
180 per_site = config[oidreq.trust_root] || {}
183 sregreq.all_requested_fields.each do |field|
184 sreg_data[field] = per_site[field] || config[field]
187 sregresp = OpenID::SReg::Response.extract_response(sregreq, sreg_data)
188 oidresp.add_extension(sregresp)
191 def add_pape(oidreq, oidresp)
192 papereq = OpenID::PAPE::Request.from_openid_request(oidreq) or return
193 paperesp = OpenID::PAPE::Response.new(papereq.preferred_auth_policies,
194 papereq.max_auth_age)
195 # since this implementation requires shell/filesystem access to the
196 # OpenID server to authenticate, we can say we're at the highest
197 # auth level possible...
198 paperesp.add_policy_uri(OpenID::PAPE::AUTH_MULTI_FACTOR_PHYSICAL)
199 paperesp.auth_time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
200 paperesp.nist_auth_level = 4
201 oidresp.add_extension(paperesp)
205 env['rack.errors'].write("#{msg}\n")
209 def finalize_response(oidresp)
210 server.signatory.sign(oidresp) if oidresp.needs_signing
211 web_response = server.encode_response(oidresp)
213 case web_response.code
217 location = web_response.headers['location']
218 err("redirecting to: #{location} ...")
221 halt(500, web_response.body)
225 # the heart of our custom authentication logic
226 def is_authorized?(oidreq)
227 (config['allowed_ips'] ||= []).include?(request.ip) or
228 return err("Not allowed: #{request.ip}\n" \
229 "You need to put this IP in the 'allowed_ips' array "\
230 "in:\n #@@dir/config.yml")
232 request.ip == session[:ip] or
233 return err("session IP mismatch: " \
234 "#{request.ip.inspect} != #{session[:ip].inspect}")
236 trust_root = oidreq.trust_root
237 per_site = config[trust_root] or
238 return err("trust_root unknown: #{trust_root}")
240 session_id = session[:id] or return err("no session ID")
242 assoc_handle = per_site['assoc_handle'] # this may be nil
243 expires = per_site['expires'] or
244 return err("no expires (trust_root=#{trust_root})")
246 assoc_handle == oidreq.assoc_handle or
247 return err("assoc_handle mismatch: " \
248 "#{assoc_handle.inspect} != #{oidreq.assoc_handle.inspect}" \
249 " (trust_root=#{trust_root})")
251 per_site['session_id'] == session_id or
252 return err("session ID mismatch: " \
253 "#{per_site['session_id'].inspect} != #{session_id.inspect}" \
254 " (trust_root=#{trust_root})")
256 expires > Time.now or
257 return err("Expired: #{expires.inspect} (trust_root=#{trust_root})")
264 YAML.load(File.read("#@@dir/config.yml"))
270 def merge_config(oidreq)
271 per_site = config[oidreq.trust_root] ||= {}
273 'assoc_handle' => oidreq.assoc_handle,
274 'expires' => Time.at(0).utc,
275 'updated' => Time.now.utc,
276 'expires1m' => Time.now.utc + 60, # easy edit to "expires" in $EDITOR
277 'session_id' => session[:id],
282 path = "#@@dir/config.yml"
283 tmp = Tempfile.new('config.yml', File.dirname(path))
284 tmp.syswrite(CONFIG_HEADER.gsub(/^/m, "# "))
285 tmp.syswrite(config.to_yaml)
286 tmp.syswrite(CONFIG_TRAILER.gsub(/^/m, "# "))
288 File.rename(tmp.path, path)
292 # this output is designed to be parsed by OpenID consumers
293 def render_provider_xrds(force = false)
294 if force || request.accept.include?('application/xrds+xml')
296 # this seems to work...
297 types = [ OpenID::OPENID_IDP_2_0_TYPE ]
299 headers['Content-Type'] = 'application/xrds+xml'
300 types = types.map { |uri| "<Type>#{uri}</Type>" }.join("\n")
301 PROVIDER_XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
302 else # render a browser-friendly page with an XRDS pointer
303 headers['X-XRDS-Location'] = "#{server_root}provider/xrds"
304 PROVIDER_XRDS_HTML.gsub(/%s/, server_root)
308 def render_identity_xrds(force = false)
309 if force || request.accept.include?('application/xrds+xml')
311 # this seems to work...
312 types = [ OpenID::OPENID_2_0_TYPE,
313 OpenID::OPENID_1_0_TYPE,
316 headers['Content-Type'] = 'application/xrds+xml'
317 types = types.map { |uri| "<Type>#{uri}</Type>" }.join("\n")
318 IDENTITY_XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
319 else # render a browser-friendly page with an XRDS pointer
320 headers['X-XRDS-Location'] = "#{server_root}xrds"
321 IDENTITY_XRDS_HTML.gsub(/%s/, server_root)
325 # if a single-user OpenID provider like us is being hit by multiple
326 # clients at once, then something is seriously wrong. Can't use
327 # Mutexes here since somebody could be running this as a CGI script
330 File.open(lock, File::WRONLY|File::CREAT|File::EXCL, 0600) do |fp|
338 err("Lock: #{lock} exists! Possible hijacking attempt") rescue nil