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)
164 "#{request.base_url}/"
168 @server ||= Server.new(
169 OpenID::Store::Filesystem.new("#@@dir/store"),
170 server_root + "provider")
173 # support the simple registration extension if possible,
174 # allow per-site overrides of various data points
175 def add_sreg(oidreq, oidresp)
176 sregreq = OpenID::SReg::Request.from_openid_request(oidreq) or return
177 per_site = config[oidreq.trust_root] || {}
180 sregreq.all_requested_fields.each do |field|
181 sreg_data[field] = per_site[field] || config[field]
184 sregresp = OpenID::SReg::Response.extract_response(sregreq, sreg_data)
185 oidresp.add_extension(sregresp)
188 def add_pape(oidreq, oidresp)
189 papereq = OpenID::PAPE::Request.from_openid_request(oidreq) or return
190 paperesp = OpenID::PAPE::Response.new(papereq.preferred_auth_policies,
191 papereq.max_auth_age)
192 # since this implementation requires shell/filesystem access to the
193 # OpenID server to authenticate, we can say we're at the highest
194 # auth level possible...
195 paperesp.add_policy_uri(OpenID::PAPE::AUTH_MULTI_FACTOR_PHYSICAL)
196 paperesp.auth_time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
197 paperesp.nist_auth_level = 4
198 oidresp.add_extension(paperesp)
202 env['rack.errors'].write("#{msg}\n")
206 def finalize_response(oidresp)
207 server.signatory.sign(oidresp) if oidresp.needs_signing
208 web_response = server.encode_response(oidresp)
210 case web_response.code
214 location = web_response.headers['location']
215 err("redirecting to: #{location} ...")
218 halt(500, web_response.body)
222 # the heart of our custom authentication logic
223 def is_authorized?(oidreq)
224 (config['allowed_ips'] ||= []).include?(request.ip) or
225 return err("Not allowed: #{request.ip}\n" \
226 "You need to put this IP in the 'allowed_ips' array "\
227 "in:\n #@@dir/config.yml")
229 request.ip == session[:ip] or
230 return err("session IP mismatch: " \
231 "#{request.ip.inspect} != #{session[:ip].inspect}")
233 trust_root = oidreq.trust_root
234 per_site = config[trust_root] or
235 return err("trust_root unknown: #{trust_root}")
237 session_id = session[:id] or return err("no session ID")
239 assoc_handle = per_site['assoc_handle'] # this may be nil
240 expires = per_site['expires'] or
241 return err("no expires (trust_root=#{trust_root})")
243 assoc_handle == oidreq.assoc_handle or
244 return err("assoc_handle mismatch: " \
245 "#{assoc_handle.inspect} != #{oidreq.assoc_handle.inspect}" \
246 " (trust_root=#{trust_root})")
248 per_site['session_id'] == session_id or
249 return err("session ID mismatch: " \
250 "#{per_site['session_id'].inspect} != #{session_id.inspect}" \
251 " (trust_root=#{trust_root})")
253 expires > Time.now or
254 return err("Expired: #{expires.inspect} (trust_root=#{trust_root})")
261 YAML.load(File.read("#@@dir/config.yml"))
267 def merge_config(oidreq)
268 per_site = config[oidreq.trust_root] ||= {}
270 'assoc_handle' => oidreq.assoc_handle,
271 'expires' => Time.at(0).utc,
272 'updated' => Time.now.utc,
273 'expires1m' => Time.now.utc + 60, # easy edit to "expires" in $EDITOR
274 'session_id' => session[:id],
279 path = "#@@dir/config.yml"
280 tmp = Tempfile.new('config.yml', File.dirname(path))
281 tmp.syswrite(CONFIG_HEADER.gsub(/^/m, "# "))
282 tmp.syswrite(config.to_yaml)
283 tmp.syswrite(CONFIG_TRAILER.gsub(/^/m, "# "))
285 File.rename(tmp.path, path)
289 # this output is designed to be parsed by OpenID consumers
290 def render_provider_xrds(force = false)
291 if force || request.accept.include?('application/xrds+xml')
293 # this seems to work...
294 types = [ OpenID::OPENID_IDP_2_0_TYPE ]
296 headers['Content-Type'] = 'application/xrds+xml'
297 types = types.map { |uri| "<Type>#{uri}</Type>" }.join("\n")
298 PROVIDER_XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
299 else # render a browser-friendly page with an XRDS pointer
300 headers['X-XRDS-Location'] = "#{server_root}provider/xrds"
301 PROVIDER_XRDS_HTML.gsub(/%s/, server_root)
305 def render_identity_xrds(force = false)
306 if force || request.accept.include?('application/xrds+xml')
308 # this seems to work...
309 types = [ OpenID::OPENID_2_0_TYPE,
310 OpenID::OPENID_1_0_TYPE,
313 headers['Content-Type'] = 'application/xrds+xml'
314 types = types.map { |uri| "<Type>#{uri}</Type>" }.join("\n")
315 IDENTITY_XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
316 else # render a browser-friendly page with an XRDS pointer
317 headers['X-XRDS-Location'] = "#{server_root}xrds"
318 IDENTITY_XRDS_HTML.gsub(/%s/, server_root)
322 # if a single-user OpenID provider like us is being hit by multiple
323 # clients at once, then something is seriously wrong. Can't use
324 # Mutexes here since somebody could be running this as a CGI script
327 File.open(lock, File::WRONLY|File::CREAT|File::EXCL, 0600) do |fp|
335 err("Lock: #{lock} exists! Possible hijacking attempt") rescue nil