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,14 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'rack/oauth2'
|
|
2
4
|
require 'digest/sha2'
|
|
3
5
|
require 'openid_connect'
|
|
4
6
|
require 'himari/access_token'
|
|
7
|
+
require 'himari/refresh_token'
|
|
5
8
|
require 'himari/id_token'
|
|
9
|
+
require 'himari/storages/base'
|
|
10
|
+
require 'himari/services/downstream_authorization'
|
|
11
|
+
require 'himari/services/upstream_authentication'
|
|
6
12
|
|
|
7
13
|
module Himari
|
|
8
14
|
module Services
|
|
9
15
|
class OidcTokenEndpoint
|
|
10
16
|
class SigningKeyMissing < StandardError; end
|
|
11
17
|
|
|
18
|
+
Issued = Struct.new(:access, :access_token_string, :id_token_jwt, :signing_key, keyword_init: true)
|
|
19
|
+
|
|
12
20
|
# @param client_provider [Himari::ProviderChain<Himari::ClientRegistration>]
|
|
13
21
|
# @param signing_key_provider [Himari::ProviderChain<Himari::SigningKey>]
|
|
14
22
|
# @param storage [Himari::Storages::Base]
|
|
@@ -25,70 +33,241 @@ module Himari
|
|
|
25
33
|
def call(env)
|
|
26
34
|
app(env).call(env)
|
|
27
35
|
rescue Rack::OAuth2::Server::Abstract::Error => e
|
|
28
|
-
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: returning error', req: env['himari.request_as_log'],
|
|
36
|
+
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: returning error', req: env['himari.request_as_log'], err: e.class.inspect, err_content: e.protocol_params))
|
|
29
37
|
e.finish
|
|
30
38
|
end
|
|
31
39
|
|
|
32
40
|
def app(env)
|
|
33
41
|
Rack::OAuth2::Server::Token.new do |req, res|
|
|
34
|
-
code_dgst = req.code ? Digest::SHA256.hexdigest(req.code) : nil
|
|
35
42
|
client = @client_provider.find(id: req.client_id)
|
|
36
43
|
unless client
|
|
37
|
-
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_client, no client registration', req: env['himari.request_as_log'], client_id: req.client_id
|
|
44
|
+
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_client, no client registration', req: env['himari.request_as_log'], client_id: req.client_id))
|
|
38
45
|
next req.invalid_client!
|
|
39
46
|
end
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
# Public clients (token_endpoint_auth_method=none) present no secret; they are bound
|
|
48
|
+
# to the authorization code by PKCE and the client_id check in handle_authorization_code.
|
|
49
|
+
if client.confidential? && !client.match_secret?(req.client_secret)
|
|
50
|
+
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_client, client secret mismatch', req: env['himari.request_as_log'], client: client.as_log))
|
|
51
|
+
next req.invalid_client!
|
|
43
52
|
end
|
|
44
53
|
|
|
45
54
|
case req.grant_type
|
|
46
55
|
when :authorization_code
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
next req.invalid_grant!
|
|
51
|
-
end
|
|
52
|
-
unless authz.valid_redirect_uri?(req.redirect_uri)
|
|
53
|
-
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, redirect_uri mismatch', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
|
|
54
|
-
next req.invalid_grant!
|
|
55
|
-
end
|
|
56
|
-
if authz.expiry <= Time.now.to_i
|
|
57
|
-
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, expired grant', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
|
|
58
|
-
next req.invalid_grant!
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
if authz.pkce?
|
|
62
|
-
if req.verify_code_verifier!(authz.code_challenge, authz.code_challenge_method)
|
|
63
|
-
# do nothing
|
|
64
|
-
else
|
|
65
|
-
# :nocov:
|
|
66
|
-
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, invalid pkce', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
|
|
67
|
-
next req.invalid_grant!
|
|
68
|
-
# :nocov:
|
|
69
|
-
end
|
|
70
|
-
elsif client.require_pkce
|
|
71
|
-
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, pkce is mandatory', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
|
|
72
|
-
next req.invalid_grant!
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
token = AccessToken.from_authz(authz)
|
|
76
|
-
@storage.put_token(token)
|
|
77
|
-
res.access_token = token.to_bearer
|
|
78
|
-
|
|
79
|
-
if authz.openid
|
|
80
|
-
signing_key = @signing_key_provider.find(group: client.preferred_key_group, active: true)
|
|
81
|
-
raise SigningKeyMissing unless signing_key
|
|
82
|
-
res.id_token = IdToken.from_authz(authz, signing_key: signing_key, access_token: token.format.to_s, issuer: @issuer).to_jwt
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
@storage.delete_authorization(authz)
|
|
86
|
-
@logger&.info(Himari::LogLine.new('OidcTokenEndpoint: issued', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log, token: token.as_log, signing_key_kid: signing_key&.id))
|
|
56
|
+
handle_authorization_code(env, req, res, client)
|
|
57
|
+
when :refresh_token
|
|
58
|
+
handle_refresh_token(env, req, res, client)
|
|
87
59
|
else
|
|
88
60
|
req.unsupported_response_type!
|
|
89
61
|
end
|
|
90
62
|
end
|
|
91
63
|
end
|
|
64
|
+
|
|
65
|
+
private def handle_authorization_code(env, req, res, client)
|
|
66
|
+
authz = @storage.find_authorization(req.code)
|
|
67
|
+
unless authz
|
|
68
|
+
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, no grant code found', req: env['himari.request_as_log'], client: client.as_log))
|
|
69
|
+
return req.invalid_grant!
|
|
70
|
+
end
|
|
71
|
+
unless authz.client_id == client.id
|
|
72
|
+
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, grant client_id mismatch', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
|
|
73
|
+
return req.invalid_grant!
|
|
74
|
+
end
|
|
75
|
+
unless authz.valid_redirect_uri?(req.redirect_uri)
|
|
76
|
+
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, redirect_uri mismatch', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
|
|
77
|
+
return req.invalid_grant!
|
|
78
|
+
end
|
|
79
|
+
if authz.expiry <= Time.now.to_i
|
|
80
|
+
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, expired grant', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
|
|
81
|
+
return req.invalid_grant!
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if authz.pkce?
|
|
85
|
+
if req.verify_code_verifier!(authz.code_challenge, authz.code_challenge_method)
|
|
86
|
+
# do nothing
|
|
87
|
+
else
|
|
88
|
+
# :nocov:
|
|
89
|
+
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, invalid pkce', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
|
|
90
|
+
return req.invalid_grant!
|
|
91
|
+
# :nocov:
|
|
92
|
+
end
|
|
93
|
+
elsif client.require_pkce
|
|
94
|
+
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, pkce is mandatory', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
|
|
95
|
+
return req.invalid_grant!
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
issued = issue_access_and_id(
|
|
99
|
+
client: client,
|
|
100
|
+
claims: authz.claims,
|
|
101
|
+
scopes: authz.scopes,
|
|
102
|
+
lifetime: authz.lifetime,
|
|
103
|
+
openid: authz.openid,
|
|
104
|
+
session_handle: authz.session_handle,
|
|
105
|
+
nonce: authz.nonce,
|
|
106
|
+
mint_jwt_access_token: authz.mint_jwt_access_token,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
refresh = nil
|
|
110
|
+
if authz.offline_access && authz.session_handle && authz.lifetime&.refresh_token
|
|
111
|
+
refresh = RefreshToken.make(client_id: client.id, claims: authz.claims, session_handle: authz.session_handle, openid: authz.openid, scopes: authz.scopes, lifetime: authz.lifetime.refresh_token)
|
|
112
|
+
@storage.put_refresh_token(refresh)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
bearer = issued.access.to_bearer(token_string: issued.access_token_string)
|
|
116
|
+
bearer.refresh_token = refresh.format.to_s if refresh
|
|
117
|
+
res.access_token = bearer
|
|
118
|
+
res.id_token = issued.id_token_jwt if issued.id_token_jwt
|
|
119
|
+
|
|
120
|
+
@storage.delete_authorization(authz)
|
|
121
|
+
@logger&.info(Himari::LogLine.new('OidcTokenEndpoint: issued', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log, token: issued.access.as_log, refresh_token: refresh&.as_log, signing_key_kid: issued.signing_key&.id))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private def handle_refresh_token(env, req, res, client)
|
|
125
|
+
given_token_str = req.refresh_token
|
|
126
|
+
unless given_token_str
|
|
127
|
+
return reject_refresh!(env, req, client, 'no refresh_token given')
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
begin
|
|
131
|
+
parsed = Himari::RefreshToken.parse(given_token_str)
|
|
132
|
+
rescue Himari::TokenString::InvalidFormat => e
|
|
133
|
+
return reject_refresh!(env, req, client, 'invalid refresh_token format', err: e.class.inspect)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
refresh = @storage.find_refresh_token(parsed.handle)
|
|
137
|
+
unless refresh
|
|
138
|
+
return reject_refresh!(env, req, client, 'unknown refresh_token')
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
begin
|
|
142
|
+
refresh.verify!(secret: parsed.secret)
|
|
143
|
+
rescue Himari::TokenString::Error => e
|
|
144
|
+
return reject_refresh!(env, req, client, 'refresh_token verify failed', refresh: refresh, err: e.class.inspect)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
unless refresh.client_id == client.id
|
|
148
|
+
return reject_refresh!(env, req, client, 'refresh_token client_id mismatch', refresh: refresh)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
session = refresh.session_handle && @storage.find_session(refresh.session_handle)
|
|
152
|
+
unless session
|
|
153
|
+
return reject_refresh!(env, req, client, 'refresh_token has no session', refresh: refresh)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
unless session.refreshable?
|
|
157
|
+
return reject_refresh!(env, req, client, 'session is not refreshable (no refresh_info)', refresh: refresh, session: session.as_log)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
unless session.active?
|
|
161
|
+
return reject_refresh!(env, req, client, 'session expired', refresh: refresh, session: session.as_log)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
rack_request = Rack::Request.new(env)
|
|
165
|
+
|
|
166
|
+
begin
|
|
167
|
+
authn = Himari::Services::UpstreamAuthentication.revalidate_from_request(session: session, request: rack_request).perform
|
|
168
|
+
rescue Himari::Services::UpstreamAuthentication::UnauthorizedError => e
|
|
169
|
+
return reject_refresh!(env, req, client, 'refresh upstream authn denied', refresh: refresh, session: session.as_log, result: e.as_log)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
updated_session = authn.session_data
|
|
173
|
+
|
|
174
|
+
begin
|
|
175
|
+
downstream = Himari::Services::DownstreamAuthorization.from_request(session: updated_session, client: client, request: rack_request, grant_type: :refresh_token, requested_scopes: refresh.scopes).perform
|
|
176
|
+
rescue Himari::Services::DownstreamAuthorization::ForbiddenError => e
|
|
177
|
+
return reject_refresh!(env, req, client, 'refresh downstream authz denied', refresh: refresh, session: updated_session.as_log, result: e.as_log)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Refresh lifetime is recomputed by the authz rules on every refresh; if it is no
|
|
181
|
+
# longer configured the session is no longer refreshable. Fail closed.
|
|
182
|
+
unless downstream.lifetime&.refresh_token
|
|
183
|
+
return reject_refresh!(env, req, client, 'refresh_token lifetime no longer configured', refresh: refresh, session: updated_session.as_log)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Rotate the token in place; verify! above recorded which secret the client presented,
|
|
187
|
+
# which rotate keeps valid as the previous one. The token's original expiry is
|
|
188
|
+
# preserved (absolute cap); the lifetime guard above only gates whether refresh is
|
|
189
|
+
# still permitted by the rules, not how long the rotated token lives.
|
|
190
|
+
rotated = refresh.rotate(claims: downstream.claims, openid: refresh.openid)
|
|
191
|
+
|
|
192
|
+
# Compare-and-swap on the version we read. A concurrent refresh that already rotated
|
|
193
|
+
# this token bumps the version, so the loser's write conflicts. Reject the loser
|
|
194
|
+
# without revoking — the winner's rotation (same handle) must survive.
|
|
195
|
+
begin
|
|
196
|
+
@storage.put_refresh_token(rotated, if_version: refresh.version)
|
|
197
|
+
rescue Himari::Storages::Base::Conflict
|
|
198
|
+
return reject_refresh!(env, req, client, 'refresh_token version conflict (concurrent use)', refresh: refresh, revoke: false)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
@storage.put_session(updated_session, overwrite: true)
|
|
202
|
+
|
|
203
|
+
# OIDC core §12.2: refreshed ID Token MAY be returned, with no nonce on refresh.
|
|
204
|
+
issued = issue_access_and_id(
|
|
205
|
+
client: client,
|
|
206
|
+
claims: downstream.claims,
|
|
207
|
+
scopes: downstream.scopes,
|
|
208
|
+
lifetime: downstream.lifetime,
|
|
209
|
+
openid: refresh.openid,
|
|
210
|
+
session_handle: updated_session.handle,
|
|
211
|
+
nonce: nil,
|
|
212
|
+
mint_jwt_access_token: downstream.mint_jwt_access_token,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
bearer = issued.access.to_bearer(token_string: issued.access_token_string)
|
|
216
|
+
bearer.refresh_token = rotated.format.to_s
|
|
217
|
+
res.access_token = bearer
|
|
218
|
+
res.id_token = issued.id_token_jwt if issued.id_token_jwt
|
|
219
|
+
|
|
220
|
+
@logger&.info(Himari::LogLine.new('OidcTokenEndpoint: refreshed', req: env['himari.request_as_log'], client: client.as_log, session: updated_session.as_log, token: issued.access.as_log, refresh_token: rotated.as_log, prev_version: refresh.version, secret_slot: refresh.verification&.via, signing_key_kid: issued.signing_key&.id))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Reject a refresh request with invalid_grant. By default this revokes the presented
|
|
224
|
+
# refresh token when one was looked up, keeping refresh failures fail-closed against
|
|
225
|
+
# replay. revoke: false is used only for the concurrent-conflict path, where the
|
|
226
|
+
# winning request has already rotated this same handle and must not be revoked.
|
|
227
|
+
private def reject_refresh!(env, req, client, reason, refresh: nil, revoke: true, **fields)
|
|
228
|
+
log = {req: env['himari.request_as_log'], client: client.as_log}
|
|
229
|
+
log[:refresh] = refresh.as_log if refresh
|
|
230
|
+
@logger&.warn(Himari::LogLine.new("OidcTokenEndpoint: invalid_grant, #{reason}", **log, **fields))
|
|
231
|
+
@storage.delete_refresh_token(refresh) if refresh && revoke
|
|
232
|
+
req.invalid_grant!
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Mint an access token (and, for OIDC, an id_token JWT). Refresh tokens are handled
|
|
236
|
+
# separately by each grant path: the authorization_code path mints a fresh one, while
|
|
237
|
+
# the refresh path rotates the presented token in place.
|
|
238
|
+
private def issue_access_and_id(client:, claims:, scopes:, lifetime:, openid:, session_handle:, nonce:, mint_jwt_access_token:)
|
|
239
|
+
access = AccessToken.make(client_id: client.id, claims: claims, scopes: scopes, session_handle: session_handle, lifetime: lifetime.access_token)
|
|
240
|
+
@storage.put_token(access)
|
|
241
|
+
|
|
242
|
+
# Both the ID Token and a JWT access token are signed by the same key; resolve it once.
|
|
243
|
+
signing_key = nil
|
|
244
|
+
if openid || mint_jwt_access_token
|
|
245
|
+
signing_key = @signing_key_provider.find(group: client.preferred_key_group, active: true)
|
|
246
|
+
raise SigningKeyMissing unless signing_key
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
access_token_string = if mint_jwt_access_token
|
|
250
|
+
access.to_jwt(signing_key: signing_key, issuer: @issuer)
|
|
251
|
+
else
|
|
252
|
+
access.format.to_s
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
id_token_jwt = nil
|
|
256
|
+
if openid
|
|
257
|
+
id_token_jwt = IdToken.new(
|
|
258
|
+
claims: claims,
|
|
259
|
+
client_id: client.id,
|
|
260
|
+
nonce: nonce,
|
|
261
|
+
signing_key: signing_key,
|
|
262
|
+
issuer: @issuer,
|
|
263
|
+
# at_hash binds the ID Token to the access token actually delivered (JWT or opaque).
|
|
264
|
+
access_token: access_token_string,
|
|
265
|
+
lifetime: lifetime.id_token,
|
|
266
|
+
).to_jwt
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
Issued.new(access: access, access_token_string: access_token_string, id_token_jwt: id_token_jwt, signing_key: signing_key)
|
|
270
|
+
end
|
|
92
271
|
end
|
|
93
272
|
end
|
|
94
273
|
end
|
|
@@ -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,11 +40,13 @@ 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))
|
|
@@ -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
|