padlock_auth 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.
@@ -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