himari 0.5.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 +72 -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 +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/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 +54 -3
- data/lib/himari/services/oidc_provider_metadata_endpoint.rb +30 -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 +49 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dcb5e4f61b85b69f1ec9b36205664930ab488c46d4bc1f0800a3481926092d2b
|
|
4
|
+
data.tar.gz: eacc822fbf0ccb630a42dae0e7c130122980da8875480132047d23378e88b83a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 125693e2f92b453fffd5bf5d1f0b34bce0e5f29e2c86ac8e6b068ce3d223d4ee4abda646c99165653a01bc2304d496ac7a26779553659afca7501acef247fc05
|
|
7
|
+
data.tar.gz: 1227491aa891bbaa40c3db1c144bf2ceb429941206cf859f799f409623854c4a099613883579739b1c6ff6f7ee1fef47038063b1f8dc3f313e477a10f8a71ba9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
## [0.6.0] - 2026-06-03
|
|
2
|
+
|
|
3
|
+
### Enhancements
|
|
4
|
+
|
|
5
|
+
- Refresh token (`refresh_token` grant) support [#14](https://github.com/sorah/himari/pull/14)
|
|
6
|
+
- Dynamic client registration (RFC 7591), Client ID Metadata Documents, an RFC 8414 `oauth-authorization-server` metadata endpoint, and Himari-owned `redirect_uri` matching with loopback-port relaxation and `Regexp` entries [#15](https://github.com/sorah/himari/pull/15)
|
|
7
|
+
- Interactive consent page gated by the client `skip_consent` attribute [#16](https://github.com/sorah/himari/pull/16)
|
|
8
|
+
- Per-client `scopes` allow-list [#17](https://github.com/sorah/himari/pull/17)
|
|
9
|
+
- Persist granted scopes on the grant and expose them to authorization rules as `context.scopes` [#19](https://github.com/sorah/himari/pull/19)
|
|
10
|
+
- Opt-in RFC 9068 (`at+jwt`) JWT access tokens [#20](https://github.com/sorah/himari/pull/20)
|
|
11
|
+
- Configurable `scopes_supported`/`claims_supported` and advertise `refresh_token`/`offline_access` in discovery metadata [#21](https://github.com/sorah/himari/pull/21)
|
|
12
|
+
|
|
13
|
+
## [0.5.0] - 2024-05-11
|
|
14
|
+
|
|
15
|
+
### Enhancements
|
|
16
|
+
|
|
17
|
+
- Userinfo endpoint now returns the `aud` claim.
|
|
18
|
+
- Client gains the `require_pkce` attribute.
|
|
19
|
+
|
|
20
|
+
## [0.4.0] - 2023-03-26
|
|
21
|
+
|
|
22
|
+
### Enhancements
|
|
23
|
+
|
|
24
|
+
- Support `prompt=login` for reauthentication; the userinfo endpoint now also answers POST [#8](https://github.com/sorah/himari/pull/8)
|
|
25
|
+
- Store `SessionData` in a storage backend [#7](https://github.com/sorah/himari/pull/7)
|
|
26
|
+
- Introduce the `omniauth-himari` strategy gem [#6](https://github.com/sorah/himari/pull/6)
|
|
27
|
+
- Access token gains its own lifetime.
|
|
28
|
+
|
|
29
|
+
### Changes
|
|
30
|
+
|
|
31
|
+
- Rename `AccessToken#handler` to `handle` and stop treating the token handle as a sensitive value [#5](https://github.com/sorah/himari/pull/5)
|
|
32
|
+
- Disable `Rack::Protection::JsonCsrf` for ALB OIDC compatibility [#1](https://github.com/sorah/himari/pull/1)
|
|
33
|
+
|
|
34
|
+
### Bug fixes
|
|
35
|
+
|
|
36
|
+
- Fix error when logging an expired session token.
|
|
37
|
+
|
|
38
|
+
## [0.3.0] - 2023-03-22
|
|
39
|
+
|
|
40
|
+
### Enhancements
|
|
41
|
+
|
|
42
|
+
- Customizable session and token lifetimes.
|
|
43
|
+
- `suggest=reauthenticate` to prompt login, with a decision `user_facing_message`.
|
|
44
|
+
|
|
45
|
+
### Bug fixes
|
|
46
|
+
|
|
47
|
+
- Callback returns 400 when the auth hash is missing.
|
|
48
|
+
|
|
49
|
+
## [0.2.0] - 2023-03-22
|
|
50
|
+
|
|
51
|
+
### Enhancements
|
|
52
|
+
|
|
53
|
+
- Better login page template with cachebuster.
|
|
54
|
+
- Prebuilt container image.
|
|
55
|
+
|
|
56
|
+
## [0.1.0] - 2023-02-25
|
|
57
|
+
|
|
58
|
+
- Initial release
|
data/lib/himari/access_token.rb
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'rack/oauth2'
|
|
2
4
|
require 'openid_connect'
|
|
5
|
+
require 'json/jwt'
|
|
3
6
|
|
|
4
7
|
require 'himari/token_string'
|
|
8
|
+
require 'himari/access_token_jwt'
|
|
5
9
|
|
|
6
10
|
module Himari
|
|
7
11
|
class AccessToken
|
|
@@ -23,26 +27,67 @@ module Himari
|
|
|
23
27
|
3600
|
|
24
28
|
end
|
|
25
29
|
|
|
30
|
+
# Parse a presented access token into its opaque Format (handle + secret) for verification
|
|
31
|
+
# against storage. Two on-the-wire shapes are accepted:
|
|
32
|
+
#
|
|
33
|
+
# - the opaque token "hmat.<handle>.<secret>" (TokenString format), or
|
|
34
|
+
# - an RFC 9068 JWT (Himari::AccessTokenJwt) carrying the opaque token in its +hmat+ claim.
|
|
35
|
+
#
|
|
36
|
+
# For a JWT, the signature is verified first (requires signing_key_provider to resolve the
|
|
37
|
+
# kid), then the embedded opaque token is returned so the caller validates the secret against
|
|
38
|
+
# storage exactly as for an opaque token. Any malformed/unverifiable JWT becomes
|
|
39
|
+
# TokenString::InvalidFormat so callers handle one failure type.
|
|
40
|
+
#
|
|
41
|
+
# @param signing_key_provider [Himari::ProviderChain<Himari::SigningKey>, nil]
|
|
42
|
+
def self.parse(str, signing_key_provider: nil)
|
|
43
|
+
return TokenString::Format.parse(magic_header, str) if str.to_s.start_with?("#{magic_header}.")
|
|
44
|
+
|
|
45
|
+
parse_jwt(str, signing_key_provider)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.parse_jwt(str, signing_key_provider)
|
|
49
|
+
raise TokenString::InvalidFormat, 'signing keys are required to verify a JWT access token' unless signing_key_provider
|
|
50
|
+
|
|
51
|
+
jwt = JSON::JWT.decode(str, :skip_verification)
|
|
52
|
+
key = jwt.kid && signing_key_provider.find(id: jwt.kid)
|
|
53
|
+
raise TokenString::InvalidFormat, 'unknown or missing signing key (kid)' unless key
|
|
54
|
+
|
|
55
|
+
jwt.verify!(key.pkey)
|
|
56
|
+
|
|
57
|
+
hmat = jwt[magic_header]
|
|
58
|
+
raise TokenString::InvalidFormat, 'missing hmat claim' unless hmat.is_a?(String) && hmat.start_with?("#{magic_header}.")
|
|
59
|
+
|
|
60
|
+
TokenString::Format.parse(magic_header, hmat)
|
|
61
|
+
rescue JSON::JWT::Exception, JSON::ParserError => e
|
|
62
|
+
raise TokenString::InvalidFormat, "invalid JWT access token: #{e.class}"
|
|
63
|
+
end
|
|
64
|
+
|
|
26
65
|
# @param authz [Himari::AuthorizationCode]
|
|
27
66
|
def self.from_authz(authz)
|
|
28
67
|
make(
|
|
29
68
|
client_id: authz.client_id,
|
|
30
69
|
claims: authz.claims,
|
|
70
|
+
scopes: authz.scopes,
|
|
71
|
+
session_handle: authz.session_handle,
|
|
31
72
|
lifetime: authz.lifetime.access_token,
|
|
32
73
|
)
|
|
33
74
|
end
|
|
34
75
|
|
|
35
|
-
def initialize(handle:, client_id:, claims:, expiry:, secret: nil, secret_hash: nil)
|
|
76
|
+
def initialize(handle:, client_id:, claims:, expiry:, scopes: [], session_handle: nil, secret: nil, secret_hash: nil)
|
|
36
77
|
@handle = handle
|
|
37
78
|
@client_id = client_id
|
|
38
79
|
@claims = claims
|
|
80
|
+
@scopes = scopes
|
|
81
|
+
@session_handle = session_handle
|
|
39
82
|
@expiry = expiry
|
|
40
83
|
|
|
41
84
|
@secret = secret
|
|
42
85
|
@secret_hash = secret_hash
|
|
86
|
+
@secret_hash_prev = nil
|
|
87
|
+
@verification = nil
|
|
43
88
|
end
|
|
44
89
|
|
|
45
|
-
attr_reader :handle, :client_id, :claims, :expiry
|
|
90
|
+
attr_reader :handle, :client_id, :claims, :scopes, :session_handle, :expiry
|
|
46
91
|
|
|
47
92
|
def userinfo
|
|
48
93
|
claims.merge(
|
|
@@ -50,18 +95,39 @@ module Himari
|
|
|
50
95
|
)
|
|
51
96
|
end
|
|
52
97
|
|
|
53
|
-
|
|
98
|
+
# @param token_string [String] the on-the-wire access token to deliver. Defaults to the
|
|
99
|
+
# opaque format; the token endpoint passes the RFC 9068 JWT when one was minted.
|
|
100
|
+
def to_bearer(token_string: format.to_s)
|
|
54
101
|
Bearer.new(
|
|
55
|
-
access_token:
|
|
102
|
+
access_token: token_string,
|
|
56
103
|
expires_in: (expiry - Time.now.to_i).to_i,
|
|
57
104
|
)
|
|
58
105
|
end
|
|
59
106
|
|
|
107
|
+
# Render this token as an RFC 9068 JWT (Himari::AccessTokenJwt). The opaque secret travels in
|
|
108
|
+
# the JWT's hmat claim, so the token validates against storage the same way either form does.
|
|
109
|
+
# exp is tied to this token's own expiry rather than recomputed, keeping both forms in sync.
|
|
110
|
+
# @param signing_key [Himari::SigningKey]
|
|
111
|
+
# @param issuer [String]
|
|
112
|
+
def to_jwt(signing_key:, issuer:, now: Time.now)
|
|
113
|
+
AccessTokenJwt.new(
|
|
114
|
+
access: self,
|
|
115
|
+
claims: claims,
|
|
116
|
+
client_id: client_id,
|
|
117
|
+
signing_key: signing_key,
|
|
118
|
+
issuer: issuer,
|
|
119
|
+
time: now,
|
|
120
|
+
lifetime: expiry - now.to_i,
|
|
121
|
+
).to_jwt
|
|
122
|
+
end
|
|
123
|
+
|
|
60
124
|
def as_log
|
|
61
125
|
{
|
|
62
126
|
handle: handle,
|
|
63
127
|
client_id: client_id,
|
|
64
128
|
claims: claims,
|
|
129
|
+
scopes: scopes,
|
|
130
|
+
session_handle: session_handle,
|
|
65
131
|
expiry: expiry,
|
|
66
132
|
}
|
|
67
133
|
end
|
|
@@ -72,6 +138,8 @@ module Himari
|
|
|
72
138
|
secret_hash: secret_hash,
|
|
73
139
|
client_id: client_id,
|
|
74
140
|
claims: claims,
|
|
141
|
+
scopes: scopes,
|
|
142
|
+
session_handle: session_handle,
|
|
75
143
|
expiry: expiry.to_i,
|
|
76
144
|
}
|
|
77
145
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'himari/jwt_token'
|
|
4
|
+
require 'himari/access_token'
|
|
5
|
+
|
|
6
|
+
module Himari
|
|
7
|
+
# RFC 9068 (JWT Profile for OAuth 2.0 Access Tokens) representation of an access token. The
|
|
8
|
+
# signed JWT carries the same IdP claims as the ID Token for relying parties to consume
|
|
9
|
+
# directly, plus the registered claims RFC 9068 requires. Himari still authenticates the token
|
|
10
|
+
# by the opaque secret embedded in the +hmat+ claim (see Himari::AccessToken.parse), so the
|
|
11
|
+
# JWT signature is an additional, self-contained guarantee for relying parties.
|
|
12
|
+
class AccessTokenJwt < JwtToken
|
|
13
|
+
# sub is the one RFC 9068 §2.2 required claim sourced from variable IdP claims rather than set
|
|
14
|
+
# by us; fail closed at mint time if a misconfigured allowed_claims stripped it.
|
|
15
|
+
class MissingSubject < StandardError; end
|
|
16
|
+
|
|
17
|
+
# @param access [Himari::AccessToken] the minted, persisted opaque access token this JWT wraps
|
|
18
|
+
def initialize(access:, **kwargs)
|
|
19
|
+
super(**kwargs)
|
|
20
|
+
@access = access
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# https://www.rfc-editor.org/rfc/rfc9068.html#section-2.1
|
|
24
|
+
def jwt_header
|
|
25
|
+
{typ: 'at+jwt'}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# https://www.rfc-editor.org/rfc/rfc9068.html#section-2.2
|
|
29
|
+
def final_claims
|
|
30
|
+
raise MissingSubject, 'RFC 9068 access token requires a sub claim' unless claims[:sub]
|
|
31
|
+
|
|
32
|
+
standard_claims.merge(
|
|
33
|
+
client_id: @client_id,
|
|
34
|
+
jti: @access.handle,
|
|
35
|
+
# The opaque access token Himari validates against storage; relying parties ignore it.
|
|
36
|
+
AccessToken.magic_header.to_sym => @access.format.to_s,
|
|
37
|
+
).merge(scope_claim)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# https://www.rfc-editor.org/rfc/rfc9068.html#section-2.2.1 — space-delimited granted scopes.
|
|
41
|
+
private def scope_claim
|
|
42
|
+
scopes = @access.scopes
|
|
43
|
+
scopes && !scopes.empty? ? {scope: scopes.join(' ')} : {}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/himari/app.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'sinatra/base'
|
|
2
4
|
require 'addressable'
|
|
3
5
|
require 'base64'
|
|
@@ -15,10 +17,13 @@ require 'himari/session_data'
|
|
|
15
17
|
require 'himari/middlewares/client'
|
|
16
18
|
require 'himari/middlewares/config'
|
|
17
19
|
require 'himari/middlewares/signing_key'
|
|
20
|
+
require 'himari/middlewares/dynamic_clients'
|
|
21
|
+
require 'himari/middlewares/metadata_clients'
|
|
18
22
|
|
|
19
23
|
require 'himari/services/downstream_authorization'
|
|
20
24
|
require 'himari/services/upstream_authentication'
|
|
21
25
|
|
|
26
|
+
require 'himari/services/client_registration_endpoint'
|
|
22
27
|
require 'himari/services/jwks_endpoint'
|
|
23
28
|
require 'himari/services/oidc_authorization_endpoint'
|
|
24
29
|
require 'himari/services/oidc_provider_metadata_endpoint'
|
|
@@ -32,6 +37,7 @@ module Himari
|
|
|
32
37
|
# remote_token: disabled in favor of authenticity_token (more stricter)
|
|
33
38
|
# json_csrf: can be prevented using x-content-type-options:nosniff
|
|
34
39
|
set :protection, use: %i(authenticity_token), except: %i(remote_token json_csrf)
|
|
40
|
+
set :host_authorization, {}
|
|
35
41
|
|
|
36
42
|
set :logging, nil
|
|
37
43
|
|
|
@@ -42,13 +48,15 @@ module Himari
|
|
|
42
48
|
helpers do
|
|
43
49
|
def current_user
|
|
44
50
|
return @current_user if defined? @current_user
|
|
51
|
+
|
|
45
52
|
given_token = session[:himari_session]
|
|
46
|
-
return
|
|
53
|
+
return unless given_token
|
|
47
54
|
|
|
48
55
|
given_parsed_token = Himari::SessionData.parse(given_token)
|
|
49
56
|
|
|
50
57
|
token = config.storage.find_session(given_parsed_token.handle)
|
|
51
58
|
raise InvalidSessionToken, "no session found in storage (possibly expired)" unless token
|
|
59
|
+
|
|
52
60
|
token.verify!(secret: given_parsed_token.secret)
|
|
53
61
|
|
|
54
62
|
@current_user = token
|
|
@@ -70,12 +78,28 @@ module Himari
|
|
|
70
78
|
Himari::ProviderChain.new(request.env[Himari::Middlewares::Client::RACK_KEY] || [])
|
|
71
79
|
end
|
|
72
80
|
|
|
81
|
+
def dynamic_clients_enabled?
|
|
82
|
+
request.env.key?(Himari::Middlewares::DynamicClients::RACK_KEY)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def metadata_clients_enabled?
|
|
86
|
+
request.env.key?(Himari::Middlewares::MetadataClients::RACK_KEY)
|
|
87
|
+
end
|
|
88
|
+
|
|
73
89
|
def known_providers
|
|
74
90
|
back_to = if request.query_string.empty?
|
|
75
91
|
request.path
|
|
76
92
|
else
|
|
77
93
|
Addressable::URI.parse(request.fullpath).tap do |u|
|
|
78
|
-
|
|
94
|
+
# Drop only the prompt values that would re-trigger the login screen once the user
|
|
95
|
+
# comes back authenticated (otherwise we'd loop); keep the rest (e.g. consent) so they
|
|
96
|
+
# still apply at the authorize endpoint after login.
|
|
97
|
+
u.query_values = u.query_values.filter_map do |k, v|
|
|
98
|
+
next [k, v] unless k == 'prompt'
|
|
99
|
+
|
|
100
|
+
remaining = v.to_s.split(/\s+/) - %w(login select_account)
|
|
101
|
+
[k, remaining.join(' ')] unless remaining.empty?
|
|
102
|
+
end.to_h
|
|
79
103
|
end.to_s
|
|
80
104
|
end
|
|
81
105
|
query = Addressable::URI.form_encode(back_to: back_to)
|
|
@@ -103,12 +127,10 @@ module Himari
|
|
|
103
127
|
end
|
|
104
128
|
|
|
105
129
|
def release_code
|
|
106
|
-
env['himari.release'] ||=
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
].compact.join(':')
|
|
111
|
-
end
|
|
130
|
+
env['himari.release'] ||= [
|
|
131
|
+
Himari::VERSION,
|
|
132
|
+
config.release_fragment,
|
|
133
|
+
].compact.join(':')
|
|
112
134
|
end
|
|
113
135
|
|
|
114
136
|
def request_id
|
|
@@ -134,7 +156,7 @@ module Himari
|
|
|
134
156
|
end
|
|
135
157
|
|
|
136
158
|
before do
|
|
137
|
-
request_as_log
|
|
159
|
+
request_as_log
|
|
138
160
|
end
|
|
139
161
|
|
|
140
162
|
get '/' do
|
|
@@ -142,16 +164,22 @@ module Himari
|
|
|
142
164
|
"Himari #{release_code}\n"
|
|
143
165
|
end
|
|
144
166
|
|
|
145
|
-
|
|
167
|
+
# Served on GET (initial request and consent display) and POST (consent submission). The
|
|
168
|
+
# consent form posts the original authorization params back here as hidden fields plus a
|
|
169
|
+
# _consent decision; everything else flows through OidcAuthorizationEndpoint identically.
|
|
170
|
+
authorize_ep = proc do
|
|
146
171
|
client = client_provider.find(id: params[:client_id])
|
|
147
172
|
unless client
|
|
148
173
|
logger&.warn(Himari::LogLine.new('authorize: no client registration found', req: request_as_log, client_id: params[:client_id]))
|
|
149
|
-
next halt 401, 'unknown client'
|
|
174
|
+
next halt 401, 'unknown client'
|
|
150
175
|
end
|
|
151
176
|
|
|
152
177
|
if current_user
|
|
153
|
-
# do downstream authz and process oidc request
|
|
154
|
-
|
|
178
|
+
# do downstream authz and process oidc request. The OIDC request's scope is parsed the
|
|
179
|
+
# same way rack-oauth2 parses it downstream (OidcAuthorizationEndpoint), so the scopes the
|
|
180
|
+
# authz rules see match those the grant is filtered to.
|
|
181
|
+
requested_scopes = request.params['scope'].to_s.split(' ')
|
|
182
|
+
decision = Himari::Services::DownstreamAuthorization.from_request(session: current_user, client: client, request: request, requested_scopes: requested_scopes).perform
|
|
155
183
|
logger&.info(Himari::LogLine.new('authorize: downstream authorized', req: request_as_log, session: current_user.as_log, allowed: decision.authz_result.allowed, result: decision.as_log))
|
|
156
184
|
raise unless decision.authz_result.allowed # sanity check
|
|
157
185
|
|
|
@@ -159,23 +187,34 @@ module Himari
|
|
|
159
187
|
client_id: decision.client.id,
|
|
160
188
|
claims: decision.claims,
|
|
161
189
|
lifetime: decision.lifetime,
|
|
190
|
+
mint_jwt_access_token: decision.mint_jwt_access_token,
|
|
191
|
+
session_handle: current_user.handle,
|
|
162
192
|
)
|
|
163
193
|
|
|
194
|
+
consent = case params[:_consent]
|
|
195
|
+
when 'approve' then :approve
|
|
196
|
+
when 'deny' then :deny
|
|
197
|
+
end
|
|
198
|
+
|
|
164
199
|
Himari::Services::OidcAuthorizationEndpoint.new(
|
|
165
200
|
authz: authz,
|
|
166
201
|
client: client,
|
|
167
202
|
storage: config.storage,
|
|
203
|
+
consent: consent,
|
|
168
204
|
logger: logger,
|
|
169
205
|
).call(env)
|
|
170
206
|
else
|
|
171
207
|
logger&.info(Himari::LogLine.new('authorize: prompt login', req: request_as_log, client_id: params[:client_id]))
|
|
172
208
|
erb(config.custom_templates[:login] || :login)
|
|
173
209
|
end
|
|
174
|
-
|
|
175
210
|
rescue Himari::Services::OidcAuthorizationEndpoint::ReauthenticationRequired
|
|
176
|
-
logger&.warn(Himari::LogLine.new('authorize: prompt login to reauthenticate (demanded by oidc request)',
|
|
211
|
+
logger&.warn(Himari::LogLine.new('authorize: prompt login to reauthenticate (demanded by oidc request)', req: request_as_log, session: current_user&.as_log, allowed: decision&.authz_result&.allowed, result: decision&.as_log))
|
|
177
212
|
next erb(config.custom_templates[:login] || :login)
|
|
178
|
-
|
|
213
|
+
rescue Himari::Services::OidcAuthorizationEndpoint::ConsentRequired => e
|
|
214
|
+
logger&.info(Himari::LogLine.new('authorize: prompt consent', req: request_as_log, session: current_user&.as_log, client: e.client.as_log, scopes: e.scopes))
|
|
215
|
+
@consent_client = e.client
|
|
216
|
+
@consent_scopes = e.scopes
|
|
217
|
+
next erb(config.custom_templates[:consent] || :consent)
|
|
179
218
|
rescue Himari::Services::DownstreamAuthorization::ForbiddenError => e
|
|
180
219
|
logger&.warn(Himari::LogLine.new('authorize: downstream forbidden', req: request_as_log, session: current_user&.as_log, allowed: e.result.authz_result.allowed, err: e.class.inspect, result: e.as_log))
|
|
181
220
|
|
|
@@ -193,6 +232,8 @@ module Himari
|
|
|
193
232
|
|
|
194
233
|
halt(403, "Forbidden#{message_human ? "; #{message_human}" : nil}")
|
|
195
234
|
end
|
|
235
|
+
get '/oidc/authorize', &authorize_ep
|
|
236
|
+
post '/oidc/authorize', &authorize_ep
|
|
196
237
|
|
|
197
238
|
token_ep = proc do
|
|
198
239
|
Himari::Services::OidcTokenEndpoint.new(
|
|
@@ -209,6 +250,7 @@ module Himari
|
|
|
209
250
|
userinfo_ep = proc do
|
|
210
251
|
Himari::Services::OidcUserinfoEndpoint.new(
|
|
211
252
|
storage: config.storage,
|
|
253
|
+
signing_key_provider: signing_key_provider,
|
|
212
254
|
logger: logger,
|
|
213
255
|
).call(env)
|
|
214
256
|
end
|
|
@@ -225,12 +267,43 @@ module Himari
|
|
|
225
267
|
get '/jwks', &jwks_ep
|
|
226
268
|
get '/public/jwks', &jwks_ep
|
|
227
269
|
|
|
228
|
-
|
|
270
|
+
# RFC 7591 Dynamic Client Registration. Enabled by presence of the DynamicClients
|
|
271
|
+
# middleware; the route always exists but 404s when the feature is off.
|
|
272
|
+
register_ep = proc do
|
|
273
|
+
next halt 404, 'not found' unless dynamic_clients_enabled?
|
|
274
|
+
|
|
275
|
+
Himari::Services::ClientRegistrationEndpoint.new(
|
|
276
|
+
storage: config.storage,
|
|
277
|
+
registration_lifetime: request.env[Himari::Middlewares::DynamicClients::RACK_KEY].registration_lifetime,
|
|
278
|
+
ignore_localhost_redirect_uri_port: request.env[Himari::Middlewares::DynamicClients::RACK_KEY].ignore_localhost_redirect_uri_port,
|
|
279
|
+
logger: logger,
|
|
280
|
+
).call(env)
|
|
281
|
+
end
|
|
282
|
+
post '/oidc/register', ®ister_ep
|
|
283
|
+
post '/public/oidc/register', ®ister_ep
|
|
284
|
+
# Wire the non-POST verbs too so they reach the endpoint, which answers 405 (RFC 7591 only
|
|
285
|
+
# defines POST). Without these Sinatra would 404 a GET, masking the method error.
|
|
286
|
+
%w(/oidc/register /public/oidc/register).each do |path|
|
|
287
|
+
get(path, ®ister_ep)
|
|
288
|
+
put(path, ®ister_ep)
|
|
289
|
+
patch(path, ®ister_ep)
|
|
290
|
+
delete(path, ®ister_ep)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
metadata_ep = proc do
|
|
229
294
|
Himari::Services::OidcProviderMetadataEndpoint.new(
|
|
230
295
|
signing_key_provider: signing_key_provider,
|
|
231
296
|
issuer: config.issuer,
|
|
297
|
+
registration_endpoint: dynamic_clients_enabled? ? "#{config.issuer}/public/oidc/register" : nil,
|
|
298
|
+
client_id_metadata_document_supported: metadata_clients_enabled?,
|
|
299
|
+
scopes_supported: config.scopes_supported,
|
|
300
|
+
claims_supported: config.claims_supported,
|
|
232
301
|
).call(env)
|
|
233
302
|
end
|
|
303
|
+
# OpenID Connect Discovery 1.0
|
|
304
|
+
get '/.well-known/openid-configuration', &metadata_ep
|
|
305
|
+
# RFC 8414 OAuth 2.0 Authorization Server Metadata
|
|
306
|
+
get '/.well-known/oauth-authorization-server', &metadata_ep
|
|
234
307
|
|
|
235
308
|
omniauth_callback = proc do
|
|
236
309
|
authhash = request.env['omniauth.auth']
|
|
@@ -243,17 +316,17 @@ module Himari
|
|
|
243
316
|
|
|
244
317
|
given_back_to = request.env['omniauth.params']&.fetch('back_to', nil)
|
|
245
318
|
back_to = if given_back_to
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
319
|
+
uri = begin
|
|
320
|
+
Addressable::URI.parse(given_back_to)
|
|
321
|
+
rescue Addressable::URI::InvalidURIError
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
if uri && uri.host.nil? && uri.scheme.nil? && uri.path.start_with?('/')
|
|
325
|
+
given_back_to
|
|
326
|
+
else
|
|
327
|
+
logger&.warn(Himari::LogLine.new('invalid back_to', req: request_as_log, given_back_to: given_back_to))
|
|
328
|
+
nil
|
|
329
|
+
end
|
|
257
330
|
end || '/'
|
|
258
331
|
|
|
259
332
|
session.destroy
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'digest/sha2'
|
|
2
4
|
require 'himari/lifetime_value'
|
|
3
5
|
|
|
@@ -6,7 +8,11 @@ module Himari
|
|
|
6
8
|
code
|
|
7
9
|
client_id
|
|
8
10
|
claims
|
|
11
|
+
scopes
|
|
12
|
+
mint_jwt_access_token
|
|
9
13
|
openid
|
|
14
|
+
offline_access
|
|
15
|
+
session_handle
|
|
10
16
|
redirect_uri
|
|
11
17
|
nonce
|
|
12
18
|
code_challenge
|
|
@@ -24,23 +30,23 @@ module Himari
|
|
|
24
30
|
)
|
|
25
31
|
end
|
|
26
32
|
|
|
27
|
-
|
|
33
|
+
alias_method :_lifetime_raw, :lifetime
|
|
28
34
|
private :_lifetime_raw
|
|
29
35
|
def lifetime
|
|
30
36
|
case _lifetime_raw
|
|
31
37
|
when Hash
|
|
32
38
|
self.lifetime = LifetimeValue.new(**_lifetime_raw)
|
|
33
|
-
when Integer #compat
|
|
39
|
+
when Integer # compat
|
|
34
40
|
self.lifetime = LifetimeValue.from_integer(_lifetime_raw)
|
|
35
41
|
else
|
|
36
42
|
_lifetime_raw
|
|
37
43
|
end
|
|
38
44
|
end
|
|
39
45
|
|
|
40
|
-
|
|
46
|
+
alias_method :_expiry_raw, :expiry
|
|
41
47
|
private :_expiry_raw
|
|
42
48
|
def expiry
|
|
43
|
-
|
|
49
|
+
_expiry_raw || (self.expiry = created_at + (lifetime&.code || 900))
|
|
44
50
|
end
|
|
45
51
|
|
|
46
52
|
def valid_redirect_uri?(given_uri)
|
|
@@ -80,7 +86,11 @@ module Himari
|
|
|
80
86
|
client_id: client_id,
|
|
81
87
|
claims: claims,
|
|
82
88
|
nonce: nonce,
|
|
89
|
+
scopes: scopes,
|
|
90
|
+
mint_jwt_access_token: mint_jwt_access_token,
|
|
83
91
|
openid: openid,
|
|
92
|
+
offline_access: offline_access,
|
|
93
|
+
session_handle: session_handle,
|
|
84
94
|
created_at: created_at.to_i,
|
|
85
95
|
lifetime: lifetime.as_log,
|
|
86
96
|
expiry: expiry.to_i,
|
|
@@ -95,7 +105,11 @@ module Himari
|
|
|
95
105
|
code: code,
|
|
96
106
|
client_id: client_id,
|
|
97
107
|
claims: claims,
|
|
108
|
+
scopes: scopes,
|
|
109
|
+
mint_jwt_access_token: mint_jwt_access_token,
|
|
98
110
|
openid: openid,
|
|
111
|
+
offline_access: offline_access,
|
|
112
|
+
session_handle: session_handle,
|
|
99
113
|
redirect_uri: redirect_uri,
|
|
100
114
|
nonce: nonce,
|
|
101
115
|
code_challenge: code_challenge,
|
|
@@ -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
|