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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +883 -0
- data/Rakefile +23 -0
- data/docs/README.md +3 -0
- data/docs/styles.css +527 -0
- data/examples/hanami.ru +29 -0
- data/examples/service.rb +323 -0
- data/examples/sinatra.rb +38 -0
- data/lib/docs_builder.rb +253 -0
- data/lib/steppe/auth/basic.rb +130 -0
- data/lib/steppe/auth/bearer.rb +130 -0
- data/lib/steppe/auth.rb +46 -0
- data/lib/steppe/content_type.rb +80 -0
- data/lib/steppe/endpoint.rb +742 -0
- data/lib/steppe/openapi_visitor.rb +155 -0
- data/lib/steppe/request.rb +22 -0
- data/lib/steppe/responder.rb +165 -0
- data/lib/steppe/responder_registry.rb +79 -0
- data/lib/steppe/result.rb +68 -0
- data/lib/steppe/serializer.rb +180 -0
- data/lib/steppe/service.rb +232 -0
- data/lib/steppe/status_map.rb +82 -0
- data/lib/steppe/utils.rb +19 -0
- data/lib/steppe/version.rb +5 -0
- data/lib/steppe.rb +44 -0
- data/sig/steppe.rbs +4 -0
- metadata +143 -0
|
@@ -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
|
data/lib/steppe/auth.rb
ADDED
|
@@ -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
|