diff --git a/app/controllers/doorkeeper/openid_connect/userinfo_controller.rb b/app/controllers/doorkeeper/openid_connect/userinfo_controller.rb index 7be704b..0b60cd7 100644 --- a/app/controllers/doorkeeper/openid_connect/userinfo_controller.rb +++ b/app/controllers/doorkeeper/openid_connect/userinfo_controller.rb @@ -6,7 +6,7 @@ class UserinfoController < ::Doorkeeper::ApplicationController def show resource_owner = doorkeeper_token.instance_eval(&Doorkeeper::OpenidConnect.configuration.resource_owner_from_access_token) - user_info = Doorkeeper::OpenidConnect::Models::UserInfo.new(resource_owner) + user_info = Doorkeeper::OpenidConnect::UserInfo.new(resource_owner, doorkeeper_token.scopes) render json: user_info, status: :ok end end diff --git a/lib/doorkeeper/openid_connect.rb b/lib/doorkeeper/openid_connect.rb index a066414..febb748 100644 --- a/lib/doorkeeper/openid_connect.rb +++ b/lib/doorkeeper/openid_connect.rb @@ -3,17 +3,16 @@ require 'json/jwt' require 'doorkeeper/openid_connect/claims_builder' +require 'doorkeeper/openid_connect/claims/claim' +require 'doorkeeper/openid_connect/claims/normal_claim' require 'doorkeeper/openid_connect/config' require 'doorkeeper/openid_connect/engine' +require 'doorkeeper/openid_connect/id_token' +require 'doorkeeper/openid_connect/user_info' require 'doorkeeper/openid_connect/version' require 'doorkeeper/openid_connect/helpers/controller' -require 'doorkeeper/openid_connect/models/id_token' -require 'doorkeeper/openid_connect/models/user_info' -require 'doorkeeper/openid_connect/models/claims/claim' -require 'doorkeeper/openid_connect/models/claims/normal_claim' - require 'doorkeeper/openid_connect/oauth/authorization/code' require 'doorkeeper/openid_connect/oauth/authorization_code_request' require 'doorkeeper/openid_connect/oauth/password_access_token_request' diff --git a/lib/doorkeeper/openid_connect/claims/aggregated_claim.rb b/lib/doorkeeper/openid_connect/claims/aggregated_claim.rb new file mode 100644 index 0000000..735fb45 --- /dev/null +++ b/lib/doorkeeper/openid_connect/claims/aggregated_claim.rb @@ -0,0 +1,9 @@ +module Doorkeeper + module OpenidConnect + module Claims + class AggregatedClaim < Claim + attr_accessor :jwt + end + end + end +end diff --git a/lib/doorkeeper/openid_connect/claims/claim.rb b/lib/doorkeeper/openid_connect/claims/claim.rb new file mode 100644 index 0000000..9582149 --- /dev/null +++ b/lib/doorkeeper/openid_connect/claims/claim.rb @@ -0,0 +1,34 @@ +module Doorkeeper + module OpenidConnect + module Claims + class Claim + attr_accessor :name, :scope + + # http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + # http://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims + STANDARD_CLAIMS = { + profile: %w[ + name family_name given_name middle_name nickname preferred_username + profile picture website gender birthdate zoneinfo locale updated_at + ], + email: %w[ email email_verified ], + address: %w[ address ], + phone: %w[ phone_number phone_number_verified ], + } + + def initialize(options = {}) + @name = options[:name] + @scope = options[:scope] + + # use default scope for Standard Claims + @scope ||= STANDARD_CLAIMS.find do |_scope, claims| + claims.include? @name + end.try(:first) + + # use profile scope as default fallback + @scope ||= :profile + end + end + end + end +end diff --git a/lib/doorkeeper/openid_connect/claims/distributed_claim.rb b/lib/doorkeeper/openid_connect/claims/distributed_claim.rb new file mode 100644 index 0000000..d8af12e --- /dev/null +++ b/lib/doorkeeper/openid_connect/claims/distributed_claim.rb @@ -0,0 +1,9 @@ +module Doorkeeper + module OpenidConnect + module Claims + class DistributedClaim < Claim + attr_accessor :endpoint, :access_token + end + end + end +end diff --git a/lib/doorkeeper/openid_connect/claims/normal_claim.rb b/lib/doorkeeper/openid_connect/claims/normal_claim.rb new file mode 100644 index 0000000..fc8c2a7 --- /dev/null +++ b/lib/doorkeeper/openid_connect/claims/normal_claim.rb @@ -0,0 +1,18 @@ +module Doorkeeper + module OpenidConnect + module Claims + class NormalClaim < Claim + attr_reader :generator + + def initialize(options = {}) + super(options) + @generator = options[:generator] + end + + def type + :normal + end + end + end + end +end diff --git a/lib/doorkeeper/openid_connect/claims_builder.rb b/lib/doorkeeper/openid_connect/claims_builder.rb index 29990bb..5bce61b 100644 --- a/lib/doorkeeper/openid_connect/claims_builder.rb +++ b/lib/doorkeeper/openid_connect/claims_builder.rb @@ -12,13 +12,15 @@ def build @claims end - def normal_claim(name, &block) + def normal_claim(name, scope: nil, &block) @claims[name] = - Doorkeeper::OpenidConnect::Models::Claims::NormalClaim.new( + Claims::NormalClaim.new( name: name, - value: block + scope: scope, + generator: block ) end + alias_method :claim, :normal_claim end end end diff --git a/lib/doorkeeper/openid_connect/id_token.rb b/lib/doorkeeper/openid_connect/id_token.rb new file mode 100644 index 0000000..56dde64 --- /dev/null +++ b/lib/doorkeeper/openid_connect/id_token.rb @@ -0,0 +1,62 @@ +module Doorkeeper + module OpenidConnect + class IdToken + include ActiveModel::Validations + + attr_reader :nonce + + def initialize(access_token, nonce = nil) + @access_token = access_token + @nonce = nonce + @resource_owner = access_token.instance_eval(&Doorkeeper::OpenidConnect.configuration.resource_owner_from_access_token) + @issued_at = Time.now + end + + def claims + { + iss: issuer, + sub: subject, + aud: audience, + exp: expiration, + iat: issued_at, + nonce: nonce, + auth_time: auth_time, + } + end + + def as_json(*_) + claims.reject { |_, value| value.blank? } + end + + def as_jws_token + JSON::JWT.new(as_json).sign(Doorkeeper::OpenidConnect.signing_key).to_s + end + + private + + def issuer + Doorkeeper::OpenidConnect.configuration.issuer + end + + def subject + @resource_owner.instance_eval(&Doorkeeper::OpenidConnect.configuration.subject).to_s + end + + def audience + @access_token.application.uid + end + + def expiration + (@issued_at.utc + Doorkeeper::OpenidConnect.configuration.expiration).to_i + end + + def issued_at + @issued_at.utc.to_i + end + + def auth_time + @resource_owner.instance_eval(&Doorkeeper::OpenidConnect.configuration.auth_time_from_resource_owner).try(:to_i) + end + end + end +end diff --git a/lib/doorkeeper/openid_connect/models/claims/aggregated_claim.rb b/lib/doorkeeper/openid_connect/models/claims/aggregated_claim.rb deleted file mode 100644 index ecae673..0000000 --- a/lib/doorkeeper/openid_connect/models/claims/aggregated_claim.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Doorkeeper - module OpenidConnect - module Models - module Claims - class AggregatedClaim < Claim - attr_accessor :jwt - end - end - end - end -end diff --git a/lib/doorkeeper/openid_connect/models/claims/claim.rb b/lib/doorkeeper/openid_connect/models/claims/claim.rb deleted file mode 100644 index a28a92a..0000000 --- a/lib/doorkeeper/openid_connect/models/claims/claim.rb +++ /dev/null @@ -1,15 +0,0 @@ -module Doorkeeper - module OpenidConnect - module Models - module Claims - class Claim - attr_accessor :name - - def initialize(options = {}) - @name = options[:name] - end - end - end - end - end -end diff --git a/lib/doorkeeper/openid_connect/models/claims/distributed_claim.rb b/lib/doorkeeper/openid_connect/models/claims/distributed_claim.rb deleted file mode 100644 index 5fe7b01..0000000 --- a/lib/doorkeeper/openid_connect/models/claims/distributed_claim.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Doorkeeper - module OpenidConnect - module Models - module Claims - class DistributedClaim < Claim - attr_accessor :endpoint, :access_token - end - end - end - end -end diff --git a/lib/doorkeeper/openid_connect/models/claims/normal_claim.rb b/lib/doorkeeper/openid_connect/models/claims/normal_claim.rb deleted file mode 100644 index ac58800..0000000 --- a/lib/doorkeeper/openid_connect/models/claims/normal_claim.rb +++ /dev/null @@ -1,24 +0,0 @@ -module Doorkeeper - module OpenidConnect - module Models - module Claims - class NormalClaim < Claim - attr_reader :value - - def initialize(options = {}) - super(options) - @value = options[:value] - end - - def type - :normal - end - - def to_proc - @value - end - end - end - end - end -end diff --git a/lib/doorkeeper/openid_connect/models/id_token.rb b/lib/doorkeeper/openid_connect/models/id_token.rb deleted file mode 100644 index 96058b3..0000000 --- a/lib/doorkeeper/openid_connect/models/id_token.rb +++ /dev/null @@ -1,64 +0,0 @@ -module Doorkeeper - module OpenidConnect - module Models - class IdToken - include ActiveModel::Validations - - attr_reader :nonce - - def initialize(access_token, nonce = nil) - @access_token = access_token - @nonce = nonce - @resource_owner = access_token.instance_eval(&Doorkeeper::OpenidConnect.configuration.resource_owner_from_access_token) - @issued_at = Time.now - end - - def claims - { - iss: issuer, - sub: subject, - aud: audience, - exp: expiration, - iat: issued_at, - nonce: nonce, - auth_time: auth_time, - } - end - - def as_json(*_) - claims.reject { |_, value| value.blank? } - end - - def as_jws_token - JSON::JWT.new(as_json).sign(Doorkeeper::OpenidConnect.signing_key).to_s - end - - private - - def issuer - Doorkeeper::OpenidConnect.configuration.issuer - end - - def subject - @resource_owner.instance_eval(&Doorkeeper::OpenidConnect.configuration.subject).to_s - end - - def audience - @access_token.application.uid - end - - def expiration - (@issued_at.utc + Doorkeeper::OpenidConnect.configuration.expiration).to_i - end - - def issued_at - @issued_at.utc.to_i - end - - def auth_time - @resource_owner.instance_eval(&Doorkeeper::OpenidConnect.configuration.auth_time_from_resource_owner).try(:to_i) - end - end - end - end -end diff --git a/lib/doorkeeper/openid_connect/models/user_info.rb b/lib/doorkeeper/openid_connect/models/user_info.rb deleted file mode 100644 index cc82d17..0000000 --- a/lib/doorkeeper/openid_connect/models/user_info.rb +++ /dev/null @@ -1,39 +0,0 @@ -module Doorkeeper - module OpenidConnect - module Models - class UserInfo - include ActiveModel::Validations - - def initialize(resource_owner) - @resource_owner = resource_owner - end - - def claims - base_claims.merge resource_owner_claims - end - - def as_json(*_) - claims - end - - private - - def base_claims - { - sub: subject - } - end - - def resource_owner_claims - Doorkeeper::OpenidConnect.configuration.claims.to_h.map do |name, claim| - [name, @resource_owner.instance_eval(&claim)] - end.to_h - end - - def subject - @resource_owner.instance_eval(&Doorkeeper::OpenidConnect.configuration.subject).to_s - end - end - end - end -end diff --git a/lib/doorkeeper/openid_connect/oauth/authorization_code_request.rb b/lib/doorkeeper/openid_connect/oauth/authorization_code_request.rb index 9aa3797..e7c7974 100644 --- a/lib/doorkeeper/openid_connect/oauth/authorization_code_request.rb +++ b/lib/doorkeeper/openid_connect/oauth/authorization_code_request.rb @@ -13,7 +13,7 @@ def after_successful_response openid_request.nonce end - id_token = Doorkeeper::OpenidConnect::Models::IdToken.new(access_token, nonce) + id_token = Doorkeeper::OpenidConnect::IdToken.new(access_token, nonce) @response.id_token = id_token end end diff --git a/lib/doorkeeper/openid_connect/oauth/password_access_token_request.rb b/lib/doorkeeper/openid_connect/oauth/password_access_token_request.rb index 5f3af2e..166d4c7 100644 --- a/lib/doorkeeper/openid_connect/oauth/password_access_token_request.rb +++ b/lib/doorkeeper/openid_connect/oauth/password_access_token_request.rb @@ -13,7 +13,7 @@ def initialize(server, client, resource_owner, parameters = {}) def after_successful_response super - id_token = Doorkeeper::OpenidConnect::Models::IdToken.new(access_token, nonce) + id_token = Doorkeeper::OpenidConnect::IdToken.new(access_token, nonce) @response.id_token = id_token end end diff --git a/lib/doorkeeper/openid_connect/user_info.rb b/lib/doorkeeper/openid_connect/user_info.rb new file mode 100644 index 0000000..1900b57 --- /dev/null +++ b/lib/doorkeeper/openid_connect/user_info.rb @@ -0,0 +1,40 @@ +module Doorkeeper + module OpenidConnect + class UserInfo + include ActiveModel::Validations + + def initialize(resource_owner, scopes) + @resource_owner = resource_owner + @scopes = scopes + end + + def claims + base_claims.merge resource_owner_claims + end + + def as_json(*_) + claims + end + + private + + def base_claims + { + sub: subject + } + end + + def resource_owner_claims + Doorkeeper::OpenidConnect.configuration.claims.to_h.map do |name, claim| + if @scopes.exists? claim.scope + [name, @resource_owner.instance_eval(&claim.generator)] + end + end.compact.to_h + end + + def subject + @resource_owner.instance_eval(&Doorkeeper::OpenidConnect.configuration.subject).to_s + end + end + end +end diff --git a/spec/controllers/discovery_controller_spec.rb b/spec/controllers/discovery_controller_spec.rb index f3c895f..a2ddc43 100644 --- a/spec/controllers/discovery_controller_spec.rb +++ b/spec/controllers/discovery_controller_spec.rb @@ -42,6 +42,8 @@ 'exp', 'iat', 'name', + 'created_at', + 'updated_at', ], }.sort) end diff --git a/spec/controllers/userinfo_controller_spec.rb b/spec/controllers/userinfo_controller_spec.rb index 303e69b..208cae7 100644 --- a/spec/controllers/userinfo_controller_spec.rb +++ b/spec/controllers/userinfo_controller_spec.rb @@ -9,11 +9,22 @@ context 'with a valid access token authorized for the openid scope' do let(:token) { create :access_token, application: client, resource_owner_id: user.id, scopes: 'openid' } - it 'returns the user information as JSON' do + it 'returns the basic user information as JSON' do get :show, access_token: token.token expect(response.status).to eq 200 - expect(response.body).to eq %Q{{"sub":"#{user.id}","name":"Joe"}} + expect(response.body).to eq %Q{{"sub":"#{user.id}","created_at":#{user.created_at.to_i}}} + end + end + + context 'with a valid access token authorized for the openid and profile scopes' do + let(:token) { create :access_token, application: client, resource_owner_id: user.id, scopes: 'openid profile' } + + it 'returns the full user information as JSON' do + get :show, access_token: token.token + + expect(response.status).to eq 200 + expect(response.body).to eq %Q{{"sub":"#{user.id}","name":"Joe","created_at":#{user.created_at.to_i},"updated_at":#{user.updated_at.to_i}}} end end diff --git a/spec/dummy/config/initializers/doorkeeper_openid_connect.rb b/spec/dummy/config/initializers/doorkeeper_openid_connect.rb index c3b224a..98cfaf2 100644 --- a/spec/dummy/config/initializers/doorkeeper_openid_connect.rb +++ b/spec/dummy/config/initializers/doorkeeper_openid_connect.rb @@ -61,5 +61,13 @@ claims do normal_claim :name, &:name + + normal_claim :created_at, scope: :openid do |user| + user.created_at.to_i + end + + normal_claim :updated_at do |user| + user.updated_at.to_i + end end end diff --git a/spec/lib/claims/claim_spec.rb b/spec/lib/claims/claim_spec.rb new file mode 100644 index 0000000..2cd5d5d --- /dev/null +++ b/spec/lib/claims/claim_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +describe Doorkeeper::OpenidConnect::Claims::Claim do + subject { described_class.new name: 'username', scope: 'profile' } + + describe '#initialize' do + it 'uses the given name' do + expect(subject.name).to eq 'username' + end + + it 'uses the given scope' do + expect(subject.scope).to eq 'profile' + end + + it 'falls back to the default scope for standard claims' do + expect(described_class.new(name: 'family_name').scope).to eq :profile + expect(described_class.new(name: 'email').scope).to eq :email + expect(described_class.new(name: 'address').scope).to eq :address + expect(described_class.new(name: 'phone_number').scope).to eq :phone + end + + it 'falls back to the profile scope for non-standard claims' do + expect(described_class.new(name: 'unknown').scope).to eq :profile + end + end +end diff --git a/spec/models/id_token_spec.rb b/spec/lib/id_token_spec.rb similarity index 94% rename from spec/models/id_token_spec.rb rename to spec/lib/id_token_spec.rb index 5e9c34b..7cde0da 100644 --- a/spec/models/id_token_spec.rb +++ b/spec/lib/id_token_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -describe Doorkeeper::OpenidConnect::Models::IdToken, type: :model do +describe Doorkeeper::OpenidConnect::IdToken do subject { described_class.new(access_token, nonce) } let(:access_token) { create :access_token, resource_owner_id: user.id } let(:user) { create :user } diff --git a/spec/lib/oauth/authorization_code_request_spec.rb b/spec/lib/oauth/authorization_code_request_spec.rb index 98f2e4e..4fe038b 100644 --- a/spec/lib/oauth/authorization_code_request_spec.rb +++ b/spec/lib/oauth/authorization_code_request_spec.rb @@ -19,7 +19,7 @@ it 'adds the ID token to the response' do subject.send :after_successful_response - expect(response.id_token).to be_a Doorkeeper::OpenidConnect::Models::IdToken + expect(response.id_token).to be_a Doorkeeper::OpenidConnect::IdToken expect(response.id_token.nonce).to eq '123456' end diff --git a/spec/lib/oauth/password_access_token_request_spec.rb b/spec/lib/oauth/password_access_token_request_spec.rb index d88f150..4d911b4 100644 --- a/spec/lib/oauth/password_access_token_request_spec.rb +++ b/spec/lib/oauth/password_access_token_request_spec.rb @@ -20,7 +20,7 @@ subject.access_token = token subject.send :after_successful_response - expect(response.id_token).to be_a Doorkeeper::OpenidConnect::Models::IdToken + expect(response.id_token).to be_a Doorkeeper::OpenidConnect::IdToken expect(response.id_token.nonce).to eq '123456' end end diff --git a/spec/lib/oauth/token_response_spec.rb b/spec/lib/oauth/token_response_spec.rb index cb884a6..d9a1955 100644 --- a/spec/lib/oauth/token_response_spec.rb +++ b/spec/lib/oauth/token_response_spec.rb @@ -3,7 +3,7 @@ describe Doorkeeper::OpenidConnect::OAuth::TokenResponse do subject { Doorkeeper::OAuth::TokenResponse.new token } let(:token) { create :access_token } - let(:id_token) { Doorkeeper::OpenidConnect::Models::IdToken.new token, '123456' } + let(:id_token) { Doorkeeper::OpenidConnect::IdToken.new token, '123456' } describe '#body' do before do diff --git a/spec/lib/user_info_spec.rb b/spec/lib/user_info_spec.rb new file mode 100644 index 0000000..a60382b --- /dev/null +++ b/spec/lib/user_info_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +describe Doorkeeper::OpenidConnect::UserInfo do + subject { described_class.new user, token.scopes } + let(:user) { create :user, name: 'Joe' } + let(:token) { create :access_token, resource_owner_id: user.id, scopes: scopes } + let(:scopes) { 'openid' } + + describe '#claims' do + it 'returns all accessible claims' do + expect(subject.claims).to eq({ + sub: user.id.to_s, + created_at: user.created_at.to_i, + }) + end + + context 'with a grant for the profile scopes' do + let(:scopes) { 'openid profile' } + + it 'returns additional profile claims' do + expect(subject.claims).to eq({ + sub: user.id.to_s, + name: 'Joe', + created_at: user.created_at.to_i, + updated_at: user.updated_at.to_i, + }) + end + end + end + + describe '#as_json' do + it 'returns all accessible claims' do + expect(subject.as_json).to eq subject.claims + end + end +end