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.
13 require 'openid/extensions/sreg'
14 require 'openid/extensions/pape'
15 require 'openid/store/filesystem'
18 set
:environment, :production
19 set
:logging, false # load Rack::CommonLogger in config.ru instead
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
} }
34 # yes, I use gsub for templating because I find it easier than erb :P
36 <head
><title
>OpenID login
: %s
</title></head
>
37 <body
><h1
>reload this page
when approved
: %s
</h1></body
>
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"?>
49 xmlns
:xrds="xri://$xrds"
50 xmlns
="xri://$xrd*($v*2.0)">
52 <Service priority
="0">
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
.
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|
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
104 server.decode_request(params)
105 rescue ProtocolError => err
109 oidreq or return render_xrds
111 oidresp = case oidreq
113 if oidreq.id_select && oidreq.immediate
115 elsif is_authorized?(oidreq)
116 resp = oidreq.answer(true, nil, server_root)
117 add_sreg(oidreq, resp)
118 add_pape(oidreq, resp)
120 elsif oidreq.immediate
121 oidreq.answer(false, server_root)
123 session[:id] ||= "#{Time.now.to_i}.#$$.#{rand}"
124 session[:ip] = request.ip
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)
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.
143 "http://#{request.host}/"
147 @server ||= Server
.new(
148 OpenID
::Store::Filesystem.new("#$local_openid/store"),
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
] || {}
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
)
181 env['rack.errors'].write("#{msg}\n")
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
193 location
= web_response
.headers
['location']
194 err("redirecting to: #{location} ...")
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})")
240 YAML
.load(File
.read("#$local_openid/config.yml"))
246 def merge_config(oidreq
)
247 per_site
= config
[oidreq
.trust_root
] ||= {}
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],
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
, "# "))
264 File
.rename(tmp
.path
, path
)
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
290 lock
= "#$local_openid/lock"
291 File
.open(lock
, File
::WRONLY|File
::CREAT|File
::EXCL, 0600) do |fp
|
299 err("Lock: #{lock} exists! Possible hijacking attempt") rescue nil