Class: Tina4::Sso

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/sso.rb

Overview

Provider-neutral, configuration-first OpenID Connect SSO.

Constant Summary collapse

PENDING_KEY =
"_tina4_sso_pending"
SESSION_KEY =
"_tina4_sso"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ Sso

Returns a new instance of Sso.



22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/tina4/sso.rb', line 22

def initialize(options = {})
  @issuer = (options[:issuer] || ENV["TINA4_SSO_ISSUER"] || "").sub(%r{/$}, "")
  @client_id = options[:client_id] || ENV["TINA4_SSO_CLIENT_ID"] || ""
  @client_secret = options.key?(:client_secret) ? options[:client_secret] : ENV["TINA4_SSO_CLIENT_SECRET"]
  @redirect_uri = options[:redirect_uri] || ENV["TINA4_SSO_REDIRECT_URI"] || ""
  @scopes = options[:scopes] || json_env("TINA4_SSO_SCOPES", %w[openid profile email])
  @verify = (options[:verify] || ENV["TINA4_SSO_VERIFY"] || "introspection").downcase
  @post_logout_redirect_uri = options[:post_logout_redirect_uri] || ENV["TINA4_SSO_POST_LOGOUT_REDIRECT_URI"]
  @claim_map = options[:claim_map] || json_env("TINA4_SSO_CLAIM_MAP", {})
  @metadata = {}
  validate_config!
end

Instance Attribute Details

#claim_mapObject (readonly)

Returns the value of attribute claim_map.



18
19
20
# File 'lib/tina4/sso.rb', line 18

def claim_map
  @claim_map
end

#client_idObject (readonly)

Returns the value of attribute client_id.



18
19
20
# File 'lib/tina4/sso.rb', line 18

def client_id
  @client_id
end

#client_secretObject (readonly)

Returns the value of attribute client_secret.



18
19
20
# File 'lib/tina4/sso.rb', line 18

def client_secret
  @client_secret
end

#issuerObject (readonly)

Returns the value of attribute issuer.



18
19
20
# File 'lib/tina4/sso.rb', line 18

def issuer
  @issuer
end

#post_logout_redirect_uriObject (readonly)

Returns the value of attribute post_logout_redirect_uri.



18
19
20
# File 'lib/tina4/sso.rb', line 18

def post_logout_redirect_uri
  @post_logout_redirect_uri
end

#redirect_uriObject (readonly)

Returns the value of attribute redirect_uri.



18
19
20
# File 'lib/tina4/sso.rb', line 18

def redirect_uri
  @redirect_uri
end

#scopesObject (readonly)

Returns the value of attribute scopes.



18
19
20
# File 'lib/tina4/sso.rb', line 18

def scopes
  @scopes
end

#verifyObject (readonly)

Returns the value of attribute verify.



18
19
20
# File 'lib/tina4/sso.rb', line 18

def verify
  @verify
end

Class Method Details

.configured?Boolean

Returns:

  • (Boolean)


39
40
41
# File 'lib/tina4/sso.rb', line 39

def self.configured?
  %w[TINA4_SSO_ISSUER TINA4_SSO_CLIENT_ID TINA4_SSO_REDIRECT_URI].all? { |key| !ENV[key].to_s.empty? }
end

.from_issuer(options = {}) ⇒ Object



35
36
37
# File 'lib/tina4/sso.rb', line 35

def self.from_issuer(options = {})
  new(options).tap(&:discover)
end

.jwt_payload(token) ⇒ Object



141
142
143
144
145
146
# File 'lib/tina4/sso.rb', line 141

def self.jwt_payload(token)
  part = token.split(".")[1].to_s
  JSON.parse(Base64.urlsafe_decode64(part.ljust((part.length + 3) / 4 * 4, "=")))
rescue JSON::ParserError, ArgumentError
  raise SsoError, "provider returned an invalid ID token"
end

.mount_configuredObject



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/tina4/sso.rb', line 257

