padlock_auth 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,211 @@
1
+ require "padlock_auth/config/option"
2
+ require "padlock_auth/config/scopes"
3
+
4
+ module PadlockAuth
5
+ # Configuration for PadlockAuth.
6
+ #
7
+ # @example
8
+ # PadlockAuth.configure do |config|
9
+ # config.secure_with :token do
10
+ # secret_key "my_secret_key"
11
+ # end
12
+ #
13
+ # config.default_scopes :read, :write
14
+ # config.access_token_methods :from_bearer_authorization, :from_access_token_param
15
+ # config.raise_on_errors!
16
+ # end
17
+ #
18
+ class Config
19
+ include PadlockAuth::Mixins::BuildWith
20
+
21
+ # The configuration builder for `PadlockAuth::Config`.
22
+ #
23
+ # @see PadlockAuth::Utils::AbstractBuilder
24
+ #
25
+ class Builder < PadlockAuth::Utils::AbstractBuilder
26
+ # Configure the strategy to use for authentication.
27
+ #
28
+ # Strategies are responsible for building access tokens and authenticating them.
29
+ # PadlockAuth comes with a default strategy, `PadlockAuth::Token::Strategy`,
30
+ # which uses a shared secret key to build and authenticate access tokens.
31
+ #
32
+ # A strategy can be provided as:
33
+ # - an instance of `PadlockAuth::AbstractStrategy`, or one matching its interface,
34
+ # - a class that responds to `.build` and returns an instance of `PadlockAuth::AbstractStrategy`, or
35
+ # - a string or symbol representing a built-in strategy (e.g. `:token`)
36
+ #
37
+ # The string or symbol strategy will be resolved to a class in the `PadlockAuth` namespace
38
+ # by appending `::Strategy` to the string and looking up the constant in the `PadlockAuth`
39
+ # namespace. For example, `:token` will resolve to `PadlockAuth::Token::Strategy`.
40
+ #
41
+ # You can define your own strategy by subclassing `PadlockAuth::AbstractStrategy`,
42
+ # and passing an instance of your strategy to `secure_with`, or by using the naming convention
43
+ # and passing a string or symbol.
44
+ #
45
+ # @param strategy [PadlockAuth::AbstractStrategy, Class, String, Symbol] The strategy to use for authentication
46
+ #
47
+ # @yield A block to configure the strategy (yielded by the strategy's `build` method)
48
+ #
49
+ # @return [PadlockAuth::AbstractStrategy] The strategy instance
50
+ def secure_with(strategy, &)
51
+ strategy = case strategy
52
+ when String, Symbol
53
+ strategy_klass = "PadlockAuth::#{strategy.to_s.camelize}::Strategy".safe_constantize
54
+ raise ArgumentError, "unknown strategy: #{strategy}" unless strategy_klass
55
+ strategy_klass.build(&)
56
+ when Class
57
+ strategy.build(&)
58
+ else
59
+ strategy
60
+ end
61
+ config.instance_variable_set(:@strategy, strategy)
62
+ end
63
+
64
+ # Define default access token scopes for your endpoint.
65
+ #
66
+ # Scopes are used to limit access to certain parts of your API. When a token
67
+ # is created, it is assigned a set of scopes that define what it can access.
68
+ #
69
+ # Calls to `padlock_authorize!` will check that the token has the required scopes,
70
+ # if no scopes are provided, the default scopes will be used.
71
+ #
72
+ # @param scopes [Array] Default set of access (PadlockAuth::Config::Scopes.new)
73
+ # token scopes
74
+ #
75
+ def default_scopes(*scopes)
76
+ config.instance_variable_set(:@default_scopes, PadlockAuth::Config::Scopes.from_array(*scopes))
77
+ end
78
+
79
+ # Change the way access token is authenticated from the request object.
80
+ #
81
+ # By default it retrieves a Bearer token from the `HTTP_AUTHORIZATION` header, then
82
+ # falls back to the `:access_token` or `:bearer_token` params from the
83
+ # `params` object.
84
+ #
85
+ # Available methods:
86
+ # - `:from_bearer_authorization` - Extracts a Bearer token from the `HTTP_AUTHORIZATION` header
87
+ # - `:from_access_token_param` - Extracts the token from the `:access_token` param
88
+ # - `:from_bearer_param` - Extracts the token from the `:bearer_token` param
89
+ # - `:from_basic_authorization` - Extracts Basic Auth credentials from the `HTTP_AUTHORIZATION` header
90
+ #
91
+ # @param methods [Array<Symbol>] Define access token methods, in order of preference
92
+ #
93
+ def access_token_methods(*methods)
94
+ config.instance_variable_set(:@access_token_methods, methods.flatten.compact)
95
+ end
96
+
97
+ # Calls to `padlock_authorize!` will raise an exception when authentication fails.
98
+ #
99
+ def raise_on_errors!
100
+ handle_auth_errors(:raise)
101
+ end
102
+
103
+ # Calls to `padlock_authorize!` will render an error response when authentication fails.
104
+ #
105
+ # This is the default behavior.
106
+ #
107
+ def render_on_errors!
108
+ handle_auth_errors(:render)
109
+ end
110
+ end
111
+
112
+ # @!method build(&)
113
+ # @!scope class
114
+ #
115
+ # Builds the configuration instance using the builder.
116
+ #
117
+ # @yield block to configure the configuration
118
+ #
119
+ # @return [PadlockAuth::Config] the configuration instance
120
+ #
121
+ # @see PadlockAuth::Config::Builder
122
+ build_with Builder
123
+
124
+ extend PadlockAuth::Config::Option
125
+
126
+ # @!attribute [r] realm
127
+ #
128
+ # The strategy to use for authentication.
129
+ #
130
+ # @return [PadlockAuth::AbstractStrategy] The authentication strategy
131
+ #
132
+ attr_reader :strategy
133
+
134
+ # @!attribute [r] realm
135
+ #
136
+ # WWW-Authenticate Realm (default "PadlockAuth").
137
+ #
138
+ # @return [String] The Authentication realm
139
+ #
140
+ option :realm, default: "PadlockAuth"
141
+
142
+ # @!attribute [r] default_scopes
143
+ #
144
+ # Default required scopes, used whenever `padlock_authorize!` is called without arguments.
145
+ #
146
+ # Empty by default.
147
+ #
148
+ # @return [PadlockAuth::Config::Scopes] Default required scopes
149
+ #
150
+ def default_scopes
151
+ @default_scopes ||= PadlockAuth::Config::Scopes.new
152
+ end
153
+
154
+ # @!attribute [r] access_token_methods
155
+ #
156
+ # Methods to extract the access token from the request.
157
+ #
158
+ # @see PadlockAuth::Rails::TokenExtractor for available methods
159
+ #
160
+ # @return [Array<Symbol>] Methods to extract the access token
161
+ #
162
+ def access_token_methods
163
+ @access_token_methods ||= %i[
164
+ from_bearer_authorization
165
+ from_access_token_param
166
+ from_bearer_param
167
+ ]
168
+ end
169
+
170
+ # @!attribute [r] handle_auth_errors
171
+ #
172
+ # How to handle authentication errors.
173
+ #
174
+ # - `:raise` - Raise an exception when authentication fails
175
+ # - `:render` - Render an error response when authentication fails
176
+ #
177
+ # @return [Symbol] The error handling method
178
+ #
179
+ option :handle_auth_errors, default: :render
180
+
181
+ # @return [Boolean] Whether to render an error response when authentication fails
182
+ #
183
+ def render_on_errors?
184
+ handle_auth_errors == :render
185
+ end
186
+
187
+ # @return [Boolean] Whether to raise an exception when authentication fails
188
+ #
189
+ def raise_on_errors?
190
+ handle_auth_errors == :raise
191
+ end
192
+
193
+ # @api private
194
+ #
195
+ # Called by PadlockAuth::Utils::AbstractBuilder#build to validate the configuration.
196
+ #
197
+ # @raise [ArgumentError] If the configuration is invalid
198
+ #
199
+ def validate!
200
+ raise ArgumentError, "strategy has not been configured via secure_with" if strategy.nil?
201
+
202
+ raise ArgumentError, "realm is required" if realm.blank?
203
+
204
+ unless handle_auth_errors.in? %i[raise render]
205
+ raise ArgumentError, "handle_auth_errors must be :raise, or :render"
206
+ end
207
+
208
+ true
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PadlockAuth
4
+ # Errors for PadlockAuth.
5
+ module Errors
6
+ # A generic error for PadlockAuth.
7
+ class PadlockAuthError < StandardError; end
8
+
9
+ # An error with a HTTP response.
10
+ class ResponseError < PadlockAuthError
11
+ attr_reader :response
12
+
13
+ # Initialize a new response error.
14
+ #
15
+ # @param response [PadlockAuth::Http::ErrorResponse] The response
16
+ def initialize(response)
17
+ @response = response
18
+ end
19
+ end
20
+
21
+ # The token is invalid.
22
+ class InvalidToken < ResponseError; end
23
+
24
+ # The token has expired.
25
+ class TokenExpired < InvalidToken; end
26
+
27
+ # The token has been revoked.
28
+ class TokenRevoked < InvalidToken; end
29
+
30
+ # The token is invalid for an unknown reason.
31
+ class TokenUnknown < InvalidToken; end
32
+
33
+ # The token is forbidden for the requested resource.
34
+ class TokenForbidden < InvalidToken; end
35
+ end
36
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PadlockAuth
4
+ module Http
5
+ ##
6
+ # A generic error response for PadlockAuth.
7
+ #
8
+ # This class is intended to be extended by specific error responses.
9
+ #
10
+ class ErrorResponse
11
+ # @param attributes [Hash] error attributes
12
+ #
13
+ def initialize(attributes = {})
14
+ @name = attributes[:name]
15
+ @status = attributes[:status] || :bad_request
16
+ end
17
+
18
+ # @return [Symbol] The error name
19
+ attr_reader :name
20
+
21
+ # @return [Symbol] The HTTP status code
22
+ attr_reader :status
23
+
24
+ # @!attribute [r] description
25
+ # @return [String] A human-readable description of the error
26
+ def description
27
+ I18n.translate(
28
+ name,
29
+ scope: %i[padlock_auth errors messages],
30
+ default: :server_error
31
+ )
32
+ end
33
+
34
+ # @return [Hash] JSON response body
35
+ #
36
+ def body
37
+ {
38
+ error: name,
39
+ error_description: description
40
+ }.reject { |_, v| v.blank? }
41
+ end
42
+
43
+ # @return [Hash] HTTP headers
44
+ #
45
+ def headers
46
+ {
47
+ "Cache-Control" => "no-store, no-cache",
48
+ "Content-Type" => "application/json; charset=utf-8",
49
+ "WWW-Authenticate" => authenticate_info
50
+ }
51
+ end
52
+
53
+ # Raise an exception based on the error response.
54
+ #
55
+ # @raise [PadlockAuth::Errors::ResponseError] with the error response
56
+ def raise_exception!
57
+ raise exception_class.new(self), description
58
+ end
59
+
60
+ protected
61
+
62
+ # @return [Class<ResponseError>] Exception class to raise
63
+ #
64
+ def exception_class
65
+ raise NotImplementedError, "error response must define #exception_class"
66
+ end
67
+
68
+ private
69
+
70
+ def authenticate_info
71
+ %(Bearer realm="#{PadlockAuth.config.realm}", error="#{name}", error_description="#{description}")
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PadlockAuth
4
+ module Http
5
+ ##
6
+ # A response for a forbidden token.
7
+ #
8
+ # A forbidden token response is returned when a token is valid,
9
+ # but does not have the required scopes.
10
+ #
11
+ class ForbiddenTokenResponse < ErrorResponse
12
+ attr_reader :reason
13
+
14
+ ##
15
+ # Create a new forbidden token response from an access token.
16
+ #
17
+ # @param access_token [PadlockAuth::AbstractAccessToken] Access token
18
+ #
19
+ # @param scopes [Array<String>] Required scopes
20
+ #
21
+ # @param attributes [Hash] Additional attributes
22
+ #
23
+ def self.from_access_token(access_token, scopes, attributes = {})
24
+ new(attributes.merge(reason: access_token&.forbidden_token_reason, scopes: scopes))
25
+ end
26
+
27
+ # Create a new forbidden token response.
28
+ #
29
+ # @param attributes [Hash] Attributes
30
+ #
31
+ def initialize(attributes = {})
32
+ super(attributes.merge(name: :invalid_scope, status: :forbidden))
33
+ @reason = attributes[:reason] || :unknown
34
+ @scopes = attributes[:scopes]
35
+ end
36
+
37
+ # @!attribute [r] description
38
+ # @return [String] A translated description of the error
39
+ #
40
+ def description
41
+ @description ||=
42
+ I18n.translate(
43
+ @reason,
44
+ scope: %i[padlock_auth errors messages forbidden_token],
45
+ oauth_scopes: @scopes.map(&:to_s).join(" "),
46
+ default: :unknown
47
+ )
48
+ end
49
+
50
+ # @return [Hash] HTTP headers
51
+ #
52
+ def headers
53
+ headers = super
54
+ headers.delete "WWW-Authenticate" # Authentication was successful, so no need to display auth error info
55
+ headers
56
+ end
57
+
58
+ protected
59
+
60
+ # @return [Class<PadlockAuth::Errors::TokenForbidden>] Exception class
61
+ #
62
+ def exception_class
63
+ PadlockAuth::Errors::TokenForbidden
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PadlockAuth
4
+ module Http
5
+ ##
6
+ # A response for an invalid token.
7
+ #
8
+ # An invalid token response is returned when a token is invalid.
9
+ #
10
+ class InvalidTokenResponse < ErrorResponse
11
+ # Reason for the invalid token.
12
+ #
13
+ # Possible reasons are:
14
+ # - :revoked
15
+ # - :expired
16
+ # - :unknown
17
+ #
18
+ # @return [Symbol] The reason for the invalid token
19
+ attr_reader :reason
20
+
21
+ # Create a new invalid token response from an access token.
22
+ #
23
+ # @param access_token [PadlockAuth::AbstractAccessToken] Access token
24
+ #
25
+ # @param attributes [Hash] Additional attributes
26
+ #
27
+ def self.from_access_token(access_token, attributes = {})
28
+ new(attributes.merge(reason: access_token&.invalid_token_reason))
29
+ end
30
+
31
+ # Create a new invalid token response.
32
+ #
33
+ # @param attributes [Hash] Attributes
34
+ #
35
+ def initialize(attributes = {})
36
+ super(attributes.merge(name: :invalid_grant, status: :unauthorized))
37
+ @reason = attributes[:reason] || :unknown
38
+ end
39
+
40
+ # @!attribute [r] description
41
+ # @return [String] A translated description of the error
42
+ #
43
+ def description
44
+ @description ||=
45
+ I18n.translate(
46
+ reason,
47
+ scope: %i[padlock_auth errors messages invalid_token],
48
+ default: :unknown
49
+ )
50
+ end
51
+
52
+ protected
53
+
54
+ # Return the exception class for the error response.
55
+ #
56
+ # Depending on the reason, a different exception class will be raised.
57
+ #
58
+ # Defaults to `PadlockAuth::Errors::TokenUnknown`.
59
+ #
60
+ # @return [Class<InvalidToken>] Exception class
61
+ #
62
+ def exception_class
63
+ {
64
+ revoked: PadlockAuth::Errors::TokenRevoked,
65
+ expired: PadlockAuth::Errors::TokenExpired
66
+ }.fetch(reason, PadlockAuth::Errors::TokenUnknown)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,49 @@
1
+ module PadlockAuth
2
+ module Mixins
3
+ # Provides a quick way to build configuration instances.
4
+ #
5
+ # Provide `PadlockAuth::Utils::AbstractBuilder` sub-class to the `build_with` class method,
6
+ # to define a build method that will take a block to configure the instance.
7
+ #
8
+ # @example
9
+ # class MyConfig
10
+ # include PadlockAuth::Mixins::BuildWith
11
+ #
12
+ # class Builder < PadlockAuth::Utils::AbstractBuilder
13
+ # def name(value)
14
+ # config.instance_variable_set(:@name, value)
15
+ # end
16
+ # end
17
+ #
18
+ # build_with Builder
19
+ # end
20
+ #
21
+ # config = MyConfig.build do
22
+ # name 'My Name'
23
+ # end
24
+ # config.name # => 'My Name'
25
+ #
26
+ module BuildWith
27
+ extend ActiveSupport::Concern
28
+
29
+ included do
30
+ # Prevent direct instantiation of the class
31
+ private_class_method :new
32
+ end
33
+
34
+ class_methods do
35
+ # Define a builder class for the configuration.
36
+ #
37
+ # The builder class should accept a call to `new` with a new instance of the
38
+ # configuration class, and a block to configure the instance.
39
+ #
40
+ # @yield block to configure the builder class
41
+ #
42
+ def build_with(builder_class)
43
+ mattr_reader(:builder_class) { builder_class }
44
+ define_singleton_method(:build) { |&block| builder_class.new(new, &block).build }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,31 @@
1
+ module PadlockAuth
2
+ module Mixins
3
+ # Hide an attribute from inspection and JSON serialization.
4
+ #
5
+ # Prevents accidental exposure of sensitive data in logs or responses.
6
+ #
7
+ module HideAttribute
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # Hide an attribute from inspection and JSON serialization.
12
+ #
13
+ # @param attribute [Symbol, String] The attribute to hide
14
+ #
15
+ def hide_attribute(attribute)
16
+ mod = Module.new
17
+ mod.define_method(:inspect) do
18
+ super().gsub(instance_variable_get(:"@#{attribute}"), "[REDACTED]")
19
+ end
20
+ mod.define_method(:as_json) do |options = nil|
21
+ options ||= {}
22
+ options[:except] ||= []
23
+ options[:except] |= [attribute.to_s]
24
+ super(options)
25
+ end
26
+ include mod
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,142 @@
1
+ module PadlockAuth
2
+ module Rails
3
+ ##
4
+ # Helpers for Rails controllers.
5
+ #
6
+ # Provides `padlock_authorize!` method to controllers.
7
+ module Helpers
8
+ protected
9
+
10
+ # @!visibility public
11
+ #
12
+ # Authorize the request with the given scopes.
13
+ #
14
+ # If the request is not authorized, an error response will be rendered
15
+ # or an exception will be raised, depending on the configuration.
16
+ #
17
+ # @param scopes [Array<String>] Scopes required for the request, defaults to the default scopes.
18
+ #
19
+ def padlock_authorize!(*scopes)
20
+ @_padlock_auth_scopes = scopes.presence || PadlockAuth.config.default_scopes
21
+
22
+ padlock_render_error unless valid_padlock_auth_token?
23
+ end
24
+
25
+ # Default render options for unauthorized requests.
26
+ #
27
+ # As the OAuth 2.0 specification does not explicitly define the error response
28
+ # for an invalid request to a protected resource, this method replicates the
29
+ # error response for an invalid request access token request.
30
+ #
31
+ # @example
32
+ # {
33
+ # error: "invalid_grant",
34
+ # error_description: "The access token is invalid."
35
+ # }
36
+ #
37
+ # @see https://datatracker.ietf.org/doc/html/rfc6749#section-7.2
38
+ #
39
+ # @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
40
+ #
41
+ # @param error [PadlockAuth::Http::InvalidTokenResponse] Invalid grant response.
42
+ #
43
+ # @return [Hash] Render options, passed to `render`
44
+ #
45
+ def padlock_auth_unauthorized_render_options(error:, **)
46
+ {json: error.body, status: error.status}
47
+ end
48
+
49
+ # Default render options for forbidden requests.
50
+ #
51
+ # As the OAuth 2.0 specification does not explicitly define the error response
52
+ # for an invalid request to a protected resource, this method replicates the
53
+ # error response for an invalid request access token request.
54
+ #
55
+ # @example
56
+ # {
57
+ # error: "invalid_scope",
58
+ # error_description: 'Access to this resource requires scope "foo bar baz".'
59
+ # }
60
+ #
61
+ # @see https://datatracker.ietf.org/doc/html/rfc6749#section-7.2
62
+ #
63
+ # @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
64
+ #
65
+ # @param error [PadlockAuth::Http::ForbiddenTokenResponse] Invalid scope response
66
+ #
67
+ # @return [Hash] Render options, passed to `render`
68
+ #
69
+ def padlock_auth_forbidden_render_options(error:, **)
70
+ {json: error.body, status: error.status}
71
+ end
72
+
73
+ # @!visibility public
74
+ #
75
+ # Retrieve the access token from the request.
76
+ #
77
+ # Does not check if the token is valid or matches the required scopes.
78
+ #
79
+ # @return [PadlockAuth::AbstractToken, nil] Access token
80
+ #
81
+ def padlock_auth_token
82
+ @padlock_auth_token ||= TokenFactory.authenticate(request)
83
+ end
84
+
85
+ private
86
+
87
+ def valid_padlock_auth_token?
88
+ padlock_auth_token&.acceptable?(@_padlock_auth_scopes)
89
+ end
90
+
91
+ def padlock_render_error
92
+ error = padlock_auth_error
93
+ error.raise_exception! if PadlockAuth.config.raise_on_errors?
94
+
95
+ headers.merge!(error.headers.reject { |k| k == "Content-Type" })
96
+ padlock_render_error_with(error)
97
+ end
98
+
99
+ def padlock_render_error_with(error)
100
+ options = padlock_auth_render_options(error) || {}
101
+ status = padlock_auth_status_for_error(
102
+ error, options.delete(:respond_not_found_when_forbidden)
103
+ )
104
+ if options.blank? || !respond_to?(:render)
105
+ head status
106
+ else
107
+ options[:status] = status
108
+ options[:layout] = false if options[:layout].nil?
109
+ render options
110
+ end
111
+ end
112
+
113
+ def padlock_auth_error
114
+ if padlock_auth_invalid_token_response?
115
+ PadlockAuth.build_invalid_token_response(padlock_auth_token)
116
+ else
117
+ PadlockAuth.build_forbidden_token_response(padlock_auth_token, @_padlock_auth_scopes)
118
+ end
119
+ end
120
+
121
+ def padlock_auth_render_options(error)
122
+ if padlock_auth_invalid_token_response?
123
+ padlock_auth_unauthorized_render_options(error: error)
124
+ else
125
+ padlock_auth_forbidden_render_options(error: error)
126
+ end
127
+ end
128
+
129
+ def padlock_auth_status_for_error(error, respond_not_found_when_forbidden)
130
+ if respond_not_found_when_forbidden && error.status == :forbidden
131
+ :not_found
132
+ else
133
+ error.status
134
+ end
135
+ end
136
+
137
+ def padlock_auth_invalid_token_response?
138
+ !padlock_auth_token || !padlock_auth_token.accessible?
139
+ end
140
+ end
141
+ end
142
+ end