steppe 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,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Steppe
4
+ module Auth
5
+ # HTTP Basic authentication security scheme.
6
+ # Validates username and password credentials from the Authorization header against a credentials store.
7
+ #
8
+ # @example Using a simple hash store
9
+ # store = {
10
+ # 'joe' => 'secret123',
11
+ # 'anna' => 'password456'
12
+ # }
13
+ # basic_auth = Steppe::Auth::Basic.new('my_auth', store: store)
14
+ #
15
+ # # In a service definition:
16
+ # api.security_scheme basic_auth
17
+ #
18
+ # # Or use the shortcut
19
+ # api.basic_auth 'my_auth', store: { 'joe' => 'secret123' }
20
+ #
21
+ # # Then in an endpoint in the service:
22
+ # e.security 'my_auth'
23
+ #
24
+ # @example Using a custom credentials store
25
+ # class DatabaseCredentialsStore
26
+ # def lookup(username)
27
+ # user = User.find_by(username: username)
28
+ # user&.password_digest
29
+ # end
30
+ # end
31
+ #
32
+ # store = DatabaseCredentialsStore.new
33
+ # api.basic_auth 'my_auth', store: store
34
+ #
35
+ class Basic
36
+ include Responses
37
+
38
+ SCHEME = 'basic'
39
+ EXP = /^Basic\s+([A-Za-z0-9+\/=]+)\s*$/
40
+
41
+ # Interface for custom credentials store implementations.
42
+ # Required methods:
43
+ # - lookup(username): Returns the password for the given username, or nil if not found
44
+ CredentialsStoreInterface = Types::Interface[:lookup]
45
+
46
+ # Simple hash-based credentials store implementation.
47
+ # Stores username/password pairs in memory.
48
+ #
49
+ # @example
50
+ # store = SimpleUserPasswordStore.new({
51
+ # 'joe' => 'secret123',
52
+ # 'anna' => 'password456'
53
+ # })
54
+ class SimpleUserPasswordStore
55
+ # Interface for hash-based credentials stores (Hash[String => String]).
56
+ # Maps username strings to password strings.
57
+ HashInterface = Types::Hash[String, String]
58
+
59
+ # @param hash [Hash] Hash mapping usernames to passwords
60
+ def initialize(hash)
61
+ @lookup = hash
62
+ end
63
+
64
+ # Retrieve a password by username.
65
+ #
66
+ # @param username [String] The username to look up
67
+ # @return [String, nil] The password or nil if not found
68
+ def lookup(username) = @lookup[username.strip]
69
+ end
70
+
71
+ attr_reader :name
72
+
73
+ # @param name [String] The security scheme name (used in OpenAPI)
74
+ # @param store [CredentialsStoreInterface] Credentials store for validating username/password pairs
75
+ def initialize(name, store:)
76
+ @name = name
77
+ @scheme = SCHEME
78
+ @store = case store
79
+ when SimpleUserPasswordStore::HashInterface
80
+ SimpleUserPasswordStore.new(store)
81
+ when CredentialsStoreInterface
82
+ store
83
+ else
84
+ raise ArgumentError, "expected a CredentialsStoreInterface interface #{CredentialsStoreInterface}, but got #{store.inspect}"
85
+ end
86
+ end
87
+
88
+ # Handle authentication for a connection.
89
+ # Validates the Basic credentials from the Authorization header and checks username/password match.
90
+ #
91
+ # @param conn [Steppe::Result] The connection/result object
92
+ # @param _required_scopes [nil] Unused parameter (Basic auth does not support scopes)
93
+ # @return [Steppe::Result::Continue, Steppe::Result::Halt] The connection, or halted with 401/403 status
94
+ def handle(conn, _required_scopes = nil)
95
+ auth_str = conn.request.env[HTTP_AUTHORIZATION]
96
+ return unauthorized(conn) if auth_str.nil?
97
+
98
+ match = auth_str.match(EXP)
99
+ return unauthorized(conn) if match.nil?
100
+
101
+ username, password = decode(match[1])
102
+ return forbidden(conn) if @store.lookup(username) != password
103
+
104
+ conn
105
+ end
106
+
107
+ # Convert this security scheme to OpenAPI 3.0 format.
108
+ #
109
+ # @return [Hash] OpenAPI security scheme object
110
+ def to_openapi
111
+ {
112
+ 'type' => 'http',
113
+ 'scheme' => scheme
114
+ }
115
+ end
116
+
117
+ private
118
+
119
+ attr_reader :scheme
120
+
121
+ # Decode Base64-encoded Basic authentication credentials.
122
+ #
123
+ # @param auth_str [String] The Base64-encoded credentials string
124
+ # @return [Array<String>] Array containing [username, password]
125
+ def decode(auth_str)
126
+ auth_str.to_s.unpack1('m').split(':', 2)
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Steppe
4
+ module Auth
5
+ # HTTP Bearer token authentication security scheme.
6
+ # Validates Bearer tokens from the Authorization header and checks permissions against a token store.
7
+ #
8
+ # @example
9
+ # store = Steppe::Auth::HashTokenStore.new({
10
+ # 'token123' => ['read:users', 'write:users']
11
+ # })
12
+ # bearer = Steppe::Auth::Bearer.new('my_auth', store: store)
13
+ #
14
+ # # In a service definition:
15
+ # api.security_scheme bearer
16
+ #
17
+ # # Or use the shortcut
18
+ # api.bearer_auth 'my_auth', store: { 'token123' => ['read:users'] }
19
+ #
20
+ # # Then in an endpoint in the service:
21
+ # e.security 'my_auth', ['read:users']
22
+ #
23
+ class Bearer
24
+ include Responses
25
+
26
+ # Interface for custom token store implementations.
27
+ # Required methods:
28
+ # - get(token): Returns an access token object or nil
29
+ TokenStoreInterface = Types::Interface[:get]
30
+
31
+ # Simple hash-based token store implementation.
32
+ # Stores tokens in memory with their associated scopes.
33
+ #
34
+ # @example
35
+ # store = HashTokenStore.new({
36
+ # 'abc123' => ['read:users', 'write:users'],
37
+ # 'xyz789' => ['read:posts']
38
+ # })
39
+ class HashTokenStore
40
+ # Interface for hash-based token stores (Hash[String => Array[String]]).
41
+ # Maps token strings to arrays of scope strings.
42
+ Interface = Types::Hash[String, Types::Array[String]]
43
+
44
+ # Represents an access token with associated permission scopes.
45
+ class AccessToken < Data.define(:scopes)
46
+ # Check if this token has any of the required scopes.
47
+ #
48
+ # @param required_scopes [Array<String>] Scopes to check against
49
+ # @return [Boolean] True if token has at least one required scope
50
+ def allows?(required_scopes)
51
+ (scopes & required_scopes).any?
52
+ end
53
+ end
54
+
55
+ # @param hash [Hash] Hash mapping token strings to scope arrays
56
+ def initialize(hash)
57
+ @lookup = hash.transform_values { |scopes| AccessToken.new(scopes) }
58
+ end
59
+
60
+ # Retrieve an access token by its token string.
61
+ #
62
+ # @param token [String] The token string to look up
63
+ # @return [AccessToken, nil] The access token or nil if not found
64
+ def get(token)
65
+ @lookup[token]
66
+ end
67
+ end
68
+
69
+ attr_reader :name, :format, :scheme, :header_schema
70
+
71
+ # Initialize a new Bearer authentication scheme.
72
+ #
73
+ # @param name [String] The security scheme name (used in OpenAPI)
74
+ # @param store [TokenStoreInterface] Token store for validating access tokens
75
+ # @param scheme [String] The authentication scheme (default: 'bearer')
76
+ # @param format [String, nil] Optional bearer format hint (e.g., 'JWT')
77
+ # @param header [String] The HTTP header to check (default: HTTP_AUTHORIZATION)
78
+ def initialize(name, store:, scheme: 'bearer', format: nil, header: HTTP_AUTHORIZATION)
79
+ @name = name
80
+ @store = case store
81
+ when HashTokenStore::Interface
82
+ HashTokenStore.new(store)
83
+ when TokenStoreInterface
84
+ store
85
+ else
86
+ raise ArgumentError, "expected a TokenStore interface #{TokenStoreInterface}, but got #{store.inspect}"
87
+ end
88
+
89
+ @format = format.to_s
90
+ @scheme = scheme.to_s
91
+ @header = header
92
+ # We mark the key as optional
93
+ # because we don't validate presence of the header and return a 422.
94
+ # (even though that'll most likely result in a 401 response after running #handle)
95
+ @header_schema = Types::Hash["#{@header}?" => String]
96
+ @matcher = %r{\A\s*#{Regexp.escape(@scheme)}\s+(.+?)\s*\z}i
97
+ end
98
+
99
+ # Convert this security scheme to OpenAPI 3.0 format.
100
+ #
101
+ # @return [Hash] OpenAPI security scheme object
102
+ def to_openapi
103
+ {
104
+ 'type' => 'http',
105
+ 'scheme' => scheme,
106
+ 'bearerFormat' => format
107
+ }
108
+ end
109
+
110
+ # Handle authentication and authorization for a connection.
111
+ # Validates the Bearer token from the Authorization header and checks if it has required scopes.
112
+ #
113
+ # @param conn [Steppe::Result] The connection/result object
114
+ # @param required_scopes [Array<String>] The scopes required for this endpoint
115
+ # @return [Steppe::Result::Continue, Steppe::Result::Halt] The connection, or halted with 401/403 status
116
+ def handle(conn, required_scopes)
117
+ header_value = conn.request.get_header(@header).to_s.strip
118
+ return unauthorized(conn) if header_value.empty?
119
+
120
+ token = header_value[@matcher, 1]
121
+ return unauthorized(conn) if token.nil?
122
+
123
+ access_token = @store.get(token)
124
+ return forbidden(conn) unless access_token&.allows?(required_scopes)
125
+
126
+ conn
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Steppe
4
+ # Authentication and authorization module for Steppe endpoints.
5
+ # Provides security schemes for protecting API endpoints and validating access tokens.
6
+ # Implements common security schemes supported by OpenAPI spec
7
+ # @see https://swagger.io/docs/specification/v3_0/authentication/
8
+ module Auth
9
+ WWW_AUTHENTICATE = 'www-authenticate'
10
+ HTTP_AUTHORIZATION = 'HTTP_AUTHORIZATION'
11
+
12
+ # Interface that security schemes must implement.
13
+ # Required methods:
14
+ # - name: Returns the security scheme name
15
+ # - handle: Processes authentication/authorization for a connection
16
+ SecuritySchemeInterface = Types::Interface[
17
+ :name,
18
+ :handle,
19
+ :to_openapi
20
+ ]
21
+ end
22
+
23
+ module Responses
24
+ private
25
+
26
+ def unauthorized(conn)
27
+ conn.response.add_header(Auth::WWW_AUTHENTICATE, %(#{scheme.capitalize} realm="#{name}"))
28
+ conn.respond_with(401).halt
29
+ end
30
+
31
+ def forbidden(conn)
32
+ conn.respond_with(403).halt
33
+ end
34
+
35
+ def name
36
+ raise NotImplementedError
37
+ end
38
+
39
+ def scheme
40
+ raise NotImplementedError
41
+ end
42
+ end
43
+ end
44
+
45
+ require 'steppe/auth/bearer'
46
+ require 'steppe/auth/basic'
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/mime'
4
+
5
+ module Steppe
6
+ class ContentType
7
+ TOKEN = /[!#$%&'*+\-.^_`|~0-9A-Z]+/i
8
+ MIME_TYPE = /(?<type>#{TOKEN})\/(?<subtype>#{TOKEN})/
9
+ QUOTED_STRING = /"(?:\\.|[^"\\])*"/
10
+ PARAMETER = /\s*;\s*(?<key>#{TOKEN})=(?<value>#{TOKEN}|#{QUOTED_STRING})/
11
+
12
+ def self.parse(str)
13
+ return str if str.is_a?(ContentType)
14
+
15
+ if str.is_a?(Symbol)
16
+ str = Rack::Mime.mime_type(".#{str}")
17
+ end
18
+
19
+ m = MIME_TYPE.match(str) or
20
+ raise ArgumentError, "invalid content type: #{str.inspect}"
21
+
22
+ params = {}
23
+ str.scan(PARAMETER) do
24
+ key, value = Regexp.last_match.values_at(:key, :value)
25
+ value = value[1..-2] if value&.start_with?('"') # unquote
26
+ params[key.downcase] = value
27
+ end
28
+
29
+ new(m[:type], m[:subtype], params)
30
+ end
31
+
32
+ def self.parse_accept(header)
33
+ header.split(/\s*,\s*/).map do |entry|
34
+ # Match the MIME type part
35
+ m = MIME_TYPE.match(entry)
36
+ next unless m
37
+
38
+ params = {}
39
+ # Iterate over all parameters
40
+ entry.scan(PARAMETER) do |match|
41
+ key, value = Regexp.last_match.values_at(:key, :value)
42
+ # Remove quotes if quoted
43
+ value = value[1..-2] if value&.start_with?('"')
44
+ params[key.downcase] = value
45
+ end
46
+
47
+ new(m[:type], m[:subtype], params)
48
+ end.compact.sort_by { |ct| -ct.quality }
49
+ end
50
+
51
+ attr_reader :type, :subtype, :params, :media_type
52
+
53
+ def initialize(type, subtype, params)
54
+ @type = type.downcase
55
+ @subtype = subtype.downcase
56
+ @params = params
57
+ @media_type = "#{type}/#{subtype}"
58
+ freeze
59
+ end
60
+
61
+ def qualified? = !(type == '*' && subtype == '*')
62
+
63
+ def to_s
64
+ param_str = params.map { |k, v| "#{k}=#{v}" }.join('; ')
65
+ [ media_type, param_str ].reject(&:empty?).join('; ')
66
+ end
67
+
68
+ def ==(other)
69
+ other.is_a?(ContentType) &&
70
+ type == other.type &&
71
+ subtype == other.subtype &&
72
+ params == other.params
73
+ end
74
+
75
+ alias eql? ==
76
+ def hash = [type, subtype, params].hash
77
+
78
+ def quality = params.fetch('q', 1.0).to_f
79
+ end
80
+ end