himari 0.4.0 → 0.6.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 +58 -0
- data/lib/himari/access_token.rb +77 -4
- data/lib/himari/access_token_jwt.rb +46 -0
- data/lib/himari/app.rb +101 -28
- data/lib/himari/authorization_code.rb +18 -4
- data/lib/himari/client_registration.rb +71 -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/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 +56 -3
- data/lib/himari/services/oidc_provider_metadata_endpoint.rb +30 -7
- data/lib/himari/services/oidc_token_endpoint.rb +225 -38
- data/lib/himari/services/oidc_userinfo_endpoint.rb +14 -8
- 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 +49 -14
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'himari/access_token'
|
|
2
4
|
require 'himari/token_string'
|
|
3
5
|
require 'himari/log_line'
|
|
@@ -6,9 +8,12 @@ module Himari
|
|
|
6
8
|
module Services
|
|
7
9
|
class OidcUserinfoEndpoint
|
|
8
10
|
# @param storage [Himari::Storages::Base]
|
|
11
|
+
# @param signing_key_provider [Himari::ProviderChain<Himari::SigningKey>] verifies RFC 9068
|
|
12
|
+
# JWT access tokens; opaque tokens do not need it
|
|
9
13
|
# @param logger [Logger]
|
|
10
|
-
def initialize(storage:, logger: nil)
|
|
14
|
+
def initialize(storage:, signing_key_provider: nil, logger: nil)
|
|
11
15
|
@storage = storage
|
|
16
|
+
@signing_key_provider = signing_key_provider
|
|
12
17
|
@logger = logger
|
|
13
18
|
end
|
|
14
19
|
|
|
@@ -17,14 +22,15 @@ module Himari
|
|
|
17
22
|
end
|
|
18
23
|
|
|
19
24
|
def call(env)
|
|
20
|
-
Handler.new(storage: @storage, env: env, logger: @logger).response
|
|
25
|
+
Handler.new(storage: @storage, signing_key_provider: @signing_key_provider, env: env, logger: @logger).response
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
class Handler
|
|
24
29
|
class InvalidToken < StandardError; end
|
|
25
30
|
|
|
26
|
-
def initialize(storage:, env:, logger:)
|
|
31
|
+
def initialize(storage:, env:, logger:, signing_key_provider: nil)
|
|
27
32
|
@storage = storage
|
|
33
|
+
@signing_key_provider = signing_key_provider
|
|
28
34
|
@env = env
|
|
29
35
|
@logger = logger
|
|
30
36
|
end
|
|
@@ -34,18 +40,20 @@ module Himari
|
|
|
34
40
|
return [404, {'Content-Type' => 'application/json'}, ['{"error": "not_found"}']] unless %w(GET POST).include?(@env['REQUEST_METHOD'])
|
|
35
41
|
|
|
36
42
|
raise InvalidToken unless given_token
|
|
37
|
-
|
|
43
|
+
|
|
44
|
+
given_parsed_token = Himari::AccessToken.parse(given_token, signing_key_provider: @signing_key_provider)
|
|
38
45
|
|
|
39
46
|
token = @storage.find_token(given_parsed_token.handle)
|
|
40
47
|
raise InvalidToken unless token
|
|
41
|
-
|
|
48
|
+
|
|
49
|
+
token.verify_expiry!
|
|
42
50
|
token.verify_secret!(given_parsed_token.secret)
|
|
43
51
|
|
|
44
52
|
@logger&.info(Himari::LogLine.new('OidcUserinfoEndpoint: returning', req: @env['himari.request_as_log'], token: token.as_log))
|
|
45
53
|
[
|
|
46
54
|
200,
|
|
47
55
|
{'Content-Type' => 'application/json; charset=utf-8'},
|
|
48
|
-
[JSON.pretty_generate(token.
|
|
56
|
+
[JSON.pretty_generate(token.userinfo), "\n"],
|
|
49
57
|
]
|
|
50
58
|
rescue InvalidToken, Himari::TokenString::SecretIncorrect, Himari::TokenString::InvalidFormat, Himari::TokenString::TokenExpired => e
|
|
51
59
|
@logger&.warn(Himari::LogLine.new('OidcUserinfoEndpoint: invalid_token', req: @env['himari.request_as_log'], err: e.class.inspect, token: token&.as_log))
|
|
@@ -63,8 +71,6 @@ module Himari
|
|
|
63
71
|
method, token = ah&.split(/\s+/, 2) # https://www.rfc-editor.org/rfc/rfc9110#name-credentials
|
|
64
72
|
if method&.downcase == 'bearer' && token && !token.empty?
|
|
65
73
|
token
|
|
66
|
-
else
|
|
67
|
-
nil
|
|
68
74
|
end
|
|
69
75
|
end
|
|
70
76
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'himari/log_line'
|
|
2
4
|
require 'himari/decisions/authentication'
|
|
3
5
|
require 'himari/decisions/claims'
|
|
@@ -29,20 +31,26 @@ module Himari
|
|
|
29
31
|
{
|
|
30
32
|
session: session_data&.as_log,
|
|
31
33
|
decision: {
|
|
32
|
-
claims: claims_result&.as_log&.reject{ |k,_v| %i(allowed explicit_deny).include?(k) },
|
|
34
|
+
claims: claims_result&.as_log&.reject { |k, _v| %i(allowed explicit_deny).include?(k) },
|
|
33
35
|
authentication: authn_result&.as_log,
|
|
34
36
|
},
|
|
35
37
|
}
|
|
36
38
|
end
|
|
37
39
|
end
|
|
38
40
|
|
|
39
|
-
# @param auth [Hash] Omniauth Auth Hash
|
|
41
|
+
# @param auth [Hash, nil] Omniauth Auth Hash (nil on revalidation)
|
|
42
|
+
# @param session [Himari::SessionData, nil] Existing session to revalidate (nil on initial login)
|
|
43
|
+
# @param grant_type [Symbol] :initial for omniauth callback, :refresh_token for revalidation
|
|
40
44
|
# @param claims_rules [Array<Himari::Rule>] Claims Rules
|
|
41
45
|
# @param authn_rules [Array<Himari::Rule>] Authentication Rules
|
|
42
46
|
# @param logger [Logger]
|
|
43
|
-
def initialize(auth
|
|
47
|
+
def initialize(auth: nil, session: nil, grant_type: :initial, request: nil, claims_rules: [], authn_rules: [], logger: nil)
|
|
48
|
+
raise ArgumentError, "auth or session is required" if auth.nil? && session.nil?
|
|
49
|
+
|
|
44
50
|
@request = request
|
|
45
51
|
@auth = auth
|
|
52
|
+
@session = session
|
|
53
|
+
@grant_type = grant_type
|
|
46
54
|
@claims_rules = claims_rules
|
|
47
55
|
@authn_rules = authn_rules
|
|
48
56
|
@logger = logger
|
|
@@ -52,6 +60,22 @@ module Himari
|
|
|
52
60
|
def self.from_request(request)
|
|
53
61
|
new(
|
|
54
62
|
auth: request.env.fetch('omniauth.auth'),
|
|
63
|
+
grant_type: :initial,
|
|
64
|
+
request: request,
|
|
65
|
+
claims_rules: Himari::ProviderChain.new(request.env[Himari::Middlewares::ClaimsRule::RACK_KEY] || []).collect,
|
|
66
|
+
authn_rules: Himari::ProviderChain.new(request.env[Himari::Middlewares::AuthenticationRule::RACK_KEY] || []).collect,
|
|
67
|
+
logger: request.env['rack.logger'],
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Re-run claims/authn rules against an existing session, e.g. on refresh_token grant.
|
|
72
|
+
#
|
|
73
|
+
# @param session [Himari::SessionData] existing session loaded from storage
|
|
74
|
+
# @param request [Rack::Request]
|
|
75
|
+
def self.revalidate_from_request(session:, request:)
|
|
76
|
+
new(
|
|
77
|
+
session: session,
|
|
78
|
+
grant_type: :refresh_token,
|
|
55
79
|
request: request,
|
|
56
80
|
claims_rules: Himari::ProviderChain.new(request.env[Himari::Middlewares::ClaimsRule::RACK_KEY] || []).collect,
|
|
57
81
|
authn_rules: Himari::ProviderChain.new(request.env[Himari::Middlewares::AuthenticationRule::RACK_KEY] || []).collect,
|
|
@@ -60,27 +84,37 @@ module Himari
|
|
|
60
84
|
end
|
|
61
85
|
|
|
62
86
|
def provider
|
|
63
|
-
@auth&.
|
|
87
|
+
(@auth && @auth[:provider]) || @session&.user_data&.dig(:provider)
|
|
64
88
|
end
|
|
65
89
|
|
|
66
|
-
def
|
|
67
|
-
@
|
|
68
|
-
|
|
69
|
-
session_data = claims_result.decision.output
|
|
90
|
+
def uid_for_log
|
|
91
|
+
(@auth && @auth[:uid]) || @session&.claims&.dig(:sub)
|
|
92
|
+
end
|
|
70
93
|
|
|
71
|
-
|
|
94
|
+
def perform
|
|
95
|
+
@logger&.debug(Himari::LogLine.new('UpstreamAuthentication: perform', objid: object_id.to_s(16), uid: uid_for_log, provider: provider, grant_type: @grant_type))
|
|
96
|
+
claims_result = make_claims
|
|
97
|
+
base = derive_base_session(claims_result)
|
|
72
98
|
|
|
99
|
+
authn_result = check_authn(claims_result, base)
|
|
100
|
+
final_refresh_info = authn_result.decision&.refresh_info || claims_result.decision&.refresh_info
|
|
101
|
+
session_data = base.with(refresh_info: final_refresh_info)
|
|
73
102
|
|
|
74
103
|
result = Result.new(claims_result, authn_result, session_data)
|
|
75
|
-
@logger&.debug(Himari::LogLine.new('UpstreamAuthentication: result', objid:
|
|
104
|
+
@logger&.debug(Himari::LogLine.new('UpstreamAuthentication: result', objid: object_id.to_s(16), uid: uid_for_log, provider: provider, grant_type: @grant_type, result: result.as_log))
|
|
76
105
|
result
|
|
77
106
|
end
|
|
78
107
|
|
|
79
108
|
def make_claims
|
|
80
|
-
context = Himari::Decisions::Claims::Context.new(request: @request, auth: @auth).freeze
|
|
109
|
+
context = Himari::Decisions::Claims::Context.new(request: @request, auth: @auth, provider: provider, grant_type: @grant_type, refresh_info: @session&.refresh_info).freeze
|
|
81
110
|
result = Himari::RuleProcessor.new(context, Himari::Decisions::Claims.new).run(@claims_rules)
|
|
82
111
|
|
|
83
|
-
@logger&.debug(Himari::LogLine.new('UpstreamAuthentication: claims', objid:
|
|
112
|
+
@logger&.debug(Himari::LogLine.new('UpstreamAuthentication: claims', objid: object_id.to_s(16), uid: uid_for_log, provider: provider, grant_type: @grant_type, claims_result: result.as_log))
|
|
113
|
+
|
|
114
|
+
if result.explicit_deny
|
|
115
|
+
@logger&.warn(Himari::LogLine.new('UpstreamAuthentication: claims explicit deny', objid: object_id.to_s(16), uid: uid_for_log, provider: provider, grant_type: @grant_type, claims_result: result.as_log))
|
|
116
|
+
raise UnauthorizedError.new(Result.new(result, nil, nil))
|
|
117
|
+
end
|
|
84
118
|
|
|
85
119
|
begin
|
|
86
120
|
claims = result.decision&.output&.claims
|
|
@@ -92,13 +126,27 @@ module Himari
|
|
|
92
126
|
result
|
|
93
127
|
end
|
|
94
128
|
|
|
129
|
+
def derive_base_session(claims_result)
|
|
130
|
+
decision = claims_result.decision
|
|
131
|
+
if @session
|
|
132
|
+
# revalidation: keep existing handle/secret/expiry, refresh claims/user_data
|
|
133
|
+
@session.with(claims: decision.claims, user_data: decision.user_data)
|
|
134
|
+
else
|
|
135
|
+
decision.output
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
95
139
|
def check_authn(claims_result, session_data)
|
|
96
|
-
context = Himari::Decisions::Authentication::Context.new(provider: provider, claims: session_data.claims, user_data: session_data.user_data, request: @request).freeze
|
|
140
|
+
context = Himari::Decisions::Authentication::Context.new(provider: provider, claims: session_data.claims, user_data: session_data.user_data, request: @request, grant_type: @grant_type, refresh_info: @session&.refresh_info).freeze
|
|
141
|
+
# Don't preseed decision.refresh_info from session; otherwise a no-op authn rule would clobber whatever
|
|
142
|
+
# the claims rule wrote (via Claims#refresh_info=). Authn rules that want to preserve session.refresh_info
|
|
143
|
+
# must read context.refresh_info and assign it explicitly.
|
|
97
144
|
result = Himari::RuleProcessor.new(context, Himari::Decisions::Authentication.new).run(@authn_rules)
|
|
98
145
|
|
|
99
|
-
@logger&.debug(Himari::LogLine.new('UpstreamAuthentication: authentication', objid:
|
|
146
|
+
@logger&.debug(Himari::LogLine.new('UpstreamAuthentication: authentication', objid: object_id.to_s(16), uid: uid_for_log, provider: provider, grant_type: @grant_type, authn_result: result.as_log))
|
|
100
147
|
|
|
101
148
|
raise UnauthorizedError.new(Result.new(claims_result, result, nil)) unless result.allowed
|
|
149
|
+
|
|
102
150
|
result
|
|
103
151
|
end
|
|
104
152
|
end
|
data/lib/himari/session_data.rb
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'himari/token_string'
|
|
2
4
|
|
|
3
5
|
module Himari
|
|
4
6
|
class SessionData
|
|
5
7
|
include Himari::TokenString
|
|
6
8
|
|
|
7
|
-
def initialize(claims: {}, user_data: {}, handle:, secret: nil, secret_hash: nil, expiry: nil)
|
|
9
|
+
def initialize(claims: {}, user_data: {}, refresh_info: nil, handle:, secret: nil, secret_hash: nil, expiry: nil)
|
|
8
10
|
@claims = claims
|
|
9
11
|
@user_data = user_data
|
|
12
|
+
@refresh_info = refresh_info
|
|
10
13
|
|
|
11
14
|
@handle = handle
|
|
12
15
|
@secret = secret
|
|
13
16
|
@secret_hash = secret_hash
|
|
17
|
+
@secret_hash_prev = nil
|
|
14
18
|
@expiry = expiry
|
|
19
|
+
@verification = nil
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
def self.magic_header
|
|
@@ -22,13 +27,36 @@ module Himari
|
|
|
22
27
|
3600
|
|
23
28
|
end
|
|
24
29
|
|
|
25
|
-
attr_reader :claims, :user_data
|
|
30
|
+
attr_reader :claims, :user_data, :refresh_info
|
|
31
|
+
|
|
32
|
+
def refreshable?
|
|
33
|
+
!@refresh_info.nil?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def active?(now: Time.now)
|
|
37
|
+
@expiry.nil? || @expiry > now.to_i
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Return a copy with selected fields replaced. Reads @secret directly to
|
|
41
|
+
# sidestep TokenString#secret raising SecretMissing for storage-loaded sessions.
|
|
42
|
+
def with(claims: @claims, user_data: @user_data, refresh_info: @refresh_info, expiry: @expiry)
|
|
43
|
+
self.class.new(
|
|
44
|
+
handle: @handle,
|
|
45
|
+
secret: @secret,
|
|
46
|
+
secret_hash: @secret_hash,
|
|
47
|
+
expiry: expiry,
|
|
48
|
+
claims: claims,
|
|
49
|
+
user_data: user_data,
|
|
50
|
+
refresh_info: refresh_info,
|
|
51
|
+
)
|
|
52
|
+
end
|
|
26
53
|
|
|
27
54
|
def as_log
|
|
28
55
|
{
|
|
29
56
|
handle: handle,
|
|
30
57
|
claims: claims,
|
|
31
58
|
expiry: expiry,
|
|
59
|
+
refreshable: refreshable?,
|
|
32
60
|
}
|
|
33
61
|
end
|
|
34
62
|
|
|
@@ -40,6 +68,7 @@ module Himari
|
|
|
40
68
|
|
|
41
69
|
claims: claims,
|
|
42
70
|
user_data: user_data,
|
|
71
|
+
refresh_info: refresh_info,
|
|
43
72
|
}
|
|
44
73
|
end
|
|
45
74
|
end
|
data/lib/himari/signing_key.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'digest/sha2'
|
|
2
4
|
require 'base64'
|
|
3
5
|
module Himari
|
|
@@ -15,7 +17,6 @@ module Himari
|
|
|
15
17
|
|
|
16
18
|
attr_reader :id, :pkey, :group
|
|
17
19
|
|
|
18
|
-
|
|
19
20
|
def active?
|
|
20
21
|
!@inactive
|
|
21
22
|
end
|
|
@@ -30,7 +31,7 @@ module Himari
|
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
result &&= if !active.nil?
|
|
33
|
-
active ==
|
|
34
|
+
active == active?
|
|
34
35
|
else
|
|
35
36
|
true
|
|
36
37
|
end
|
|
@@ -55,9 +56,9 @@ module Himari
|
|
|
55
56
|
'RS256'
|
|
56
57
|
when OpenSSL::PKey::EC
|
|
57
58
|
case ec_crv
|
|
58
|
-
when 'P-256'
|
|
59
|
-
when 'P-384'
|
|
60
|
-
when 'P-521'
|
|
59
|
+
when 'P-256' then 'ES256'
|
|
60
|
+
when 'P-384' then 'ES384'
|
|
61
|
+
when 'P-521' then 'ES512'
|
|
61
62
|
else
|
|
62
63
|
raise AlgUnknown
|
|
63
64
|
end
|
|
@@ -68,9 +69,9 @@ module Himari
|
|
|
68
69
|
|
|
69
70
|
def hash_function
|
|
70
71
|
case alg
|
|
71
|
-
when 'ES256', 'RS256'
|
|
72
|
-
when 'ES384'
|
|
73
|
-
when 'ES512'
|
|
72
|
+
when 'ES256', 'RS256' then Digest::SHA256
|
|
73
|
+
when 'ES384' then Digest::SHA384
|
|
74
|
+
when 'ES512' then Digest::SHA512
|
|
74
75
|
else
|
|
75
76
|
raise AlgUnknown
|
|
76
77
|
end
|
|
@@ -78,6 +79,7 @@ module Himari
|
|
|
78
79
|
|
|
79
80
|
def ec_crv
|
|
80
81
|
raise OperationInvalid, "this key is not EC" unless pkey.is_a?(OpenSSL::PKey::EC)
|
|
82
|
+
|
|
81
83
|
# https://www.rfc-editor.org/rfc/rfc8422.html#appendix-A
|
|
82
84
|
case pkey.group.curve_name
|
|
83
85
|
when 'prime256v1', 'secp256r1'
|
|
@@ -97,10 +99,11 @@ module Himari
|
|
|
97
99
|
when OpenSSL::PKey::EC # https://www.rfc-editor.org/rfc/rfc7518#section-6.2
|
|
98
100
|
# https://www.secg.org/sec1-v2.pdf - 2.3.3. Elliptic-Curve-Point-to-Octet-String Conversion
|
|
99
101
|
xy = pkey.public_key.to_octet_string(:uncompressed) # 0x04 || X || Y
|
|
100
|
-
len = pkey.group.degree/8
|
|
101
|
-
raise unless xy[0] == "\x04".b && xy.size == ((len*2)+1)
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
len = pkey.group.degree / 8
|
|
103
|
+
raise unless xy[0] == "\x04".b && xy.size == ((len * 2) + 1)
|
|
104
|
+
|
|
105
|
+
x = xy[1, len]
|
|
106
|
+
y = xy[1 + len, len]
|
|
104
107
|
|
|
105
108
|
{
|
|
106
109
|
kid: id,
|
|
@@ -117,8 +120,8 @@ module Himari
|
|
|
117
120
|
kty: 'RSA',
|
|
118
121
|
use: "sig",
|
|
119
122
|
alg: alg,
|
|
120
|
-
n: Base64.urlsafe_encode64(pkey.n.to_s(2)).gsub(/=+/,''),
|
|
121
|
-
e: Base64.urlsafe_encode64(pkey.e.to_s(2)).gsub(/=+/,''),
|
|
123
|
+
n: Base64.urlsafe_encode64(pkey.n.to_s(2)).gsub(/=+/, ''),
|
|
124
|
+
e: Base64.urlsafe_encode64(pkey.e.to_s(2)).gsub(/=+/, ''),
|
|
122
125
|
}
|
|
123
126
|
else
|
|
124
127
|
raise AlgUnknown
|
data/lib/himari/storages/base.rb
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'himari/authorization_code'
|
|
2
4
|
require 'himari/access_token'
|
|
5
|
+
require 'himari/refresh_token'
|
|
3
6
|
require 'himari/session_data'
|
|
7
|
+
require 'himari/dynamic_client_registration'
|
|
4
8
|
|
|
5
9
|
module Himari
|
|
6
10
|
module Storages
|
|
@@ -42,6 +46,46 @@ module Himari
|
|
|
42
46
|
delete('token', handle)
|
|
43
47
|
end
|
|
44
48
|
|
|
49
|
+
def find_refresh_token(handle)
|
|
50
|
+
content = read('refresh', handle)
|
|
51
|
+
content && RefreshToken.new(**content)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param if_version [Integer, nil] when given, only write if the stored record's
|
|
55
|
+
# version equals this value (compare-and-swap); raises Conflict otherwise.
|
|
56
|
+
def put_refresh_token(token, overwrite: false, if_version: nil)
|
|
57
|
+
write('refresh', token.handle, token.as_json, overwrite: overwrite, if_version: if_version)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def delete_refresh_token(token)
|
|
61
|
+
delete_refresh_token_by_handle(token.handle)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def delete_refresh_token_by_handle(handle)
|
|
65
|
+
delete('refresh', handle)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def find_dynamic_client(id)
|
|
69
|
+
# ids are server-generated url-safe base64; reject anything else before it reaches a
|
|
70
|
+
# storage key (defense-in-depth against path traversal on filesystem-backed storage).
|
|
71
|
+
return unless id.is_a?(String) && id.match?(/\A[A-Za-z0-9_-]+\z/)
|
|
72
|
+
|
|
73
|
+
content = read('dynamic_client', id)
|
|
74
|
+
content && DynamicClientRegistration.from_json(content)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def put_dynamic_client(client, overwrite: false)
|
|
78
|
+
write('dynamic_client', client.id, client.as_json, overwrite: overwrite)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def delete_dynamic_client(client)
|
|
82
|
+
delete_dynamic_client_by_id(client.id)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def delete_dynamic_client_by_id(id)
|
|
86
|
+
delete('dynamic_client', id)
|
|
87
|
+
end
|
|
88
|
+
|
|
45
89
|
def find_session(handle)
|
|
46
90
|
content = read('session', handle)
|
|
47
91
|
content && SessionData.new(**content)
|
|
@@ -59,7 +103,7 @@ module Himari
|
|
|
59
103
|
delete('session', handle)
|
|
60
104
|
end
|
|
61
105
|
|
|
62
|
-
private def write(kind, key, content, overwrite: false)
|
|
106
|
+
private def write(kind, key, content, overwrite: false, if_version: nil)
|
|
63
107
|
raise NotImplementedError
|
|
64
108
|
end
|
|
65
109
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'himari/storages/base'
|
|
2
4
|
|
|
3
5
|
module Himari
|
|
@@ -11,11 +13,20 @@ module Himari
|
|
|
11
13
|
|
|
12
14
|
attr_reader :path
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
# The version compare-and-swap below is a read-compare-write, which is not atomic
|
|
17
|
+
# across processes. Adequate for filesystem storage's dev/single-node use; the
|
|
18
|
+
# production atomic path is DynamoDB's conditional update.
|
|
19
|
+
private def write(kind, key, content, overwrite: false, if_version: nil)
|
|
15
20
|
dir = File.join(@path, kind)
|
|
16
21
|
path = File.join(dir, key)
|
|
17
22
|
Dir.mkdir(dir) unless Dir.exist?(dir)
|
|
18
|
-
|
|
23
|
+
if if_version
|
|
24
|
+
existing = read(kind, key)
|
|
25
|
+
raise Himari::Storages::Base::Conflict unless existing && existing[:version] == if_version
|
|
26
|
+
elsif File.exist?(path) && !overwrite
|
|
27
|
+
raise Himari::Storages::Base::Conflict
|
|
28
|
+
end
|
|
29
|
+
|
|
19
30
|
File.write(path, "#{JSON.pretty_generate(content)}\n")
|
|
20
31
|
nil
|
|
21
32
|
end
|
|
@@ -24,7 +35,7 @@ module Himari
|
|
|
24
35
|
path = File.join(@path, kind, key)
|
|
25
36
|
JSON.parse(File.read(path), symbolize_names: true)
|
|
26
37
|
rescue Errno::ENOENT
|
|
27
|
-
|
|
38
|
+
nil
|
|
28
39
|
end
|
|
29
40
|
|
|
30
41
|
private def delete(kind, key)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'himari/storages/base'
|
|
2
4
|
|
|
3
5
|
module Himari
|
|
@@ -9,9 +11,15 @@ module Himari
|
|
|
9
11
|
@memory = {}
|
|
10
12
|
end
|
|
11
13
|
|
|
12
|
-
private def write(kind, key, content, overwrite: false)
|
|
14
|
+
private def write(kind, key, content, overwrite: false, if_version: nil)
|
|
13
15
|
path = File.join(kind, key)
|
|
14
|
-
|
|
16
|
+
if if_version
|
|
17
|
+
existing = read(kind, key)
|
|
18
|
+
raise Himari::Storages::Base::Conflict unless existing && existing[:version] == if_version
|
|
19
|
+
elsif @memory.key?(path) && !overwrite
|
|
20
|
+
raise Himari::Storages::Base::Conflict
|
|
21
|
+
end
|
|
22
|
+
|
|
15
23
|
@memory[path] = JSON.pretty_generate(content)
|
|
16
24
|
nil
|
|
17
25
|
end
|
data/lib/himari/token_string.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'securerandom'
|
|
2
4
|
require 'base64'
|
|
3
5
|
require 'digest/sha2'
|
|
@@ -11,6 +13,10 @@ module Himari
|
|
|
11
13
|
class TokenExpired < Error; end
|
|
12
14
|
class InvalidFormat < Error; end
|
|
13
15
|
|
|
16
|
+
# Outcome of a successful verify_secret!: which stored secret slot the presented secret
|
|
17
|
+
# matched (:current or :previous) and the hash it matched against. nil until verified.
|
|
18
|
+
Verification = Data.define(:via, :secret_hash)
|
|
19
|
+
|
|
14
20
|
module ClassMethods
|
|
15
21
|
def magic_header
|
|
16
22
|
raise NotImplementedError
|
|
@@ -25,7 +31,7 @@ module Himari
|
|
|
25
31
|
handle: SecureRandom.urlsafe_base64(32),
|
|
26
32
|
secret: SecureRandom.urlsafe_base64(48),
|
|
27
33
|
expiry: Time.now.to_i + (lifetime || default_lifetime),
|
|
28
|
-
**kwargs
|
|
34
|
+
**kwargs,
|
|
29
35
|
)
|
|
30
36
|
end
|
|
31
37
|
|
|
@@ -38,6 +44,10 @@ module Himari
|
|
|
38
44
|
k.extend(ClassMethods)
|
|
39
45
|
end
|
|
40
46
|
|
|
47
|
+
def self.hash_secret(secret)
|
|
48
|
+
Base64.urlsafe_encode64(Digest::SHA384.digest(secret), padding: false)
|
|
49
|
+
end
|
|
50
|
+
|
|
41
51
|
def handle
|
|
42
52
|
@handle
|
|
43
53
|
end
|
|
@@ -48,11 +58,19 @@ module Himari
|
|
|
48
58
|
|
|
49
59
|
def secret
|
|
50
60
|
raise SecretMissing unless @secret
|
|
61
|
+
|
|
51
62
|
@secret
|
|
52
63
|
end
|
|
53
64
|
|
|
54
65
|
def secret_hash
|
|
55
|
-
@secret_hash ||=
|
|
66
|
+
@secret_hash ||= TokenString.hash_secret(secret)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Optional second valid secret hash. Tokens that rotate in place (RefreshToken) keep the
|
|
70
|
+
# previously-issued secret valid for one more turn so a client whose rotation response was
|
|
71
|
+
# lost can retry. nil for single-secret tokens (AccessToken, SessionData).
|
|
72
|
+
def secret_hash_prev
|
|
73
|
+
@secret_hash_prev
|
|
56
74
|
end
|
|
57
75
|
|
|
58
76
|
def verify!(secret:, now: Time.now)
|
|
@@ -61,13 +79,30 @@ module Himari
|
|
|
61
79
|
end
|
|
62
80
|
|
|
63
81
|
def verify_secret!(given_secret)
|
|
64
|
-
dgst = Base64.urlsafe_decode64(secret_hash) # TODO: rescue errors
|
|
65
82
|
given_dgst = Digest::SHA384.digest(given_secret)
|
|
66
|
-
|
|
83
|
+
@verification =
|
|
84
|
+
if secret_hash_match(secret_hash, given_dgst)
|
|
85
|
+
Verification.new(via: :current, secret_hash: secret_hash)
|
|
86
|
+
elsif secret_hash_prev && secret_hash_match(secret_hash_prev, given_dgst)
|
|
87
|
+
Verification.new(via: :previous, secret_hash: secret_hash_prev)
|
|
88
|
+
end
|
|
89
|
+
raise SecretIncorrect unless @verification
|
|
90
|
+
|
|
67
91
|
@secret = given_secret
|
|
68
92
|
true
|
|
69
93
|
end
|
|
70
94
|
|
|
95
|
+
# The Verification from the last successful verify_secret!, or nil. Used for logging
|
|
96
|
+
# (#via) and to let a rotating token keep the just-presented secret valid (#secret_hash).
|
|
97
|
+
attr_reader :verification
|
|
98
|
+
|
|
99
|
+
private def secret_hash_match(stored_hash, given_dgst)
|
|
100
|
+
stored_dgst = Base64.urlsafe_decode64(stored_hash)
|
|
101
|
+
Rack::Utils.secure_compare(stored_dgst, given_dgst)
|
|
102
|
+
rescue ArgumentError
|
|
103
|
+
raise SecretIncorrect
|
|
104
|
+
end
|
|
105
|
+
|
|
71
106
|
def verify_expiry!(now = Time.now)
|
|
72
107
|
raise TokenExpired if @expiry <= now.to_i
|
|
73
108
|
end
|
|
@@ -77,6 +112,7 @@ module Himari
|
|
|
77
112
|
parts = str.split('.')
|
|
78
113
|
raise InvalidFormat unless parts.size == 3
|
|
79
114
|
raise InvalidFormat unless parts[0] == header
|
|
115
|
+
|
|
80
116
|
new(header: header, handle: parts[1], secret: parts[2])
|
|
81
117
|
end
|
|
82
118
|
|
data/lib/himari/version.rb
CHANGED
data/public/public/index.css
CHANGED
|
@@ -60,6 +60,24 @@ main > header img, main > footer img {
|
|
|
60
60
|
margin-top: 30px;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
.consent-detail {
|
|
64
|
+
margin: 12px 0 24px;
|
|
65
|
+
}
|
|
66
|
+
.consent-scopes {
|
|
67
|
+
display: inline-block;
|
|
68
|
+
text-align: left;
|
|
69
|
+
}
|
|
70
|
+
.himari-consent .actions form {
|
|
71
|
+
display: flex;
|
|
72
|
+
flex-direction: row;
|
|
73
|
+
gap: 12px;
|
|
74
|
+
}
|
|
75
|
+
.consent-deny {
|
|
76
|
+
border-color: #4E6994 !important;
|
|
77
|
+
background: transparent !important;
|
|
78
|
+
color: #4E6994 !important;
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
.notice {
|
|
64
82
|
background-color: white;
|
|
65
83
|
border: 1px #bfa88a solid;
|
data/views/consent.erb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title><%= h(msg(:consent_page_title, nil) || msg(:consent_title, "Authorize access")) %></title>
|
|
6
|
+
<link rel="stylesheet" href="/public/index.css?cb=<%= cachebuster %>" type="text/css" />
|
|
7
|
+
<meta name="viewport" content="initial-scale=1">
|
|
8
|
+
<meta name="robots" content="noindex, nofollow">
|
|
9
|
+
|
|
10
|
+
<meta name="himari:release" content="<%= release_code %>">
|
|
11
|
+
</head>
|
|
12
|
+
|
|
13
|
+
<body class='himari-app himari-consent'>
|
|
14
|
+
<main>
|
|
15
|
+
|
|
16
|
+
<header>
|
|
17
|
+
<h1><%= msg(:consent_title, "Authorize access") %></h1>
|
|
18
|
+
<%= msg(:consent_header) %>
|
|
19
|
+
|
|
20
|
+
<% if @notice %>
|
|
21
|
+
<div class='notice'>
|
|
22
|
+
<p><%=h @notice %></p>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
|
25
|
+
</header>
|
|
26
|
+
|
|
27
|
+
<section class='consent-detail'>
|
|
28
|
+
<p><strong><%=h(@consent_client.name || @consent_client.id) %></strong> <%= msg(:consent_request_message, "is requesting access to your account.") %></p>
|
|
29
|
+
|
|
30
|
+
<% if @consent_scopes.any? %>
|
|
31
|
+
<p><%= msg(:consent_scopes_message, "The following will be shared:") %></p>
|
|
32
|
+
<ul class='consent-scopes'>
|
|
33
|
+
<% @consent_scopes.each do |scope| %>
|
|
34
|
+
<li><%=h msg(:"scope_#{scope}", scope) %></li>
|
|
35
|
+
<% end %>
|
|
36
|
+
</ul>
|
|
37
|
+
<% else %>
|
|
38
|
+
<p><%= msg(:consent_no_scopes_message, "Basic sign-in information will be shared.") %></p>
|
|
39
|
+
<% end %>
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<nav class='actions'>
|
|
43
|
+
<form action="<%=h request.path %>" method="POST" id='consent-form'>
|
|
44
|
+
<input type="hidden" name="<%= csrf_token_name %>" value="<%= csrf_token_value %>" />
|
|
45
|
+
<% request.GET.each do |k, v| %>
|
|
46
|
+
<% next if k == csrf_token_name || k == '_consent' %>
|
|
47
|
+
<input type="hidden" name="<%=h k %>" value="<%=h v %>" />
|
|
48
|
+
<% end %>
|
|
49
|
+
<button type='submit' name='_consent' value='approve' class='consent-approve'><%= msg(:consent_approve, "Approve") %></button>
|
|
50
|
+
<button type='submit' name='_consent' value='deny' class='consent-deny'><%= msg(:consent_deny, "Deny") %></button>
|
|
51
|
+
</form>
|
|
52
|
+
</nav>
|
|
53
|
+
|
|
54
|
+
<footer>
|
|
55
|
+
<%= msg(:consent_footer) %>
|
|
56
|
+
</footer>
|
|
57
|
+
</main>
|
|
58
|
+
</body>
|
|
59
|
+
</html>
|