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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/lib/himari/access_token.rb +72 -4
- data/lib/himari/access_token_jwt.rb +46 -0
- data/lib/himari/app.rb +102 -28
- data/lib/himari/authorization_code.rb +18 -4
- data/lib/himari/client_registration.rb +70 -4
- data/lib/himari/config.rb +8 -3
- data/lib/himari/decisions/authentication.rb +18 -2
- data/lib/himari/decisions/authorization.rb +18 -7
- data/lib/himari/decisions/base.rb +7 -3
- data/lib/himari/decisions/claims.rb +14 -9
- data/lib/himari/dynamic_client_registration.rb +255 -0
- data/lib/himari/id_token.rb +15 -28
- data/lib/himari/item_provider.rb +3 -1
- data/lib/himari/item_providers/oauth_client_metadata.rb +222 -0
- data/lib/himari/item_providers/static.rb +2 -0
- data/lib/himari/item_providers/storage.rb +33 -0
- data/lib/himari/jwt_token.rb +50 -0
- data/lib/himari/lifetime_value.rb +5 -3
- data/lib/himari/log_line.rb +2 -0
- data/lib/himari/middlewares/authentication_rule.rb +2 -0
- data/lib/himari/middlewares/authorization_rule.rb +2 -0
- data/lib/himari/middlewares/claims_rule.rb +2 -0
- data/lib/himari/middlewares/client.rb +2 -0
- data/lib/himari/middlewares/config.rb +2 -0
- data/lib/himari/middlewares/dynamic_clients.rb +55 -0
- data/lib/himari/middlewares/metadata_clients.rb +121 -0
- data/lib/himari/middlewares/signing_key.rb +2 -0
- data/lib/himari/provider_chain.rb +3 -1
- data/lib/himari/rack_oauth2_ext.rb +58 -0
- data/lib/himari/refresh_token.rb +93 -0
- data/lib/himari/rule.rb +2 -0
- data/lib/himari/rule_processor.rb +3 -0
- data/lib/himari/services/client_registration_endpoint.rb +78 -0
- data/lib/himari/services/downstream_authorization.rb +22 -7
- data/lib/himari/services/jwks_endpoint.rb +3 -1
- data/lib/himari/services/oidc_authorization_endpoint.rb +63 -3
- data/lib/himari/services/oidc_provider_metadata_endpoint.rb +31 -7
- data/lib/himari/services/oidc_token_endpoint.rb +225 -46
- data/lib/himari/services/oidc_userinfo_endpoint.rb +13 -7
- data/lib/himari/services/upstream_authentication.rb +62 -14
- data/lib/himari/session_data.rb +31 -2
- data/lib/himari/signing_key.rb +17 -14
- data/lib/himari/storages/base.rb +45 -1
- data/lib/himari/storages/filesystem.rb +14 -3
- data/lib/himari/storages/memory.rb +10 -2
- data/lib/himari/token_string.rb +40 -4
- data/lib/himari/version.rb +1 -1
- data/public/public/index.css +18 -0
- data/views/consent.erb +59 -0
- 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
|
-
|
|
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,
|
|
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
|
-
|
|
46
|
+
x
|
|
37
47
|
else
|
|
38
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/himari/id_token.rb
CHANGED
|
@@ -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(
|
|
21
|
-
|
|
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 :
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
@
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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
|
data/lib/himari/item_provider.rb
CHANGED
|
@@ -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:
|