capsium 0.1.2 → 0.3.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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +46 -7
  4. data/CHANGELOG.md +183 -0
  5. data/Gemfile +6 -0
  6. data/LICENSE +21 -0
  7. data/README.adoc +496 -127
  8. data/Rakefile +8 -1
  9. data/bin/console +1 -1
  10. data/capsium.gemspec +12 -19
  11. data/exe/capsium +1 -1
  12. data/lib/capsium/cli/convert.rb +18 -0
  13. data/lib/capsium/cli/formatting.rb +27 -0
  14. data/lib/capsium/cli/package.rb +148 -0
  15. data/lib/capsium/cli/reactor.rb +33 -0
  16. data/lib/capsium/cli.rb +4 -96
  17. data/lib/capsium/converters/jekyll.rb +5 -7
  18. data/lib/capsium/converters.rb +7 -0
  19. data/lib/capsium/package/authentication.rb +38 -0
  20. data/lib/capsium/package/authentication_config.rb +78 -0
  21. data/lib/capsium/package/cipher.rb +197 -0
  22. data/lib/capsium/package/composition.rb +77 -0
  23. data/lib/capsium/package/dataset.rb +67 -34
  24. data/lib/capsium/package/dependency_resolver.rb +61 -0
  25. data/lib/capsium/package/encryption_config.rb +38 -0
  26. data/lib/capsium/package/manifest.rb +33 -22
  27. data/lib/capsium/package/manifest_config.rb +52 -7
  28. data/lib/capsium/package/merged_view.rb +168 -0
  29. data/lib/capsium/package/metadata.rb +4 -8
  30. data/lib/capsium/package/metadata_config.rb +97 -9
  31. data/lib/capsium/package/preparation.rb +81 -0
  32. data/lib/capsium/package/routes.rb +39 -41
  33. data/lib/capsium/package/routes_config.rb +174 -44
  34. data/lib/capsium/package/security.rb +140 -0
  35. data/lib/capsium/package/security_config.rb +60 -0
  36. data/lib/capsium/package/signer.rb +201 -0
  37. data/lib/capsium/package/storage.rb +20 -45
  38. data/lib/capsium/package/storage_config.rb +128 -3
  39. data/lib/capsium/package/store.rb +109 -0
  40. data/lib/capsium/package/testing/config_test.rb +70 -0
  41. data/lib/capsium/package/testing/context.rb +11 -0
  42. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  43. data/lib/capsium/package/testing/file_test.rb +45 -0
  44. data/lib/capsium/package/testing/report.rb +33 -0
  45. data/lib/capsium/package/testing/route_test.rb +64 -0
  46. data/lib/capsium/package/testing/test_case.rb +60 -0
  47. data/lib/capsium/package/testing/test_suite.rb +86 -0
  48. data/lib/capsium/package/testing.rb +20 -0
  49. data/lib/capsium/package/validator.rb +160 -0
  50. data/lib/capsium/package/verification.rb +32 -0
  51. data/lib/capsium/package/version.rb +204 -0
  52. data/lib/capsium/package.rb +92 -106
  53. data/lib/capsium/packager.rb +75 -6
  54. data/lib/capsium/reactor/authenticator.rb +180 -0
  55. data/lib/capsium/reactor/deploy.rb +71 -0
  56. data/lib/capsium/reactor/htpasswd.rb +154 -0
  57. data/lib/capsium/reactor/introspection.rb +91 -0
  58. data/lib/capsium/reactor/oauth2.rb +106 -0
  59. data/lib/capsium/reactor/serving.rb +75 -0
  60. data/lib/capsium/reactor/session.rb +82 -0
  61. data/lib/capsium/reactor.rb +93 -43
  62. data/lib/capsium/thor_ext.rb +1 -1
  63. data/lib/capsium/version.rb +1 -1
  64. data/lib/capsium.rb +7 -9
  65. data/sig/capsium/package/authentication.rbs +23 -0
  66. data/sig/capsium/package/authentication_config.rbs +71 -0
  67. data/sig/capsium/package/cipher.rbs +51 -0
  68. data/sig/capsium/package/composition.rbs +20 -0
  69. data/sig/capsium/package/dataset.rbs +28 -0
  70. data/sig/capsium/package/dependency_resolver.rbs +49 -0
  71. data/sig/capsium/package/encryption_config.rbs +35 -0
  72. data/sig/capsium/package/manifest.rbs +34 -0
  73. data/sig/capsium/package/manifest_config.rbs +38 -0
  74. data/sig/capsium/package/merged_view.rbs +46 -0
  75. data/sig/capsium/package/metadata.rbs +26 -0
  76. data/sig/capsium/package/metadata_config.rbs +49 -0
  77. data/sig/capsium/package/preparation.rbs +23 -0
  78. data/sig/capsium/package/routes.rbs +37 -0
  79. data/sig/capsium/package/routes_config.rbs +107 -0
  80. data/sig/capsium/package/security.rbs +72 -0
  81. data/sig/capsium/package/security_config.rbs +57 -0
  82. data/sig/capsium/package/signer.rbs +59 -0
  83. data/sig/capsium/package/storage.rbs +28 -0
  84. data/sig/capsium/package/storage_config.rbs +85 -0
  85. data/sig/capsium/package/store.rbs +37 -0
  86. data/sig/capsium/package/testing/config_test.rbs +21 -0
  87. data/sig/capsium/package/testing/context.rbs +14 -0
  88. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  89. data/sig/capsium/package/testing/file_test.rbs +17 -0
  90. data/sig/capsium/package/testing/report.rbs +20 -0
  91. data/sig/capsium/package/testing/route_test.rbs +22 -0
  92. data/sig/capsium/package/testing/test_case.rbs +41 -0
  93. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  94. data/sig/capsium/package/testing.rbs +7 -0
  95. data/sig/capsium/package/validator.rbs +29 -0
  96. data/sig/capsium/package/verification.rbs +24 -0
  97. data/sig/capsium/package/version.rbs +39 -0
  98. data/sig/capsium/package.rbs +61 -0
  99. data/sig/capsium/packager.rbs +32 -0
  100. data/sig/capsium/reactor/authenticator.rbs +40 -0
  101. data/sig/capsium/reactor/deploy.rbs +34 -0
  102. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  103. data/sig/capsium/reactor/introspection.rbs +31 -0
  104. data/sig/capsium/reactor/oauth2.rbs +31 -0
  105. data/sig/capsium/reactor/serving.rbs +22 -0
  106. data/sig/capsium/reactor/session.rbs +36 -0
  107. data/sig/capsium/reactor.rbs +35 -0
  108. data/sig/capsium.rbs +3 -1
  109. metadata +108 -170
  110. data/lib/capsium/package/dataset_config.rb +0 -28
  111. data/lib/capsium/protector.rb +0 -103
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "bcrypt"
5
+ require "digest"
6
+ require "openssl"
7
+
8
+ module Capsium
9
+ class Reactor
10
+ # Apache htpasswd verification (05x-authentication). Supported hash
11
+ # formats:
12
+ # - bcrypt ($2a$/$2b$/$2y$, `htpasswd -B`) via the bcrypt gem
13
+ # - Apache apr1 MD5 ($apr1$, `htpasswd -m`) and md5-crypt ($1$),
14
+ # pure-Ruby md5-crypt
15
+ # - unsalted SHA-1 ({SHA}, `htpasswd -s`)
16
+ # - anything else (DES, $5$/$6$) via the platform's crypt(3) —
17
+ # support depends on the OS
18
+ class Htpasswd
19
+ # md5-crypt as deployed by Apache htpasswd, OpenSSL and glibc
20
+ # ($1$ and $apr1$ magics; identical except for the magic string).
21
+ # NOTE: Poul-Henning Kamp's original FreeBSD implementation mixed
22
+ # the magic in at the % 7 step where all deployed implementations
23
+ # mix in the password (see apr_md5.c, glibc crypt-md5.c).
24
+ module Md5Crypt
25
+ ITOA64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
26
+ DIGEST_GROUPS = [[0, 6, 12], [1, 7, 13], [2, 8, 14], [3, 9, 15], [4, 10, 5]].freeze
27
+
28
+ def self.verify(hash, password)
29
+ _prefix, magic, salt, = hash.split("$", 4)
30
+ return false if salt.nil? || salt.empty?
31
+
32
+ Reactor.secure_compare(digest(password, salt, "$#{magic}$"), hash)
33
+ end
34
+
35
+ def self.digest(password, salt, magic)
36
+ final = initial_digest(password, salt, magic)
37
+ 1000.times do |i|
38
+ final = stretch_digest(i, password, salt, final)
39
+ end
40
+ "#{magic}#{salt}$#{to64(final)}"
41
+ end
42
+
43
+ def self.initial_digest(password, salt, magic)
44
+ inner = Digest::MD5.digest(password + salt + password)
45
+ context = Digest::MD5.new
46
+ context << password << magic << salt
47
+ length = password.length
48
+ while length.positive?
49
+ context << inner[0, [16, length].min]
50
+ length -= 16
51
+ end
52
+ length = password.length
53
+ while length.positive?
54
+ context << (length.odd? ? "\0" : password[0])
55
+ length >>= 1
56
+ end
57
+ context.digest
58
+ end
59
+
60
+ def self.stretch_digest(iteration, password, salt, final)
61
+ context = Digest::MD5.new
62
+ context << (iteration.odd? ? password : final)
63
+ context << salt unless (iteration % 3).zero?
64
+ context << password unless (iteration % 7).zero?
65
+ context << (iteration.odd? ? final : password)
66
+ context.digest
67
+ end
68
+
69
+ # The custom base-64 of md5-crypt: digest triples permuted, first
70
+ # index to the high bits, 22 characters total.
71
+ def self.to64(final)
72
+ encoded = +""
73
+ DIGEST_GROUPS.each do |a, b, c|
74
+ value = final[c].ord | (final[b].ord << 8) | (final[a].ord << 16)
75
+ 4.times do
76
+ encoded << ITOA64[value & 0x3f]
77
+ value >>= 6
78
+ end
79
+ end
80
+ value = final[11].ord
81
+ 2.times do
82
+ encoded << ITOA64[value & 0x3f]
83
+ value >>= 6
84
+ end
85
+ encoded
86
+ end
87
+ end
88
+
89
+ attr_reader :path
90
+
91
+ def initialize(path)
92
+ raise Error, "htpasswd file not found: #{path}" unless File.file?(path)
93
+
94
+ @path = path
95
+ @entries = parse(path)
96
+ end
97
+
98
+ def usernames
99
+ @entries.keys
100
+ end
101
+
102
+ def verify?(username, password)
103
+ hash = @entries[username]
104
+ return false unless hash
105
+
106
+ verify_hash(hash, password)
107
+ end
108
+
109
+ private
110
+
111
+ def parse(path)
112
+ File.readlines(path, chomp: true).each_with_object({}) do |line, entries|
113
+ next if line.strip.empty? || line.start_with?("#")
114
+
115
+ username, hash = line.split(":", 2)
116
+ entries[username] = hash
117
+ end
118
+ end
119
+
120
+ def verify_hash(hash, password)
121
+ case hash
122
+ when /\A\$2[aby]\$/ then verify_bcrypt(hash, password)
123
+ when /\A\$(?:apr1|1)\$/ then Md5Crypt.verify(hash, password)
124
+ when /\A\{SHA\}/ then verify_sha1(hash, password)
125
+ else verify_crypt(hash, password)
126
+ end
127
+ end
128
+
129
+ # The bcrypt gem predates htpasswd's $2y$ tag; the variants are
130
+ # algorithmically identical, normalized here.
131
+ def verify_bcrypt(hash, password)
132
+ BCrypt::Password.new(hash.sub(/\A\$2y\$/, "$2a$")) == password
133
+ rescue BCrypt::Errors::InvalidHash
134
+ false
135
+ end
136
+
137
+ def verify_sha1(hash, password)
138
+ digest = "{SHA}#{Base64.strict_encode64(Digest::SHA1.digest(password))}"
139
+ Reactor.secure_compare(digest, hash)
140
+ end
141
+
142
+ def verify_crypt(hash, password)
143
+ computed = password.crypt(hash)
144
+ computed && Reactor.secure_compare(computed, hash)
145
+ end
146
+ end
147
+
148
+ # Constant-time string comparison (length-check first).
149
+ def self.secure_compare(own, theirs)
150
+ own.bytesize == theirs.bytesize &&
151
+ OpenSSL.fixed_length_secure_compare(own, theirs)
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Capsium
8
+ class Reactor
9
+ # Monitoring HTTP API reports for the package this reactor serves
10
+ # (ARCHITECTURE.md section 7). Each report wraps its single entry in
11
+ # the list shape all reactors converge on.
12
+ class Introspection
13
+ METADATA_PATH = "/api/v1/introspect/metadata"
14
+ ROUTES_PATH = "/api/v1/introspect/routes"
15
+ CONTENT_HASHES_PATH = "/api/v1/introspect/content-hashes"
16
+ CONTENT_VALIDITY_PATH = "/api/v1/introspect/content-validity"
17
+ PATHS = [METADATA_PATH, ROUTES_PATH, CONTENT_HASHES_PATH,
18
+ CONTENT_VALIDITY_PATH].freeze
19
+
20
+ attr_reader :package
21
+
22
+ def initialize(package)
23
+ @package = package
24
+ end
25
+
26
+ def endpoint?(path)
27
+ PATHS.include?(path)
28
+ end
29
+
30
+ # The report body for an introspection endpoint, or nil when the
31
+ # path is not an introspection endpoint.
32
+ def report_for(path)
33
+ case path
34
+ when METADATA_PATH then metadata_report
35
+ when ROUTES_PATH then routes_report
36
+ when CONTENT_HASHES_PATH then content_hashes_report
37
+ when CONTENT_VALIDITY_PATH then content_validity_report
38
+ end
39
+ end
40
+
41
+ def metadata_report
42
+ metadata = package.metadata
43
+ { packages: [{
44
+ name: metadata.name,
45
+ version: metadata.version,
46
+ author: metadata.author,
47
+ description: metadata.description
48
+ }] }
49
+ end
50
+
51
+ def routes_report
52
+ entries = package.routes.config.routes.map do |route|
53
+ { method: route.http_method || "GET", path: route.path }
54
+ end
55
+ { routes: [{ package: package.name, routes: entries }] }
56
+ end
57
+
58
+ def content_hashes_report
59
+ { contentHashes: [{ package: package.name, hash: content_hash }] }
60
+ end
61
+
62
+ def content_validity_report
63
+ errors = package.verify_integrity
64
+ entry = {
65
+ package: package.name,
66
+ valid: errors.empty?,
67
+ lastChecked: Time.now.utc.iso8601,
68
+ signed: package.signed?,
69
+ encrypted: package.encrypted?
70
+ }
71
+ entry[:signatureValid] = package.verify_signature if package.signed?
72
+ entry[:reason] = errors.map(&:message).join("; ") unless errors.empty?
73
+ { contentValidity: [entry] }
74
+ end
75
+
76
+ private
77
+
78
+ # SHA-256 of the .cap blob when the package was loaded from one.
79
+ # For directory sources there is no blob, so the hash covers a
80
+ # canonical (sorted-key) JSON serialization of the package content
81
+ # checksums — the same data security.json integrityChecks carry.
82
+ def content_hash
83
+ cap_file = package.cap_file_path
84
+ return Digest::SHA256.file(cap_file).hexdigest if cap_file
85
+
86
+ checksums = Package::Security.checksums_for(package.path)
87
+ Digest::SHA256.hexdigest(JSON.generate(checksums))
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ module Capsium
9
+ class Reactor
10
+ # OAuth2 authorization-code flow client (05x-authentication), used by
11
+ # the Authenticator. Talks to the provider over net/http; the client
12
+ # secret never comes from the package. State is a random nonce signed
13
+ # with the session HMAC key (CSRF protection, self-contained).
14
+ class OAuth2
15
+ # The token exchange or userinfo fetch failed at the provider.
16
+ class FlowError < Capsium::Error; end
17
+
18
+ GRANT_TYPE = "authorization_code"
19
+
20
+ attr_reader :config
21
+
22
+ def initialize(config, client_secret:, session:, base_url: nil)
23
+ @config = config
24
+ @client_secret = client_secret
25
+ @session = session
26
+ @base_url = base_url
27
+ end
28
+
29
+ # The provider URL the login endpoint redirects the browser to.
30
+ def authorization_url(request)
31
+ params = {
32
+ "response_type" => "code",
33
+ "client_id" => @config.client_id,
34
+ "redirect_uri" => callback_uri(request),
35
+ "state" => generate_state
36
+ }
37
+ params["scope"] = @config.scopes.join(" ") unless @config.scopes.empty?
38
+ "#{@config.authorization_url}?#{URI.encode_www_form(params)}"
39
+ end
40
+
41
+ def valid_state?(state)
42
+ nonce, signature = state.to_s.split(".", 2)
43
+ return false if nonce.nil? || nonce.empty? || signature.nil?
44
+
45
+ Reactor.secure_compare(@session.sign(nonce), signature)
46
+ end
47
+
48
+ # Exchanges the authorization code and fetches the userinfo claims.
49
+ # Returns the raw userinfo hash; raises FlowError on provider
50
+ # errors.
51
+ def complete(code, request)
52
+ userinfo(exchange_code(code, callback_uri(request)))
53
+ end
54
+
55
+ def callback_uri(request)
56
+ "#{base_url_for(request)}#{@config.redirect_path}"
57
+ end
58
+
59
+ private
60
+
61
+ def generate_state
62
+ nonce = SecureRandom.hex(16)
63
+ "#{nonce}.#{@session.sign(nonce)}"
64
+ end
65
+
66
+ def base_url_for(request)
67
+ @base_url || "http://#{request['Host']}"
68
+ end
69
+
70
+ def exchange_code(code, redirect_uri)
71
+ response = Net::HTTP.post_form(
72
+ URI(@config.token_url),
73
+ "grant_type" => GRANT_TYPE, "code" => code,
74
+ "redirect_uri" => redirect_uri, "client_id" => @config.client_id,
75
+ "client_secret" => @client_secret
76
+ )
77
+ unless response.is_a?(Net::HTTPSuccess)
78
+ raise FlowError, "token exchange failed (HTTP #{response.code})"
79
+ end
80
+
81
+ JSON.parse(response.body).fetch("access_token") do
82
+ raise FlowError, "token response without access_token"
83
+ end
84
+ rescue JSON::ParserError
85
+ raise FlowError, "token response was not JSON"
86
+ end
87
+
88
+ def userinfo(access_token)
89
+ uri = URI(@config.userinfo_url)
90
+ response = Net::HTTP.start(uri.host, uri.port,
91
+ use_ssl: uri.scheme == "https") do |http|
92
+ http.get(uri.request_uri,
93
+ "authorization" => "Bearer #{access_token}",
94
+ "accept" => "application/json")
95
+ end
96
+ unless response.is_a?(Net::HTTPSuccess)
97
+ raise FlowError, "userinfo request failed (HTTP #{response.code})"
98
+ end
99
+
100
+ JSON.parse(response.body)
101
+ rescue JSON::ParserError
102
+ raise FlowError, "userinfo response was not JSON"
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "marcel"
4
+ require "pathname"
5
+
6
+ module Capsium
7
+ class Reactor
8
+ # Serving of resolved routes (static files through the merged view,
9
+ # datasets, handler-route 501s), mixed into Reactor. Includes route
10
+ # inheritance processing per 05x-routing.
11
+ module Serving
12
+ private
13
+
14
+ def serve_route(route, response)
15
+ case route.kind
16
+ when :dataset then serve_dataset(route.dataset, response)
17
+ when :resource then serve_file(route, response)
18
+ else respond_not_implemented(response)
19
+ end
20
+ end
21
+
22
+ def serve_file(route, response)
23
+ content_path = @merged_view.resolve(route.resource)
24
+ return respond_not_found(response) unless content_path
25
+
26
+ body, headers = inherited_processing(route, File.read(content_path),
27
+ headers_for(route))
28
+ response.status = 200
29
+ response["Content-Type"] = content_type_for(route, content_path)
30
+ headers.each { |name, value| response[name] = value }
31
+ response.body = body
32
+ end
33
+
34
+ # Route inheritance processing (05x-routing section "Route
35
+ # Inheritance"): responseRewrite replaces the body and/or overrides
36
+ # headers; responseHeaders are merged over the served headers.
37
+ # requestHeaders are parsed and exposed on the route for forwarding
38
+ # reactors; this reactor serves statically without an upstream, so
39
+ # they do not alter its responses.
40
+ def inherited_processing(route, body, headers)
41
+ rewrite = route.response_rewrite
42
+ if rewrite
43
+ body = rewrite.body unless rewrite.body.nil?
44
+ headers = headers.merge(rewrite.headers) if rewrite.headers
45
+ end
46
+ headers = headers.merge(route.response_headers) if route.response_headers
47
+ [body, headers]
48
+ end
49
+
50
+ # The route's declared MIME type, detected from the resolved file
51
+ # for resources the manifest does not list (dependency content
52
+ # reached through inheritance, layer-only files).
53
+ def content_type_for(route, content_path)
54
+ route.mime(@package.manifest) ||
55
+ Marcel::MimeType.for(Pathname.new(content_path),
56
+ name: File.basename(content_path))
57
+ end
58
+
59
+ def headers_for(route)
60
+ route.headers || (@cache_control ? { "Cache-Control" => @cache_control } : {})
61
+ end
62
+
63
+ def serve_dataset(dataset_name, response)
64
+ dataset = @package.storage.dataset(dataset_name)
65
+ if dataset
66
+ response.status = 200
67
+ response["Content-Type"] = "application/json"
68
+ response.body = JSON.generate(dataset.data)
69
+ else
70
+ respond_not_found(response)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+ require "securerandom"
7
+
8
+ module Capsium
9
+ class Reactor
10
+ # HMAC-SHA256 signed session cookies (05x-authentication: "Session
11
+ # via signed cookie"). The cookie value is
12
+ # "base64url(JSON payload).hmac hex" — signed, not encrypted, so it
13
+ # carries only the identity claims. The signing secret comes from
14
+ # deploy.json (sessionSecret) or is generated and persisted
15
+ # reactor-side (never in the package).
16
+ class Session
17
+ COOKIE_NAME = "capsium_session"
18
+ SECRET_BYTES = 32
19
+
20
+ def self.hmac(secret, data)
21
+ OpenSSL::HMAC.hexdigest("SHA256", secret, data)
22
+ end
23
+
24
+ attr_reader :secret
25
+
26
+ def initialize(secret: nil, state_file: nil)
27
+ @secret = secret || self.class.load_or_generate_secret(state_file)
28
+ end
29
+
30
+ # HMAC-SHA256 of data with the session secret (hex).
31
+ def sign(data)
32
+ self.class.hmac(@secret, data)
33
+ end
34
+
35
+ # The persisted reactor-side secret: loaded from state_file when
36
+ # present, generated and written (mode 0600) otherwise.
37
+ def self.load_or_generate_secret(state_file)
38
+ raise Error, "session secret or state_file is required" unless state_file
39
+
40
+ return File.read(state_file).strip if File.file?(state_file)
41
+
42
+ secret = SecureRandom.hex(SECRET_BYTES)
43
+ File.write(state_file, secret, perm: 0o600)
44
+ secret
45
+ end
46
+
47
+ def encode(payload)
48
+ data = Base64.urlsafe_encode64(JSON.generate(payload), padding: false)
49
+ "#{data}.#{self.class.hmac(@secret, data)}"
50
+ end
51
+
52
+ # The verified payload, or nil when the cookie is missing or the
53
+ # signature does not match.
54
+ def decode(cookie_value)
55
+ data, signature = cookie_value.to_s.split(".", 2)
56
+ return if data.nil? || signature.nil?
57
+
58
+ expected = self.class.hmac(@secret, data)
59
+ return unless Reactor.secure_compare(expected, signature)
60
+
61
+ JSON.parse(Base64.urlsafe_decode64(data))
62
+ rescue JSON::ParserError, ArgumentError
63
+ nil
64
+ end
65
+
66
+ # The identity for a request's Cookie header, or nil.
67
+ def identity_from(request)
68
+ cookie = request["Cookie"].to_s.split(/;\s*/).find do |part|
69
+ part.start_with?("#{COOKIE_NAME}=")
70
+ end
71
+ return unless cookie
72
+
73
+ decode(cookie.delete_prefix("#{COOKIE_NAME}="))
74
+ end
75
+
76
+ # A Set-Cookie value establishing the identity session.
77
+ def cookie_for(identity)
78
+ "#{COOKIE_NAME}=#{encode(identity)}; Path=/; HttpOnly; SameSite=Lax"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -1,52 +1,86 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "webrick"
4
3
  require "json"
