lobby_boy 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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>