himari 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/lib/himari/access_token.rb +72 -4
  4. data/lib/himari/access_token_jwt.rb +46 -0
  5. data/lib/himari/app.rb +102 -28
  6. data/lib/himari/authorization_code.rb +18 -4
  7. data/lib/himari/client_registration.rb +70 -4
  8. data/lib/himari/config.rb +8 -3
  9. data/lib/himari/decisions/authentication.rb +18 -2
  10. data/lib/himari/decisions/authorization.rb +18 -7
  11. data/lib/himari/decisions/base.rb +7 -3
  12. data/lib/himari/decisions/claims.rb +14 -9
  13. data/lib/himari/dynamic_client_registration.rb +255 -0
  14. data/lib/himari/id_token.rb +15 -28
  15. data/lib/himari/item_provider.rb +3 -1
  16. data/lib/himari/item_providers/oauth_client_metadata.rb +222 -0
  17. data/lib/himari/item_providers/static.rb +2 -0
  18. data/lib/himari/item_providers/storage.rb +33 -0
  19. data/lib/himari/jwt_token.rb +50 -0
  20. data/lib/himari/lifetime_value.rb +5 -3
  21. data/lib/himari/log_line.rb +2 -0
  22. data/lib/himari/middlewares/authentication_rule.rb +2 -0
  23. data/lib/himari/middlewares/authorization_rule.rb +2 -0
  24. data/lib/himari/middlewares/claims_rule.rb +2 -0
  25. data/lib/himari/middlewares/client.rb +2 -0
  26. data/lib/himari/middlewares/config.rb +2 -0
  27. data/lib/himari/middlewares/dynamic_clients.rb +55 -0
  28. data/lib/himari/middlewares/metadata_clients.rb +121 -0
  29. data/lib/himari/middlewares/signing_key.rb +2 -0
  30. data/lib/himari/provider_chain.rb +3 -1
  31. data/lib/himari/rack_oauth2_ext.rb +58 -0
  32. data/lib/himari/refresh_token.rb +93 -0
  33. data/lib/himari/rule.rb +2 -0
  34. data/lib/himari/rule_processor.rb +3 -0
  35. data/lib/himari/services/client_registration_endpoint.rb +78 -0
  36. data/lib/himari/services/downstream_authorization.rb +22 -7
  37. data/lib/himari/services/jwks_endpoint.rb +3 -1
  38. data/lib/himari/services/oidc_authorization_endpoint.rb +63 -3
  39. data/lib/himari/services/oidc_provider_metadata_endpoint.rb +31 -7
  40. data/lib/himari/services/oidc_token_endpoint.rb +225 -46
  41. data/lib/himari/services/oidc_userinfo_endpoint.rb +13 -7
  42. data/lib/himari/services/upstream_authentication.rb +62 -14
  43. data/lib/himari/session_data.rb +31 -2
  44. data/lib/himari/signing_key.rb +17 -14
  45. data/lib/himari/storages/base.rb +45 -1
  46. data/lib/himari/storages/filesystem.rb +14 -3
  47. data/lib/himari/storages/memory.rb +10 -2
  48. data/lib/himari/token_string.rb +40 -4
  49. data/lib/himari/version.rb +1 -1
  50. data/public/public/index.css +18 -0
  51. data/views/consent.erb +59 -0
  52. metadata +50 -14
@@ -1,8 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'digest/sha2'
4
+ require 'addressable/uri'
2
5
 
3
6
  module Himari
4
7
  class ClientRegistration
5
- def initialize(name:, id:, secret: nil, secret_hash: nil, redirect_uris:, preferred_key_group: nil, require_pkce: false)
8
+ # Loopback hosts whose redirect_uri port may be relaxed (RFC 8252 §7.3,
9
+ # draft-ietf-oauth-v2-1-15 §8.4.2). Addressable returns IPv6 hosts bracketed.
10
+ LOOPBACK_HOSTS = %w[127.0.0.1 [::1] localhost].freeze
11
+
12
+ # Scopes Himari itself acts on; recognised for every client regardless of the configured
13
+ # scopes list, so a client need not enumerate them to use OIDC or obtain a refresh token.
14
+ IMPLICIT_SCOPES = %w[openid offline_access].freeze
15
+
16
+ def initialize(id:, redirect_uris:, name: nil, secret: nil, secret_hash: nil, preferred_key_group: nil, require_pkce: false, confidential: true, ignore_localhost_redirect_uri_port: true, skip_consent: false, scopes: IMPLICIT_SCOPES)
6
17
  @name = name