def self.mount_configured
  return false if @mounted || !configured?
  owned = [["GET", "/auth/login"], ["GET", "/auth/callback"], ["POST", "/auth/logout"]]
  collisions = Tina4::Router.routes.select { |route| owned.include?([route.method, route.path]) }
  unless collisions.empty?
    raise SsoError, "SSO route collision: #{collisions.map { |route| "#{route.method} #{route.path}" }.join(', ')}"
  end
  sso = from_issuer
  Tina4::Router.get("/auth/login") do |request, response|
    response.redirect(sso.(request, request.params["return_to"] || "/"))
  end
  Tina4::Router.get("/auth/callback") do |request, response|
    begin
      response.redirect(sso.callback(request)["return_to"])
    rescue SsoError => e
      response.json({ error: "SSO_CALLBACK_FAILED", message: e.message }, 400)
    end
  end
  Tina4::Router.post("/auth/logout") do |request, response|
    response.redirect(sso.logout(request, request.params["return_to"] || "/"))
  end
  @mounted = true
  true
end

.safe_return(value) ⇒ Object



107
108
109
110
# File 'lib/tina4/sso.rb', line 107

def self.safe_return(value)
  return "/" if value.to_s.empty? || !value.start_with?("/") || value.start_with?("//") || value.include?("\\")
  value.each_byte.any? { |byte| byte < 32 } ? "/" : value
end

.secure_equal(left, right) ⇒ Object



135
136
137
138
139
# File 'lib/tina4/sso.rb', line 135

def self.secure_equal(left, right)
  a = left.to_s
  b = right.to_s
  a.bytesize == b.bytesize && OpenSSL.fixed_length_secure_compare(a, b)
end

.secure_url!(value, name) ⇒ Object



50
51
52
53
54
55
56
57
58
# File 'lib/tina4/sso.rb', line 50

def self.secure_url!(value, name)
  uri = URI.parse(value)
  raise SsoError, "#{name} must be an absolute URL" unless uri.absolute? && uri.host

  loopback = %w[localhost 127.0.0.1 ::1].include?(uri.host)
  raise SsoError, "#{name} must use HTTPS except on loopback" unless uri.scheme == "https" || (uri.scheme == "http" && loopback)
rescue URI::InvalidURIError => e
  raise SsoError, "#{name} must be an absolute URL", cause: e
end

Instance Method Details

#callback(request_or_session, query = nil) ⇒ Object

Raises:



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/tina4/sso.rb', line 181

def callback(request_or_session, query = nil)
  current = session(request_or_session)
  values = query || request_or_session.params
  pending = current&.get(PENDING_KEY)
  current&.delete(PENDING_KEY)
  unless pending.is_a?(Hash) && !values["code"].to_s.empty? && self.class.secure_equal(values["state"], pending["state"])
    raise SsoError, "OIDC callback state is invalid or already consumed"
  end
  raise SsoError, "OIDC callback state has expired" if Time.now.to_i - pending.fetch("created_at", 0).to_i > 600
   = discover
  tokens = request_json(["token_endpoint"], form: {
                          grant_type: "authorization_code", code: values["code"], redirect_uri: @redirect_uri,
                          client_id: @client_id, code_verifier: pending["verifier"]
                        }, basic: !@client_secret.to_s.empty?)
  raise SsoError, "OIDC token response is incomplete" if tokens["access_token"].to_s.empty? || tokens["id_token"].to_s.empty?
  raise SsoError, "JWKS verification requires an installed cryptography capability" if @verify == "jwks"
  claims = introspect(tokens["access_token"])
  unless self.class.secure_equal(self.class.jwt_payload(tokens["id_token"])["nonce"], pending["nonce"])
    raise SsoError, "OIDC ID token nonce mismatch"
  end
  claims.merge!(request_json(["userinfo_endpoint"], bearer: tokens["access_token"])) if ["userinfo_endpoint"]
  identity = normalize(claims)
  current.regenerate
  current.set(SESSION_KEY, {
                "version" => 1, "identity" => identity, "access_token" => tokens["access_token"],
                "refresh_token" => tokens["refresh_token"], "id_token" => tokens["id_token"],
                "expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
              })
  { "identity" => identity, "return_to" => self.class.safe_return(pending["return_to"]) }
end

#claim(claims, configured, fallback) ⇒ Object



161
162
163
# File 'lib/tina4/sso.rb', line 161

