Skip to content

Commit 0ff3710

Browse files
committed
start front channel logout flow
1 parent d1f348f commit 0ff3710

File tree

4 files changed

+95
-0
lines changed

4 files changed

+95
-0
lines changed

app/controllers/rpi_auth/auth_controller.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,67 @@ def failure
5353
redirect_to '/'
5454
end
5555

56+
def frontchannel_logout
57+
# Front-channel logout: The identity provider redirects the user's browser here
58+
# in an iframe with 'iss' and 'sid' parameters if the Hydra client has been configured to do so.
59+
# Since this is a user request, we have their session cookie and can reset the session directly.
60+
61+
validation_error = validate_frontchannel_logout
62+
return head :bad_request if validation_error
63+
64+
# All validations passed - reset the session
65+
reset_session
66+
head :ok
67+
end
68+
5669
private
5770

71+
def validate_frontchannel_logout
72+
error = check_iframe || check_issuer || check_sid_param || check_current_user || check_sid_match
73+
log_validation_error(error) if error
74+
error
75+
end
76+
77+
def request_in_iframe?
78+
request.headers['Sec-Fetch-Dest'] == 'iframe'
79+
end
80+
81+
def check_iframe
82+
return 'request not in iframe' unless request_in_iframe?
83+
84+
nil
85+
end
86+
87+
def check_issuer
88+
iss = params[:iss]
89+
return 'issuer mismatch or missing' unless iss && RpiAuth.configuration.issuer.chomp('/') == iss.chomp('/')
90+
91+
nil
92+
end
93+
94+
def check_sid_param
95+
return 'sid parameter missing' unless params[:sid].present?
96+
97+
nil
98+
end
99+
100+
def check_current_user
101+
return 'no current user' unless current_user
102+
return 'current user has no sid (old session), rejecting for security' if current_user.sid.nil?
103+
104+
nil
105+
end
106+
107+
def check_sid_match
108+
return nil if current_user.sid == params[:sid]
109+
110+
"sid mismatch (expected: #{current_user.sid}, got: #{params[:sid]})"
111+
end
112+
113+
def log_validation_error(message)
114+
Rails.logger.warn("Front-channel logout: #{message}")
115+
end
116+
58117
def run_login_success_callback
59118
return unless RpiAuth.configuration.on_login_success.is_a?(Proc)
60119

config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
get RpiAuth::Engine::CALLBACK_PATH, to: 'rpi_auth/auth#callback', as: 'rpi_auth_callback'
1111
get RpiAuth::Engine::LOGOUT_PATH, to: 'rpi_auth/auth#destroy', as: 'rpi_auth_logout'
12+
get RpiAuth::Engine::FRONTCHANNEL_LOGOUT_PATH, to: 'rpi_auth/auth#frontchannel_logout',
13+
as: 'rpi_auth_frontchannel_logout'
1214

1315
# This route can be used in testing to log in, avoiding the need to interact
1416
# with shadow root in the RPF global nav.

lib/rpi_auth/engine.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class Engine < ::Rails::Engine
1212
LOGIN_PATH = '/auth/rpi'
1313
CALLBACK_PATH = '/rpi_auth/auth/callback'
1414
LOGOUT_PATH = '/rpi_auth/logout'
15+
FRONTCHANNEL_LOGOUT_PATH = '/rpi_auth/frontchannel_logout'
1516
TEST_PATH = '/rpi_auth/test'
1617

1718
ENABLE_TEST_PATH = Rails.env.development? || Rails.env.test?

lib/rpi_auth/models/authenticatable.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# frozen_string_literal: true
22

3+
require 'json'
4+
require 'base64'
5+
36
module RpiAuth
47
module Models
58
module Authenticatable
@@ -16,6 +19,7 @@ module Authenticatable
1619
postcode
1720
profile
1821
roles
22+
sid
1923
].freeze
2024

2125
included do
@@ -41,8 +45,37 @@ def from_omniauth(auth)
4145
args = auth.extra.raw_info.to_h.slice(*PROFILE_KEYS)
4246
args['user_id'] = auth.uid
4347

48+
# Extract sid from id_token if not already in raw_info
49+
# sid may be nil if not available (backward compatible with old sessions)
50+
args['sid'] ||= extract_sid(auth)
51+
4452
new(args)
4553
end
54+
55+
private
56+
57+
def extract_sid(auth)
58+
# Decode sid from id_token (raw_info is already checked via slice above)
59+
id_token = auth.extra&.id_token || auth.credentials&.id_token
60+
return unless id_token
61+
62+
decode_sid_from_token(id_token)
63+
end
64+
65+
def decode_sid_from_token(id_token)
66+
parts = id_token.split('.')
67+
return unless parts.length == 3
68+
69+
payload = parts[1]
70+
payload += '=' * (4 - (payload.length % 4)) if payload.length % 4 != 0
71+
decoded_payload = Base64.urlsafe_decode64(payload)
72+
claims = JSON.parse(decoded_payload)
73+
74+
claims['sid']
75+
rescue StandardError => e
76+
Rails.logger.warn("Failed to extract sid from id_token: #{e.message}")
77+
nil
78+
end
4679
end
4780
end
4881
end

0 commit comments

Comments
 (0)