7
18
  @id = id
8
19
  @secret = secret
@@ -10,18 +21,28 @@ module Himari
10
21
  @redirect_uris = redirect_uris
11
22
  @preferred_key_group = preferred_key_group
12
23
  @require_pkce = require_pkce
24
+ @confidential = confidential
25
+ @ignore_localhost_redirect_uri_port = ignore_localhost_redirect_uri_port
26
+ @skip_consent = skip_consent
27
+ @scopes = (Array(scopes) | IMPLICIT_SCOPES).freeze
13
28
 
14
29
  raise ArgumentError, "name starts with '_' is reserved" if @name&.start_with?('_')
15
- raise ArgumentError, "either secret or secret_hash must be present" if !@secret && !@secret_hash
30
+ raise ArgumentError, "either secret or secret_hash must be present" if confidential && !@secret && !@secret_hash
16
31
  end
17
32
 
18
- attr_reader :name, :id, :redirect_uris, :preferred_key_group, :require_pkce
33
+ attr_reader :name, :id, :redirect_uris, :preferred_key_group, :require_pkce, :ignore_localhost_redirect_uri_port, :skip_consent, :scopes
34
+
35
+ def confidential?
36
+ @confidential
37
+ end
19
38
 
20
39
  def secret_hash
21
40
  @secret_hash ||= Digest::SHA384.hexdigest(secret)
22
41
  end
23
42
 
24
43
  def match_secret?(given_secret)
44
+ return false unless confidential? && given_secret
45
+
25
46
  if @secret
26
47
  Rack::Utils.secure_compare(@secret, given_secret)
27
48
  else
@@ -30,8 +51,26 @@ module Himari
30
51
  end
31
52
  end
32
53
 
54
+ # True when one of the registered redirect_uris covers the given (request) redirect_uri.
55
+ # draft-ietf-oauth-v2-1-15 §4.1.3 / RFC 3986 §6.2.1: simple (exact) string comparison, with the
56
+ # loopback-port exception of RFC 8252 §7.3 / draft-v2-1 §8.4.2 applied when enabled. A registered
57
+ # entry may also be a Regexp (operator-supplied via static config), matched against the request URI.
58
+ def redirect_uri_covers?(given)
59
+ given = given.to_s
60
+ return false if given.empty?
61
+
62
+ redirect_uris.any? { |registered| redirect_uri_match?(registered, given) }
63
+ end
64
+
65
+ # Drop requested scopes this client does not recognise. OAuth servers are expected to ignore
66
+ # unknown scopes rather than reject the request (draft-ietf-oauth-v2-1 §3.2.2.1); request
67
+ # order is preserved.
68
+ def filter_scopes(requested)
69
+ Array(requested).select { |scope| scopes.include?(scope) }
70
+ end
71
+
33
72
  def as_log
34
- {name: name, id: id}
73
+ {name: name, id: id, skip_consent: skip_consent, scopes: scopes}
35
74
  end
36
75
 
37
76
  def match_hint?(id: nil)
@@ -45,5 +84,32 @@ module Himari
45
84
 
46
85
  result
47
86
  end
87
+
88
+ private def redirect_uri_match?(registered, given)
89
+ return registered.match?(given) if registered.is_a?(Regexp)
90
+
91
+ registered = registered.to_s
92
+ return true if registered == given
93
+ return false unless ignore_localhost_redirect_uri_port
94
+
95
+ reg = loopback_uri(registered) or return false
96
+ giv = loopback_uri(given) or return false
97
+
98
+ # Port is intentionally ignored to allow ephemeral loopback ports; fragments are
99
+ # rejected at registration time, so loopback_uri requires their absence here too.
100
+ reg.scheme == giv.scheme && reg.host == giv.host && reg.path == giv.path && reg.query == giv.query
101
+ end
102
+
103
+ private def loopback_uri(str)
104
+ uri = begin
105
+ Addressable::URI.parse(str)
106
+ rescue Addressable::URI::InvalidURIError
107
+ nil
108
+ end
109
+ return unless uri && LOOPBACK_HOSTS.include?(uri.host)
110
+ return if uri.fragment
111
+
112
+ uri
113
+ end
48
114
  end
