lobby_boy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # lobby_boy
2
+ Rails engine for OpenID Connect Session Management
3
+
4
+ Assumes the use of OmniAuth and the `omniauth-openid-connect` strategy.
5
+
6
+ ## Dependencies
7
+
8
+ If not present yet add the following gem:
9
+
10
+ ```ruby
11
+ gem 'omniauth-openid-connect', git: 'https://github.com/jjbohn/omniauth-openid-connect.git', branch: 'master'
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ You have to do 6 steps to enable session management in your application:
17
+
18
+ 1. Mount the engine.
19
+ 2. Configure lobby_boy.
20
+ 3. Render lobby_boy's iframes partial in your layout.
21
+ 4. Call lobby_boy's `SessionHelper#confirm_login!` when the user is logged in.
22
+ 5. Call lobby_boy's `SessionHelper#logout_at_op!` when the user logs out.
23
+ 6. Implement the end_session_endpoint to be used by lobby_boys Javascript.
24
+
25
+ The following sections will describe those steps in more detail.
26
+
27
+ ### 1. Mount the engine
28
+
29
+ To mount the engine into your application add the following to your `config/routes.rb`:
30
+
31
+ ```ruby
32
+ require 'lobby_boy'
33
+
34
+ Rails.application.routes.draw do
35
+ mount LobbyBoy::Engine, at: '/'
36
+ end
37
+ ```
38
+
39
+ ### 2. Configure lobby_boy
40
+
41
+ The are two sections to be configured:
42
+
43
+ *client*
44
+
45
+ This refers to your application which is an OpenID Connect client.
46
+ All lobby_boy needs to know about it is its `host` and `end_session_endpoint` (i.e. logout URL).
47
+
48
+ Here are all available client options, the rest of them which are optional:
49
+
50
+ ```ruby
51
+ LobbyBoy.configure_client! host: 'https://myapp.com',
52
+ end_session_endpoint: '/logout',
53
+ # (optional) Derived from host per default:
54
+ cookie_domain: "myapp.com",
55
+ # (optional) A block executed in the context of a rails controller
56
+ # which returns true if the user is logged into
57
+ # the application:
58
+ logged_in: ->() { session.include? :user },
59
+ # (optional) Seconds before the ID token's expiration at which
60
+ # to re-authenticate early:
61
+ refresh_offset: 60,
62
+ # (optional) Check the session state every 30 seconds and refresh
63
+ # if out of sync:
64
+ refresh_interval: 30,
65
+ # (optional) A .js.erb (app/views/session/_on_login.js.erb) partial
66
+ # to be rendered the code of which will be executed if the user is
67
+ # logged in automatically:
68
+ on_login_js_partial: 'session/on_login',
69
+ # (optional) A .js.erb (app/views/session/_on_logout.js.erb) partial
70
+ # to be rendered the code of which will be executed if the user is
71
+ # logged out automatically:
72
+ on_logout_js_partial: 'session/on_logout'
73
+
74
+ ```
75
+
76
+ *provider*
77
+
78
+ The OpenIDConnect provider has to support Session Management too. The essential details
79
+ required for the provider are its `name` (its strategy being available under `/auth/$name`)
80
+ and the `identifier` under which your client is registered at the provider.
81
+
82
+ If the provider supports discovery this is everything. If not you will also have to configure
83
+ the `issuer`, `end_session_endpoint` and `check_session_iframe`.
84
+
85
+ For instance for **Concierge** which does not support discovery yet:
86
+
87
+ ```ruby
88
+ LobbyBoy.configure_provider! name: 'concierge',
89
+ client_id: 'openproject',
90
+ issuer: 'https://concierge.openproject.com',
91
+ end_session_endpoint: '/session/end',
92
+ check_session_iframe: '/session/check']
93
+ ```
94
+
95
+ ### 3. Render lobby_boy's iframes partial in your layout.
96
+
97
+ Session Management requires two iframes, the relying party iframe and the OpenIDConnect provider iframe,
98
+ to be rendered at all times, on every page.
99
+ In a standard rails application you would do this by inserting the following line into
100
+ `app/views/layouts/application.html.erb`:
101
+
102
+ ```
103
+ <%= render 'lobby_boy/iframes' %>
104
+ ```
105
+
106
+ ### 4. Call lobby_boy's `SessionHelper#confirm_login!` when the user is logged in.
107
+
108
+ The `#confirm_login!` helper stores the logged-in user's ID token in the session
109
+ and sets the `oidc_rp_state` cookie. Which means that this helper has to be called
110
+ in the context of the final action handling the user's login.
111
+
112
+ Another thing the login must do is to redirect the user to the omniauth origin.
113
+ It should do that already if implemented correctly.
114
+
115
+ For instance:
116
+
117
+ ```ruby
118
+ class SessionController < ApplicationController
119
+ include LobbyBoy::SessionHelper
120
+
121
+ def login
122
+ # existing logic:
123
+ # ...
124
+
125
+ confirm_login!
126
+ redirect_to(request.env['omniauth.origin'] || default_url)
127
+ end
128
+ end
129
+ ```
130
+
131
+ It is important that your login action redirects to `omniauth.origin` after a
132
+ successful login.
133
+
134
+ *Optional: reauthentication*
135
+
136
+ If you have to behave differently when the user is reauthenticated you can
137
+ additionally to the changes above use the reauthentication helpers.
138
+
139
+ ```ruby
140
+ class SessionController < ApplicationController
141
+ include LobbyBoy::SessionHelper
142
+
143
+ def login
144
+ # existing logic:
145
+ # ...
146
+
147
+ confirm_login!
148
+
149
+ if reauthentication?
150
+ finish_reauthentication!
151
+ else
152
+ go_on_to_do_more_stuff_not_necessary_on_reauthentication
153
+ end
154
+ end
155
+ end
156
+
157
+ ### Call lobby_boy's `SessionHelper#logout_at_op!` when the user logs out.
158
+
159
+ When the user logs out of the application they should also be logged out of the
160
+ OpenID Connect provider. To do that call the `#logout_at_op!` helper in your existing logout action.
161
+ The helper will redirect the user to the provider's logout endpoint to log them out globally.
162
+ You can pass a return URL to which the provider will send the user after the logout.
163
+
164
+ For instance:
165
+
166
+ ```ruby
167
+ class SessionController < ApplicationController
168
+ def logout
169
+ # The helper will return false and do nothing the provider's
170
+ # end_session_endpoint is not configured.
171
+ unless logout_at_op! root_url
172
+ redirect_to root_url
173
+ end
174
+ end
175
+ end
176
+ ```
177
+
178
+ ### 6. Implement the end_session_endpoint to be used by lobby_boys Javascript.
179
+
180
+ The Javascript of lobby_boy may logout the user when it realizes that
181
+ the user has been logged out at the OpenID Connect provider.
182
+ You have to implement the `end_session_endpoint` and after logging out
183
+ the user you have to call `finish_logout!` from LobbyBoy's `SessionHelper`.
184
+
185
+ For instance:
186
+
187
+ ```ruby
188
+ class SessionController < ApplicationController
189
+ include LobbyBoy::SessionHelper
190
+
191
+ def lobby_boy_logout
192
+ logout_user!
193
+ finish_logout!
194
+ end
195
+ end
196
+ ```
data/Rakefile ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env rake
2
+ begin
3
+ require 'bundler/setup'
4
+ rescue LoadError
5
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ end
7
+ begin
8
+ require 'rdoc/task'
9
+ rescue LoadError
10
+ require 'rdoc/rdoc'
11
+ require 'rake/rdoctask'
12
+ RDoc::Task = Rake::RDocTask
13
+ end
14
+
15
+ RDoc::Task.new(:rdoc) do |rdoc|
16
+ rdoc.rdoc_dir = 'rdoc'
17
+ rdoc.title = 'LobbyBoy'
18
+ rdoc.options << '--line-numbers'
19
+ rdoc.rdoc_files.include('README.rdoc')
20
+ rdoc.rdoc_files.include('lib/**/*.rb')
21
+ end
22
+
23
+ APP_RAKEFILE = File.expand_path("../test/dummy/Rakefile", __FILE__)
24
+ load 'rails/tasks/engine.rake'
25
+
26
+
27
+
28
+ Bundler::GemHelper.install_tasks
29
+
30
+ require 'rake/testtask'
31
+
32
+ Rake::TestTask.new(:test) do |t|
33
+ t.libs << 'lib'
34
+ t.libs << 'test'
35
+ t.pattern = 'test/**/*_test.rb'
36
+ t.verbose = false
37
+ end
38
+
39
+
40
+ task :default => :test
@@ -0,0 +1,93 @@
1
+ module LobbyBoy
2
+ class SessionController < ActionController::Base
3
+ layout false
4
+
5
+ before_filter :set_cache_buster
6
+
7
+ def check
8
+ response.headers['X-Frame-Options'] = 'SAMEORIGIN'
9
+
10
+ render_check 'init'
11
+ end
12
+
13
+ def state
14
+ current_state =
15
+ if params[:state] == 'unauthenticated'
16
+ 'unauthenticated'
17
+ elsif params[:state] == 'logout'
18
+ 'logout'
19
+ else
20
+ self.id_token ? 'authenticated' : 'unauthenticated'
21
+ end
22
+
23
+ render_check current_state
24
+ end
25
+
26
+ def end
27
+ cookies.delete :oidc_rp_state, domain: LobbyBoy.client.cookie_domain
28
+
29
+ redirect_to LobbyBoy.client.end_session_endpoint
30
+ end
31
+
32
+ def refresh
33
+ provider = LobbyBoy.provider.name
34
+
35
+ id_token = self.id_token
36
+
37
+ id_token_hint = id_token && id_token.jwt_token
38
+ origin = '/session/state'
39
+
40
+ params = {
41
+ prompt: 'none',
42
+ origin: origin,
43
+ id_token_hint: id_token_hint
44
+ }
45
+
46
+ redirect_to "#{omniauth_prefix}/#{provider}?#{compact_hash(params).to_query}"
47
+ end
48
+
49
+ ##
50
+ # Defines used functions. All of which are only dependent on
51
+ # their input parameters and not on some random global state.
52
+ module Functions
53
+ module_function
54
+
55
+ ##
56
+ # Returns a new hash only containing entries the values of which are not nil.
57
+ def compact_hash(hash)
58
+ hash.reject { |_, v| v.nil? }
59
+ end
60
+
61
+ def omniauth_prefix
62
+ ::OmniAuth.config.path_prefix
63
+ end
64
+
65
+ ##
66
+ # Returns true if the user is logged in locally or false
67
+ # if they aren't or we don't know whether or not they are.
68
+ def logged_in?
69
+ instance_exec &LobbyBoy.client.logged_in
70
+ end
71
+ end
72
+
73
+ module InstanceMethods
74
+ def id_token
75
+ token = session['lobby_boy.id_token']
76
+ ::LobbyBoy::OpenIDConnect::IdToken.new token if token
77
+ end
78
+
79
+ def render_check(state)
80
+ render 'check', locals: { state: state, logged_in: logged_in? }
81
+ end
82
+
83
+ def set_cache_buster
84
+ response.headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate"
85
+ response.headers["Pragma"] = "no-cache"
86
+ response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT"
87
+ end
88
+ end
89
+
90
+ include Functions
91
+ include InstanceMethods
92
+ end
93
+ end
@@ -0,0 +1,52 @@
1
+ module LobbyBoy
2
+ module SessionHelper
3
+ ##
4
+ # Call in host rails controller to confirm that the user was logged in.
5
+ def confirm_login!
6
+ if LobbyBoy.configured?
7
+ session['lobby_boy.id_token'] = env['lobby_boy.id_token'].jwt_token
8
+ cookies[:oidc_rp_state] = env['lobby_boy.cookie']
9
+ end
10
+ end
11
+
12
+ def finish_reauthentication!
13
+ redirect_to(lobby_boy_path + 'session/state')
14
+ end
15
+
16
+ def reauthentication?
17
+ env['omniauth.origin'] == '/session/state'
18
+ end
19
+
20
+ def finish_logout!
21
+ redirect_to(lobby_boy_path + 'session/state?state=logout')
22
+ end
23
+
24
+ def id_token_expired?
25
+ id_token && id_token.expires_in == 0
26
+ end
27
+
28
+ def id_token
29
+ token = session['lobby_boy.id_token']
30
+ ::LobbyBoy::OpenIDConnect::IdToken.new token if token
31
+ end
32
+
33
+ def logout_at_op!(return_url = nil)
34
+ return false unless LobbyBoy.configured?
35
+
36
+ id_token_hint = id_token && id_token.jwt_token
37
+ logout_url = LobbyBoy::Util::URI.add_query_params(
38
+ LobbyBoy.provider.end_session_endpoint,
39
+ id_token_hint: id_token_hint,
40
+ post_logout_redirect_uri: return_url)
41
+
42
+ cookies.delete :oidc_rp_state, domain: LobbyBoy.client.cookie_domain
43
+
44
+ if logout_url # may be nil if not configured
45
+ redirect_to logout_url # log out at OpenIDConnect SSO provider too
46
+ true
47
+ else
48
+ false
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,7 @@
1
+ <% if LobbyBoy.configured? %>
2
+ <% op_src = LobbyBoy.provider.check_session_iframe %>
3
+ <% rp_src = "#{LobbyBoy.client.host}/session/check" %>
4
+
5
+ <iframe id="openid_connect_provider" src="<%= op_src %>" style="display: none;"></iframe>
6
+ <iframe id="openid_connect_relying_party" src="<%= rp_src %>" style="display: none;"></iframe>
7
+ <% end %>
@@ -0,0 +1,171 @@
1
+ <%= javascript_include_tag 'js.cookie-1.5.1.min' %>
2
+
3
+ <%#
4
+ We're defining all this Javascript here as we are using URL helpers in it which
5
+ won't work in a .js.erb file without cheating.
6
+ %>
7
+
8
+ <script type="text/javascript">
9
+ Cookies.json = true;
10
+
11
+ window.parent.OpenIDConnect = new function() {
12
+ var self = this;
13
+ var locallyLoggedIn = <%= logged_in %>;
14
+
15
+ this.clientId = "<%= LobbyBoy.provider.client_id %>";
16
+ this.targetOrigin = "<%= LobbyBoy.provider.issuer %>";
17
+ this.loggedIn = (window.parent.OpenIDConnect && window.parent.OpenIDConnect.loggedIn)
18
+ || locallyLoggedIn;
19
+ this.timerIntervalMs = <%= LobbyBoy.client.refresh_interval %> * 1000;
20
+
21
+ this.getIFrame = function(id) {
22
+ return window.parent.document.getElementById(id).contentWindow;
23
+ }
24
+
25
+ this.checkSession = function() {
26
+ var provider = self.getIFrame("openid_connect_provider");
27
+
28
+ var message = function(state) {
29
+ return self.clientId + " " + state;
30
+ };
31
+
32
+ /**
33
+ * Checking the state has two possible outcomes depending on the user's
34
+ * global login state. Either the user is logged in in which case
35
+ * their ID token is refreshed, or they aren't in which case they
36
+ * are logged out of the application and notified about the logout.
37
+ */
38
+ var checkState = function(state) {
39
+ provider.postMessage(message(state), self.targetOrigin);
40
+ };
41
+
42
+ var state = Cookies.get("oidc_rp_state");
43
+ var time = new Date().getTime() / 1000;
44
+ var offset = <%= LobbyBoy.client.refresh_offset %>;
45
+
46
+ if (state || locallyLoggedIn) {
47
+ self.loggedIn = true;
48
+
49
+ if (state && state.expires_at - time > offset) {
50
+ checkState(state.state);
51
+ } else if (!state && locallyLoggedIn) {
52
+ /**
53
+ * This branch is executed in two cases:
54
+ *
55
+ * 1) The state cookie is not set but the user is logged into the application
56
+ *
57
+ * This can happen if the previous state cookie expired but the user's normal
58
+ * application session hasn't. At this point we don't know the user's
59
+ * global login status. Hence we force a re-authentication to sync the state.
60
+ *
61
+ * 2) The state is set but is about to expire.
62
+ *
63
+ * We prematurely 'expire' the session state to ensure that the user
64
+ * remains logged in constantly. Otherwise it could happen that the
65
+ * user is logged out briefly between the time the old ID token expired
66
+ * and the time it takes to re-authenticate.
67
+ */
68
+ checkState("refresh.please");
69
+ }
70
+ } else {
71
+ // not logged in
72
+ }
73
+ };
74
+
75
+ this.startTimer = function() {
76
+ if (self.timerID) {
77
+ self.stopTimer(); // stop previous timer if present
78
+ }
79
+
80
+ self.timerID = setInterval(self.checkSession, self.timerIntervalMs);
81
+ };
82
+
83
+ this.stopTimer = function() {
84
+ clearInterval(self.timerID)
85
+ };
86
+
87
+ this.reauthenticate = function() {
88
+ console.log("I ought to re-authenticate");
89
+
90
+ var check_session_iframe = self.getIFrame("openid_connect_relying_party");
91
+
92
+ check_session_iframe.document.location.href = "<%= session_refresh_url %>";
93
+ };
94
+
95
+ this.onReauthenticationSuccess = function() {
96
+ console.log("Re-authenticated successfully.");
97
+
98
+ /**
99
+ * The login status will only be updated with the next call of checkSession
100
+ * which is why it may still be false at this point.
101
+ *
102
+ * If it is false it means the user may be seeing a sign-in button in the menu.
103
+ * So reload the page to have them see that they are now logged in.
104
+ */
105
+ if (self.loggedIn === false) {
106
+ self.onLogin();
107
+ }
108
+ };
109
+
110
+ this.onReauthenticationFailure = function() {
111
+ if (self.loggedIn !== false) {
112
+ self.loggedIn = false;
113
+ self.logout();
114
+ }
115
+ };
116
+
117
+ this.onLogin = function() {
118
+ <% if LobbyBoy.client.on_login_js_partial %>
119
+ <%= render partial: LobbyBoy.client.on_login_js_partial, formats: [:js] %>
120
+ <% else %>
121
+ window.parent.document.location.reload();
122
+ <% end %>
123
+ };
124
+
125
+ this.onLogout = function() {
126
+ <% if LobbyBoy.client.on_logout_js_partial %>
127
+ <%= render partial: LobbyBoy.client.on_logout_js_partial, formats: [:js] %>
128
+ <% else %>
129
+ alert("You've been logged out.");
130
+ <% end %>
131
+ };
132
+
133
+ this.logout = function(callback) {
134
+ console.log("I ought to log out.");
135
+
136
+ var check_session_iframe = self.getIFrame("openid_connect_relying_party");
137
+
138
+ check_session_iframe.document.location.href = "<%= session_end_url %>";
139
+ };
140
+
141
+ this.receiveMessage = function(e) {
142
+ if (e.origin !== self.targetOrigin ) {
143
+ return;
144
+ }
145
+
146
+ self.state = e.data;
147
+
148
+ if (self.state == "changed") {
149
+ self.reauthenticate();
150
+ } else if (self.state == "error") {
151
+ console.log("error checking session state");
152
+ }
153
+ };
154
+
155
+ window.addEventListener("message", self.receiveMessage, false);
156
+
157
+ <% if state == 'authenticated' %>
158
+ self.onReauthenticationSuccess();
159
+ <% elsif state == 'unauthenticated' %>
160
+ self.onReauthenticationFailure();
161
+ <% elsif state == 'logout' %>
162
+ self.onLogout();
163
+ <% else %>
164
+ document.addEventListener("DOMContentLoaded", function(event) {
165
+ setTimeout(self.checkSession, 1000);
166
+ });
167
+ <% end %>
168
+
169
+ self.startTimer();
170
+ };
171
+ </script>