safire 0.1.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 +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +62 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +35 -0
- data/CODE_OF_CONDUCT.md +17 -0
- data/CONTRIBUTION.md +283 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +186 -0
- data/LICENSE +201 -0
- data/README.md +159 -0
- data/ROADMAP.md +54 -0
- data/Rakefile +26 -0
- data/docs/.gitignore +5 -0
- data/docs/404.html +25 -0
- data/docs/Gemfile +37 -0
- data/docs/Gemfile.lock +195 -0
- data/docs/_config.yml +103 -0
- data/docs/_includes/footer_custom.html +6 -0
- data/docs/_includes/head_custom.html +14 -0
- data/docs/_sass/custom/custom.scss +108 -0
- data/docs/adr/ADR-001-activesupport-dependency.md +50 -0
- data/docs/adr/ADR-002-facade-and-forwardable.md +79 -0
- data/docs/adr/ADR-003-protocol-vs-client-type.md +67 -0
- data/docs/adr/ADR-004-clientconfig-immutability-and-entity-masking.md +59 -0
- data/docs/adr/ADR-005-per-client-http-ownership.md +58 -0
- data/docs/adr/ADR-006-lazy-discovery.md +83 -0
- data/docs/adr/ADR-007-https-only-redirects-and-localhost-exception.md +59 -0
- data/docs/adr/ADR-008-warn-return-false-for-compliance-validation.md +74 -0
- data/docs/adr/index.md +22 -0
- data/docs/advanced.md +284 -0
- data/docs/configuration/client-setup.md +158 -0
- data/docs/configuration/index.md +60 -0
- data/docs/configuration/logging.md +86 -0
- data/docs/index.md +64 -0
- data/docs/installation.md +96 -0
- data/docs/security.md +256 -0
- data/docs/smart-on-fhir/confidential-asymmetric/authorization.md +72 -0
- data/docs/smart-on-fhir/confidential-asymmetric/index.md +162 -0
- data/docs/smart-on-fhir/confidential-asymmetric/token-exchange.md +250 -0
- data/docs/smart-on-fhir/confidential-symmetric/authorization.md +75 -0
- data/docs/smart-on-fhir/confidential-symmetric/index.md +69 -0
- data/docs/smart-on-fhir/confidential-symmetric/token-exchange.md +215 -0
- data/docs/smart-on-fhir/discovery/capability-checks.md +142 -0
- data/docs/smart-on-fhir/discovery/index.md +96 -0
- data/docs/smart-on-fhir/discovery/metadata.md +147 -0
- data/docs/smart-on-fhir/index.md +72 -0
- data/docs/smart-on-fhir/post-based-authorization.md +190 -0
- data/docs/smart-on-fhir/public-client/authorization.md +112 -0
- data/docs/smart-on-fhir/public-client/index.md +80 -0
- data/docs/smart-on-fhir/public-client/token-exchange.md +249 -0
- data/docs/troubleshooting/auth-errors.md +124 -0
- data/docs/troubleshooting/client-errors.md +130 -0
- data/docs/troubleshooting/index.md +99 -0
- data/docs/troubleshooting/token-errors.md +99 -0
- data/docs/udap.md +78 -0
- data/lib/safire/client.rb +195 -0
- data/lib/safire/client_config.rb +169 -0
- data/lib/safire/client_config_builder.rb +72 -0
- data/lib/safire/entity.rb +26 -0
- data/lib/safire/errors.rb +247 -0
- data/lib/safire/http_client.rb +87 -0
- data/lib/safire/jwt_assertion.rb +237 -0
- data/lib/safire/middleware/https_only_redirects.rb +39 -0
- data/lib/safire/pkce.rb +39 -0
- data/lib/safire/protocols/behaviours.rb +54 -0
- data/lib/safire/protocols/smart.rb +378 -0
- data/lib/safire/protocols/smart_metadata.rb +231 -0
- data/lib/safire/version.rb +4 -0
- data/lib/safire.rb +54 -0
- data/safire.gemspec +36 -0
- metadata +184 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
require 'uri'
|
|
3
|
+
|
|
4
|
+
module Safire
|
|
5
|
+
module Middleware
|
|
6
|
+
# Faraday middleware that blocks redirects to non-HTTPS URLs.
|
|
7
|
+
#
|
|
8
|
+
# Sits inside the follow_redirects middleware's app stack so it sees every
|
|
9
|
+
# intermediate 3xx response before the redirect is followed. HTTP redirects
|
|
10
|
+
# to localhost/127.0.0.1 are allowed (consistent with ClientConfig's
|
|
11
|
+
# localhost exception for local development).
|
|
12
|
+
class HttpsOnlyRedirects < Faraday::Middleware
|
|
13
|
+
LOCALHOST = %w[localhost 127.0.0.1].freeze
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
@app.call(env).on_complete do |response_env|
|
|
17
|
+
check_redirect_safety!(response_env)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def check_redirect_safety!(env)
|
|
24
|
+
return unless (300..308).cover?(env.status)
|
|
25
|
+
|
|
26
|
+
location = env.response_headers['location']
|
|
27
|
+
return unless location
|
|
28
|
+
|
|
29
|
+
uri = URI.parse(location)
|
|
30
|
+
return if uri.scheme == 'https'
|
|
31
|
+
return if LOCALHOST.include?(uri.host)
|
|
32
|
+
|
|
33
|
+
raise Safire::Errors::NetworkError.new(
|
|
34
|
+
error_description: "Redirect to non-HTTPS URL blocked: #{location}"
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/safire/pkce.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Safire
|
|
2
|
+
# PKCE (Proof Key for Code Exchange) implementation
|
|
3
|
+
# This class generates a code verifier and corresponding code challenge for use in OAuth2 authorization flows.
|
|
4
|
+
# It supports the S256 code challenge method.
|
|
5
|
+
# @see https://datatracker.ietf.org/doc/html/rfc7636
|
|
6
|
+
class PKCE
|
|
7
|
+
class << self
|
|
8
|
+
def generate_code_verifier
|
|
9
|
+
# Using 96 bytes will produce a 128-character URL-safe base64 string which is the max length allowed
|
|
10
|
+
SecureRandom.urlsafe_base64(96).tr('=', '')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Generates a code challenge from the given code verifier using SHA256 and base64url encoding
|
|
14
|
+
# @param code_verifier [String] the code verifier
|
|
15
|
+
# @return [String] the generated code challenge
|
|
16
|
+
# @raise [ArgumentError] if the code verifier is invalid
|
|
17
|
+
def generate_code_challenge(code_verifier)
|
|
18
|
+
validate_verifier(code_verifier)
|
|
19
|
+
|
|
20
|
+
digest = Digest::SHA256.digest(code_verifier)
|
|
21
|
+
Base64.urlsafe_encode64(digest).tr('=', '')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def validate_verifier(code_verifier)
|
|
27
|
+
length = code_verifier.length
|
|
28
|
+
unless length.between?(43, 128)
|
|
29
|
+
raise ArgumentError, "Code verifier must be between 43 and 128 characters long, got #{length}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# RFC 7636: unreserved characters only
|
|
33
|
+
return if code_verifier.match?(/\A[A-Za-z0-9\-._~]+\z/)
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, 'Code verifier contains invalid characters. Only unreserved characters are allowed.'
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Safire
|
|
2
|
+
module Protocols
|
|
3
|
+
# Abstract contract that all Safire protocol implementations must satisfy.
|
|
4
|
+
#
|
|
5
|
+
# Include this module in a protocol class to declare conformance with the
|
|
6
|
+
# Safire protocol interface. Each method raises +NotImplementedError+ by
|
|
7
|
+
# default; concrete protocol classes must override every method.
|
|
8
|
+
#
|
|
9
|
+
# @abstract
|
|
10
|
+
# @api private
|
|
11
|
+
module Behaviours
|
|
12
|
+
# Returns protocol-specific server metadata from discovery.
|
|
13
|
+
# @abstract
|
|
14
|
+
def server_metadata(...)
|
|
15
|
+
raise NotImplementedError, "#{self.class}#server_metadata is not implemented"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Builds the authorization request URL/data.
|
|
19
|
+
# @abstract
|
|
20
|
+
def authorization_url(...)
|
|
21
|
+
raise NotImplementedError, "#{self.class}#authorization_url is not implemented"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Exchanges an authorization code for an access token.
|
|
25
|
+
# @abstract
|
|
26
|
+
def request_access_token(...)
|
|
27
|
+
raise NotImplementedError, "#{self.class}#request_access_token is not implemented"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Exchanges a refresh token for a new access token.
|
|
31
|
+
# @abstract
|
|
32
|
+
def refresh_token(...)
|
|
33
|
+
raise NotImplementedError, "#{self.class}#refresh_token is not implemented"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Validates a token response for compliance with this protocol's specification.
|
|
37
|
+
# @abstract
|
|
38
|
+
def token_response_valid?(...)
|
|
39
|
+
raise NotImplementedError, "#{self.class}#token_response_valid? is not implemented"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Dynamically registers this client with the authorization server (RFC 7591).
|
|
43
|
+
#
|
|
44
|
+
# SMART App Launch 2.2.0 encourages implementers to consider the OAuth 2.0
|
|
45
|
+
# Dynamic Client Registration Protocol for an out-of-the-box solution.
|
|
46
|
+
# Implementations should override this method when registration is supported.
|
|
47
|
+
#
|
|
48
|
+
# @abstract
|
|
49
|
+
def register_client(...)
|
|
50
|
+
raise NotImplementedError, "#{self.class}#register_client is not implemented"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
module Safire
|
|
2
|
+
module Protocols
|
|
3
|
+
# SMART on FHIR OAuth2 implementation for authorization code, access token, and refresh token flows.
|
|
4
|
+
#
|
|
5
|
+
# This is an internal class used exclusively by {Safire::Client}. Do not instantiate it directly —
|
|
6
|
+
# use {Safire::Client} instead.
|
|
7
|
+
#
|
|
8
|
+
# Accepts a {Safire::ClientConfig} and a +client_type+ symbol. Reads all configuration
|
|
9
|
+
# attributes directly from the +ClientConfig+ object. Discovery of authorization and token
|
|
10
|
+
# endpoints from the FHIR server's +/.well-known/smart-configuration+ metadata is performed
|
|
11
|
+
# automatically when those endpoints are not present in the config.
|
|
12
|
+
#
|
|
13
|
+
# @note For internal use by {Safire::Client} only.
|
|
14
|
+
# @api private
|
|
15
|
+
#
|
|
16
|
+
# @raise [Safire::Errors::ConfigurationError]
|
|
17
|
+
# if required configuration attributes are missing or invalid
|
|
18
|
+
class Smart
|
|
19
|
+
include Behaviours
|
|
20
|
+
|
|
21
|
+
ATTRIBUTES = %i[
|
|
22
|
+
base_url client_id client_secret redirect_uri scopes issuer
|
|
23
|
+
authorization_endpoint token_endpoint
|
|
24
|
+
private_key kid jwt_algorithm jwks_uri
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# Attributes that are not required during validation
|
|
28
|
+
OPTIONAL_ATTRIBUTES = %i[scopes client_secret private_key kid jwt_algorithm jwks_uri].freeze
|
|
29
|
+
|
|
30
|
+
WELL_KNOWN_PATH = '/.well-known/smart-configuration'.freeze
|
|
31
|
+
|
|
32
|
+
attr_reader(*ATTRIBUTES)
|
|
33
|
+
attr_accessor :client_type
|
|
34
|
+
|
|
35
|
+
# @api private
|
|
36
|
+
def initialize(config, client_type: :public)
|
|
37
|
+
ATTRIBUTES.each { |attr| instance_variable_set("@#{attr}", config.public_send(attr)) }
|
|
38
|
+
|
|
39
|
+
@client_type = client_type.to_sym
|
|
40
|
+
@http_client = Safire::HTTPClient.new
|
|
41
|
+
@issuer ||= base_url
|
|
42
|
+
|
|
43
|
+
validate!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def authorization_endpoint
|
|
47
|
+
@authorization_endpoint ||= server_metadata.authorization_endpoint
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def token_endpoint
|
|
51
|
+
@token_endpoint ||= server_metadata.token_endpoint
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Retrieves and parses SMART on FHIR configuration metadata from the FHIR server.
|
|
55
|
+
#
|
|
56
|
+
# This method sends a GET request to the server's
|
|
57
|
+
# +/.well-known/smart-configuration+ endpoint, validates the response format,
|
|
58
|
+
# and builds a {Safire::Protocols::SmartMetadata} object containing the
|
|
59
|
+
# authorization and token endpoints, among other SMART metadata fields.
|
|
60
|
+
#
|
|
61
|
+
# The result is cached after the first successful discovery and reused on
|
|
62
|
+
# subsequent calls within the same instance.
|
|
63
|
+
#
|
|
64
|
+
# @return [Safire::Protocols::SmartMetadata]
|
|
65
|
+
# Parsed SMART configuration metadata object.
|
|
66
|
+
# @raise [Safire::Errors::DiscoveryError]
|
|
67
|
+
# If the discovery request fails or the response is not a valid JSON object.
|
|
68
|
+
def server_metadata
|
|
69
|
+
return @server_metadata if @server_metadata
|
|
70
|
+
|
|
71
|
+
response = @http_client.get(well_known_endpoint)
|
|
72
|
+
@server_metadata = SmartMetadata.new(parse_discovery_body(response.body))
|
|
73
|
+
rescue Faraday::Error => e
|
|
74
|
+
status = e.response&.dig(:status)
|
|
75
|
+
Safire.logger.error("SMART discovery failed for `#{well_known_endpoint}`: HTTP #{status}")
|
|
76
|
+
raise Errors::DiscoveryError.new(endpoint: well_known_endpoint, status: status)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Builds the authorization request data for the authorization code flow.
|
|
80
|
+
#
|
|
81
|
+
# @param launch [String, nil] optional launch parameter
|
|
82
|
+
# @param custom_scopes [Array<String>, nil] optional custom scopes to override the configured ones
|
|
83
|
+
# @param method [Symbol, String] authorization request method; +:get+ (default) or +:post+.
|
|
84
|
+
# Both symbol and string forms are accepted (e.g. +method: :post+ or +method: 'post'+).
|
|
85
|
+
# * +:get+ — builds a redirect URL with all parameters in the query string (standard flow)
|
|
86
|
+
# * +:post+ — returns the endpoint and parameters separately for POST-based authorization
|
|
87
|
+
# (SMART App Launch 2.2.0 +authorize-post+ capability)
|
|
88
|
+
# @return [Hash] containing:
|
|
89
|
+
# * :auth_url [String] authorization URL (GET) or bare endpoint URL (POST)
|
|
90
|
+
# * :state [String] state parameter for CSRF protection; store and verify on callback
|
|
91
|
+
# * :code_verifier [String] PKCE code verifier for the token exchange
|
|
92
|
+
# * :params [Hash] (POST only) authorization parameters to submit as the request body
|
|
93
|
+
# @raise [Errors::ConfigurationError] if no scopes are configured or if method is invalid
|
|
94
|
+
def authorization_url(launch: nil, custom_scopes: nil, method: :get)
|
|
95
|
+
method = method.to_sym
|
|
96
|
+
validate_presence_of_scopes(custom_scopes)
|
|
97
|
+
validate_authorization_method(method)
|
|
98
|
+
|
|
99
|
+
Safire.logger.info("Generating authorization URL for SMART #{client_type} (method: #{method})...")
|
|
100
|
+
|
|
101
|
+
code_verifier = PKCE.generate_code_verifier
|
|
102
|
+
params = authorization_params(launch:, custom_scopes:, code_verifier:)
|
|
103
|
+
|
|
104
|
+
build_authorization_response(method, params, code_verifier)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Exchanges the authorization code for an access token.
|
|
108
|
+
#
|
|
109
|
+
# @param code [String] authorization code from the authorization server
|
|
110
|
+
# @param code_verifier [String] PKCE code verifier from the authorization step
|
|
111
|
+
# @param client_secret [String, nil] optional; used for confidential symmetric clients when not already configured
|
|
112
|
+
# @param private_key [OpenSSL::PKey, String, nil] optional; private key for asymmetric auth (overrides configured)
|
|
113
|
+
# @param kid [String, nil] optional; key ID for asymmetric auth (overrides configured)
|
|
114
|
+
# @return [Hash] token response parsed from the authorization server, including:
|
|
115
|
+
# * "access_token" [String] new access token issued by the authorization server (required)
|
|
116
|
+
# * "token_type" [String] token type, fixed value "bearer" (required)
|
|
117
|
+
# * "expires_in" [Integer] lifetime of the access token in seconds (required)
|
|
118
|
+
# * "scope" [String] authorized scopes for this token (required)
|
|
119
|
+
# * "refresh_token" [String] refresh token, if issued (optional)
|
|
120
|
+
# * "authorization_details" [Hash] additional authorization details, if provided (optional)
|
|
121
|
+
# * Context parameters such as "patient" or "encounter" MAY be present, depending on server behavior.
|
|
122
|
+
# @raise [Safire::Errors::TokenError] if the request fails or response is invalid.
|
|
123
|
+
def request_access_token(code:, code_verifier:, client_secret: self.client_secret,
|
|
124
|
+
private_key: self.private_key, kid: self.kid)
|
|
125
|
+
Safire.logger.info('Requesting access token using authorization code...')
|
|
126
|
+
|
|
127
|
+
response = @http_client.post(
|
|
128
|
+
token_endpoint,
|
|
129
|
+
body: access_token_params(code, code_verifier, private_key:, kid:),
|
|
130
|
+
headers: oauth2_headers(client_secret)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
parse_token_response(response.body)
|
|
134
|
+
rescue Faraday::Error => e
|
|
135
|
+
raise token_error_from(e)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Exchanges a refresh token for a new access token.
|
|
139
|
+
#
|
|
140
|
+
# @param refresh_token [String] the refresh token issued by the authorization server (required)
|
|
141
|
+
# @param scopes [Array<String>, nil] optional reduced scope list;
|
|
142
|
+
# if omitted, the same scopes as the original token are requested
|
|
143
|
+
# @param client_secret [String, nil] optional; used for confidential symmetric clients when not already configured
|
|
144
|
+
# @param private_key [OpenSSL::PKey, String, nil] optional; private key for asymmetric auth (overrides configured)
|
|
145
|
+
# @param kid [String, nil] optional; key ID for asymmetric auth (overrides configured)
|
|
146
|
+
# @return [Hash] token response parsed from the authorization server.
|
|
147
|
+
# See {#request_access_token} for token response format.
|
|
148
|
+
# @raise [Safire::Errors::TokenError] if the refresh request fails or the response is invalid.
|
|
149
|
+
def refresh_token(refresh_token:, scopes: nil, client_secret: self.client_secret,
|
|
150
|
+
private_key: self.private_key, kid: self.kid)
|
|
151
|
+
Safire.logger.info('Refreshing access token...')
|
|
152
|
+
|
|
153
|
+
response = @http_client.post(
|
|
154
|
+
token_endpoint,
|
|
155
|
+
body: refresh_token_params(refresh_token:, scopes:, private_key:, kid:),
|
|
156
|
+
headers: oauth2_headers(client_secret)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
parse_token_response(response.body)
|
|
160
|
+
rescue Faraday::Error => e
|
|
161
|
+
raise token_error_from(e)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Validates a token response for SMART App Launch 2.2.0 compliance.
|
|
165
|
+
#
|
|
166
|
+
# Checks all required token response fields per SMART App Launch 2.2.0 §Token Response:
|
|
167
|
+
# - +access_token+ must be present (SHALL)
|
|
168
|
+
# - +token_type+ must be present and exactly +"Bearer"+ (SHALL, case-sensitive)
|
|
169
|
+
# - +scope+ must be present (SHALL)
|
|
170
|
+
#
|
|
171
|
+
# Logs a warning via {Safire.logger} for each violation found and returns false.
|
|
172
|
+
# Never raises an exception.
|
|
173
|
+
#
|
|
174
|
+
# @param response [Hash] the token response returned by the server
|
|
175
|
+
# @return [Boolean] true if the response is compliant, false otherwise
|
|
176
|
+
def token_response_valid?(response)
|
|
177
|
+
unless response.is_a?(Hash)
|
|
178
|
+
Safire.logger.warn('SMART token response non-compliance: response is not a JSON object')
|
|
179
|
+
return false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
valid = true
|
|
183
|
+
|
|
184
|
+
%w[access_token scope].each do |field|
|
|
185
|
+
next if response[field].present?
|
|
186
|
+
|
|
187
|
+
Safire.logger.warn(
|
|
188
|
+
"SMART token response non-compliance: required field '#{field}' is missing"
|
|
189
|
+
)
|
|
190
|
+
valid = false
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
token_type_valid?(response) && valid
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def validate!
|
|
199
|
+
missing = (ATTRIBUTES - OPTIONAL_ATTRIBUTES).select { |attr| send(attr).blank? }
|
|
200
|
+
return if missing.empty?
|
|
201
|
+
|
|
202
|
+
raise Errors::ConfigurationError.new(missing_attributes: missing)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def validate_authorization_method(method)
|
|
206
|
+
return if %i[get post].include?(method)
|
|
207
|
+
|
|
208
|
+
raise Errors::ConfigurationError.new(
|
|
209
|
+
invalid_attribute: :method,
|
|
210
|
+
invalid_value: method,
|
|
211
|
+
valid_values: %i[get post]
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def build_authorization_response(method, params, code_verifier)
|
|
216
|
+
if method == :post
|
|
217
|
+
{ auth_url: authorization_endpoint, params:, state: params[:state], code_verifier: }
|
|
218
|
+
else
|
|
219
|
+
uri = Addressable::URI.parse(authorization_endpoint)
|
|
220
|
+
uri.query_values = params
|
|
221
|
+
{ auth_url: uri.to_s, state: uri.query_values['state'], code_verifier: }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def validate_presence_of_scopes(custom_scopes = nil)
|
|
226
|
+
return if (scopes || custom_scopes).present?
|
|
227
|
+
|
|
228
|
+
raise Errors::ConfigurationError.new(missing_attributes: [:scopes])
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def validate_client_secret(secret)
|
|
232
|
+
return if secret.present?
|
|
233
|
+
|
|
234
|
+
raise Errors::ConfigurationError.new(missing_attributes: [:client_secret])
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def parse_discovery_body(body)
|
|
238
|
+
return body if body.is_a?(Hash)
|
|
239
|
+
|
|
240
|
+
raise Errors::DiscoveryError.new(
|
|
241
|
+
endpoint: well_known_endpoint,
|
|
242
|
+
error_description: 'response is not a JSON object'
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def parse_token_response(token_response)
|
|
247
|
+
unless token_response.is_a?(Hash)
|
|
248
|
+
raise Errors::TokenError.new(error_description: 'response is not a JSON object')
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
return token_response if token_response['access_token'].present?
|
|
252
|
+
|
|
253
|
+
raise Errors::TokenError.new(received_fields: token_response.keys)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def token_type_valid?(response)
|
|
257
|
+
if response['token_type'].blank?
|
|
258
|
+
Safire.logger.warn(
|
|
259
|
+
"SMART token response non-compliance: required field 'token_type' is missing"
|
|
260
|
+
)
|
|
261
|
+
return false
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
return true if response['token_type'] == 'Bearer'
|
|
265
|
+
|
|
266
|
+
Safire.logger.warn(
|
|
267
|
+
"SMART token response non-compliance: token_type is #{response['token_type'].inspect}; " \
|
|
268
|
+
"expected 'Bearer' (SMART App Launch 2.2.0 requires token_type \"Bearer\")"
|
|
269
|
+
)
|
|
270
|
+
false
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def authorization_params(launch:, custom_scopes:, code_verifier:)
|
|
274
|
+
{
|
|
275
|
+
response_type: 'code',
|
|
276
|
+
client_id:,
|
|
277
|
+
redirect_uri:,
|
|
278
|
+
launch:,
|
|
279
|
+
scope: [custom_scopes || scopes].flatten.join(' '),
|
|
280
|
+
state: SecureRandom.hex(16),
|
|
281
|
+
aud: issuer.to_s,
|
|
282
|
+
code_challenge_method: 'S256',
|
|
283
|
+
code_challenge: PKCE.generate_code_challenge(code_verifier)
|
|
284
|
+
}.compact
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def access_token_params(code, code_verifier, private_key:, kid:)
|
|
288
|
+
{
|
|
289
|
+
grant_type: 'authorization_code',
|
|
290
|
+
code:,
|
|
291
|
+
redirect_uri:,
|
|
292
|
+
code_verifier:
|
|
293
|
+
}.merge(client_auth_params(private_key:, kid:))
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def refresh_token_params(refresh_token:, scopes:, private_key:, kid:)
|
|
297
|
+
params = {
|
|
298
|
+
grant_type: 'refresh_token',
|
|
299
|
+
refresh_token:
|
|
300
|
+
}
|
|
301
|
+
params[:scope] = [scopes].flatten.join(' ') if scopes.present?
|
|
302
|
+
params.merge(client_auth_params(private_key:, kid:))
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def client_auth_params(private_key:, kid:)
|
|
306
|
+
case client_type
|
|
307
|
+
when :public
|
|
308
|
+
{ client_id: client_id }
|
|
309
|
+
when :confidential_asymmetric
|
|
310
|
+
jwt_assertion_params(private_key:, kid:)
|
|
311
|
+
else
|
|
312
|
+
{}
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def oauth2_headers(secret)
|
|
317
|
+
headers = { content_type: 'application/x-www-form-urlencoded' }
|
|
318
|
+
|
|
319
|
+
if client_type == :confidential_symmetric
|
|
320
|
+
headers[:Authorization] = authentication_header(secret.presence || client_secret)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
headers
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def authentication_header(secret)
|
|
327
|
+
validate_client_secret(secret)
|
|
328
|
+
|
|
329
|
+
"Basic #{Base64.strict_encode64("#{client_id}:#{secret}")}"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def jwt_assertion_params(private_key:, kid:)
|
|
333
|
+
validate_asymmetric_credentials!(private_key, kid)
|
|
334
|
+
|
|
335
|
+
assertion = Safire::JWTAssertion.new(
|
|
336
|
+
client_id: client_id,
|
|
337
|
+
token_endpoint: token_endpoint,
|
|
338
|
+
private_key: private_key,
|
|
339
|
+
kid: kid,
|
|
340
|
+
algorithm: jwt_algorithm,
|
|
341
|
+
jku: jwks_uri
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
{
|
|
345
|
+
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
|
346
|
+
client_assertion: assertion.to_jwt
|
|
347
|
+
}
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def validate_asymmetric_credentials!(private_key, kid)
|
|
351
|
+
missing = []
|
|
352
|
+
missing << :private_key if private_key.blank?
|
|
353
|
+
missing << :kid if kid.blank?
|
|
354
|
+
return if missing.empty?
|
|
355
|
+
|
|
356
|
+
raise Errors::ConfigurationError.new(missing_attributes: missing)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def token_error_from(faraday_error)
|
|
360
|
+
response = faraday_error.response
|
|
361
|
+
status = response&.dig(:status)
|
|
362
|
+
body = JSON.parse(response&.dig(:body))
|
|
363
|
+
|
|
364
|
+
Errors::TokenError.new(
|
|
365
|
+
status:,
|
|
366
|
+
error_code: body.is_a?(Hash) ? body['error'] : nil,
|
|
367
|
+
error_description: body.is_a?(Hash) ? body['error_description'] : nil
|
|
368
|
+
)
|
|
369
|
+
rescue JSON::ParserError
|
|
370
|
+
Errors::TokenError.new(status:)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def well_known_endpoint
|
|
374
|
+
"#{base_url.to_s.chomp('/')}#{WELL_KNOWN_PATH}"
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|