49
115
  end
data/lib/himari/config.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'logger'
2
4
  require 'time'
3
5
  require 'json'
@@ -5,7 +7,7 @@ require 'himari/log_line'
5
7
 
6
8
  module Himari
7
9
  class Config
8
- def initialize(issuer:, storage:, providers: [], log_output: $stdout, log_level: Logger::INFO, preserve_rack_logger: false, custom_templates: {}, custom_messages: {}, release_fragment: nil)
10
+ def initialize(issuer:, storage:, providers: [], log_output: $stdout, log_level: Logger::INFO, preserve_rack_logger: false, custom_templates: {}, custom_messages: {}, release_fragment: nil, scopes_supported: [], claims_supported: [])
9
11
  @issuer = issuer
10
12
  @providers = providers
11
13
  @storage = storage
@@ -17,14 +19,17 @@ module Himari
17
19
  @custom_messages = custom_messages
18
20
  @custom_templates = custom_templates
19
21
  @release_fragment = release_fragment
22
+
23
+ @scopes_supported = scopes_supported
24
+ @claims_supported = claims_supported
20
25
  end
21
26
 
22
- attr_reader :issuer, :providers, :storage, :preserve_rack_logger, :custom_messages, :custom_templates, :release_fragment
27
+ attr_reader :issuer, :providers, :storage, :preserve_rack_logger, :custom_messages, :custom_templates, :release_fragment, :scopes_supported, :claims_supported
23
28
 
24
29
  def logger
25
30
  @logger ||= Logger.new(@log_output).tap do |l|
26
31
  l.level = @log_level
27
- l.formatter = proc do |severity, datetime, progname, msg|
32
+ l.formatter = proc do |severity, datetime, _progname, msg|
28
33
  log = {time: datetime.xmlschema, severity: severity.to_s, pid: Process.pid}
29
34
 
30
35
  case msg
@@ -1,15 +1,31 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/decisions/base'
2
4
  require 'himari/session_data'
3
5
 
4
6
  module Himari
5
7
  module Decisions
6
8
  class Authentication < Base
7
- Context = Struct.new(:provider, :claims, :user_data, :request, keyword_init: true)
9
+ Context = Struct.new(:provider, :claims, :user_data, :request, :grant_type, :refresh_info, keyword_init: true) do
10
+ def initial?; grant_type.nil? || grant_type == :initial; end
11
+ def refresh?; grant_type == :refresh_token; end
12
+ end
8
13
 
9
14
  allow_effects(:allow, :deny, :skip)
10
15
 
16
+ def initialize(refresh_info: nil)
17
+ super()
18
+ @refresh_info = refresh_info
19
+ end
20
+
21
+ attr_accessor :refresh_info
22
+
11
23
  def to_evolve_args
12
- {}
24
+ {refresh_info: @refresh_info}
25
+ end
26
+
27
+ def as_log
28
+ to_h.merge(refresh_info_set: !@refresh_info.nil?)
13
29
  end
14
30
  end
15
31
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/decisions/base'
2
4
  require 'himari/lifetime_value'
3
5
 
@@ -16,26 +18,34 @@ module Himari
16
18
  email_verified
17
19
  )
18
20
 
19
- Context = Struct.new(:claims, :user_data, :request, :client, keyword_init: true)
21
+ Context = Struct.new(:claims, :user_data, :request, :client, :scopes, :grant_type, keyword_init: true) do
22
+ def initial?; grant_type.nil? || grant_type == :initial; end
23
+ def refresh?; grant_type == :refresh_token; end
24
+ end
20
25
 
21
26
  allow_effects(:allow, :deny, :continue, :skip)
22
27
 
23
- def initialize(claims: {}, allowed_claims: DEFAULT_ALLOWED_CLAIMS, lifetime: 3600)
28
+ def initialize(claims: {}, allowed_claims: DEFAULT_ALLOWED_CLAIMS, lifetime: 3600, mint_jwt_access_token: false)
24
29
  super()
25
30
  @claims = claims
26
31
  @allowed_claims = allowed_claims
32
+ @mint_jwt_access_token = mint_jwt_access_token
27
33
  self.lifetime = lifetime
