initial commit
[local-openid.git] / bin / local-openid
blob45c4edec01aac540485cd89115ce99f1ad1130bf
1 #!/home/ew/bin/ruby
2 # A personal OpenID identity provider, authentication is done by editing
3 # a YAML file on the server where this application runs
4 # (~/.local-openid/config.yml by default) instead of via HTTP/HTTPS
5 # form authentication in the browser.
7 require 'tempfile'
8 require 'time'
9 require 'yaml'
11 require 'sinatra'
12 require 'openid'
13 require 'openid/extensions/sreg'
14 require 'openid/extensions/pape'
15 require 'openid/store/filesystem'
16 set :static, false
17 set :sessions, true
18 set :environment, :production
19 set :logging, false # load Rack::CommonLogger in config.ru instead
21 BEGIN {
22 $local_openid ||=
23 File.expand_path(ENV['LOCAL_OPENID_DIR'] || '~/.local-openid')
24 Dir.mkdir($local_openid) unless File.directory?($local_openid)
27 # all the sinatra endpoints:
28 get('/xrds') { big_lock { render_xrds(true) } }
29 get('/') { big_lock { get_or_post } }
30 post('/') { big_lock { get_or_post } }
32 private
34 # yes, I use gsub for templating because I find it easier than erb :P
35 PROMPT = %q!<html>
36 <head><title>OpenID login: %s</title></head>
37 <body><h1>reload this page when approved: %s</h1></body>
38 </html>!
40 XRDS_HTML = %q!<html><head>
41 <link rel="openid.server" href="%s" />
42 <link rel="openid2.provider" href="%s" />
43 <meta http-equiv="X-XRDS-Location" content="%sxrds" />
44 <title>OpenID server endpoint</title>
45 </head><body>OpenID server endpoint</body></html>!
47 XRDS_XML = %q!<?xml version="1.0" encoding="UTF-8"?>
48 <xrds:XRDS
49 xmlns:xrds="xri://$xrds"
50 xmlns="xri://$xrd*($v*2.0)">
51 <XRD>
52 <Service priority="0">
53 %types
54 <URI>%s</URI>
55 </Service>
56 </XRD>
57 </xrds:XRDS>!
59 CONFIG_HEADER = %!
60 This file may be changed by #{__FILE__} or your favorite $EDITOR
61 comments will be deleted when modified by #{__FILE__}. See the
62 comments end of this file for help on the format.
63 !.lstrip!
65 CONFIG_TRAILER = %!
66 Configuration file description.
68 * allowed_ips An array of strings representing IPs that may
69 authenticate through local-openid. Only put
70 IP addresses that you trust in here.
72 Each OpenID consumer trust root will have its own hash keyed by
73 the trust root URL. Keys in this hash are:
75 - expires The time at which this login will expire.
76 This is generally the only entry you need to edit
77 to approve a site. You may also delete this line
78 and rename the "expires1m" to this.
79 - expires1m The time 1 minute from when this entry was updated.
80 This is provided as a convenience for replacing
81 the default "expires" entry. This key may be safely
82 removed by a user editing it.
83 - updated Time this entry was updated, strictly informational.
84 - session_id Unique identifier in your session cookie to prevent
85 other users from hijacking your session. You may
86 delete this if you've changed browsers or computers.
87 - assoc_handle See the OpenID specs, may be empty. Do not edit this.
89 SReg keys supported by the Ruby OpenID implementation should be
90 supported, they include (but are not limited to):
91 ! << OpenID::SReg::DATA_FIELDS.map do |key, value|
92 " - #{key}: #{value}"
93 end.join("\n") << %!
94 SReg keys may be global at the top-level or private to each trust root.
95 Per-trust root SReg entries override the global settings.
98 include OpenID::Server
100 # this is the heart of our provider logic, adapted from the
101 # Ruby OpenID gem Rails example
102 def get_or_post
103 oidreq = begin
104 server.decode_request(params)
105 rescue ProtocolError => err
106 halt(500, err.to_s)
109 oidreq or return render_xrds
111 oidresp = case oidreq
112 when CheckIDRequest
113 if oidreq.id_select && oidreq.immediate
114 oidreq.answer(false)
115 elsif is_authorized?(oidreq)
116 resp = oidreq.answer(true, nil, server_root)
117 add_sreg(oidreq, resp)
118 add_pape(oidreq, resp)
119 resp
120 elsif oidreq.immediate
121 oidreq.answer(false, server_root)
122 else
123 session[:id] ||= "#{Time.now.to_i}.#$$.#{rand}"
124 session[:ip] = request.ip
125 merge_config(oidreq)
126 write_config
128 # here we allow our user to open $EDITOR and edit the appropriate
129 # 'expires' field in config.yml corresponding to oidreq.trust_root
130 return PROMPT.gsub(/%s/, oidreq.trust_root)
132 else
133 server.handle_request(oidreq)
136 finalize_response(oidresp)
139 # we're the provider for exactly one identity. However, we do rely on
140 # being proxied and being hit with an appropriate HTTP Host: header.
141 # Don't expect OpenID consumers to handle port != 80.
142 def server_root
143 "http://#{request.host}/"
146 def server
147 @server ||= Server.new(
148 OpenID::Store::Filesystem.new("#$local_openid/store"),
149 server_root)
152 # support the simple registration extension if possible,
153 # allow per-site overrides of various data points
154 def add_sreg(oidreq, oidresp)
155 sregreq = OpenID::SReg::Request.from_openid_request(oidreq) or return
156 per_site = config[oidreq.trust_root] || {}
158 sreg_data = {}
159 sregreq.all_requested_fields.each do |field|
160 sreg_data[field] = per_site[field] || config[field]
163 sregresp = OpenID::SReg::Response.extract_response(sregreq, sreg_data)
164 oidresp.add_extension(sregresp)
167 def add_pape(oidreq, oidresp)
168 papereq = OpenID::PAPE::Request.from_openid_request(oidreq) or return
169 paperesp = OpenID::PAPE::Response.new(papereq.preferred_auth_policies,
170 papereq.max_auth_age)
171 # since this implementation requires shell/filesystem access to the
172 # OpenID server to authenticate, we can say we're at the highest
173 # auth level possible...
174 paperesp.add_policy_uri(OpenID::PAPE::AUTH_MULTI_FACTOR_PHYSICAL)
175 paperesp.auth_time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
176 paperesp.nist_auth_level = 4
177 oidresp.add_extension(paperesp)
180 def err(msg)
181 env['rack.errors'].write("#{msg}\n")
182 false
185 def finalize_response(oidresp)
186 server.signatory.sign(oidresp) if oidresp.needs_signing
187 web_response = server.encode_response(oidresp)
189 case web_response.code
190 when HTTP_OK
191 web_response.body
192 when HTTP_REDIRECT
193 location = web_response.headers['location']
194 err("redirecting to: #{location} ...")
195 redirect(location)
196 else
197 halt(500, web_response.body)
201 # the heart of our custom authentication logic
202 def is_authorized?(oidreq)
203 (config['allowed_ips'] ||= []).include?(request.ip) or
204 return err("Not allowed: #{request.ip}\n" \
205 "You need to put this IP in the 'allowed_ips' array "\
206 "in:\n #$local_openid/config.yml")
208 request.ip == session[:ip] or
209 return err("session IP mismatch: " \
210 "#{request.ip.inspect} != #{session[:ip].inspect}")
212 trust_root = oidreq.trust_root
213 per_site = config[trust_root] or
214 return err("trust_root unknown: #{trust_root}")
216 session_id = session[:id] or return err("no session ID")
218 assoc_handle = per_site['assoc_handle'] # this may be nil
219 expires = per_site['expires'] or
220 return err("no expires (trust_root=#{trust_root})")
222 assoc_handle == oidreq.assoc_handle or
223 return err("assoc_handle mismatch: " \
224 "#{assoc_handle.inspect} != #{oidreq.assoc_handle.inspect}" \
225 " (trust_root=#{trust_root})")
227 per_site['session_id'] == session_id or
228 return err("session ID mismatch: " \
229 "#{per_site['session_id'].inspect} != #{session_id.inspect}" \
230 " (trust_root=#{trust_root})")
232 expires > Time.now or
233 return err("Expired: #{expires.inspect} (trust_root=#{trust_root})")
235 true
238 def config
239 @config ||= begin
240 YAML.load(File.read("#$local_openid/config.yml"))
241 rescue Errno::ENOENT
246 def merge_config(oidreq)
247 per_site = config[oidreq.trust_root] ||= {}
248 per_site.merge!({
249 'assoc_handle' => oidreq.assoc_handle,
250 'expires' => Time.at(0).utc,
251 'updated' => Time.now.utc,
252 'expires1m' => Time.now.utc + 60, # easy edit to "expires" in $EDITOR
253 'session_id' => session[:id],
257 def write_config
258 path = "#$local_openid/config.yml"
259 tmp = Tempfile.new('config.yml', File.dirname(path))
260 tmp.syswrite(CONFIG_HEADER.gsub(/^/m, "# "))
261 tmp.syswrite(config.to_yaml)
262 tmp.syswrite(CONFIG_TRAILER.gsub(/^/m, "# "))
263 tmp.fsync
264 File.rename(tmp.path, path)
265 tmp.close!
268 # this output is designed to be parsed by OpenID consumers
269 def render_xrds(force = false)
270 if force || request.accept.include?('application/xrds+xml')
272 # this seems to work...
273 types = request.accept.include?('application/xrds+xml') ?
274 [ OpenID::OPENID_2_0_TYPE, OpenID::OPENID_1_0_TYPE, OpenID::SREG_URI ] :
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
289 def big_lock(&block)
290 lock = "#$local_openid/lock"
291 File.open(lock, File::WRONLY|File::CREAT|File::EXCL, 0600) do |fp|
292 begin
293 yield
294 ensure
295 File.unlink(lock)
298 rescue Errno::EEXIST
299 err("Lock: #{lock} exists! Possible hijacking attempt") rescue nil