token_authority 0.1.0 → 0.2.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 +23 -0
- data/README.md +199 -7
- data/app/controllers/concerns/token_authority/client_authentication.rb +141 -0
- data/app/controllers/concerns/token_authority/controller_event_logging.rb +98 -0
- data/app/controllers/concerns/token_authority/initial_access_token_authentication.rb +35 -0
- data/app/controllers/concerns/token_authority/token_authentication.rb +128 -0
- data/app/controllers/token_authority/authorization_grants_controller.rb +119 -0
- data/app/controllers/token_authority/authorizations_controller.rb +105 -0
- data/app/controllers/token_authority/clients_controller.rb +99 -0
- data/app/controllers/token_authority/metadata_controller.rb +12 -0
- data/app/controllers/token_authority/resource_metadata_controller.rb +12 -0
- data/app/controllers/token_authority/sessions_controller.rb +228 -0
- data/app/helpers/token_authority/authorization_grants_helper.rb +27 -0
- data/app/models/concerns/token_authority/claim_validatable.rb +95 -0
- data/app/models/concerns/token_authority/event_logging.rb +144 -0
- data/app/models/concerns/token_authority/resourceable.rb +111 -0
- data/app/models/concerns/token_authority/scopeable.rb +105 -0
- data/app/models/concerns/token_authority/session_creatable.rb +101 -0
- data/app/models/token_authority/access_token.rb +127 -0
- data/app/models/token_authority/access_token_request.rb +193 -0
- data/app/models/token_authority/authorization_grant.rb +119 -0
- data/app/models/token_authority/authorization_request.rb +276 -0
- data/app/models/token_authority/authorization_server_metadata.rb +101 -0
- data/app/models/token_authority/client.rb +263 -0
- data/app/models/token_authority/client_id_resolver.rb +114 -0
- data/app/models/token_authority/client_metadata_document.rb +164 -0
- data/app/models/token_authority/client_metadata_document_cache.rb +33 -0
- data/app/models/token_authority/client_metadata_document_fetcher.rb +266 -0
- data/app/models/token_authority/client_registration_request.rb +214 -0
- data/app/models/token_authority/client_registration_response.rb +58 -0
- data/app/models/token_authority/jwks_cache.rb +37 -0
- data/app/models/token_authority/jwks_fetcher.rb +70 -0
- data/app/models/token_authority/protected_resource_metadata.rb +74 -0
- data/app/models/token_authority/refresh_token.rb +110 -0
- data/app/models/token_authority/refresh_token_request.rb +116 -0
- data/app/models/token_authority/session.rb +193 -0
- data/app/models/token_authority/software_statement.rb +70 -0
- data/app/views/token_authority/authorization_grants/new.html.erb +25 -0
- data/app/views/token_authority/client_error.html.erb +8 -0
- data/config/locales/token_authority.en.yml +248 -0
- data/config/routes.rb +29 -0
- data/lib/generators/token_authority/install/install_generator.rb +61 -0
- data/lib/generators/token_authority/install/templates/create_token_authority_tables.rb.erb +116 -0
- data/lib/generators/token_authority/install/templates/token_authority.rb +247 -0
- data/lib/token_authority/configuration.rb +397 -0
- data/lib/token_authority/engine.rb +34 -0
- data/lib/token_authority/errors.rb +221 -0
- data/lib/token_authority/instrumentation.rb +80 -0
- data/lib/token_authority/instrumentation_log_subscriber.rb +62 -0
- data/lib/token_authority/json_web_token.rb +78 -0
- data/lib/token_authority/log_event_subscriber.rb +43 -0
- data/lib/token_authority/routing/constraints.rb +71 -0
- data/lib/token_authority/routing/routes.rb +39 -0
- data/lib/token_authority/version.rb +4 -1
- data/lib/token_authority.rb +30 -1
- metadata +65 -5
- data/app/assets/stylesheets/token_authority/application.css +0 -15
- data/app/controllers/token_authority/application_controller.rb +0 -4
- data/app/helpers/token_authority/application_helper.rb +0 -4
- data/app/views/layouts/token_authority/application.html.erb +0 -17
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
##
|
|
5
|
+
# Models a access token request
|
|
6
|
+
class AccessTokenRequest
|
|
7
|
+
include ActiveModel::Model
|
|
8
|
+
include ActiveModel::Validations
|
|
9
|
+
include TokenAuthority::Resourceable
|
|
10
|
+
include TokenAuthority::Scopeable
|
|
11
|
+
include TokenAuthority::EventLogging
|
|
12
|
+
|
|
13
|
+
attr_accessor :code_verifier, :token_authority_authorization_grant, :redirect_uri
|
|
14
|
+
|
|
15
|
+
validate :token_authority_authorization_grant_must_be_valid
|
|
16
|
+
validate :code_verifier_must_be_valid
|
|
17
|
+
validate :redirect_uri_must_be_valid
|
|
18
|
+
validate :resources_must_be_valid
|
|
19
|
+
validate :scope_must_be_valid
|
|
20
|
+
|
|
21
|
+
# Returns the effective resources (requested or falls back to grant's resources)
|
|
22
|
+
def effective_resources
|
|
23
|
+
return resources if resources.any?
|
|
24
|
+
|
|
25
|
+
token_authority_authorization_grant&.resources || []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns the effective scopes (requested or falls back to grant's scopes)
|
|
29
|
+
def effective_scopes
|
|
30
|
+
return scope if scope.present?
|
|
31
|
+
|
|
32
|
+
token_authority_authorization_grant&.scopes || []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def token_authority_authorization_grant_must_be_valid
|
|
38
|
+
errors.add(:token_authority_authorization_grant, :invalid) and return unless token_authority_authorization_grant_present?
|
|
39
|
+
|
|
40
|
+
errors.add(:token_authority_authorization_grant, :redeemed) if token_authority_authorization_grant.redeemed?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def code_verifier_must_be_valid
|
|
44
|
+
return unless token_authority_authorization_grant_present?
|
|
45
|
+
|
|
46
|
+
if token_authority_client.public_client_type?
|
|
47
|
+
validate_code_verifier_for_public
|
|
48
|
+
else
|
|
49
|
+
validate_code_verifier_for_confidential
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_code_verifier_for_public
|
|
54
|
+
debug_event("validation.pkce.started",
|
|
55
|
+
client_type: "public",
|
|
56
|
+
has_code_verifier: code_verifier.present?)
|
|
57
|
+
|
|
58
|
+
errors.add(:code_verifier, :blank) and return if code_verifier.blank?
|
|
59
|
+
|
|
60
|
+
validate_code_verifier_matches_code_challenge
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate_code_verifier_for_confidential
|
|
64
|
+
return unless challenge_required?
|
|
65
|
+
|
|
66
|
+
debug_event("validation.pkce.started",
|
|
67
|
+
client_type: "confidential",
|
|
68
|
+
has_code_verifier: code_verifier.present?,
|
|
69
|
+
challenge_params_present: challenge_params_present_in_authorize?)
|
|
70
|
+
|
|
71
|
+
if code_verifier.blank? && challenge_params_present_in_authorize?
|
|
72
|
+
errors.add(:code_verifier, :present_in_authorize) and return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
validate_code_verifier_matches_code_challenge
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def challenge_required?
|
|
79
|
+
code_verifier.present? || challenge_params_present_in_authorize?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def challenge_params_present_in_authorize?
|
|
83
|
+
token_authority_authorization_grant.code_challenge.present? || token_authority_authorization_grant.code_challenge_method.present?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_code_verifier_matches_code_challenge
|
|
87
|
+
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
|
88
|
+
valid = token_authority_authorization_grant.code_challenge == challenge
|
|
89
|
+
|
|
90
|
+
debug_event("validation.pkce.completed",
|
|
91
|
+
valid: valid,
|
|
92
|
+
code_challenge_method: token_authority_authorization_grant.code_challenge_method)
|
|
93
|
+
|
|
94
|
+
errors.add(:code_verifier, :does_not_validate_code_challenge) unless valid
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def redirect_uri_must_be_valid
|
|
98
|
+
return unless token_authority_authorization_grant_present?
|
|
99
|
+
|
|
100
|
+
if token_authority_client.public_client_type?
|
|
101
|
+
validate_redirect_uri_for_public
|
|
102
|
+
else
|
|
103
|
+
validate_redirect_uri_for_confidential
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_redirect_uri_for_public
|
|
108
|
+
errors.add(:redirect_uri, :blank) and return if redirect_uri.blank?
|
|
109
|
+
|
|
110
|
+
validate_redirect_uri_matches_authorize_param
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_redirect_uri_for_confidential
|
|
114
|
+
return unless redirect_uri.present? || redirect_uri_param_present_in_authorize?
|
|
115
|
+
|
|
116
|
+
errors.add(:redirect_uri, :present_in_authorize) and return if redirect_uri.blank?
|
|
117
|
+
|
|
118
|
+
validate_redirect_uri_matches_authorize_param
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate_redirect_uri_matches_authorize_param
|
|
122
|
+
errors.add(:redirect_uri, :mismatched) unless token_authority_authorization_grant.redirect_uri == redirect_uri
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def redirect_uri_param_present_in_authorize?
|
|
126
|
+
token_authority_authorization_grant.redirect_uri.present?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def token_authority_authorization_grant_present?
|
|
130
|
+
return @token_authority_authorization_grant_present if defined?(@token_authority_authorization_grant_present)
|
|
131
|
+
|
|
132
|
+
@token_authority_authorization_grant_present = token_authority_authorization_grant&.is_a?(TokenAuthority::AuthorizationGrant)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def token_authority_client
|
|
136
|
+
@token_authority_client ||= token_authority_authorization_grant.resolved_client
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def resources_must_be_valid
|
|
140
|
+
return unless token_authority_authorization_grant_present?
|
|
141
|
+
return if resources.empty?
|
|
142
|
+
|
|
143
|
+
# If resources are provided but feature is disabled, reject them
|
|
144
|
+
unless TokenAuthority.config.rfc_8707_enabled?
|
|
145
|
+
errors.add(:resources, :not_allowed)
|
|
146
|
+
return
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Validate all resource URIs
|
|
150
|
+
unless valid_resource_uris?
|
|
151
|
+
errors.add(:resources, :invalid_uri)
|
|
152
|
+
return
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Check against allowed resources list
|
|
156
|
+
unless allowed_resources?
|
|
157
|
+
errors.add(:resources, :not_allowed)
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Check that requested resources are a subset of granted resources (downscoping)
|
|
162
|
+
granted_resources = token_authority_authorization_grant.resources
|
|
163
|
+
errors.add(:resources, :not_subset) unless resources_subset_of?(granted_resources)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def scope_must_be_valid
|
|
167
|
+
return unless token_authority_authorization_grant_present?
|
|
168
|
+
return if scope.blank?
|
|
169
|
+
|
|
170
|
+
# If scopes are provided but feature is disabled, reject them
|
|
171
|
+
unless TokenAuthority.config.scopes_enabled?
|
|
172
|
+
errors.add(:scope, :not_allowed)
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Validate all scope tokens
|
|
177
|
+
unless valid_scope_tokens?
|
|
178
|
+
errors.add(:scope, :invalid)
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Check against allowed scopes list
|
|
183
|
+
unless allowed_scopes?
|
|
184
|
+
errors.add(:scope, :not_allowed)
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Check that requested scopes are a subset of granted scopes (downscoping)
|
|
189
|
+
granted_scopes = token_authority_authorization_grant.scopes || []
|
|
190
|
+
errors.add(:scope, :not_subset) unless scopes_subset_of?(granted_scopes)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
# Represents an OAuth authorization grant (authorization code).
|
|
5
|
+
#
|
|
6
|
+
# An authorization grant is created after a user consents to allow a client
|
|
7
|
+
# application access. It contains a one-time-use authorization code and PKCE
|
|
8
|
+
# challenge parameters for public clients.
|
|
9
|
+
#
|
|
10
|
+
# The grant can be associated with either:
|
|
11
|
+
# - A registered Client (stored in database)
|
|
12
|
+
# - A URL-based ClientMetadataDocument (fetched via client_id_url)
|
|
13
|
+
#
|
|
14
|
+
# Grants have a short expiration time (5 minutes by default) and can only
|
|
15
|
+
# be redeemed once. After redemption, they create a Session with access
|
|
16
|
+
# and refresh tokens.
|
|
17
|
+
#
|
|
18
|
+
# @example Redeeming a grant
|
|
19
|
+
# session = authorization_grant.redeem(
|
|
20
|
+
# resources: ["https://api.example.com"],
|
|
21
|
+
# scopes: ["read", "write"]
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
# @since 0.2.0
|
|
25
|
+
class AuthorizationGrant < ApplicationRecord
|
|
26
|
+
include TokenAuthority::SessionCreatable
|
|
27
|
+
|
|
28
|
+
VALID_CODE_CHALLENGE_METHODS = %w[S256].freeze
|
|
29
|
+
|
|
30
|
+
belongs_to :user, class_name: TokenAuthority.config.user_class
|
|
31
|
+
belongs_to :token_authority_client, class_name: "TokenAuthority::Client", optional: true
|
|
32
|
+
has_many :token_authority_sessions,
|
|
33
|
+
class_name: "TokenAuthority::Session",
|
|
34
|
+
foreign_key: :token_authority_authorization_grant_id,
|
|
35
|
+
inverse_of: :token_authority_authorization_grant,
|
|
36
|
+
dependent: :destroy
|
|
37
|
+
|
|
38
|
+
validates :code_challenge_method, inclusion: {in: VALID_CODE_CHALLENGE_METHODS}, allow_nil: true
|
|
39
|
+
validate :must_have_client_identifier
|
|
40
|
+
|
|
41
|
+
before_validation :generate_expires_at
|
|
42
|
+
before_create :generate_public_id
|
|
43
|
+
|
|
44
|
+
# Returns the client associated with this grant.
|
|
45
|
+
#
|
|
46
|
+
# Resolves to either a registered Client or a URL-based ClientMetadataDocument
|
|
47
|
+
# depending on which association is populated. URL-based clients are fetched
|
|
48
|
+
# and cached on first access.
|
|
49
|
+
#
|
|
50
|
+
# @return [TokenAuthority::Client, TokenAuthority::ClientMetadataDocument, nil]
|
|
51
|
+
# the client object, or nil if neither association exists
|
|
52
|
+
#
|
|
53
|
+
# @see TokenAuthority::ClientIdResolver
|
|
54
|
+
def resolved_client
|
|
55
|
+
return token_authority_client if token_authority_client.present?
|
|
56
|
+
return nil if client_id_url.blank?
|
|
57
|
+
|
|
58
|
+
@resolved_client ||= TokenAuthority::ClientIdResolver.resolve(client_id_url)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns the most recent active session for this grant.
|
|
62
|
+
#
|
|
63
|
+
# After a grant is redeemed, subsequent refresh operations create new sessions
|
|
64
|
+
# with "created" status while marking old sessions as "refreshed". This method
|
|
65
|
+
# returns the current active session.
|
|
66
|
+
#
|
|
67
|
+
# @return [TokenAuthority::Session, nil] the active session, or nil if none exists
|
|
68
|
+
def active_token_authority_session
|
|
69
|
+
token_authority_sessions.created_status.order(created_at: :desc).first
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Redeems the authorization code to create a new token session.
|
|
73
|
+
#
|
|
74
|
+
# This is the final step in the authorization code flow. The grant must not
|
|
75
|
+
# have been redeemed previously, and PKCE verification (if applicable) must
|
|
76
|
+
# pass before redemption succeeds.
|
|
77
|
+
#
|
|
78
|
+
# After successful redemption, the grant is marked as redeemed and a new
|
|
79
|
+
# Session is created with access and refresh tokens.
|
|
80
|
+
#
|
|
81
|
+
# @param resources [Array<String>] resource indicators for the token session
|
|
82
|
+
# @param scopes [Array<String>] scopes for the token session
|
|
83
|
+
#
|
|
84
|
+
# @return [TokenAuthority::Session] the newly created session
|
|
85
|
+
#
|
|
86
|
+
# @raise [TokenAuthority::ServerError] if session creation fails
|
|
87
|
+
#
|
|
88
|
+
# @example
|
|
89
|
+
# session = grant.redeem(
|
|
90
|
+
# resources: ["https://api.example.com"],
|
|
91
|
+
# scopes: ["read", "write"]
|
|
92
|
+
# )
|
|
93
|
+
def redeem(resources: [], scopes: [])
|
|
94
|
+
instrument("grant.redeem") do
|
|
95
|
+
create_token_authority_session(grant: self, resources:, scopes:) do
|
|
96
|
+
update(redeemed: true)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
rescue TokenAuthority::ServerError => error
|
|
100
|
+
raise TokenAuthority::ServerError, error.message
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def must_have_client_identifier
|
|
106
|
+
return if token_authority_client.present? || client_id_url.present?
|
|
107
|
+
|
|
108
|
+
errors.add(:base, :must_have_client_identifier)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def generate_expires_at
|
|
112
|
+
self.expires_at ||= 5.minutes.from_now
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def generate_public_id
|
|
116
|
+
self.public_id = SecureRandom.uuid
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
# Validates OAuth 2.1 authorization requests.
|
|
5
|
+
#
|
|
6
|
+
# This service object validates all parameters of an authorization request
|
|
7
|
+
# according to OAuth 2.1 specifications, including PKCE requirements, redirect
|
|
8
|
+
# URI validation, resource indicators (RFC 8707), and scope validation.
|
|
9
|
+
#
|
|
10
|
+
# It enforces different validation rules for public vs confidential clients:
|
|
11
|
+
# - Public clients MUST include PKCE parameters
|
|
12
|
+
# - Confidential clients MAY include PKCE parameters
|
|
13
|
+
# - Public clients MUST include redirect_uri
|
|
14
|
+
# - Confidential clients MAY omit redirect_uri if only one is registered
|
|
15
|
+
#
|
|
16
|
+
# After validation, the request can be serialized to an internal state token
|
|
17
|
+
# (JWT) for storage during the consent flow, then deserialized when processing
|
|
18
|
+
# the user's approval or denial.
|
|
19
|
+
#
|
|
20
|
+
# @example Validating an authorization request
|
|
21
|
+
# request = AuthorizationRequest.new(
|
|
22
|
+
# token_authority_client: client,
|
|
23
|
+
# client_id: "abc123",
|
|
24
|
+
# redirect_uri: "https://app.example.com/callback",
|
|
25
|
+
# response_type: "code",
|
|
26
|
+
# state: "xyz",
|
|
27
|
+
# code_challenge: "E9Melhoa...",
|
|
28
|
+
# code_challenge_method: "S256"
|
|
29
|
+
# )
|
|
30
|
+
# if request.valid?
|
|
31
|
+
# # Process authorization
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# @since 0.2.0
|
|
35
|
+
class AuthorizationRequest
|
|
36
|
+
include ActiveModel::Model
|
|
37
|
+
include ActiveModel::Validations
|
|
38
|
+
include TokenAuthority::Resourceable
|
|
39
|
+
include TokenAuthority::Scopeable
|
|
40
|
+
|
|
41
|
+
# Valid PKCE code challenge methods per OAuth 2.1.
|
|
42
|
+
# Only S256 (SHA-256) is supported; plain is not allowed.
|
|
43
|
+
VALID_CODE_CHALLENGE_METHODS = ["S256"].freeze
|
|
44
|
+
|
|
45
|
+
# Valid response types. Currently only "code" is supported.
|
|
46
|
+
VALID_RESPONSE_TYPES = ["code"].freeze
|
|
47
|
+
|
|
48
|
+
# @!attribute [rw] token_authority_client
|
|
49
|
+
# The client making the authorization request.
|
|
50
|
+
# @return [TokenAuthority::Client, TokenAuthority::ClientMetadataDocument]
|
|
51
|
+
attr_accessor :token_authority_client
|
|
52
|
+
|
|
53
|
+
# @!attribute [rw] client_id
|
|
54
|
+
# The client identifier.
|
|
55
|
+
# @return [String]
|
|
56
|
+
attr_accessor :client_id
|
|
57
|
+
|
|
58
|
+
# @!attribute [rw] code_challenge
|
|
59
|
+
# The PKCE code challenge.
|
|
60
|
+
# @return [String, nil]
|
|
61
|
+
attr_accessor :code_challenge
|
|
62
|
+
|
|
63
|
+
# @!attribute [rw] code_challenge_method
|
|
64
|
+
# The PKCE code challenge method (S256).
|
|
65
|
+
# @return [String, nil]
|
|
66
|
+
attr_accessor :code_challenge_method
|
|
67
|
+
|
|
68
|
+
# @!attribute [rw] redirect_uri
|
|
69
|
+
# The URI to redirect to after authorization.
|
|
70
|
+
# @return [String, nil]
|
|
71
|
+
attr_accessor :redirect_uri
|
|
72
|
+
|
|
73
|
+
# @!attribute [rw] response_type
|
|
74
|
+
# The OAuth response type (code).
|
|
75
|
+
# @return [String]
|
|
76
|
+
attr_accessor :response_type
|
|
77
|
+
|
|
78
|
+
# @!attribute [rw] state
|
|
79
|
+
# The state parameter for CSRF protection.
|
|
80
|
+
# @return [String, nil]
|
|
81
|
+
attr_accessor :state
|
|
82
|
+
|
|
83
|
+
validates :response_type, presence: true, inclusion: {in: VALID_RESPONSE_TYPES}
|
|
84
|
+
|
|
85
|
+
validate :token_authority_client_must_be_valid
|
|
86
|
+
validate :client_id_must_be_valid
|
|
87
|
+
validate :pkce_params_must_be_valid
|
|
88
|
+
validate :redirect_uri_must_be_valid
|
|
89
|
+
validate :resources_must_be_valid
|
|
90
|
+
validate :scope_must_be_valid
|
|
91
|
+
|
|
92
|
+
# Deserializes an authorization request from an internal state token.
|
|
93
|
+
#
|
|
94
|
+
# The state token is a JWT that preserves the authorization request parameters
|
|
95
|
+
# during the consent flow. This allows the request to survive redirects to
|
|
96
|
+
# the consent screen and back.
|
|
97
|
+
#
|
|
98
|
+
# @param token [String] the JWT state token
|
|
99
|
+
#
|
|
100
|
+
# @return [TokenAuthority::AuthorizationRequest] the deserialized request
|
|
101
|
+
#
|
|
102
|
+
# @note If the client cannot be resolved, token_authority_client will be nil
|
|
103
|
+
# and validation will fail.
|
|
104
|
+
def self.from_internal_state_token(token)
|
|
105
|
+
attributes = TokenAuthority::JsonWebToken.decode(token)
|
|
106
|
+
token_authority_client = TokenAuthority::ClientIdResolver.resolve(attributes[:token_authority_client])
|
|
107
|
+
new(
|
|
108
|
+
**attributes.except(:token_authority_client, :exp).merge(token_authority_client:)
|
|
109
|
+
)
|
|
110
|
+
rescue TokenAuthority::ClientNotFoundError
|
|
111
|
+
new(**attributes.except(:token_authority_client, :exp).merge(token_authority_client: nil))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Converts the authorization request to a hash for serialization.
|
|
115
|
+
#
|
|
116
|
+
# @return [Hash] the request parameters
|
|
117
|
+
def to_h
|
|
118
|
+
{
|
|
119
|
+
token_authority_client: token_authority_client.public_id,
|
|
120
|
+
client_id:,
|
|
121
|
+
state:,
|
|
122
|
+
code_challenge:,
|
|
123
|
+
code_challenge_method:,
|
|
124
|
+
redirect_uri:,
|
|
125
|
+
response_type:,
|
|
126
|
+
resources:,
|
|
127
|
+
scope:
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Serializes the authorization request to an internal state token.
|
|
132
|
+
#
|
|
133
|
+
# The state token is used to preserve authorization request parameters
|
|
134
|
+
# during the consent flow without storing them in the session.
|
|
135
|
+
#
|
|
136
|
+
# @return [String] the JWT state token
|
|
137
|
+
def to_internal_state_token
|
|
138
|
+
TokenAuthority::JsonWebToken.encode(to_h)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def token_authority_client_must_be_valid
|
|
144
|
+
errors.add(:token_authority_client, :invalid) unless valid_token_authority_client?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def client_id_must_be_valid
|
|
148
|
+
return unless valid_token_authority_client?
|
|
149
|
+
|
|
150
|
+
# For URL-based clients, client_id must match the URL
|
|
151
|
+
if token_authority_client.is_a?(TokenAuthority::ClientMetadataDocument)
|
|
152
|
+
errors.add(:client_id, :blank) and return if client_id.blank?
|
|
153
|
+
errors.add(:client_id, :mismatched) and return if client_id != token_authority_client.public_id
|
|
154
|
+
return
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# For registered clients
|
|
158
|
+
return if token_authority_client.confidential_client_type? && client_id.blank?
|
|
159
|
+
|
|
160
|
+
errors.add(:client_id, :blank) and return if client_id.blank?
|
|
161
|
+
|
|
162
|
+
client = TokenAuthority::Client.find_by(public_id: client_id)
|
|
163
|
+
errors.add(:client_id, :unregistered_client) unless client
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def pkce_params_must_be_valid
|
|
167
|
+
return unless valid_token_authority_client?
|
|
168
|
+
|
|
169
|
+
# URL-based clients are always public and must use PKCE
|
|
170
|
+
if token_authority_client.public_client_type?
|
|
171
|
+
validate_public_pkce_params
|
|
172
|
+
else
|
|
173
|
+
validate_confidential_pkce_params
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def validate_public_pkce_params
|
|
178
|
+
return unless valid_token_authority_client?
|
|
179
|
+
return unless token_authority_client.public_client_type?
|
|
180
|
+
|
|
181
|
+
errors.add(:code_challenge, :required_for_public_clients) if code_challenge.blank?
|
|
182
|
+
errors.add(:code_challenge_method, :required_for_public_clients) if code_challenge_method.blank?
|
|
183
|
+
errors.add(:code_challenge_method, :invalid) unless code_challenge_method.in?(VALID_CODE_CHALLENGE_METHODS)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def validate_confidential_pkce_params
|
|
187
|
+
return unless valid_token_authority_client?
|
|
188
|
+
return unless token_authority_client.confidential_client_type?
|
|
189
|
+
return unless code_challenge.present? || code_challenge_method.present?
|
|
190
|
+
|
|
191
|
+
errors.add(:code_challenge, :required_if_other_pkce_params_present) if code_challenge.blank?
|
|
192
|
+
errors.add(:code_challenge_method, :required_if_other_pkce_params_present) if code_challenge_method.blank?
|
|
193
|
+
errors.add(:code_challenge_method, :invalid) unless code_challenge_method.in?(VALID_CODE_CHALLENGE_METHODS)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def redirect_uri_must_be_valid
|
|
197
|
+
return unless valid_token_authority_client?
|
|
198
|
+
|
|
199
|
+
if token_authority_client.public_client_type?
|
|
200
|
+
validate_public_client_redirect_uri
|
|
201
|
+
else
|
|
202
|
+
validate_confidential_client_redirect_uri
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def validate_public_client_redirect_uri
|
|
207
|
+
errors.add(:redirect_uri, :blank) if redirect_uri.blank?
|
|
208
|
+
validate_redirect_uris_match
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def validate_confidential_client_redirect_uri
|
|
212
|
+
return if redirect_uri.blank?
|
|
213
|
+
|
|
214
|
+
validate_redirect_uris_match
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def validate_redirect_uris_match
|
|
218
|
+
errors.add(:redirect_uri, :invalid) unless token_authority_client.redirect_uri_registered?(redirect_uri)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def valid_token_authority_client?
|
|
222
|
+
token_authority_client.is_a?(TokenAuthority::Client) ||
|
|
223
|
+
token_authority_client.is_a?(TokenAuthority::ClientMetadataDocument)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def resources_must_be_valid
|
|
227
|
+
# Check if resource is required
|
|
228
|
+
if TokenAuthority.config.rfc_8707_require_resource && resources.empty?
|
|
229
|
+
errors.add(:resources, :required)
|
|
230
|
+
return
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
return if resources.empty?
|
|
234
|
+
|
|
235
|
+
# If resources are provided but feature is disabled, reject them
|
|
236
|
+
unless TokenAuthority.config.rfc_8707_enabled?
|
|
237
|
+
errors.add(:resources, :not_allowed)
|
|
238
|
+
return
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Validate all resource URIs
|
|
242
|
+
unless valid_resource_uris?
|
|
243
|
+
errors.add(:resources, :invalid_uri)
|
|
244
|
+
return
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Check against allowed resources list
|
|
248
|
+
errors.add(:resources, :not_allowed) unless allowed_resources?
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def scope_must_be_valid
|
|
252
|
+
# Check if scope is required
|
|
253
|
+
if TokenAuthority.config.require_scope && scope.blank?
|
|
254
|
+
errors.add(:scope, :required)
|
|
255
|
+
return
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
return if scope.blank?
|
|
259
|
+
|
|
260
|
+
# If scopes are provided but feature is disabled, reject them
|
|
261
|
+
unless TokenAuthority.config.scopes_enabled?
|
|
262
|
+
errors.add(:scope, :not_allowed)
|
|
263
|
+
return
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Validate all scope tokens
|
|
267
|
+
unless valid_scope_tokens?
|
|
268
|
+
errors.add(:scope, :invalid)
|
|
269
|
+
return
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Check against allowed scopes list
|
|
273
|
+
errors.add(:scope, :not_allowed) unless allowed_scopes?
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
# Builds OAuth 2.0 Authorization Server Metadata per RFC 8414.
|
|
5
|
+
#
|
|
6
|
+
# This class generates the metadata document that clients use to discover
|
|
7
|
+
# the authorization server's capabilities and endpoint locations. The metadata
|
|
8
|
+
# is typically served at /.well-known/oauth-authorization-server.
|
|
9
|
+
#
|
|
10
|
+
# The metadata includes:
|
|
11
|
+
# - Endpoint URLs (authorization, token, revocation, registration)
|
|
12
|
+
# - Supported grant types and response types
|
|
13
|
+
# - Supported token endpoint authentication methods
|
|
14
|
+
# - PKCE code challenge methods
|
|
15
|
+
# - Optional scopes and service documentation
|
|
16
|
+
#
|
|
17
|
+
# @example Building metadata
|
|
18
|
+
# metadata = AuthorizationServerMetadata.new(mount_path: "/oauth")
|
|
19
|
+
# metadata.to_h
|
|
20
|
+
# # => {
|
|
21
|
+
# # issuer: "https://example.com",
|
|
22
|
+
# # authorization_endpoint: "https://example.com/oauth/authorize",
|
|
23
|
+
# # ...
|
|
24
|
+
# # }
|
|
25
|
+
#
|
|
26
|
+
# @see https://www.rfc-editor.org/rfc/rfc8414.html RFC 8414: OAuth 2.0 Authorization Server Metadata
|
|
27
|
+
# @since 0.2.0
|
|
28
|
+
class AuthorizationServerMetadata
|
|
29
|
+
# Creates a new metadata builder.
|
|
30
|
+
#
|
|
31
|
+
# @param mount_path [String] the path where the OAuth engine is mounted
|
|
32
|
+
# (e.g., "/oauth")
|
|
33
|
+
def initialize(mount_path:)
|
|
34
|
+
@mount_path = mount_path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Converts the metadata to a hash for JSON serialization.
|
|
38
|
+
#
|
|
39
|
+
# Builds the complete metadata document including all required and optional
|
|
40
|
+
# fields based on the current configuration. The registration_endpoint is
|
|
41
|
+
# only included when dynamic client registration is enabled.
|
|
42
|
+
#
|
|
43
|
+
# @return [Hash] the authorization server metadata
|
|
44
|
+
#
|
|
45
|
+
# @example
|
|
46
|
+
# metadata = AuthorizationServerMetadata.new(mount_path: "/oauth")
|
|
47
|
+
# json = metadata.to_h.to_json
|
|
48
|
+
def to_h
|
|
49
|
+
metadata = {
|
|
50
|
+
issuer: issuer,
|
|
51
|
+
authorization_endpoint: "#{issuer}#{@mount_path}/authorize",
|
|
52
|
+
token_endpoint: "#{issuer}#{@mount_path}/token",
|
|
53
|
+
revocation_endpoint: "#{issuer}#{@mount_path}/revoke",
|
|
54
|
+
response_types_supported: ["code"],
|
|
55
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
56
|
+
token_endpoint_auth_methods_supported: token_endpoint_auth_methods_supported,
|
|
57
|
+
code_challenge_methods_supported: ["S256"]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
metadata[:scopes_supported] = scopes_supported if scopes_supported.any?
|
|
61
|
+
metadata[:service_documentation] = service_documentation if service_documentation.present?
|
|
62
|
+
|
|
63
|
+
# RFC 7591 Dynamic Client Registration
|
|
64
|
+
if TokenAuthority.config.rfc_7591_enabled
|
|
65
|
+
metadata[:registration_endpoint] = "#{issuer}#{@mount_path}/register"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
metadata
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Returns the issuer URL from configuration with trailing slashes removed.
|
|
74
|
+
# @return [String]
|
|
75
|
+
# @api private
|
|
76
|
+
def issuer
|
|
77
|
+
TokenAuthority.config.rfc_9068_issuer_url.to_s.chomp("/")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns the list of supported scopes from configuration.
|
|
81
|
+
# @return [Array<String>]
|
|
82
|
+
# @api private
|
|
83
|
+
def scopes_supported
|
|
84
|
+
TokenAuthority.config.scopes&.keys || []
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns the service documentation URL from configuration.
|
|
88
|
+
# @return [String, nil]
|
|
89
|
+
# @api private
|
|
90
|
+
def service_documentation
|
|
91
|
+
TokenAuthority.config.rfc_8414_service_documentation
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns the supported token endpoint authentication methods.
|
|
95
|
+
# @return [Array<String>]
|
|
96
|
+
# @api private
|
|
97
|
+
def token_endpoint_auth_methods_supported
|
|
98
|
+
TokenAuthority.config.rfc_7591_allowed_token_endpoint_auth_methods
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|