28
34
  end
29
35
 
30
36
  attr_reader :claims, :allowed_claims
31
37
  attr_reader :lifetime
32
38
 
39
+ # When set by an authz rule, the issued access token is an RFC 9068 JWT instead of an
40
+ # opaque token (the token is still tracked and validated against storage either way).
41
+ attr_accessor :mint_jwt_access_token
42
+
33
43
  def lifetime=(x)
34
- case x
44
+ @lifetime = case x
35
45
  when LifetimeValue
36
- @lifetime = x
46
+ x
37
47
  else
38
- @lifetime = LifetimeValue.from_integer(x)
48
+ LifetimeValue.from_integer(x)
39
49
  end
40
50
  end
41
51
 
@@ -44,15 +54,16 @@ module Himari
44
54
  claims: @claims.dup,
45
55
  allowed_claims: @allowed_claims.dup,
46
56
  lifetime: @lifetime,
57
+ mint_jwt_access_token: @mint_jwt_access_token,
47
58
  }
48
59
  end
49
60
 
50
61
  def as_log
51
- to_h.merge(claims: output_claims, lifetime: @lifetime.to_h)
62
+ to_h.merge(claims: output_claims, lifetime: @lifetime.to_h, mint_jwt_access_token: @mint_jwt_access_token)
52
63
  end
53
64
 
54
65
  def output_claims
55
- claims.select { |k,_v| allowed_claims.include?(k) }
66
+ claims.select { |k, _v| allowed_claims.include?(k) }
56
67
  end
57
68
  end
58
69
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Himari
2
4
  module Decisions
3
5
  class Base
@@ -8,8 +10,8 @@ module Himari
8
10
  @valid_effects = effects
9
11
  end
10
12
 
11
- def self.valid_effects
12
- @valid_effects
13
+ class << self
14
+ attr_reader :valid_effects
13
15
  end
14
16
 
15
17
  def initialize
@@ -45,6 +47,7 @@ module Himari
45
47
 
46
48
  def set_rule_name(rule_name)
47
49
  raise "cannot override rule_name" if @rule_name
50
+
48
51
  @rule_name = rule_name
49
52
  self
50
53
  end
@@ -52,7 +55,8 @@ module Himari
52
55
  def decide!(effect, comment = "", user_facing_message: nil, suggest: nil)
53
56
  raise DecisionAlreadyMade, "decision can only be made once per rule (#{rule_name})" if @effect
54
57
  raise InvalidEffect, "this effect is not valid under this rule. Valid effects: #{self.class.valid_effects.inspect} (#{rule_name})" unless self.class.valid_effects.include?(effect)
55
- raise InvalidEffect, "only deny effect can have suggestion" if suggest&& effect != :deny
58
+ raise InvalidEffect, "only deny effect can have suggestion" if suggest && effect != :deny
59
+
56
60
  @effect = effect
57
61
  @effect_comment = comment
58
62
  @effect_user_facing_message = user_facing_message
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/decisions/base'
2
4
  require 'himari/session_data'
3
5
 
@@ -7,31 +9,34 @@ module Himari
7
9
  class UninitializedError < StandardError; end
8
10
  class AlreadyInitializedError < StandardError; end
9
11
 
10
- Context = Struct.new(:request, :auth, keyword_init: true) do
11
- def provider; auth[:provider]; end
12
+ Context = Struct.new(:request, :auth, :provider, :grant_type, :refresh_info, keyword_init: true) do
13
+ def initial?; grant_type.nil? || grant_type == :initial; end
14
+ def refresh?; grant_type == :refresh_token; end
12
15
  end
13
16
 
14
- allow_effects(:continue, :skip)
17
+ allow_effects(:continue, :skip, :deny)
15
18
 
16
- def initialize(claims: nil, user_data: nil, lifetime: nil)
19
+ def initialize(claims: nil, user_data: nil, lifetime: nil, refresh_info: nil)
17
20
  super()
18
21
  @claims = claims
19
22
  @user_data = user_data
20
23
  @lifetime = lifetime
24
+ @refresh_info = refresh_info
21
25
  end
22
26
 
23
- attr_accessor :lifetime
27
+ attr_accessor :lifetime, :refresh_info
24
28
 
25
29
  def to_evolve_args