5
4
  require "listen"
6
- require "capsium/package"
5
+ require "tmpdir"
6
+ require "webrick"
7
7
 
8
8
  module Capsium
9
9
  class Reactor
10
+ autoload :Authenticator, "capsium/reactor/authenticator"
11
+ autoload :Deploy, "capsium/reactor/deploy"
12
+ autoload :Htpasswd, "capsium/reactor/htpasswd"
13
+ autoload :Introspection, "capsium/reactor/introspection"
14
+ autoload :OAuth2, "capsium/reactor/oauth2"
15
+ autoload :Serving, "capsium/reactor/serving"
16
+ autoload :Session, "capsium/reactor/session"
17
+
18
+ include Serving
19
+
10
20
  DEFAULT_PORT = 8864
21
+ DEFAULT_CACHE_CONTROL = "public, max-age=31536000"
11
22
 
12
- attr_accessor :package, :package_path, :routes, :port, :server,
13
- :server_thread
23
+ attr_reader :package, :package_path, :routes, :port, :cache_control,
24
+ :server, :server_thread, :introspection, :authenticator
14
25
 
15
- def initialize(package:, port: DEFAULT_PORT, do_not_listen: false)
16
- @package = package.is_a?(String) ? Package.new(package) : package
26
+ def initialize(package:, port: DEFAULT_PORT,
27
+ cache_control: DEFAULT_CACHE_CONTROL, do_not_listen: false,
28
+ store: nil, deploy: nil)
29
+ @package = package.is_a?(String) ? Package.new(package, store: store) : package
17
30
  @package_path = @package.path
