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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f986e727b538f1f6f512f6787e7546e1ebdaa942398053966d52db1383135fc8
|
|
4
|
+
data.tar.gz: a300ee5b77dc2e1f7c5befbae719876cc60f08a742d97c43f71bdd44fb170adc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1bf93eaa1f6f98c45ec2649278ccb571f26a530f7b5b4d6c35d0b15e250861f9fc7e6efdb7f3c77de9abd177330b86970d9b1de29df2a0cdb4c4d65f33933fb0
|
|
7
|
+
data.tar.gz: 033b8dfa409cea02e7ddd73a6369c261e0d79402ab7a82cab49cbffafc654b12bb08dabd42b824382d4143b426d2749117014b94e96bd4d1b67de7e2ca8e37ef
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
## [0.7.0] - 2026-06-06
|
|
2
|
+
|
|
3
|
+
### Enhancements
|
|
4
|
+
|
|
5
|
+
- Authorization Server Issuer Identification (RFC 9207): the authorization endpoint now returns the `iss` parameter in all authorization responses (success and redirected errors), and discovery metadata advertises `authorization_response_iss_parameter_supported`
|
|
6
|
+
|
|
7
|
+
## [0.6.0] - 2026-06-03
|
|
8
|
+
|
|
9
|
+
### Enhancements
|
|
10
|
+
|
|
11
|
+
- Refresh token (`refresh_token` grant) support [#14](https://github.com/sorah/himari/pull/14)
|
|
12
|
+
- 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)
|
|
13
|
+
- Interactive consent page gated by the client `skip_consent` attribute [#16](https://github.com/sorah/himari/pull/16)
|
|
14
|
+
- Per-client `scopes` allow-list [#17](https://github.com/sorah/himari/pull/17)
|
|
15
|
+
- Persist granted scopes on the grant and expose them to authorization rules as `context.scopes` [#19](https://github.com/sorah/himari/pull/19)
|
|
16
|
+
- Opt-in RFC 9068 (`at+jwt`) JWT access tokens [#20](https://github.com/sorah/himari/pull/20)
|
|
17
|
+
- Configurable `scopes_supported`/`claims_supported` and advertise `refresh_token`/`offline_access` in discovery metadata [#21](https://github.com/sorah/himari/pull/21)
|
|
18
|
+
|
|
19
|
+
## [0.5.0] - 2024-05-11
|
|
20
|
+
|
|
21
|
+
### Enhancements
|
|
22
|
+
|
|
23
|
+
- Userinfo endpoint now returns the `aud` claim.
|
|
24
|
+
- Client gains the `require_pkce` attribute.
|
|
25
|
+
|
|
26
|
+
## [0.4.0] - 2023-03-26
|
|
27
|
+
|
|
28
|
+
### Enhancements
|
|
29
|
+
|
|
30
|
+
- Support `prompt=login` for reauthentication; the userinfo endpoint now also answers POST [#8](https://github.com/sorah/himari/pull/8)
|
|
31
|
+
- Store `SessionData` in a storage backend [#7](https://github.com/sorah/himari/pull/7)
|
|
32
|
+
- Introduce the `omniauth-himari` strategy gem [#6](https://github.com/sorah/himari/pull/6)
|
|
33
|
+
- Access token gains its own lifetime.
|
|
34
|
+
|
|
35
|
+
### Changes
|
|
36
|
+
|
|
37
|
+
- Rename `AccessToken#handler` to `handle` and stop treating the token handle as a sensitive value [#5](https://github.com/sorah/himari/pull/5)
|
|
38
|
+
- Disable `Rack::Protection::JsonCsrf` for ALB OIDC compatibility [#1](https://github.com/sorah/himari/pull/1)
|
|
39
|
+
|
|
40
|
+
### Bug fixes
|
|
41
|
+
|
|
42
|
+
- Fix error when logging an expired session token.
|
|
43
|
+
|
|
44
|
+
## [0.3.0] - 2023-03-22
|
|
45
|
+
|
|
46
|
+
### Enhancements
|
|
47
|
+
|
|
48
|
+
- Customizable session and token lifetimes.
|
|
49
|
+
- `suggest=reauthenticate` to prompt login, with a decision `user_facing_message`.
|
|
50
|
+
|
|
51
|
+
### Bug fixes
|
|
52
|
+
|
|
53
|
+
- Callback returns 400 when the auth hash is missing.
|
|
54
|
+
|
|
55
|
+
## [0.2.0] - 2023-03-22
|
|
56
|
+
|
|
57
|
+
### Enhancements
|
|
58
|
+
|
|
59
|
+
- Better login page template with cachebuster.
|
|
60
|
+
- Prebuilt container image.
|
|
61
|
+
|
|
62
|
+
## [0.1.0] - 2023-02-25
|
|
63
|
+
|
|
64
|
+
- 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,35 @@ 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
|
+
issuer: config.issuer,
|
|
204
|
+
consent: consent,
|
|
168
205
|
logger: logger,
|
|
169
206
|
).call(env)
|
|
170
207
|
else
|
|
171
208
|
logger&.info(Himari::LogLine.new('authorize: prompt login', req: request_as_log, client_id: params[:client_id]))
|
|
172
209
|
erb(config.custom_templates[:login] || :login)
|
|
173
210
|
end
|
|
174
|
-
|
|
175
211
|
rescue Himari::Services::OidcAuthorizationEndpoint::ReauthenticationRequired
|
|
176
|
-
logger&.warn(Himari::LogLine.new('authorize: prompt login to reauthenticate (demanded by oidc request)',
|
|
212
|
+
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
213
|
next erb(config.custom_templates[:login] || :login)
|
|
178
|
-
|
|
214
|
+
rescue Himari::Services::OidcAuthorizationEndpoint::ConsentRequired => e
|
|
215
|
+
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))
|
|
216
|
+
@consent_client = e.client
|
|
217
|
+
@consent_scopes = e.scopes
|
|
218
|
+
next erb(config.custom_templates[:consent] || :consent)
|
|
179
219
|
rescue Himari::Services::DownstreamAuthorization::ForbiddenError => e
|
|
180
220
|
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
221
|
|
|
@@ -193,6 +233,8 @@ module Himari
|
|
|
193
233
|
|
|
194
234
|
halt(403, "Forbidden#{message_human ? "; #{message_human}" : nil}")
|
|
195
235
|
end
|
|
236
|
+
get '/oidc/authorize', &authorize_ep
|
|
237
|
+
post '/oidc/authorize', &authorize_ep
|
|
196
238
|
|
|
197
239
|
token_ep = proc do
|
|
198
240
|
Himari::Services::OidcTokenEndpoint.new(
|
|
@@ -209,6 +251,7 @@ module Himari
|
|
|
209
251
|
userinfo_ep = proc do
|
|
210
252
|
Himari::Services::OidcUserinfoEndpoint.new(
|
|
211
253
|
storage: config.storage,
|
|
254
|
+
signing_key_provider: signing_key_provider,
|
|
212
255
|
logger: logger,
|
|
213
256
|
).call(env)
|
|
214
257
|
end
|
|
@@ -225,12 +268,43 @@ module Himari
|
|
|
225
268
|
get '/jwks', &jwks_ep
|
|
226
269
|
get '/public/jwks', &jwks_ep
|
|
227
270
|
|
|
228
|
-
|
|
271
|
+
# RFC 7591 Dynamic Client Registration. Enabled by presence of the DynamicClients
|
|
272
|
+
# middleware; the route always exists but 404s when the feature is off.
|
|
273
|
+
register_ep = proc do
|
|
274
|
+
next halt 404, 'not found' unless dynamic_clients_enabled?
|
|
275
|
+
|
|
276
|
+
Himari::Services::ClientRegistrationEndpoint.new(
|
|
277
|
+
storage: config.storage,
|
|
278
|
+
registration_lifetime: request.env[Himari::Middlewares::DynamicClients::RACK_KEY].registration_lifetime,
|
|
279
|
+
ignore_localhost_redirect_uri_port: request.env[Himari::Middlewares::DynamicClients::RACK_KEY].ignore_localhost_redirect_uri_port,
|
|
280
|
+
logger: logger,
|
|
281
|
+
).call(env)
|
|
282
|
+
end
|
|
283
|
+
post '/oidc/register', ®ister_ep
|
|
284
|
+
post '/public/oidc/register', ®ister_ep
|
|
285
|
+
# Wire the non-POST verbs too so they reach the endpoint, which answers 405 (RFC 7591 only
|
|
286
|
+
# defines POST). Without these Sinatra would 404 a GET, masking the method error.
|
|
287
|
+
%w(/oidc/register /public/oidc/register).each do |path|
|
|
288
|
+
get(path, ®ister_ep)
|
|
289
|
+
put(path, ®ister_ep)
|
|
290
|
+
patch(path, ®ister_ep)
|
|
291
|
+
delete(path, ®ister_ep)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
metadata_ep = proc do
|
|
229
295
|
Himari::Services::OidcProviderMetadataEndpoint.new(
|
|
230
296
|
signing_key_provider: signing_key_provider,
|
|
231
297
|
issuer: config.issuer,
|
|
298
|
+
registration_endpoint: dynamic_clients_enabled? ? "#{config.issuer}/public/oidc/register" : nil,
|
|
299
|
+
client_id_metadata_document_supported: metadata_clients_enabled?,
|
|
300
|
+
scopes_supported: config.scopes_supported,
|
|
301
|
+
claims_supported: config.claims_supported,
|
|
232
302
|
).call(env)
|
|
233
303
|
end
|
|
304
|
+
# OpenID Connect Discovery 1.0
|
|
305
|
+
get '/.well-known/openid-configuration', &metadata_ep
|
|
306
|
+
# RFC 8414 OAuth 2.0 Authorization Server Metadata
|
|
307
|
+
get '/.well-known/oauth-authorization-server', &metadata_ep
|
|
234
308
|
|
|
235
309
|
omniauth_callback = proc do
|
|
236
310
|
authhash = request.env['omniauth.auth']
|
|
@@ -243,17 +317,17 @@ module Himari
|
|
|
243
317
|
|
|
244
318
|
given_back_to = request.env['omniauth.params']&.fetch('back_to', nil)
|
|
245
319
|
back_to = if given_back_to
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
320
|
+
uri = begin
|
|
321
|
+
Addressable::URI.parse(given_back_to)
|
|
322
|
+
rescue Addressable::URI::InvalidURIError
|
|
323
|
+
nil
|
|
324
|
+
end
|
|
325
|
+
if uri && uri.host.nil? && uri.scheme.nil? && uri.path.start_with?('/')
|
|
326
|
+
given_back_to
|
|
327
|
+
else
|
|
328
|
+
logger&.warn(Himari::LogLine.new('invalid back_to', req: request_as_log, given_back_to: given_back_to))
|
|
329
|
+
nil
|
|
330
|
+
end
|
|
257
331
|
end || '/'
|
|
258
332
|
|
|
259
333
|
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,
|