26
30
  {
27
31
  claims: @claims.dup,
28
32
  user_data: @user_data.dup,
29
33
  lifetime: @lifetime&.to_i,
34
+ refresh_info: @refresh_info,
30
35
  }
31
36
  end
32
37
 
33
38
  def as_log
34
- to_h.merge(claims: @claims)
39
+ to_h.merge(claims: @claims, refresh_info_set: !@refresh_info.nil?)
35
40
  end
36
41
 
37
42
  def output
@@ -42,14 +47,14 @@ module Himari
42
47
  if @claims
43
48
  raise AlreadyInitializedError, "Claims already initialized; use decision.claims to make modification, or rule might be behaving wrong"
44
49
  end
50
+
45
51
  @claims = claims.dup
46
52
  @user_data = {}
47
53
  end
48
54
 
49
55
  def claims
50
- unless @claims
51
- raise UninitializedError, "Claims uninitialized; use decision.initialize_claims! to declare claims first (or rule order might be unintentional)" unless @claims
52
- end
56
+ raise UninitializedError, "Claims uninitialized; use decision.initialize_claims! to declare claims first (or rule order might be unintentional)" unless @claims
57
+
53
58
  @claims
54
59
  end
55
60
 
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/sha2'
4
+ require 'securerandom'
5
+ require 'addressable/uri'
6
+ require 'himari/client_registration'
7
+
8
+ module Himari
9
+ # A client created at runtime via RFC 7591 Dynamic Client Registration, persisted in
10
+ # storage. This is purely a storage/registration record: the registration endpoint
11
+ # interacts with it directly, while the OIDC endpoints only ever see the plain
12
+ # ClientRegistration produced by #to_client_registration at the provider layer.
13
+ class DynamicClientRegistration
14
+ # Default registration lifetime; overridable per deployment via Middlewares::DynamicClients.
15
+ REGISTRATION_LIFETIME = 180 * 86400
16
+
17
+ SUPPORTED_GRANT_TYPES = %w(authorization_code refresh_token).freeze
18
+ SUPPORTED_RESPONSE_TYPES = %w(code).freeze
19
+ SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS = %w(none client_secret_basic client_secret_post).freeze
20
+
21
+ DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD = 'client_secret_basic'
22
+
23
+ MAX_REDIRECT_URIS = 32
24
+ MAX_URI_LENGTH = 2000
25
+ MAX_CLIENT_NAME_LENGTH = 60
26
+ DANGEROUS_REDIRECT_URI_SCHEMES = %w(javascript data vbscript file blob).freeze
27
+
28
+ # Raised on invalid client metadata. error_code maps to an RFC 7591 §3.2.2 error code.
29
+ class ValidationError < StandardError
30
+ def initialize(error_code, message)
31
+ @error_code = error_code
32
+ super(message)
33
+ end
34
+
35
+ attr_reader :error_code
36
+ end
37
+
38
+ # Build and validate a registration from RFC 7591 client metadata.
39
+ #
40
+ # @param metadata [Hash] parsed client metadata (symbolized keys) from the request body
41
+ # @return [DynamicClientRegistration]
42
+ def self.register(metadata:, registration_ip: nil, registration_remote_addr: nil, registration_x_forwarded_for: nil, lifetime: REGISTRATION_LIFETIME, ignore_localhost_redirect_uri_port: true, now: Time.now)
43
+ raise ValidationError.new(:invalid_client_metadata, 'request body must be a JSON object') unless metadata.is_a?(Hash)
44
+
45
+ auth_method = metadata.fetch(:token_endpoint_auth_method, DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD).to_s
46
+ unless SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS.include?(auth_method)
47
+ raise ValidationError.new(:invalid_client_metadata, "unsupported token_endpoint_auth_method: #{auth_method}")
48
+ end
49
+
50
+ grant_types = Array(metadata[:grant_types] || %w(authorization_code)).map(&:to_s)
51
+ unless (grant_types - SUPPORTED_GRANT_TYPES).empty?
52
+ raise ValidationError.new(:invalid_client_metadata, "unsupported grant_types: #{(grant_types - SUPPORTED_GRANT_TYPES).join(",")}")
53
+ end
54
+
55
+ response_types = Array(metadata[:response_types] || %w(code)).map(&:to_s)
56
+ unless (response_types - SUPPORTED_RESPONSE_TYPES).empty?
57
+ raise ValidationError.new(:invalid_client_metadata, "unsupported response_types: #{(response_types - SUPPORTED_RESPONSE_TYPES).join(",")}")
58
+ end
59
+
60
+ if response_types.include?('code') && !grant_types.include?('authorization_code')
61
+ raise ValidationError.new(:invalid_client_metadata, 'response_type "code" requires grant_type "authorization_code"')
62
+ end
63
+
64
+ redirect_uris = validate_redirect_uris(metadata[:redirect_uris])
65
+
66
+ client_name = metadata[:client_name]&.to_s
67
+ if client_name && client_name.length > MAX_CLIENT_NAME_LENGTH
68
+ raise ValidationError.new(:invalid_client_metadata, "client_name must not exceed #{MAX_CLIENT_NAME_LENGTH} characters")
69
+ end
70
+
71
+ client_uri = validate_client_uri(metadata[:client_uri])
72
+
73
+ issued_at = now.to_i
74
+ secret = auth_method == 'none' ? nil : SecureRandom.urlsafe_base64(48)
75
+
76
+ new(
77
+ id: SecureRandom.urlsafe_base64(24),
78
+ redirect_uris: redirect_uris,
79
+ token_endpoint_auth_method: auth_method,
80
+ grant_types: grant_types,
81
+ response_types: response_types,
82
+ client_name: client_name,
83
+ client_uri: client_uri,
84
+ scope: metadata[:scope]&.to_s,
85
+ secret: secret,
86
+ secret_hash: secret && Digest::SHA384.hexdigest(secret),
87
+ client_id_issued_at: issued_at,
88
+ expiry: issued_at + lifetime,
89
+ registration_ip: registration_ip,
90
+ registration_remote_addr: registration_remote_addr,
91
+ registration_x_forwarded_for: registration_x_forwarded_for,
92
+ ignore_localhost_redirect_uri_port: ignore_localhost_redirect_uri_port,
93
+ )
94
+ end
95
+
96
+ def self.validate_redirect_uris(given)
97
+ raise ValidationError.new(:invalid_redirect_uri, 'redirect_uris is required and must be a non-empty array') unless given.is_a?(Array) && !given.empty?
98
+ raise ValidationError.new(:invalid_redirect_uri, "redirect_uris must not exceed #{MAX_REDIRECT_URIS} entries") if given.size > MAX_REDIRECT_URIS
99
+
100
+ given.map do |uri|
101
+ str = uri.to_s
102
+ parsed = begin
103
+ Addressable::URI.parse(str)
104
+ rescue Addressable::URI::InvalidURIError
105
+ nil
106
+ end
107
+ raise ValidationError.new(:invalid_redirect_uri, "redirect_uri must not exceed #{MAX_URI_LENGTH} characters") if str.length > MAX_URI_LENGTH
108
+ raise ValidationError.new(:invalid_redirect_uri, "invalid redirect_uri: #{str}") unless parsed&.scheme
109
+ raise ValidationError.new(:invalid_redirect_uri, "redirect_uri must not contain a fragment: #{str}") if parsed.fragment
110
+ raise ValidationError.new(:invalid_redirect_uri, "redirect_uri scheme not allowed: #{str}") if DANGEROUS_REDIRECT_URI_SCHEMES.include?(parsed.scheme.downcase)
111
+
112
+ str
113
+ end
114
+ end
115
+
116
+ def self.validate_client_uri(given)
117
+ return if given.nil?
118
+
119
+ str = given.to_s
120
+ raise ValidationError.new(:invalid_client_metadata, "client_uri must not exceed #{MAX_URI_LENGTH} characters") if str.length > MAX_URI_LENGTH
121
+
122
+ parsed = begin
123
+ Addressable::URI.parse(str)
124
+ rescue Addressable::URI::InvalidURIError
125
+ nil
126
+ end
127
+ raise ValidationError.new(:invalid_client_metadata, "invalid client_uri: #{str}") unless parsed&.scheme && parsed.host
128
+
129
+ str
130
+ end
131
+
132
+ def self.from_json(hash)
133
+ attrs = hash.dup
134
+ attrs.delete(:ttl)
135
+ new(**attrs)
136
+ end
137
+
138
+ def initialize(id:, redirect_uris:, token_endpoint_auth_method:, grant_types:, response_types:, client_id_issued_at:, expiry:, secret: nil, secret_hash: nil, client_name: nil, client_uri: nil, scope: nil, preferred_key_group: nil, registration_ip: nil, registration_remote_addr: nil, registration_x_forwarded_for: nil, ignore_localhost_redirect_uri_port: true)
139
+ @id = id
140
+ @redirect_uris = redirect_uris
141
+ @token_endpoint_auth_method = token_endpoint_auth_method
142
+ @grant_types = grant_types
143
+ @response_types = response_types
144
+ @client_id_issued_at = client_id_issued_at
145
+ @expiry = expiry
146
+ @secret = secret
147
+ @secret_hash = secret_hash
148
+ @client_name = client_name
149
+ @client_uri = client_uri
150
+ @scope = scope
151
+ @preferred_key_group = preferred_key_group
152
+ @registration_ip = registration_ip
153
+ @registration_remote_addr = registration_remote_addr
154
+ @registration_x_forwarded_for = registration_x_forwarded_for
155
+ @ignore_localhost_redirect_uri_port = ignore_localhost_redirect_uri_port
156
+ end
157
+
158
+ attr_reader :id, :redirect_uris, :token_endpoint_auth_method, :grant_types, :response_types,
159
+ :client_id_issued_at, :expiry, :secret, :secret_hash, :client_name, :client_uri, :scope,
160
+ :preferred_key_group, :registration_ip, :registration_remote_addr, :registration_x_forwarded_for,
161
+ :ignore_localhost_redirect_uri_port
162
+
163
+ def confidential?
164
+ token_endpoint_auth_method != 'none'
165
+ end
166
+
167
+ # Public clients have no secret to bind the authorization code, so PKCE is mandatory.
168
+ def require_pkce
169
+ !confidential?
170
+ end
171
+
172
+ def active?(now = Time.now)
173
+ expiry > now.to_i
174
+ end
175
+
176
+ # The client object the OIDC authorization/token endpoints consume. Dynamic records carry
177
+ # no name (so operator rules keyed on name never match them) and pass through the secret
178
+ # hash only for confidential clients. skip_consent and scopes default to the conservative
179
+ # values and are supplied by the provider from the DynamicClients middleware options.
180
+ def to_client_registration(skip_consent: false, scopes: ClientRegistration::IMPLICIT_SCOPES)
181
+ ClientRegistration.new(
182
+ id: id,
183
+ redirect_uris: redirect_uris,
184
+ secret_hash: confidential? ? secret_hash : nil,
185
+ preferred_key_group: preferred_key_group,
186
+ require_pkce: require_pkce,
187
+ confidential: confidential?,
188
+ ignore_localhost_redirect_uri_port: ignore_localhost_redirect_uri_port,
189
+ skip_consent: skip_consent,
190
+ scopes: scopes,
191
+ )
192
+ end
193
+
194
+ def as_log
195
+ {
196
+ id: id,
197
+ token_endpoint_auth_method: token_endpoint_auth_method,
198
+ redirect_uris: redirect_uris,
199
+ grant_types: grant_types,
200
+ response_types: response_types,
201
+ client_name: client_name,
202
+ client_uri: client_uri,
203
+ scope: scope,
204
+ client_id_issued_at: client_id_issued_at,
205
+ expiry: expiry,
206
+ dynamic: true,
207
+ }
208
+ end
209
+
210
+ def as_json
211
+ {
212
+ id: id,
213
+ secret_hash: secret_hash,
214
+ redirect_uris: redirect_uris,
215
+ grant_types: grant_types,
216
+ response_types: response_types,
217
+ token_endpoint_auth_method: token_endpoint_auth_method,
218
+ client_name: client_name,
219
+ client_uri: client_uri,
220
+ scope: scope,
221
+ preferred_key_group: preferred_key_group,
222
+ client_id_issued_at: client_id_issued_at,
223
+ expiry: expiry,
224
+ ttl: expiry,
225
+ registration_ip: registration_ip,
226
+ registration_remote_addr: registration_remote_addr,
227
+ registration_x_forwarded_for: registration_x_forwarded_for,
228
+ ignore_localhost_redirect_uri_port: ignore_localhost_redirect_uri_port,
229
+ }
230
+ end
231
+
232
+ # RFC 7591 §3.2.1 client information response. Includes client_secret only when freshly
233
+ # generated (the plaintext is never persisted, so it is available only right after register).
234
+ def registration_response
235
+ response = {
236
+ client_id: id,
237
+ client_id_issued_at: client_id_issued_at,
238
+ redirect_uris: redirect_uris,
239
+ grant_types: grant_types,
240
+ response_types: response_types,
241
+ token_endpoint_auth_method: token_endpoint_auth_method,
242
+ client_name: client_name,
243
+ client_uri: client_uri,
244
+ scope: scope,
245
+ }.compact
246
+
247
+ if confidential? && secret
248
+ response[:client_secret] = secret
249
+ response[:client_secret_expires_at] = expiry
250
+ end
251
+
252
+ response
253
+ end
254
+ end
255
+ end
@@ -1,60 +1,47 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rack/oauth2'
2
4
  require 'openid_connect'
