Skip to content

Commit ce9aeaa

Browse files
authored
Endpoints auth (#328)
1 parent 2e67b41 commit ce9aeaa

48 files changed

Lines changed: 705 additions & 276 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

gems/smithy-client/lib/smithy-client.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@
5050

5151
# identity and auth
5252

53+
require_relative 'smithy-client/auth'
54+
require_relative 'smithy-client/auth_scheme'
5355
require_relative 'smithy-client/identity_provider'
56+
require_relative 'smithy-client/null_signer'
5457
require_relative 'smithy-client/refreshing_identity_provider'
5558

5659
# stubbing
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
module Smithy
4+
module Client
5+
# Signs requests with the ApiKey identity.
6+
class ApiKeySigner
7+
def initialize(options = {})
8+
@name = options[:name]
9+
@in = options[:in]
10+
@scheme = options[:scheme]
11+
end
12+
13+
def sign_request(context)
14+
case @in
15+
when 'header' then sign_in_header(context.http_request, context.config.api_key_provider)
16+
when 'query' then sign_in_query_param(context.http_request, context.config.api_key_provider)
17+
end
18+
end
19+
20+
def presign_url(_context)
21+
raise NotImplementedError
22+
end
23+
24+
private
25+
26+
def sign_in_header(http_request, provider)
27+
http_request.headers.delete(@name)
28+
value = "#{@scheme} #{provider.identity.key}".strip
29+
http_request.headers[@name] = value
30+
end
31+
32+
def sign_in_query_param(http_request, provider)
33+
remove_query_param(http_request)
34+
append_query_param(http_request, provider)
35+
end
36+
37+
def append_query_param(http_request, provider)
38+
value = provider.identity.key
39+
if http_request.endpoint.query
40+
http_request.endpoint.query += "&#{@name}=#{value}"
41+
else
42+
http_request.endpoint.query = "#{@name}=#{value}"
43+
end
44+
end
45+
46+
def remove_query_param(http_request)
47+
return unless http_request.endpoint.query
48+
49+
parsed = CGI.parse(http_request.endpoint.query)
50+
parsed.delete(@name)
51+
# encode_www_form ignores query params without values
52+
# (CGI parses these as empty lists)
53+
parsed.each do |key, values|
54+
parsed[key] = values.empty? ? nil : values
55+
end
56+
http_request.endpoint.query = URI.encode_www_form(parsed)
57+
end
58+
end
59+
end
60+
end
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# frozen_string_literal: true
2+
3+
module Smithy
4+
module Client
5+
# The result of resolving the authentication scheme for a request.
6+
ResolvedAuth = Struct.new(:scheme_id, :signer, :signer_properties, :identity_provider, keyword_init: true)
7+
8+
# @api private
9+
module Auth
10+
class << self
11+
def resolve(context, endpoint_properties = {})
12+
if endpoint_properties.key?('authSchemes')
13+
resolve_with_endpoint_auth(context.config, endpoint_properties['authSchemes'])
14+
else
15+
auth_parameters = context.client.class.auth_parameters.create(context)
16+
resolve_without_endpoint_auth(context.config, auth_parameters)
17+
end
18+
end
19+
20+
private
21+
22+
def resolve_with_endpoint_auth(config, endpoint_auth_schemes)
23+
endpoint_auth_schemes_map = config.endpoint_auth_schemes
24+
normalized_endpoint_schemes = []
25+
endpoint_auth_schemes.each do |scheme|
26+
normalized_scheme_id = endpoint_auth_schemes_map[scheme['name']]
27+
next unless normalized_scheme_id
28+
29+
properties = {}
30+
scheme.each do |key, value|
31+
next if key == 'name'
32+
33+
properties[key] = value
34+
end
35+
normalized_endpoint_schemes << { scheme_id: normalized_scheme_id, signer_properties: properties }
36+
end
37+
resolved_auth_options = prioritize_auth_options(normalized_endpoint_schemes, config.auth_scheme_preference)
38+
resolve_auth_scheme(config.auth_schemes, resolved_auth_options)
39+
end
40+
41+
def resolve_without_endpoint_auth(config, auth_parameters)
42+
auth_options = config.auth_resolver.resolve(auth_parameters)
43+
resolved_auth_options = prioritize_auth_options(auth_options, config.auth_scheme_preference)
44+
resolve_auth_scheme(config.auth_schemes, resolved_auth_options)
45+
end
46+
47+
def prioritize_auth_options(auth_options, auth_scheme_preference)
48+
return auth_options if auth_scheme_preference.empty?
49+
50+
auth_options_by_id = {}
51+
auth_options.each do |option|
52+
auth_options_by_id[option[:scheme_id]] = option
53+
end
54+
55+
preferred_options = []
56+
auth_scheme_preference.each do |scheme_id|
57+
option = auth_options_by_id[scheme_id]
58+
next unless option
59+
60+
preferred_options << option
61+
end
62+
63+
preferred_options.empty? ? auth_options : preferred_options
64+
end
65+
66+
def resolve_auth_scheme(auth_schemes, auth_options) # rubocop:disable Metrics/MethodLength
67+
raise 'No auth options were resolved' if auth_options.empty?
68+
69+
failures = []
70+
auth_options.each do |auth_option|
71+
scheme_id = auth_option[:scheme_id]
72+
if scheme_id == 'smithy.api#noAuth'
73+
return ResolvedAuth.new(
74+
scheme_id: 'smithy.api#noAuth', signer: NullSigner.new, signer_properties: {},
75+
identity_provider: nil
76+
)
77+
end
78+
auth_scheme = auth_schemes[scheme_id]
79+
error = validate_auth_scheme(auth_scheme, scheme_id)
80+
unless error
81+
return ResolvedAuth.new(
82+
scheme_id: scheme_id,
83+
signer: auth_scheme.signer,
84+
signer_properties: auth_option[:signer_properties] || {},
85+
identity_provider: auth_scheme.identity_provider
86+
)
87+
end
88+
89+
failures << error
90+
end
91+
92+
raise failures.join("\n")
93+
end
94+
95+
def validate_auth_scheme(auth_scheme, scheme_id)
96+
return "Auth scheme #{scheme_id} was not enabled for this request" unless auth_scheme
97+
98+
identity_provider = auth_scheme.identity_provider
99+
return "Auth scheme #{scheme_id} did not have an identity provider configured" unless identity_provider
100+
return "Auth scheme #{scheme_id} failed to resolve identity" unless identity_provider.set?
101+
102+
nil
103+
end
104+
end
105+
end
106+
end
107+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
module Smithy
4+
module Client
5+
# Contains information about configured authentication schemes.
6+
class AuthScheme
7+
def initialize(options = {})
8+
@scheme_id = options[:scheme_id]
9+
@identity_provider = options[:identity_provider]
10+
@signer = options[:signer]
11+
end
12+
13+
# @return [String]
14+
attr_reader :scheme_id
15+
16+
# @return [IdentityProvider]
17+
attr_reader :identity_provider
18+
19+
# @return [Signer]
20+
attr_reader :signer
21+
end
22+
end
23+
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
module Smithy
4+
module Client
5+
# Signs requests with the BearerToken identity.
6+
class BearerTokenSigner
7+
def sign_request(context)
8+
context.http_request.headers.delete('Authorization')
9+
provider = context.config.bearer_token_provider
10+
context.http_request.headers['Authorization'] = "Bearer #{provider.identity.token}"
11+
end
12+
13+
def presign_url(_context)
14+
raise NotImplementedError
15+
end
16+
end
17+
end
18+
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
module Smithy
4+
module Client
5+
# Signs requests with the Login identity.
6+
class LoginSigner
7+
def initialize(options = {})
8+
@scheme_id = options[:scheme_id]
9+
end
10+
11+
def sign_request(context)
12+
raise NotImplementedError unless @scheme_id == 'smithy.api#httpBasicAuth'
13+
14+
sign_with_basic(context.http_request, context.config.login_provider)
15+
end
16+
17+
def presign_url(_context)
18+
raise NotImplementedError
19+
end
20+
21+
private
22+
23+
def sign_with_basic(http_request, provider)
24+
http_request.headers.delete('Authorization')
25+
identity = provider.identity
26+
encoded = Base64.strict_encode64("#{identity.username}:#{identity.password}")
27+
http_request.headers['Authorization'] = "Basic #{encoded}"
28+
end
29+
end
30+
end
31+
end
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
module Smithy
4+
module Client
5+
# A signer that does not sign requests. Used for anonymous authentication.
6+
class NullSigner
7+
def sign_request(_context)
8+
# no-op
9+
end
10+
11+
def presign_url(_context)
12+
# no-op
13+
end
14+
end
15+
end
16+
end

gems/smithy-client/lib/smithy-client/plugins/http_api_key_auth.rb

Lines changed: 10 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require_relative '../api_key'
44
require_relative '../api_key_provider'
5+
require_relative '../api_key_signer'
56

67
module Smithy
78
module Client
@@ -27,64 +28,18 @@ class that responds to #identity and returns a {Smithy::Client::ApiKey}.
2728
provider if provider.set?
2829
end
2930

30-
def after_initialize(client)
31-
client.config.auth_schemes['smithy.api#httpApiKeyAuth'] = client.config.api_key_provider
31+
option(:api_key_signer) do |config|
32+
trait = config.service.traits['smithy.api#httpApiKeyAuth']
33+
ApiKeySigner.new(name: trait['name'], in: trait['in'], scheme: trait['scheme'])
3234
end
3335

34-
# @api private
35-
class Handler < Client::Handler
36-
def call(context)
37-
sign(context) if context.auth[:scheme_id] == 'smithy.api#httpApiKeyAuth'
38-
@handler.call(context)
39-
end
40-
41-
private
42-
43-
def sign(context)
44-
properties = context.config.service.traits['smithy.api#httpApiKeyAuth']
45-
http_request = context.http_request
46-
identity = context.config.api_key_provider.identity
47-
case properties['in']
48-
when 'header' then sign_in_header(properties, http_request, identity)
49-
when 'query' then sign_in_query_param(properties, http_request, identity)
50-
end
51-
end
52-
53-
def sign_in_header(properties, http_request, identity)
54-
http_request.headers.delete(properties['name'])
55-
value = "#{properties['scheme']} #{identity.key}".strip
56-
http_request.headers[properties['name']] = value
57-
end
58-
59-
def sign_in_query_param(properties, http_request, identity)
60-
name = properties['name']
61-
remove_query_param(http_request, name)
62-
append_query_param(http_request, name, identity.key)
63-
end
64-
65-
def append_query_param(request, name, value)
66-
if request.endpoint.query
67-
request.endpoint.query += "&#{name}=#{value}"
68-
else
69-
request.endpoint.query = "#{name}=#{value}"
70-
end
71-
end
72-
73-
def remove_query_param(request, name)
74-
return unless request.endpoint.query
75-
76-
parsed = CGI.parse(request.endpoint.query)
77-
parsed.delete(name)
78-
# encode_www_form ignores query params without values
79-
# (CGI parses these as empty lists)
80-
parsed.each do |key, values|
81-
parsed[key] = values.empty? ? nil : values
82-
end
83-
request.endpoint.query = URI.encode_www_form(parsed)
84-
end
36+
def after_initialize(client)
37+
client.config.auth_schemes['smithy.api#httpApiKeyAuth'] = AuthScheme.new(
38+
identity_provider: client.config.api_key_provider,
39+
scheme_id: 'smithy.api#httpApiKeyAuth',
40+
signer: client.config.api_key_signer
41+
)
8542
end
86-
87-
handler(Handler, step: :sign)
8843
end
8944
end
9045
end

0 commit comments

Comments
 (0)