def claim(claims, configured, fallback)
  (configured || fallback).split(".").reduce(claims) { |value, key| value.is_a?(Hash) ? value[key] : nil }
end

#discover(force = false) ⇒ Object

Raises:



93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/tina4/sso.rb', line 93

def discover(force = false)
  return @metadata.dup unless @metadata.empty? || force
  result = request_json("#{@issuer}/.well-known/openid-configuration")
  raise SsoError, "OIDC discovery issuer does not exactly match configuration" unless result["issuer"] == @issuer
  required = %w[authorization_endpoint token_endpoint]
  required << "introspection_endpoint" if @verify == "introspection"
  required.each do |key|
    raise SsoError, "OIDC discovery is missing #{key}" if result[key].to_s.empty?
    self.class.secure_url!(result[key], key)
  end
  @metadata = result
  result.dup
end

#identity(request_or_session) ⇒ Object



212
213
214
215
216
217
# File 'lib/tina4/sso.rb', line 212

def identity(request_or_session)
  stored = session(request_or_session)&.get(SESSION_KEY)
  value = stored.is_a?(Hash) ? stored["identity"] : nil
  request_or_session.user = value if value && !request_or_session.is_a?(Tina4::Session)
  value
end

#introspect(access_token) ⇒ Object

Raises:



148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/tina4/sso.rb', line 148

def introspect(access_token)
  result = request_json(discover["introspection_endpoint"],
                        form: { token: access_token, token_type_hint: "access_token" }, basic: true)
  unless result["active"] == true && result["iss"] == @issuer
    raise SsoError, "OIDC access token is inactive or has the wrong issuer"
  end
  audience = result["aud"] || result["client_id"]
  valid = audience.is_a?(Array) ? audience.include?(@client_id) : audience == @client_id
  valid ||= result["client_id"] == @client_id
  raise SsoError, "OIDC token audience mismatch" unless valid
  result
end

#json_env(name, fallback) ⇒ Object



43
44
45
46
47
48
# File 'lib/tina4/sso.rb', line 43

def json_env(name, fallback)
  raw = ENV[name]
  raw.nil? || raw.empty? ? fallback : JSON.parse(raw)
rescue JSON::ParserError => e
  raise SsoError, "#{name} must be valid JSON", cause: e
end

#login(request_or_session, return_to = "/") ⇒ Object

Raises:



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/tina4/sso.rb', line 116

def (request_or_session, return_to = "/")
  current = session(request_or_session)
  raise SsoError, "SSO login requires a Tina4 Session" unless current
  state = SecureRandom.urlsafe_base64(32)
  nonce = SecureRandom.urlsafe_base64(32)
  verifier = SecureRandom.urlsafe_base64(64)
  challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
  current.set(PENDING_KEY, {
                "state" => state, "nonce" => nonce, "verifier" => verifier,
                "return_to" => self.class.safe_return(return_to), "created_at" => Time.now.to_i
              })
  query = URI.encode_www_form(
    client_id: @client_id, redirect_uri: @redirect_uri, response_type: "code",
    scope: @scopes.join(" "), state: state, nonce: nonce,
    code_challenge: challenge, code_challenge_method: "S256"
  )
  "#{discover['authorization_endpoint']}?#{query}"
end

#logout(request_or_session, return_to = "/") ⇒ Object



245
246
247
248
249
250
251
252
253
254
255
# File 'lib/tina4/sso.rb', line 245

def logout(request_or_session, return_to = "/")
  current = session(request_or_session)
  stored = current&.get(SESSION_KEY)
  current&.destroy
  endpoint = discover["end_session_endpoint"]
  target = @post_logout_redirect_uri || self.class.safe_return(return_to)
  return target unless endpoint
  params = { post_logout_redirect_uri: target, client_id: @client_id }
  params[:id_token_hint] = stored["id_token"] if stored.is_a?(Hash) && stored["id_token"]
  "#{endpoint}?#{URI.encode_www_form(params)}"
end

#normalize(claims) ⇒ Object

Raises:



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/tina4/sso.rb', line 165

def normalize(claims)
  subject = claim(claims, @claim_map["subject"], "sub")
  identity_issuer = claim(claims, @claim_map["issuer"], "iss") || @issuer
  raise SsoError, "OIDC identity is missing a valid issuer or subject" if subject.to_s.empty? || identity_issuer != @issuer
  roles = Array(claim(claims, @claim_map["roles"], "realm_access.roles"))
  roles += Array(claims.dig("resource_access", @client_id, "roles"))
  groups = Array(claim(claims, @claim_map["groups"], "groups"))
  {
    "issuer" => identity_issuer, "subject" => subject,
    "username" => claim(claims, @claim_map["username"], "preferred_username"),
    "email" => claim(claims, @claim_map["email"], "email"),
    "name" => claim(claims, @claim_map["name"], "name"),
    "roles" => roles.map(&:to_s).uniq.sort, "groups" => groups.map(&:to_s).uniq.sort
  }
end

#refresh(request_or_session) ⇒ Object



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/tina4/sso.rb', line 219

def refresh(request_or_session)
  current = session(request_or_session)
  stored = current&.get(SESSION_KEY)
  unless stored.is_a?(Hash) && !stored["refresh_token"].to_s.empty?
    current&.delete(SESSION_KEY)
    raise SsoError, "OIDC session cannot be refreshed"
  end
   = discover
  tokens = request_json(["token_endpoint"], form: {
                          grant_type: "refresh_token", refresh_token: stored["refresh_token"], client_id: @client_id
                        }, basic: !@client_secret.to_s.empty?)
  claims = introspect(tokens["access_token"])
  claims.merge!(request_json(["userinfo_endpoint"], bearer: tokens["access_token"])) if ["userinfo_endpoint"]
  value = normalize(claims)
  current.set(SESSION_KEY, stored.merge(
    "identity" => value, "access_token" => tokens["access_token"],
    "refresh_token" => tokens["refresh_token"] || stored["refresh_token"],
    "id_token" => tokens["id_token"] || stored["id_token"],
    "expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
  ))
  value
rescue StandardError
  current&.delete(SESSION_KEY)
  raise
end

#request_json(url, form: nil, bearer: nil, basic: false) ⇒ Object



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/tina4/sso.rb', line 72

def request_json(url, form: nil, bearer: nil, basic: false)
  uri = URI.parse(url)
  request = form ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
  request["Accept"] = "application/json"
  request.set_form_data(form.transform_values(&:to_s)) if form
  request["Authorization"] = "Bearer #{bearer}" if bearer
  request.basic_auth(@client_id, @client_secret) if basic
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme == "https"
  http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
  http.open_timeout = 10
  http.read_timeout = 10
  response = http.request(request)
  raise SsoError, "OIDC provider request failed" unless response.is_a?(Net::HTTPSuccess)
  result = JSON.parse(response.body)
  raise SsoError, "OIDC provider returned a non-object response" unless result.is_a?(Hash)
  result
rescue JSON::ParserError, IOError, SystemCallError, Timeout::Error => e
  raise SsoError, "OIDC provider request failed", cause: e
end

#session(value) ⇒ Object



112
113
114
# File 'lib/tina4/sso.rb', line 112

def session(value)
  value.is_a?(Tina4::Session) ? value : value&.session
end

#validate_config!Object

Raises:



60
61
62
63
64
65
66
67
68
69
70
# File 'lib/tina4/sso.rb', line 60

def validate_config!
  if @issuer.empty? || @client_id.empty? || @redirect_uri.empty?
    raise SsoError, "TINA4_SSO_ISSUER, TINA4_SSO_CLIENT_ID and TINA4_SSO_REDIRECT_URI are required"
  end
  self.class.secure_url!(@issuer, "issuer")
  self.class.secure_url!(@redirect_uri, "redirect URI")
  raise SsoError, "TINA4_SSO_VERIFY must be introspection or jwks" unless %w[introspection jwks].include?(@verify)
  raise SsoError, "jwks verification requires an installed cryptography capability" if @verify == "jwks"
  raise SsoError, "introspection verification requires TINA4_SSO_CLIENT_SECRET" if @verify == "introspection" && @client_secret.to_s.empty?
  raise SsoError, "TINA4_SSO_SCOPES must be a list containing openid" unless @scopes.is_a?(Array) && @scopes.include?("openid")
end