31
+ @store = store
18
32
  @port = port
33
+ @cache_control = cache_control
34
+ @deploy_config = Deploy.load(deploy)
19
35
  setup_server(do_not_listen)
20
- @routes = @package.routes
36
+ load_state
21
37
  mount_routes
22
38
  end
23
39
 
24
40
  def serve
41
+ trap("INT") { shutdown_server }
25
42
  @server_thread = start_server
26
43
  start_listener
27
44
  end
28
45
 
29
46
  def handle_request(request, response)
47
+ if @authenticator.endpoint?(request.path)
48
+ @authenticator.serve_endpoint(request, response)
49
+ return
50
+ end
51
+
52
+ identity = @authenticator.authenticate(request)
53
+ return @authenticator.challenge(response) if @authenticator.enabled? && identity.nil?
54
+ return serve_introspection(request, response) if @introspection.endpoint?(request.path)
55
+
30
56
  route = @routes.resolve(request.path)
31
- if route
32
- target = route.target
33
- content_path = target.fs_path(@package.manifest)
34
- if File.exist?(content_path)
35
- response.status = 200
36
- response["Content-Type"] = target.mime(@package.manifest)
37
- response.body = File.read(content_path)
38
- else
39
- response.status = 404
40
- response["Content-Type"] = "text/plain"
41
- response.body = "Not Found"
42
- end
43
- else
44
- response.status = 404
45
- response["Content-Type"] = "text/plain"
46
- response.body = "Not Found"
57
+ return respond_not_found(response) unless route
58
+
59
+ case @authenticator.authorize(identity, route.access_control)
60
+ when :unauthenticated then return @authenticator.challenge(response)
61
+ when :forbidden then return respond_forbidden(response)
62
+ end
63
+
64
+ serve_route(route, response)
65
+ end
66
+
67
+ def mount_routes
68
+ paths = @routes.config.routes.map(&:serving_path) +
69
+ Introspection::PATHS + @authenticator.endpoints
70
+ paths.each do |path|
71
+ @server.mount_proc(path.to_s) { |req, res| handle_request(req, res) }
47
72
  end
