diff --git a/Gemfile b/Gemfile index 870551a..e4c9d03 100644 --- a/Gemfile +++ b/Gemfile @@ -13,3 +13,5 @@ gemspec # To use a debugger # gem 'byebug', group: [:development, :test] + +gem "openid_connect" \ No newline at end of file diff --git a/app/controllers/oidc_provider/authorizations_controller.rb b/app/controllers/oidc_provider/authorizations_controller.rb index 4bd1a88..8760b2b 100644 --- a/app/controllers/oidc_provider/authorizations_controller.rb +++ b/app/controllers/oidc_provider/authorizations_controller.rb @@ -4,6 +4,8 @@ module OIDCProvider class AuthorizationsController < ApplicationController include Concerns::ConnectEndpoint + skip_before_action :verify_authenticity_token + before_action :require_oauth_request before_action :require_response_type_code before_action :require_client @@ -15,13 +17,11 @@ def create authorization = build_authorization_with(requested_scopes) - oauth_response.code = authorization.code + oauth_response.code = authorization.code if @requested_type==:code or @requested_type == :hybrid + oauth_response.id_token = authorization.id_token.to_jwt if @requested_type==:id_token or @requested_type == :hybrid oauth_response.redirect_uri = @redirect_uri oauth_response.approve! - redirect_to oauth_response.location, allow_other_host: true - - # If we ever need to support denied authorizations that is done by: - # oauth_request.access_denied! + redirect_to oauth_response.location,allow_other_host: true end private @@ -31,7 +31,8 @@ def build_authorization_with(scopes) client_id: @client.identifier, nonce: oauth_request.nonce, scopes: scopes, - account: oidc_current_account + account: oidc_current_account, + issuer: request.base_url ) end @@ -46,9 +47,22 @@ def requested_scopes helper_method :requested_scopes def require_response_type_code - return if oauth_request.response_type == :code + type = oauth_request.response_type + type.nil? && oauth_request.unsupported_response_type! + case type + when :code + @requested_type=:code + when :id_token + @requested_type=:id_token + when ->(ary) do ary.include?(:code) && ary.include?(:id_token) end + @requested_type=:hybrid + # when type.include?("token") + # @response_type=:token + else + oauth_request.unsupported_response_type! + end + - oauth_request.unsupported_response_type! end def reset_login_if_necessary diff --git a/app/controllers/oidc_provider/discovery_controller.rb b/app/controllers/oidc_provider/discovery_controller.rb index 7ace84e..c0714e1 100644 --- a/app/controllers/oidc_provider/discovery_controller.rb +++ b/app/controllers/oidc_provider/discovery_controller.rb @@ -25,23 +25,24 @@ def webfinger_discovery end def openid_configuration + issuer = request.base_url config = OpenIDConnect::Discovery::Provider::Config::Response.new( - issuer: OIDCProvider.issuer, - authorization_endpoint: authorizations_url(host: OIDCProvider.issuer), - token_endpoint: tokens_url(host: OIDCProvider.issuer), - userinfo_endpoint: user_info_url(host: OIDCProvider.issuer), - end_session_endpoint: end_session_url(host: OIDCProvider.issuer), - jwks_uri: jwks_url(host: OIDCProvider.issuer), + issuer: issuer, + authorization_endpoint: authorizations_url(host: issuer), + token_endpoint: tokens_url(host: issuer), + userinfo_endpoint: user_info_url(host: issuer), + end_session_endpoint: end_session_url(host: issuer), + jwks_uri: jwks_url(host: issuer), scopes_supported: ["openid"] + OIDCProvider.supported_scopes.map(&:name), - response_types_supported: [:code], - grant_types_supported: [:authorization_code], + response_types_supported: [:code, :id_token], + grant_types_supported: [:authorization_code, :refresh_token], subject_types_supported: [:public], id_token_signing_alg_values_supported: [:RS256], token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], - claims_supported: ['sub', 'iss', 'name', 'email'] + claims_supported: ['sub', 'iss', 'name', 'email','aud','email_verified','family_name','given_name'] ) render json: config end end -end \ No newline at end of file +end diff --git a/app/controllers/oidc_provider/user_infos_controller.rb b/app/controllers/oidc_provider/user_infos_controller.rb index 30908bd..d4a8e0d 100644 --- a/app/controllers/oidc_provider/user_infos_controller.rb +++ b/app/controllers/oidc_provider/user_infos_controller.rb @@ -3,7 +3,8 @@ class UserInfosController < ApplicationController before_action :require_access_token def show - render json: AccountToUserInfo.new.(current_token.authorization.account, current_token.authorization.scopes) + + render json: AccountToUserInfo.new.(current_token.authorization.account, current_token.authorization.scopes, current_token.authorization.nonce) end end end \ No newline at end of file diff --git a/app/models/oidc_provider/access_token.rb b/app/models/oidc_provider/access_token.rb index 992b07f..9e959d9 100644 --- a/app/models/oidc_provider/access_token.rb +++ b/app/models/oidc_provider/access_token.rb @@ -7,11 +7,37 @@ class AccessToken < ApplicationRecord attribute :token, :string, default: -> { SecureRandom.hex 32 } attribute :expires_at, :datetime, default: -> { 1.hours.from_now } - def to_bearer_token - Rack::OAuth2::AccessToken::Bearer.new( - access_token: token, - expires_in: (expires_at - Time.now).to_i + def to_bearer_token(with_refresh_token) + if with_refresh_token + Rack::OAuth2::AccessToken::Bearer.new( + access_token: token, + expires_in: (expires_at - Time.now).to_i, + refresh_token: (get_refresh_token(authorization.client_id,authorization.scopes)||generate_refresh_token(authorization.client_id,authorization.scopes)).token, + scope: authorization.scopes + ) + else + Rack::OAuth2::AccessToken::Bearer.new( + access_token: token, + expires_in: (expires_at - Time.now).to_i, + scope: authorization.scopes + ) + end + end + + private + def get_refresh_token(client_id,scopes) + RefreshToken + .valid + .where(client_id: client_id, revoked_at: nil,scopes:scopes) + .first + end + def generate_refresh_token(client_id,scopes) + RefreshToken.create!( + client_id: client_id, + scopes: scopes, + authorization: authorization ) end + end end diff --git a/app/models/oidc_provider/authorization.rb b/app/models/oidc_provider/authorization.rb index 70bbcb8..44ffbd7 100644 --- a/app/models/oidc_provider/authorization.rb +++ b/app/models/oidc_provider/authorization.rb @@ -1,13 +1,14 @@ module OIDCProvider class Authorization < ApplicationRecord belongs_to :account, class_name: OIDCProvider.account_class - has_one :access_token + has_one :access_token, dependent: :destroy has_one :id_token scope :valid, -> { where(arel_table[:expires_at].gteq(Time.now.utc)) } attribute :code, :string, default: -> { SecureRandom.hex 32 } attribute :expires_at, :datetime, default: -> { 5.minutes.from_now } + attribute :issuer, :string serialize :scopes, coder: JSON @@ -20,6 +21,11 @@ def access_token super || expire! && generate_access_token! end + def refresh! + access_token = create_access_token! + access_token.save! + end + def id_token super || generate_id_token! end diff --git a/app/models/oidc_provider/id_token.rb b/app/models/oidc_provider/id_token.rb index 8a72d0f..1669b6f 100644 --- a/app/models/oidc_provider/id_token.rb +++ b/app/models/oidc_provider/id_token.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'openid_connect/response_object/id_token/id_token_with_user_info' module OIDCProvider class IdToken < ApplicationRecord @@ -11,14 +12,29 @@ class IdToken < ApplicationRecord delegate :account, to: :authorization def to_response_object - OpenIDConnect::ResponseObject::IdToken.new( - iss: OIDCProvider.issuer, + base_claims ={ + iss: authorization.issuer, sub: account.send(OIDCProvider.account_identifier), aud: authorization.client_id, nonce: nonce, exp: expires_at.to_i, - iat: created_at.to_i - ) + iat: created_at.to_i, + auth_time: authorization.created_at.to_i, + amr: [ "pwd" ] + } + if OIDCProvider.include_user_claims_in_id_token + extra_claims = { + email: account.send(OIDCProvider.account_email), + email_verified: true, + given_name: account.send(OIDCProvider.account_given_name), + family_name: account.send(OIDCProvider.account_family_name), + } + OpenIDConnect::ResponseObject::IdTokenWithUserInfo.new(**extra_claims, **base_claims) + else + OpenIDConnect::ResponseObject::IdToken.new( + **base_claims + ) + end end def to_jwt @@ -30,7 +46,6 @@ def to_jwt class << self def config { - issuer: OIDCProvider.issuer, jwk_set: JSON::JWK::Set.new(public_jwk) } end @@ -48,7 +63,7 @@ def private_jwk end def public_jwk - JSON::JWK.new key_pair.public_key + JSON::JWK.new key_pair.public_key, {use: 'sig',alg: 'RS256'} end end end diff --git a/app/models/oidc_provider/refresh_token.rb b/app/models/oidc_provider/refresh_token.rb new file mode 100644 index 0000000..f16e8a8 --- /dev/null +++ b/app/models/oidc_provider/refresh_token.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module OIDCProvider + class RefreshToken < ApplicationRecord + scope :valid, -> { where(arel_table[:expires_at].gteq(Time.now.utc)) } + belongs_to :authorization + attribute :token, :string, default: -> { SecureRandom.hex 32 } + attribute :expires_at, :datetime, default: -> { 1.month.from_now } + attribute :revoked_at, :datetime + serialize :scopes, coder: JSON + end +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000..acfd9f6 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,15 @@ + + + + ConnectOp + <%= csrf_meta_tags %> + <%= action_cable_meta_tag %> + + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + +<%= yield %> + + \ No newline at end of file diff --git a/app/views/oidc_provider/authorizations/new.html.erb b/app/views/oidc_provider/authorizations/new.html.erb new file mode 100644 index 0000000..86b26af --- /dev/null +++ b/app/views/oidc_provider/authorizations/new.html.erb @@ -0,0 +1,15 @@ +
+