3
5
  require 'base64'
4
6
  require 'json/jwt'
5
7
 
8
+ require 'himari/jwt_token'
9
+
6
10
  module Himari
7
- class IdToken
11
+ class IdToken < JwtToken
8
12
  # @param authz [Himari::AuthorizationCode]
9
13
  def self.from_authz(authz, **kwargs)
10
-
11
14
  new(
12
15
  claims: authz.claims,
13
16
  client_id: authz.client_id,
14
17
  nonce: authz.nonce,
15
18
  lifetime: authz.lifetime.is_a?(Integer) ? authz.lifetime : authz.lifetime.id_token, # compat
16
- **kwargs
19
+ **kwargs,
17
20
  )
18
21
  end
19
22
 
20
- def initialize(claims:, client_id:, nonce:, signing_key:, issuer:, access_token: nil, time: Time.now, lifetime: 3600)
21
- @claims = claims
22
- @client_id = client_id
23
+ def initialize(nonce:, access_token: nil, **kwargs)
24
+ super(**kwargs)
23
25
  @nonce = nonce
24
- @signing_key = signing_key
25
- @issuer = issuer
26
26
  @access_token = access_token
27
- @time = time
28
- @lifetime = lifetime
29
27
  end
30
28
 
31
- attr_reader :claims, :nonce, :signing_key
29
+ attr_reader :nonce
32
30
 