48
73
  end
49
74
 
75
+ def restart_server
76
+ @server.shutdown
77
+ @server_thread&.join
78
+ load_package
79
+ setup_server(false)
80
+ mount_routes
81
+ @server_thread = start_server
82
+ end
83
+
50
84
  private
51
85
 
52
86
  def setup_server(do_not_listen)
@@ -55,18 +89,8 @@ module Capsium
55
89
  @server = WEBrick::HTTPServer.new(server_options)
56
90
  end
57
91
 
58
- def mount_routes
59
- @routes.config.routes.each do |route|
60
- path = route.path
61
- @server.mount_proc(path.to_s) do |req, res|
62
- handle_request(req, res)
63
- end
64
- end
65
- end
66
-
67
92
  def start_server
68
93
  Thread.new do
69
- trap("INT") { shutdown_server }
70
94
  puts "Starting server on http://localhost:#{@port}"
71
95
  @server.start
72
96
  end
@@ -78,15 +102,6 @@ module Capsium
78
102
  exit
79
103
  end
80
104
 
81
- def restart_server
82
- @server.shutdown
83
- @server_thread&.join
84
- load_package
85
- setup_server(false)
86
- mount_routes
87
- @server_thread = start_server
88
- end
89
-
90
105
  def start_listener
91
106
  listener = Listen.to(@package_path) do |_modified, _added, _removed|