Authorization Request by <%= @client.name %>

+ <%= form_tag authorizations_path do %> + + <% [:client_id, :response_type, :redirect_uri, :scope, :state, :nonce].each do |key| %> + <%= hidden_field_tag key, oauth_request.send(key) %> + <% end %> +

<%= submit_tag :deny %>

+

<%= submit_tag :approve %>

+ <% end %> +
\ No newline at end of file diff --git a/db/migrate/20250714123456_create_oidc_provider_refresh_tokens.rb b/db/migrate/20250714123456_create_oidc_provider_refresh_tokens.rb new file mode 100644 index 0000000..c2c9ec2 --- /dev/null +++ b/db/migrate/20250714123456_create_oidc_provider_refresh_tokens.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateOIDCProviderRefreshTokens < ActiveRecord::Migration[5.1] + def change + create_table :oidc_provider_refresh_tokens do |t| + t.string :client_id, null: false + t.belongs_to :authorization, null: false + + t.string :token, null: false + t.datetime :expires_at, null: false + t.datetime :revoked_at + t.text :scopes, null: false + + t.timestamps + end + end +end \ No newline at end of file diff --git a/db/migrate/20250808987654_add_issuer_to_oidc_provider_authorizations.rb b/db/migrate/20250808987654_add_issuer_to_oidc_provider_authorizations.rb new file mode 100644 index 0000000..153af9b --- /dev/null +++ b/db/migrate/20250808987654_add_issuer_to_oidc_provider_authorizations.rb @@ -0,0 +1,6 @@ +class AddIssuerToOIDCProviderAuthorizations < ActiveRecord::Migration[5.1] + def change + add_column :oidc_provider_authorizations, :issuer, :string + end + +end diff --git a/lib/generators/oidc_provider/templates/initializer.rb b/lib/generators/oidc_provider/templates/initializer.rb index 1dfe607..5d9b487 100644 --- a/lib/generators/oidc_provider/templates/initializer.rb +++ b/lib/generators/oidc_provider/templates/initializer.rb @@ -1,5 +1,4 @@ OIDCProvider.configure do |config| - config.issuer = "https://myoidcprovider.org" # config.account_class = "Administrator" config.add_client do diff --git a/lib/oidc_provider.rb b/lib/oidc_provider.rb index 66cddf6..af3de0c 100644 --- a/lib/oidc_provider.rb +++ b/lib/oidc_provider.rb @@ -10,6 +10,8 @@ module Scopes Profile = "profile" Email = "email" Address = "address" + Phone = "phone" + OfflineAccess = "offline_access" end autoload :TokenEndpoint, 'oidc_provider/token_endpoint' @@ -42,6 +44,19 @@ module Scopes mattr_accessor :account_identifier @@account_identifier = :id + mattr_accessor :account_email + @@account_identifier = :email + + mattr_accessor :account_given_name + @@account_identifier = :given_name + + mattr_accessor :account_family_name + @@account_identifier = :family_name + + # Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint. + mattr_accessor :include_user_claims_in_id_token + @@include_user_claims_in_id_token = false + mattr_accessor :after_sign_out_path def self.add_client(&block) diff --git a/lib/oidc_provider/account_to_user_info.rb b/lib/oidc_provider/account_to_user_info.rb index 627d63a..84ce736 100644 --- a/lib/oidc_provider/account_to_user_info.rb +++ b/lib/oidc_provider/account_to_user_info.rb @@ -1,8 +1,9 @@ +require 'openid_connect/response_object/user_info/user_info_with_nonce' module OIDCProvider class AccountToUserInfo - def call(account, scope_names) + def call(account, scope_names, nonce) scopes = scope_names.map { |name| OIDCProvider.supported_scopes.detect { |scope| scope.name == name } }.compact - OpenIDConnect::ResponseObject::UserInfo.new(sub: account.send(OIDCProvider.account_identifier)).tap do |user_info| + OpenIDConnect::ResponseObject::UserInfoWithNonce.new({sub: account.send(OIDCProvider.account_identifier), nonce: nonce}).tap do |user_info| scopes.each do |scope| UserInfoBuilder.new(user_info, account).run(&scope.work) end diff --git a/lib/oidc_provider/token_endpoint.rb b/lib/oidc_provider/token_endpoint.rb index 6298495..84fa57e 100644 --- a/lib/oidc_provider/token_endpoint.rb +++ b/lib/oidc_provider/token_endpoint.rb @@ -8,21 +8,27 @@ class TokenEndpoint def initialize @app = Rack::OAuth2::Server::Token.new do |req, res| - Rails.logger.info "Client ID: #{req.client_id}" - Rails.logger.info "Client secret: #{req.client_secret}" - Rails.logger.info "Redirect URI: #{req.redirect_uri}" - - client = find_valid_client_from(req) || req.invalid_client! - - Rails.logger.info 'Found a client!' - case req.grant_type when :authorization_code + Rails.logger.info "Client ID: #{req.client_id}" + Rails.logger.info "Client secret: #{req.client_secret}" + Rails.logger.info "Redirect URI: #{req.redirect_uri}" + + client = find_valid_client_from(req) || req.invalid_client! + + Rails.logger.info 'Found a client!' Rails.logger.info 'Grant type was an authorization code. Correct!' authorization = Authorization.valid.where(client_id: client.identifier, code: req.code).first || req.invalid_grant! Rails.logger.info 'We found an authorization matching this code!' - res.access_token = authorization.access_token.to_bearer_token + with_refresh_token = authorization.scopes.include?('offline_access') + res.access_token = authorization.access_token.to_bearer_token(with_refresh_token) res.id_token = authorization.id_token.to_jwt if authorization.scopes.include?('openid') + when :refresh_token + Rails.logger.info 'Grant type was an refresh_token code. Correct!' + refresh_token = RefreshToken.valid.where(token: req.refresh_token).first || req.invalid_grant! + authorization = refresh_token.authorization + authorization.refresh! + res.access_token = authorization.access_token.to_bearer_token(true) else Rails.logger.info "Unsupported grant type: #{req.grant_type.inspect}" req.unsupported_grant_type! @@ -31,7 +37,6 @@ def initialize end private - def find_valid_client_from(req) client = ClientStore.new.find_by( identifier: req.client_id, @@ -42,5 +47,6 @@ def find_valid_client_from(req) client.redirect_uri.include?(req.redirect_uri) ? client : nil end + end end diff --git a/lib/oidc_provider/version.rb b/lib/oidc_provider/version.rb index a55a516..888381c 100644 --- a/lib/oidc_provider/version.rb +++ b/lib/oidc_provider/version.rb @@ -1,3 +1,3 @@ module OIDCProvider - VERSION = '0.7.0' + VERSION = '0.7.2' end diff --git a/lib/openid_connect/response_object/id_token/id_token_with_user_info.rb b/lib/openid_connect/response_object/id_token/id_token_with_user_info.rb new file mode 100644 index 0000000..1381016 --- /dev/null +++ b/lib/openid_connect/response_object/id_token/id_token_with_user_info.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module OpenIDConnect + class ResponseObject + class IdTokenWithUserInfo < IdToken + attr_optional :email, :email_verified, :given_name, :family_name + + end + end + end diff --git a/lib/openid_connect/response_object/user_info/user_info_with_nonce.rb b/lib/openid_connect/response_object/user_info/user_info_with_nonce.rb new file mode 100644 index 0000000..5f0a20c --- /dev/null +++ b/lib/openid_connect/response_object/user_info/user_info_with_nonce.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module OpenIDConnect + class ResponseObject + class UserInfoWithNonce < UserInfo + attr_required :nonce + + end + end +end \ No newline at end of file