lib/rodauth/features/oidc.rb in rodauth-oauth-1.0.0.pre.beta1 vs lib/rodauth/features/oidc.rb in rodauth-oauth-1.0.0.pre.beta2
- old
+ new
@@ -15,10 +15,11 @@
VALID_METADATA_KEYS = %i[
issuer
authorization_endpoint
end_session_endpoint
+ backchannel_logout_session_supported
token_endpoint
userinfo_endpoint
jwks_uri
registration_endpoint
scopes_supported
@@ -72,30 +73,27 @@
userinfo_signed_response_alg userinfo_encrypted_response_alg userinfo_encrypted_response_enc
].each do |column|
auth_value_method :"oauth_applications_#{column}_column", column
end
- auth_value_method :oauth_grants_nonce_column, :nonce
- auth_value_method :oauth_grants_acr_column, :acr
- auth_value_method :oauth_grants_nonce_column, :nonce
- auth_value_method :oauth_grants_acr_column, :acr
+ %i[nonce acr claims_locales claims].each do |column|
+ auth_value_method :"oauth_grants_#{column}_column", column
+ end
auth_value_method :oauth_jwt_subject_type, "public" # fallback subject type: public, pairwise
auth_value_method :oauth_jwt_subject_secret, nil # salt for pairwise generation
translatable_method :oauth_invalid_scope_message, "The Access Token expired"
auth_value_method :oauth_prompt_login_cookie_key, "_rodauth_oauth_prompt_login"
auth_value_method :oauth_prompt_login_cookie_options, {}.freeze
auth_value_method :oauth_prompt_login_interval, 5 * 60 * 60 # 5 minutes
- # logout
- auth_value_method :oauth_applications_post_logout_redirect_uri_column, :post_logout_redirect_uri
- auth_value_method :use_rp_initiated_logout?, false
-
auth_value_methods(
+ :oauth_acr_values_supported,
:get_oidc_account_last_login_at,
+ :oidc_authorize_on_prompt_none?,
:get_oidc_param,
:get_additional_param,
:require_acr_value_phr,
:require_acr_value_phrh,
:require_acr_value,
@@ -120,23 +118,45 @@
oauth_scopes.delete("openid")
oidc_claims = { "sub" => claims["sub"] }
- fill_with_account_claims(oidc_claims, account, oauth_scopes)
-
@oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first
- if (algo = @oauth_application && @oauth_application[oauth_applications_userinfo_signed_response_alg_column])
+ throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless @oauth_application
+
+ oauth_grant = valid_oauth_grant_ds(
+ oauth_grants_oauth_application_id_column => @oauth_application[oauth_applications_id_column],
+ oauth_grants_account_id_column => account[account_id_column]
+ ).first
+
+ claims_locales = oauth_grant[oauth_grants_claims_locales_column] if oauth_grant
+
+ if (claims = oauth_grant[oauth_grants_claims_column])
+ claims = JSON.parse(claims)
+ if (userinfo_essential_claims = claims["userinfo"])
+ oauth_scopes |= userinfo_essential_claims.to_a
+ end
+ end
+
+ # 5.4 - The Claims requested by the profile, email, address, and phone scope values are returned from the UserInfo Endpoint
+ fill_with_account_claims(oidc_claims, account, oauth_scopes, claims_locales)
+
+ if (algo = @oauth_application[oauth_applications_userinfo_signed_response_alg_column])
params = {
jwks: oauth_application_jwks(@oauth_application),
encryption_algorithm: @oauth_application[oauth_applications_userinfo_encrypted_response_alg_column],
encryption_method: @oauth_application[oauth_applications_userinfo_encrypted_response_enc_column]
}.compact
jwt = jwt_encode(
- oidc_claims,
+ oidc_claims.merge(
+ # If signed, the UserInfo Response SHOULD contain the Claims iss (issuer) and aud (audience) as members. The iss value
+ # SHOULD be the OP's Issuer Identifier URL. The aud value SHOULD be or include the RP's Client ID value.
+ iss: oauth_jwt_issuer,
+ aud: @oauth_application[oauth_applications_client_id_column]
+ ),
signing_algorithm: algo,
**params
)
jwt_response_success(jwt)
else
@@ -146,85 +166,10 @@
throw_json_response_error(oauth_authorization_required_error_status, "invalid_token")
end
end
- # /oidc-logout
- auth_server_route(:oidc_logout) do |r|
- next unless use_rp_initiated_logout?
-
- require_authorizable_account
- before_oidc_logout_route
-
- # OpenID Providers MUST support the use of the HTTP GET and POST methods
- r.on method: %i[get post] do
- catch_error do
- validate_oidc_logout_params
-
- #
- # why this is done:
- #
- # we need to decode the id token in order to get the application, because, if the
- # signing key is application-specific, we don't know how to verify the signature
- # beforehand. Hence, we have to do it twice: decode-and-do-not-verify, initialize
- # the @oauth_application, and then decode-and-verify.
- #
- claims = jwt_decode(param("id_token_hint"), verify_claims: false)
- oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first
- oauth_grant = db[oauth_grants_table]
- .where(
- oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
- oauth_grants_account_id_column => account_id
- ).first
-
- # check whether ID token belongs to currently logged-in user
- redirect_response_error("invalid_request") unless oauth_grant && claims["sub"] == jwt_subject(
- oauth_grant, oauth_application
- )
-
- # When an id_token_hint parameter is present, the OP MUST validate that it was the issuer of the ID Token.
- redirect_response_error("invalid_request") unless claims && claims["iss"] == oauth_jwt_issuer
-
- # now let's logout from IdP
- transaction do
- before_logout
- logout
- after_logout
- end
-
- if (post_logout_redirect_uri = param_or_nil("post_logout_redirect_uri"))
- catch(:default_logout_redirect) do
- oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first
-
- throw(:default_logout_redirect) unless oauth_application
-
- post_logout_redirect_uris = oauth_application[oauth_applications_post_logout_redirect_uri_column].split(" ")
-
- throw(:default_logout_redirect) unless post_logout_redirect_uris.include?(post_logout_redirect_uri)
-
- if (state = param_or_nil("state"))
- post_logout_redirect_uri = URI(post_logout_redirect_uri)
- params = ["state=#{state}"]
- params << post_logout_redirect_uri.query if post_logout_redirect_uri.query
- post_logout_redirect_uri.query = params.join("&")
- post_logout_redirect_uri = post_logout_redirect_uri.to_s
- end
-
- redirect(post_logout_redirect_uri)
- end
-
- end
-
- # regular logout procedure
- set_notice_flash(logout_notice_flash)
- redirect(logout_redirect)
- end
-
- redirect_response_error("invalid_request")
- end
- end
-
def load_openid_configuration_route(alt_issuer = nil)
request.on(".well-known/openid-configuration") do
allow_cors(request)
request.is do
@@ -258,11 +203,15 @@
super
end
end
def oauth_response_types_supported
- super | %w[id_token none]
+ grant_types = oauth_grant_types_supported
+ oidc_response_types = %w[id_token none]
+ oidc_response_types |= ["code id_token"] if grant_types.include?("authorization_code")
+ oidc_response_types |= ["code token", "id_token token", "code id_token token"] if grant_types.include?("implicit")
+ super | oidc_response_types
end
def current_oauth_account
subject_type = current_oauth_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type
@@ -282,31 +231,75 @@
super
end
end
+ def oauth_acr_values_supported
+ acr_values = []
+ acr_values << "phrh" if features.include?(:webauthn_login)
+ acr_values << "phr" if respond_to?(:require_two_factor_authenticated)
+ acr_values
+ end
+
+ def oidc_authorize_on_prompt_none?(_account)
+ false
+ end
+
def validate_authorize_params
- return super unless (max_age = param_or_nil("max_age"))
+ if (max_age = param_or_nil("max_age"))
- max_age = Integer(max_age)
+ max_age = Integer(max_age)
- redirect_response_error("invalid_request") unless max_age.positive?
+ redirect_response_error("invalid_request") unless max_age.positive?
- if Time.now - get_oidc_account_last_login_at(session_value) > max_age
- # force user to re-login
- clear_session
- set_session_value(login_redirect_session_key, request.fullpath)
- redirect require_login_redirect
+ if Time.now - get_oidc_account_last_login_at(session_value) > max_age
+ # force user to re-login
+ clear_session
+ set_session_value(login_redirect_session_key, request.fullpath)
+ redirect require_login_redirect
+ end
end
+ if (claims = param_or_nil("claims"))
+ # The value is a JSON object listing the requested Claims.
+ claims = JSON.parse(claims)
+
+ claims.each do |_, individual_claims|
+ redirect_response_error("invalid_request") unless individual_claims.is_a?(Hash)
+
+ individual_claims.each do |_, claim|
+ redirect_response_error("invalid_request") unless claim.nil? || individual_claims.is_a?(Hash)
+ end
+ end
+ end
+
+ sc = scopes
+
+ if sc && sc.include?("offline_access")
+
+ sc.delete("offline_access")
+
+ # MUST ensure that the prompt parameter contains consent
+ # MUST ignore the offline_access request unless the Client
+ # is using a response_type value that would result in an
+ # Authorization Code
+ if param_or_nil("prompt") == "consent" && (
+ (response_type = param_or_nil("response_type")) && response_type.split(" ").include?("code")
+ )
+ request.params["access_type"] = "offline"
+ end
+
+ request.params["scope"] = sc.join(" ")
+ end
+
super
end
def require_authorizable_account
try_prompt
super
- try_acr_values
+ @acr = try_acr_values
end
def get_oidc_account_last_login_at(account_id)
get_activity_timestamp(account_id, account_activity_last_activity_column)
end
@@ -346,26 +339,22 @@
def try_prompt
return unless (prompt = param_or_nil("prompt"))
case prompt
when "none"
+ return unless request.get?
+
redirect_response_error("login_required") unless logged_in?
require_account
- if db[oauth_grants_table].where(
- oauth_grants_account_id_column => account_id,
- oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
- oauth_grants_redirect_uri_column => redirect_uri,
- oauth_grants_scopes_column => scopes.join(oauth_scope_separator),
- oauth_grants_access_type_column => "online"
- ).count.zero?
- redirect_response_error("consent_required")
- end
+ redirect_response_error("interaction_required") unless oidc_authorize_on_prompt_none?(account_from_session)
request.env["REQUEST_METHOD"] = "POST"
when "login"
+ return unless request.get?
+
if logged_in? && request.cookies[oauth_prompt_login_cookie_key] == "login"
::Rack::Utils.delete_cookie_header!(response.headers, oauth_prompt_login_cookie_key, oauth_prompt_login_cookie_options)
return
end
@@ -378,22 +367,21 @@
login_cookie_opts[:expires] = convert_timestamp(Time.now + oauth_prompt_login_interval) # 15 minutes
::Rack::Utils.set_cookie_header!(response.headers, oauth_prompt_login_cookie_key, login_cookie_opts)
redirect require_login_redirect
when "consent"
+ return unless request.post?
+
require_account
- if db[oauth_grants_table].where(
- oauth_grants_account_id_column => account_id,
- oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
- oauth_grants_redirect_uri_column => redirect_uri,
- oauth_grants_scopes_column => scopes.join(oauth_scope_separator),
- oauth_grants_access_type_column => "online"
- ).count.zero?
- redirect_response_error("consent_required")
- end
+ sc = scopes || []
+
+ redirect_response_error("consent_required") if sc.empty?
+
when "select-account"
+ return unless request.get?
+
# only works if select_account plugin is available
require_select_account if respond_to?(:require_select_account)
else
redirect_response_error("invalid_request")
end
@@ -401,48 +389,72 @@
def try_acr_values
return unless (acr_values = param_or_nil("acr_values"))
acr_values.split(" ").each do |acr_value|
+ next unless oauth_acr_values_supported.include?(acr_value)
+
case acr_value
- when "phr" then require_acr_value_phr
- when "phrh" then require_acr_value_phrh
+ when "phr"
+ return acr_value if require_acr_value_phr
+ when "phrh"
+ return acr_value if require_acr_value_phrh
else
- require_acr_value(acr_value)
+ return acr_value if require_acr_value(acr_value)
end
end
+
+ nil
end
def require_acr_value_phr
- return unless respond_to?(:require_two_factor_authenticated)
+ return false unless respond_to?(:require_two_factor_authenticated)
require_two_factor_authenticated
+ true
end
def require_acr_value_phrh
+ return false unless features.include?(:webauthn_login)
+
require_acr_value_phr && two_factor_login_type_match?("webauthn")
end
- def require_acr_value(_acr); end
+ def require_acr_value(_acr)
+ true
+ end
def create_oauth_grant(create_params = {})
- if (nonce = param_or_nil("nonce"))
- create_params[oauth_grants_nonce_column] = nonce
- end
- if (acr = param_or_nil("acr"))
- create_params[oauth_grants_acr_column] = acr
- end
+ create_params.replace(oidc_grant_params.merge(create_params))
super
end
+ def create_oauth_grant_with_token(create_params = {})
+ create_params[oauth_grants_type_column] = "hybrid"
+ create_params[oauth_grants_account_id_column] = account_id
+ create_params[oauth_grants_expires_in_column] = Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_access_token_expires_in)
+ authorization_code = create_oauth_grant(create_params)
+ access_token = if oauth_jwt_access_tokens
+ _generate_jwt_access_token(create_params)
+ else
+ oauth_grant = valid_oauth_grant_ds.where(oauth_grants_code_column => authorization_code).first
+ _generate_access_token(oauth_grant)
+ end
+
+ {
+ "code" => authorization_code,
+ **json_access_token_payload(oauth_grants_token_column => access_token)
+ }
+ end
+
def create_token(*)
oauth_grant = super
generate_id_token(oauth_grant)
oauth_grant
end
- def generate_id_token(oauth_grant)
+ def generate_id_token(oauth_grant, include_claims = false)
oauth_scopes = oauth_grant[oauth_grants_scopes_column].split(oauth_scope_separator)
return unless oauth_scopes.include?("openid")
id_token_claims = jwt_claims(oauth_grant)
@@ -459,12 +471,23 @@
# this should never happen!
# a newly minted oauth token from a grant should have been assigned to an account
# who just authorized its generation.
return unless account
- fill_with_account_claims(id_token_claims, account, oauth_scopes)
+ if (claims = oauth_grant[oauth_grants_claims_column])
+ claims = JSON.parse(claims)
+ if (id_token_essential_claims = claims["id_token"])
+ oauth_scopes |= id_token_essential_claims.to_a
+ include_claims = true
+ end
+ end
+
+ # 5.4 - However, when no Access Token is issued (which is the case for the response_type value id_token),
+ # the resulting Claims are returned in the ID Token.
+ fill_with_account_claims(id_token_claims, account, oauth_scopes, param_or_nil("claims_locales")) if include_claims
+
params = {
jwks: oauth_application_jwks(oauth_application),
signing_algorithm: (
oauth_application[oauth_applications_id_token_signed_response_alg_column] ||
oauth_jwt_keys.keys.first
@@ -475,30 +498,48 @@
oauth_grant[:id_token] = jwt_encode(id_token_claims, **params)
end
# aka fill_with_standard_claims
- def fill_with_account_claims(claims, account, scopes)
+ def fill_with_account_claims(claims, account, scopes, claims_locales)
+ additional_claims_info = {}
+
scopes_by_claim = scopes.each_with_object({}) do |scope, by_oidc|
next if scope == "openid"
- oidc, param = scope.split(".", 2)
+ if scope.is_a?(Array)
+ # essential claims
+ param, additional_info = scope
+ param = param.to_sym
+
+ oidc, = OIDC_SCOPES_MAP.find do |_, oidc_scopes|
+ oidc_scopes.include?(param)
+ end || param.to_s
+
+ param = nil if oidc == param.to_s
+
+ additional_claims_info[param] = additional_info
+ else
+
+ oidc, param = scope.split(".", 2)
+
+ param = param.to_sym if param
+ end
+
by_oidc[oidc] ||= []
by_oidc[oidc] << param.to_sym if param
end
oidc_scopes, additional_scopes = scopes_by_claim.keys.partition { |key| OIDC_SCOPES_MAP.key?(key) }
- if (claims_locales = param_or_nil("claims_locales"))
- claims_locales = claims_locales.split(" ").map(&:to_sym)
- end
+ claims_locales = claims_locales.split(" ").map(&:to_sym) if claims_locales
unless oidc_scopes.empty?
if respond_to?(:get_oidc_param)
- get_oidc_param = proxy_get_param(:get_oidc_param, claims, claims_locales)
+ get_oidc_param = proxy_get_param(:get_oidc_param, claims, claims_locales, additional_claims_info)
oidc_scopes.each do |scope|
scope_claims = claims
params = scopes_by_claim[scope]
params = params.empty? ? OIDC_SCOPES_MAP[scope] : (OIDC_SCOPES_MAP[scope] & params)
@@ -515,37 +556,50 @@
end
return if additional_scopes.empty?
if respond_to?(:get_additional_param)
- get_additional_param = proxy_get_param(:get_additional_param, claims, claims_locales)
+ get_additional_param = proxy_get_param(:get_additional_param, claims, claims_locales, additional_claims_info)
additional_scopes.each do |scope|
get_additional_param[account, scope.to_sym]
end
else
warn "`get_additional_param(account, claim)` must be implemented to use oidc scopes."
end
end
- def proxy_get_param(get_param_func, claims, claims_locales)
+ def proxy_get_param(get_param_func, claims, claims_locales, additional_claims_info)
meth = method(get_param_func)
if meth.arity == 2
- ->(account, param, cl = claims) { cl[param] = meth[account, param] }
+ lambda do |account, param, cl = claims|
+ additional_info = additional_claims_info[param] || EMPTY_HASH
+ value = additional_info["value"] || meth[account, param]
+ value = nil if additional_info["values"] && additional_info["values"].include?(value)
+ cl[param] = value if value
+ end
elsif claims_locales.nil?
- ->(account, param, cl = claims) { cl[param] = meth[account, param, nil] }
+ lambda do |account, param, cl = claims|
+ additional_info = additional_claims_info[param] || EMPTY_HASH
+ value = additional_info["value"] || meth[account, param, nil]
+ value = nil if additional_info["values"] && additional_info["values"].include?(value)
+ cl[param] = value if value
+ end
else
lambda do |account, param, cl = claims|
claims_values = claims_locales.map do |locale|
- meth[account, param, locale]
- end
+ additional_info = additional_claims_info[param] || EMPTY_HASH
+ value = additional_info["value"] || meth[account, param, locale]
+ value = nil if additional_info["values"] && additional_info["values"].include?(value)
+ value
+ end.compact
if claims_values.uniq.size == 1
cl[param] = claims_values.first
else
claims_locales.zip(claims_values).each do |locale, value|
- cl["#{param}##{locale}"] = value
+ cl["#{param}##{locale}"] = value if value
end
end
end
end
end
@@ -578,72 +632,76 @@
def supports_token_response_type?
features.include?(:oauth_implicit_grant)
end
def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
- case param("response_type")
+ response_type = param("response_type")
+ case response_type
when "id_token"
- response_params.replace(_do_authorize_id_token)
+ grant_params = oidc_grant_params
+ generate_id_token(grant_params, true)
+ response_params.replace("id_token" => grant_params[:id_token])
when "code token"
redirect_response_error("invalid_request") unless supports_token_response_type?
- response_params.replace(_do_authorize_code.merge(_do_authorize_token))
+ response_params.replace(create_oauth_grant_with_token)
when "code id_token"
- response_params.replace(_do_authorize_code.merge(_do_authorize_id_token))
+ params = _do_authorize_code
+ oauth_grant = valid_oauth_grant_ds.where(oauth_grants_code_column => params["code"]).first
+ generate_id_token(oauth_grant)
+ response_params.replace(
+ "id_token" => oauth_grant[:id_token],
+ "code" => params["code"]
+ )
when "id_token token"
redirect_response_error("invalid_request") unless supports_token_response_type?
- response_params.replace(_do_authorize_id_token.merge(_do_authorize_token))
+ oauth_grant = _do_authorize_token(oauth_grants_type_column => "hybrid")
+ generate_id_token(oauth_grant)
+
+ response_params.replace(json_access_token_payload(oauth_grant))
when "code id_token token"
redirect_response_error("invalid_request") unless supports_token_response_type?
- response_params.replace(_do_authorize_code.merge(_do_authorize_id_token).merge(_do_authorize_token))
+ params = create_oauth_grant_with_token
+ oauth_grant = valid_oauth_grant_ds.where(oauth_grants_code_column => params["code"]).first
+ generate_id_token(oauth_grant)
+
+ response_params.replace(params.merge("id_token" => oauth_grant[:id_token]))
when "none"
response_mode ||= "none"
end
response_mode ||= "fragment" unless response_params.empty?
super(response_params, response_mode)
end
- def _do_authorize_id_token
+ def oidc_grant_params
grant_params = {
oauth_grants_account_id_column => account_id,
oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
- oauth_grants_scopes_column => scopes.join(" ")
+ oauth_grants_scopes_column => scopes.join(oauth_scope_separator)
}
if (nonce = param_or_nil("nonce"))
grant_params[oauth_grants_nonce_column] = nonce
end
- if (acr = param_or_nil("acr"))
- grant_params[oauth_grants_acr_column] = acr
+ grant_params[oauth_grants_acr_column] = @acr if @acr
+ if (claims_locales = param_or_nil("claims_locales"))
+ grant_params[oauth_grants_claims_locales_column] = claims_locales
end
- oauth_grant = generate_token(grant_params, false)
- generate_id_token(oauth_grant)
- params = json_access_token_payload(oauth_grant)
- params.delete("access_token")
- params
+ if (claims = param_or_nil("claims"))
+ grant_params[oauth_grants_claims_column] = claims
+ end
+ grant_params
end
def authorize_response(params, mode)
redirect_url = URI.parse(redirect_uri)
redirect(redirect_url.to_s) if mode == "none"
super
end
- # Logout
-
- def validate_oidc_logout_params
- redirect_response_error("invalid_request") unless param_or_nil("id_token_hint")
- # check if valid token hint type
- return unless (redirect_uri = param_or_nil("post_logout_redirect_uri"))
-
- return if check_valid_uri?(redirect_uri)
-
- redirect_response_error("invalid_request")
- end
-
# Webfinger
def json_webfinger_payload
JSON.dump({
subject: param("resource"),
@@ -671,28 +729,18 @@
end
end
scope_claims.unshift("auth_time")
- response_types_supported = metadata[:response_types_supported]
-
- response_types_supported |= %w[none]
- response_types_supported |= ["code id_token"] if metadata[:grant_types_supported].include?("authorization_code")
- if metadata[:grant_types_supported].include?("implicit")
- response_types_supported |= ["code token", "id_token token", "code id_token token"]
- end
-
- alg_values, enc_values = oauth_jwt_jwe_keys.keys.transpose
-
metadata.merge(
userinfo_endpoint: userinfo_url,
- end_session_endpoint: (oidc_logout_url if use_rp_initiated_logout?),
- response_types_supported: response_types_supported,
subject_types_supported: %w[public pairwise],
+ acr_values_supported: oauth_acr_values_supported,
+ claims_parameter_supported: true,
- id_token_signing_alg_values_supported: metadata[:token_endpoint_auth_signing_alg_values_supported],
- id_token_encryption_alg_values_supported: Array(alg_values),
- id_token_encryption_enc_values_supported: Array(enc_values),
+ id_token_signing_alg_values_supported: oauth_jwt_jws_algorithms_supported,
+ id_token_encryption_alg_values_supported: oauth_jwt_jwe_algorithms_supported,
+ id_token_encryption_enc_values_supported: oauth_jwt_jwe_encryption_methods_supported,
userinfo_signing_alg_values_supported: oauth_jwt_jws_algorithms_supported,
userinfo_encryption_alg_values_supported: oauth_jwt_jwe_algorithms_supported,
userinfo_encryption_enc_values_supported: oauth_jwt_jwe_encryption_methods_supported,