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_xrds(true) } }
27 get('/') { big_lock { get_or_post } }
28 post('/') { big_lock { get_or_post } }
32 # yes, I use gsub for templating because I find it easier than erb :P
34 <head><title>OpenID login: %s</title></head>
35 <body><h1>reload this page when approved: %s</h1></body>
38 XRDS_HTML = %q!<html><head>
39 <link rel="openid.server" href="%s" />
40 <link rel="openid2.provider" href="%s" />
41 <meta http-equiv="X-XRDS-Location" content="%sxrds" />
42 <title>OpenID server endpoint</title>
43 </head><body>OpenID server endpoint</body></html>!
45 XRDS_XML = %q!<?xml version="1.0" encoding="UTF-8"?>
47 xmlns:xrds="xri://$xrds"
48 xmlns="xri://$xrd*($v*2.0)">
50 <Service priority="0">
58 This file may be changed by #{__FILE__} or your favorite $EDITOR
59 comments will be deleted when modified by #{__FILE__}. See the
60 comments end of this file for help on the format.
64 Configuration file description.
66 * allowed_ips An array of strings representing IPs that may
67 authenticate through local-openid. Only put
68 IP addresses that you trust in here.
70 Each OpenID consumer trust root will have its own hash keyed by
71 the trust root URL. Keys in this hash are:
73 - expires The time at which this login will expire.
74 This is generally the only entry you need to edit
75 to approve a site. You may also delete this line
76 and rename the "expires1m" to this.
77 - expires1m The time 1 minute from when this entry was updated.
78 This is provided as a convenience for replacing
79 the default "expires" entry. This key may be safely
80 removed by a user editing it.
81 - updated Time this entry was updated, strictly informational.
82 - session_id Unique identifier in your session cookie to prevent
83 other users from hijacking your session. You may
84 delete this if you've changed browsers or computers.
85 - assoc_handle See the OpenID specs, may be empty. Do not edit this.
87 SReg keys supported by the Ruby OpenID implementation should be
88 supported, they include (but are not limited to):
89 ! << OpenID::SReg::DATA_FIELDS.map do |key, value|
92 SReg keys may be global at the top-level or private to each trust root.
93 Per-trust root SReg entries override the global settings.
96 include OpenID::Server
98 # this is the heart of our provider logic, adapted from the
99 # Ruby OpenID gem Rails example
102 server.decode_request(params)
103 rescue ProtocolError => err
107 oidreq or return render_xrds
109 oidresp = case oidreq
111 if oidreq.id_select && oidreq.immediate
113 elsif is_authorized?(oidreq)
114 resp = oidreq.answer(true, nil, server_root)
115 add_sreg(oidreq, resp)
116 add_pape(oidreq, resp)
118 elsif oidreq.immediate
119 oidreq.answer(false, server_root)
121 session[:id] ||= "#{Time.now.to_i}.#$$.#{rand}"
122 session[:ip] = request.ip
126 # here we allow our user to open $EDITOR and edit the appropriate
127 # 'expires' field in config.yml corresponding to oidreq.trust_root
128 return PROMPT.gsub(/%s/, oidreq.trust_root)
131 server.handle_request(oidreq)
134 finalize_response(oidresp)
137 # we're the provider for exactly one identity. However, we do rely on
138 # being proxied and being hit with an appropriate HTTP Host: header.
139 # Don't expect OpenID consumers to handle port != 80.
141 "http://#{request.host}/"
145 @server ||= Server.new(
146 OpenID::Store::Filesystem.new("#@@dir/store"),
150 # support the simple registration extension if possible,
151 # allow per-site overrides of various data points
152 def add_sreg(oidreq, oidresp)
153 sregreq = OpenID::SReg::Request.from_openid_request(oidreq) or return
154 per_site = config[oidreq.trust_root] || {}
157 sregreq.all_requested_fields.each do |field|
158 sreg_data[field] = per_site[field] || config[field]
161 sregresp = OpenID::SReg::Response.extract_response(sregreq, sreg_data)
162 oidresp.add_extension(sregresp)
165 def add_pape(oidreq, oidresp)
166 papereq = OpenID::PAPE::Request.from_openid_request(oidreq) or return
167 paperesp = OpenID::PAPE::Response.new(papereq.preferred_auth_policies,
168 papereq.max_auth_age)
169 # since this implementation requires shell/filesystem access to the
170 # OpenID server to authenticate, we can say we're at the highest
171 # auth level possible...
172 paperesp.add_policy_uri(OpenID::PAPE::AUTH_MULTI_FACTOR_PHYSICAL)
173 paperesp.auth_time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
174 paperesp.nist_auth_level = 4
175 oidresp.add_extension(paperesp)
179 env['rack.errors'].write("#{msg}\n")
183 def finalize_response(oidresp)
184 server.signatory.sign(oidresp) if oidresp.needs_signing
185 web_response = server.encode_response(oidresp)
187 case web_response.code
191 location = web_response.headers['location']
192 err("redirecting to: #{location} ...")
195 halt(500, web_response.body)
199 # the heart of our custom authentication logic
200 def is_authorized?(oidreq)
201 (config['allowed_ips'] ||= []).include?(request.ip) or
202 return err("Not allowed: #{request.ip}\n" \
203 "You need to put this IP in the 'allowed_ips' array "\
204 "in:\n #@@dir/config.yml")
206 request.ip == session[:ip] or
207 return err("session IP mismatch: " \
208 "#{request.ip.inspect} != #{session[:ip].inspect}")
210 trust_root = oidreq.trust_root
211 per_site = config[trust_root] or
212 return err("trust_root unknown: #{trust_root}")
214 session_id = session[:id] or return err("no session ID")
216 assoc_handle = per_site['assoc_handle'] # this may be nil
217 expires = per_site['expires'] or
218 return err("no expires (trust_root=#{trust_root})")
220 assoc_handle == oidreq.assoc_handle or
221 return err("assoc_handle mismatch: " \
222 "#{assoc_handle.inspect} != #{oidreq.assoc_handle.inspect}" \
223 " (trust_root=#{trust_root})")
225 per_site['session_id'] == session_id or
226 return err("session ID mismatch: " \
227 "#{per_site['session_id'].inspect} != #{session_id.inspect}" \
228 " (trust_root=#{trust_root})")
230 expires > Time.now or
231 return err("Expired: #{expires.inspect} (trust_root=#{trust_root})")
238 YAML.load(File.read("#@@dir/config.yml"))
244 def merge_config(oidreq)
245 per_site = config[oidreq.trust_root] ||= {}
247 'assoc_handle' => oidreq.assoc_handle,
248 'expires' => Time.at(0).utc,
249 'updated' => Time.now.utc,
250 'expires1m' => Time.now.utc + 60, # easy edit to "expires" in $EDITOR
251 'session_id' => session[:id],
256 path = "#@@dir/config.yml"
257 tmp = Tempfile.new('config.yml', File.dirname(path))
258 tmp.syswrite(CONFIG_HEADER.gsub(/^/m, "# "))
259 tmp.syswrite(config.to_yaml)
260 tmp.syswrite(CONFIG_TRAILER.gsub(/^/m, "# "))
262 File.rename(tmp.path, path)
266 # this output is designed to be parsed by OpenID consumers
267 def render_xrds(force = false)
268 if force || request.accept.include?('application/xrds+xml')
270 # this seems to work...
271 types = request.accept.include?('application/xrds+xml') ?
272 [ OpenID::OPENID_2_0_TYPE,
273 OpenID::OPENID_1_0_TYPE,
275 [ OpenID::OPENID_IDP_2_0_TYPE ]
277 headers['Content-Type'] = 'application/xrds+xml'
278 types = types.map { |uri| "<Type>#{uri}</Type>" }.join("\n")
279 XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
280 else # render a browser-friendly page with an XRDS pointer
281 headers['X-XRDS-Location'] = "#{server_root}xrds"
282 XRDS_HTML.gsub(/%s/, server_root)
286 # if a single-user OpenID provider like us is being hit by multiple
287 # clients at once, then something is seriously wrong. Can't use
288 # Mutexes here since somebody could be running this as a CGI script
291 File.open(lock, File::WRONLY|File::CREAT|File::EXCL, 0600) do |fp|
299 err("Lock: #{lock} exists! Possible hijacking attempt") rescue nil