lib/rodauth/features/oidc.rb in rodauth-oauth-0.10.4 vs lib/rodauth/features/oidc.rb in rodauth-oauth-1.0.0.pre.beta1
- old
+ new
@@ -1,7 +1,9 @@
# frozen_string_literal: true
+require "rodauth/oauth"
+
module Rodauth
Feature.define(:oidc, :Oidc) do
# https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
OIDC_SCOPES_MAP = {
"profile" => %i[name family_name given_name middle_name nickname preferred_username
@@ -58,76 +60,77 @@
response_types_supported
subject_types_supported
id_token_signing_alg_values_supported
].freeze
- depends :account_expiration, :oauth_jwt
+ depends :account_expiration, :oauth_jwt, :oauth_jwt_jwks, :oauth_authorization_code_grant
- auth_value_method :oauth_application_default_scope, "openid"
auth_value_method :oauth_application_scopes, %w[openid]
- auth_value_method :oauth_applications_id_token_signed_response_alg_column, :id_token_signed_response_alg
- auth_value_method :oauth_applications_id_token_encrypted_response_alg_column, :id_token_encrypted_response_alg
- auth_value_method :oauth_applications_id_token_encrypted_response_enc_column, :id_token_encrypted_response_enc
- auth_value_method :oauth_applications_userinfo_signed_response_alg_column, :userinfo_signed_response_alg
- auth_value_method :oauth_applications_userinfo_encrypted_response_alg_column, :userinfo_encrypted_response_alg
- auth_value_method :oauth_applications_userinfo_encrypted_response_enc_column, :userinfo_encrypted_response_enc
+ %i[
+ subject_type application_type sector_identifier_uri
+ id_token_signed_response_alg id_token_encrypted_response_alg id_token_encrypted_response_enc
+ 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_tokens_nonce_column, :nonce
- auth_value_method :oauth_tokens_acr_column, :acr
+ auth_value_method :oauth_grants_nonce_column, :nonce
+ auth_value_method :oauth_grants_acr_column, :acr
- translatable_method :invalid_scope_message, "The Access Token expired"
+ 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
- auth_value_method :webfinger_relation, "http://openid.net/specs/connect/1.0/issuer"
+ 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(
+ :get_oidc_account_last_login_at,
:get_oidc_param,
:get_additional_param,
:require_acr_value_phr,
:require_acr_value_phrh,
- :require_acr_value
+ :require_acr_value,
+ :json_webfinger_payload
)
# /userinfo
- route(:userinfo) do |r|
- next unless is_authorization_server?
-
+ auth_server_route(:userinfo) do |r|
r.on method: %i[get post] do
catch_error do
- oauth_token = authorization_token
+ claims = authorization_token
- throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_token
+ throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless claims
- oauth_scopes = oauth_token["scope"].split(" ")
+ oauth_scopes = claims["scope"].split(" ")
- throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_scopes.include?("openid")
+ throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless oauth_scopes.include?("openid")
- account = db[accounts_table].where(account_id_column => oauth_token["sub"]).first
+ account = db[accounts_table].where(account_id_column => claims["sub"]).first
- throw_json_response_error(authorization_required_error_status, "invalid_token") unless account
+ throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless account
oauth_scopes.delete("openid")
- oidc_claims = { "sub" => oauth_token["sub"] }
+ 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 => oauth_token["client_id"]).first
+ @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])
params = {
- jwks: oauth_application_jwks,
+ 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(
@@ -139,20 +142,20 @@
else
json_response_success(oidc_claims)
end
end
- throw_json_response_error(authorization_required_error_status, "invalid_token")
+ throw_json_response_error(oauth_authorization_required_error_status, "invalid_token")
end
end
# /oidc-logout
- route(:oidc_logout) do |r|
+ auth_server_route(:oidc_logout) do |r|
next unless use_rp_initiated_logout?
- before_oidc_logout_route
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
@@ -163,32 +166,36 @@
# 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.
#
- oauth_token = jwt_decode(param("id_token_hint"), verify_claims: false)
- oauth_application_id = oauth_token["client_id"]
+ 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_token["sub"] == jwt_subject(
- oauth_tokens_account_id_column => account_id,
- oauth_tokens_oauth_application_id_column => oauth_application_id
+ 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 oauth_token && oauth_token["iss"] == issuer
+ 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 => oauth_token["client_id"]).first
+ 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(" ")
@@ -214,38 +221,33 @@
redirect_response_error("invalid_request")
end
end
- def openid_configuration(alt_issuer = nil)
+ def load_openid_configuration_route(alt_issuer = nil)
request.on(".well-known/openid-configuration") do
allow_cors(request)
- request.get do
- json_response_success(openid_configuration_body(alt_issuer), cache: true)
+ request.is do
+ request.get do
+ json_response_success(openid_configuration_body(alt_issuer), cache: true)
+ end
end
end
end
- def webfinger
+ def load_webfinger_route
request.on(".well-known/webfinger") do
request.get do
resource = param_or_nil("resource")
throw_json_response_error(400, "invalid_request") unless resource
response.status = 200
response["Content-Type"] ||= "application/jrd+json"
- json_payload = JSON.dump({
- subject: resource,
- links: [{
- rel: webfinger_relation,
- href: authorization_server_url
- }]
- })
- return_response(json_payload)
+ return_response(json_webfinger_payload)
end
end
end
def check_csrf?
@@ -255,10 +257,20 @@
else
super
end
end
+ def oauth_response_types_supported
+ super | %w[id_token none]
+ end
+
+ def current_oauth_account
+ subject_type = current_oauth_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type
+
+ return super unless subject_type == "pairwise"
+ end
+
private
if defined?(::I18n)
def before_authorize_route
if (ui_locales = param_or_nil("ui_locales"))
@@ -277,11 +289,11 @@
max_age = Integer(max_age)
redirect_response_error("invalid_request") unless max_age.positive?
- if Time.now - last_account_login_at > max_age
+ 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
@@ -293,10 +305,45 @@
try_prompt
super
try_acr_values
end
+ def get_oidc_account_last_login_at(account_id)
+ get_activity_timestamp(account_id, account_activity_last_activity_column)
+ end
+
+ def jwt_subject(oauth_grant, client_application = oauth_application)
+ subject_type = client_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type
+
+ case subject_type
+ when "public"
+ super
+ when "pairwise"
+ identifier_uri = client_application[oauth_applications_sector_identifier_uri_column]
+
+ unless identifier_uri
+ identifier_uri = client_application[oauth_applications_redirect_uri_column]
+ identifier_uri = identifier_uri.split(" ")
+ # If the Client has not provided a value for sector_identifier_uri in Dynamic Client Registration
+ # [OpenID.Registration], the Sector Identifier used for pairwise identifier calculation is the host
+ # component of the registered redirect_uri. If there are multiple hostnames in the registered redirect_uris,
+ # the Client MUST register a sector_identifier_uri.
+ if identifier_uri.size > 1
+ # return error message
+ end
+ identifier_uri = identifier_uri.first
+ end
+
+ identifier_uri = URI(identifier_uri).host
+
+ account_id = oauth_grant[oauth_grants_account_id_column]
+ Digest::SHA256.hexdigest("#{identifier_uri}#{account_id}#{oauth_jwt_subject_secret}")
+ else
+ raise StandardError, "unexpected subject (#{subject_type})"
+ end
+ end
+
# this executes before checking for a logged in account
def try_prompt
return unless (prompt = param_or_nil("prompt"))
case prompt
@@ -385,54 +432,50 @@
create_params[oauth_grants_acr_column] = acr
end
super
end
- def create_oauth_token_from_authorization_code(oauth_grant, create_params, *)
- create_params[oauth_tokens_nonce_column] = oauth_grant[oauth_grants_nonce_column] if oauth_grant[oauth_grants_nonce_column]
- create_params[oauth_tokens_acr_column] = oauth_grant[oauth_grants_acr_column] if oauth_grant[oauth_grants_acr_column]
-
- super
+ def create_token(*)
+ oauth_grant = super
+ generate_id_token(oauth_grant)
+ oauth_grant
end
- def create_oauth_token(*)
- oauth_token = super
- generate_id_token(oauth_token)
- oauth_token
- end
+ def generate_id_token(oauth_grant)
+ oauth_scopes = oauth_grant[oauth_grants_scopes_column].split(oauth_scope_separator)
- def generate_id_token(oauth_token)
- oauth_scopes = oauth_token[oauth_tokens_scopes_column].split(oauth_scope_separator)
-
return unless oauth_scopes.include?("openid")
- id_token_claims = jwt_claims(oauth_token)
+ id_token_claims = jwt_claims(oauth_grant)
- id_token_claims[:nonce] = oauth_token[oauth_tokens_nonce_column] if oauth_token[oauth_tokens_nonce_column]
+ id_token_claims[:nonce] = oauth_grant[oauth_grants_nonce_column] if oauth_grant[oauth_grants_nonce_column]
- id_token_claims[:acr] = oauth_token[oauth_tokens_acr_column] if oauth_token[oauth_tokens_acr_column]
+ id_token_claims[:acr] = oauth_grant[oauth_grants_acr_column] if oauth_grant[oauth_grants_acr_column]
# Time when the End-User authentication occurred.
- id_token_claims[:auth_time] = last_account_login_at.to_i
+ id_token_claims[:auth_time] = get_oidc_account_last_login_at(oauth_grant[oauth_grants_account_id_column]).to_i
- account = db[accounts_table].where(account_id_column => oauth_token[oauth_tokens_account_id_column]).first
+ account = db[accounts_table].where(account_id_column => oauth_grant[oauth_grants_account_id_column]).first
# 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)
params = {
- jwks: oauth_application_jwks,
- signing_algorithm: oauth_application[oauth_applications_id_token_signed_response_alg_column] || oauth_jwt_algorithm,
+ jwks: oauth_application_jwks(oauth_application),
+ signing_algorithm: (
+ oauth_application[oauth_applications_id_token_signed_response_alg_column] ||
+ oauth_jwt_keys.keys.first
+ ),
encryption_algorithm: oauth_application[oauth_applications_id_token_encrypted_response_alg_column],
encryption_method: oauth_application[oauth_applications_id_token_encrypted_response_enc_column]
}.compact
- oauth_token[:id_token] = jwt_encode(id_token_claims, **params)
+ oauth_grant[:id_token] = jwt_encode(id_token_claims, **params)
end
# aka fill_with_standard_claims
def fill_with_account_claims(claims, account, scopes)
scopes_by_claim = scopes.each_with_object({}) do |scope, by_oidc|
@@ -505,70 +548,90 @@
end
end
end
end
- def json_access_token_payload(oauth_token)
+ def json_access_token_payload(oauth_grant)
payload = super
- payload["id_token"] = oauth_token[:id_token] if oauth_token[:id_token]
+ payload["id_token"] = oauth_grant[:id_token] if oauth_grant[:id_token]
payload
end
# Authorize
def check_valid_response_type?
case param_or_nil("response_type")
- when "none", "id_token",
- "code token", "code id_token", "id_token token", "code id_token token" # multiple
+ when "none", "id_token", "code id_token" # multiple
true
+ when "code token", "id_token token", "code id_token token"
+ supports_token_response_type?
else
super
end
end
- def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
- return super unless use_oauth_implicit_grant_type?
+ def supported_response_mode?(response_mode, *)
+ return super unless response_mode == "none"
+ param("response_type") == "none"
+ end
+
+ 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")
when "id_token"
response_params.replace(_do_authorize_id_token)
when "code token"
- redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
+ redirect_response_error("invalid_request") unless supports_token_response_type?
response_params.replace(_do_authorize_code.merge(_do_authorize_token))
when "code id_token"
response_params.replace(_do_authorize_code.merge(_do_authorize_id_token))
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))
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))
+ when "none"
+ response_mode ||= "none"
end
response_mode ||= "fragment" unless response_params.empty?
super(response_params, response_mode)
end
def _do_authorize_id_token
- create_params = {
- oauth_tokens_account_id_column => account_id,
- oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
- oauth_tokens_scopes_column => scopes
+ 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(" ")
}
if (nonce = param_or_nil("nonce"))
- create_params[oauth_grants_nonce_column] = nonce
+ grant_params[oauth_grants_nonce_column] = nonce
end
if (acr = param_or_nil("acr"))
- create_params[oauth_grants_acr_column] = acr
+ grant_params[oauth_grants_acr_column] = acr
end
- oauth_token = generate_oauth_token(create_params, false)
- generate_id_token(oauth_token)
- params = json_access_token_payload(oauth_token)
+ oauth_grant = generate_token(grant_params, false)
+ generate_id_token(oauth_grant)
+ params = json_access_token_payload(oauth_grant)
params.delete("access_token")
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
@@ -577,10 +640,22 @@
return if check_valid_uri?(redirect_uri)
redirect_response_error("invalid_request")
end
+ # Webfinger
+
+ def json_webfinger_payload
+ JSON.dump({
+ subject: param("resource"),
+ links: [{
+ rel: "http://openid.net/specs/connect/1.0/issuer",
+ href: authorization_server_url
+ }]
+ })
+ end
+
# Metadata
def openid_configuration_body(path = nil)
metadata = oauth_server_metadata_body(path).select do |k, _|
VALID_METADATA_KEYS.include?(k)
@@ -598,31 +673,35 @@
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 += ["none", "id_token", "code token", "code id_token", "id_token token", "code id_token token"]
+ 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: [oauth_jwt_subject_type],
+ subject_types_supported: %w[public pairwise],
id_token_signing_alg_values_supported: metadata[:token_endpoint_auth_signing_alg_values_supported],
- id_token_encryption_alg_values_supported: [oauth_jwt_jwe_algorithm].compact,
- id_token_encryption_enc_values_supported: [oauth_jwt_jwe_encryption_method].compact,
+ id_token_encryption_alg_values_supported: Array(alg_values),
+ id_token_encryption_enc_values_supported: Array(enc_values),
- userinfo_signing_alg_values_supported: [],
- userinfo_encryption_alg_values_supported: [],
- userinfo_encryption_enc_values_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,
- request_object_signing_alg_values_supported: [],
- request_object_encryption_alg_values_supported: [],
- request_object_encryption_enc_values_supported: [],
+ request_object_signing_alg_values_supported: oauth_jwt_jws_algorithms_supported,
+ request_object_encryption_alg_values_supported: oauth_jwt_jwe_algorithms_supported,
+ request_object_encryption_enc_values_supported: oauth_jwt_jwe_encryption_methods_supported,
# These Claim Types are described in Section 5.6 of OpenID Connect Core 1.0 [OpenID.Core].
# Values defined by this specification are normal, aggregated, and distributed.
# If omitted, the implementation supports only normal Claims.
claim_types_supported: %w[normal],
@@ -641,8 +720,22 @@
response["Access-Control-Allow-Origin"] = "*"
response["Access-Control-Allow-Methods"] = "GET, OPTIONS"
response["Access-Control-Max-Age"] = "3600"
response.status = 200
return_response
+ end
+
+ def jwt_response_success(jwt, cache = false)
+ response.status = 200
+ response["Content-Type"] ||= "application/jwt"
+ if cache
+ # defaulting to 1-day for everyone, for now at least
+ max_age = 60 * 60 * 24
+ response["Cache-Control"] = "private, max-age=#{max_age}"
+ else
+ response["Cache-Control"] = "no-store"
+ response["Pragma"] = "no-cache"
+ end
+ return_response(jwt)
end
end
end