92
107
  puts "Changes detected, reloading..."
@@ -99,8 +114,43 @@ module Capsium
99
114
  end
100
115
 
101
116
  def load_package
102
- @package = Package.new(@package_path)
117
+ @package = Package.new(@package_path, store: @store)
118
+ load_state
119
+ end
120
+
121
+ def load_state
103
122
  @routes = @package.routes
123
+ @merged_view = @package.merged_view
124
+ @introspection = Introspection.new(@package)
125
+ @authenticator = Authenticator.new(
126
+ @package.authentication,
127
+ deploy: @deploy_config,
128
+ package_path: @package.path,
129
+ base_url: @deploy_config.base_url,
130
+ state_file: File.join(Dir.tmpdir, "capsium-#{@package.name}-session-secret")
131
+ )
132
+ end
133
+
134
+ def serve_introspection(request, response)
135
+ return respond_method_not_allowed(response) unless request.request_method == "GET"
136
+
137
+ response.status = 200
138
+ response["Content-Type"] = "application/json"
139
+ response.body = JSON.generate(@introspection.report_for(request.path))
140
+ end
141
+
142
+ def respond_not_found(response) = respond_text(response, 404, "Not Found")
143
+
144
+ def respond_forbidden(response) = respond_text(response, 403, "Forbidden")
145
+
146
+ def respond_not_implemented(response) = respond_text(response, 501, "Not Implemented")
147
+
148
+ def respond_method_not_allowed(response) = respond_text(response, 405, "Method Not Allowed")
149
+
150
+ def respond_text(response, status, body)
151
+ response.status = status
152
+ response["Content-Type"] = "text/plain"
153
+ response.body = body
104
154
  end
105
155
  end
106
156
  end