33
31
  def final_claims
34
32
  # https://openid.net/specs/openid-connect-core-1_0.html#IDToken
35
- claims.merge(
36
- iss: @issuer,
37
- aud: @client_id,
38
- iat: @time.to_i,
39
- nbf: @time.to_i,
40
- exp: (@time + @lifetime).to_i,
33
+ standard_claims.merge(
34
+ @nonce ? {nonce: @nonce} : {},
41
35
  ).merge(
42
- @nonce ? { nonce: @nonce } : {}
43
- ).merge(
44
- @access_token ? { at_hash: at_hash } : {}
36
+ @access_token ? {at_hash: at_hash} : {},
45
37
  )
46
38
  end
47
39
 
48
40
  def at_hash
49
- return nil unless @access_token
50
- dgst = @signing_key.hash_function.digest(@access_token)
51
- Base64.urlsafe_encode64(dgst[0, dgst.size/2], padding: false)
52
- end
41
+ return unless @access_token
53
42
 
54
- def to_jwt
55
- jwt = JSON::JWT.new(final_claims)
56
- jwt.kid = @signing_key.id
57
- jwt.sign(@signing_key.pkey, @signing_key.alg.to_sym).to_s
43
+ dgst = signing_key.hash_function.digest(@access_token)
44
+ Base64.urlsafe_encode64(dgst[0, dgst.size / 2], padding: false)
58
45
  end
59
46
  end
60
47
  end
@@ -1,9 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Himari
2
4
  module ItemProvider
3
5
  # :nocov:
4
6
  # Return items searched by hints. This method can perform fuzzy match with hints. OTOH is not expected to return exact match results.
5
7
  # Use Item#match_hint? to do exact match in later process. See also: ProviderChain
6
- def collect(**hints)
8
+ def collect(**hints)
7
9
  raise NotImplementedError
8
10
  end
9
11
  # :nocov: