syntropy 0.30.0 → 0.31.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 +4 -4
- data/CHANGELOG.md +17 -0
- data/bin/syntropy +8 -86
- data/cmd/_banner.rb +16 -0
- data/cmd/help.rb +12 -0
- data/cmd/serve.rb +95 -0
- data/cmd/test.rb +40 -0
- data/examples/{counter.rb → basic/counter.rb} +1 -1
- data/examples/{templates.rb → basic/templates.rb} +1 -1
- data/examples/mcp-oauth/.ruby-version +1 -0
- data/examples/mcp-oauth/Gemfile +8 -0
- data/examples/mcp-oauth/README.md +128 -0
- data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
- data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
- data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
- data/examples/mcp-oauth/app/index.md +1 -0
- data/examples/mcp-oauth/app/mcp.rb +38 -0
- data/examples/mcp-oauth/app/oauth/authorize.rb +26 -0
- data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
- data/examples/mcp-oauth/app/oauth/register.rb +15 -0
- data/examples/mcp-oauth/app/oauth/token.rb +79 -0
- data/examples/mcp-oauth/app/signin.rb +85 -0
- data/examples/mcp-oauth/test/helper.rb +9 -0
- data/examples/mcp-oauth/test/test_app.rb +27 -0
- data/examples/mcp-oauth/test/test_oauth.rb +628 -0
- data/lib/syntropy/app.rb +15 -4
- data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
- data/lib/syntropy/applets/builtin/req.rb +1 -1
- data/lib/syntropy/dev_mode.rb +1 -1
- data/lib/syntropy/errors.rb +6 -0
- data/lib/syntropy/http/client.rb +43 -0
- data/lib/syntropy/http/client_connection.rb +36 -0
- data/lib/syntropy/http/io_extensions.rb +148 -0
- data/lib/syntropy/http/server.rb +5 -5
- data/lib/syntropy/http/{connection.rb → server_connection.rb} +9 -38
- data/lib/syntropy/http.rb +3 -1
- data/lib/syntropy/logger.rb +5 -1
- data/lib/syntropy/papercraft_extensions.rb +1 -1
- data/lib/syntropy/request/mock_adapter.rb +2 -0
- data/lib/syntropy/request/request_info.rb +20 -1
- data/lib/syntropy/request/response.rb +2 -2
- data/lib/syntropy/request/validation.rb +10 -3
- data/lib/syntropy/routing_tree.rb +2 -1
- data/lib/syntropy/test.rb +65 -0
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +1 -21
- data/syntropy.gemspec +1 -2
- data/test/app/.well-known/foo.rb +3 -0
- data/test/helper.rb +1 -25
- data/test/test_app.rb +53 -68
- data/test/test_caching.rb +1 -1
- data/test/test_http_client.rb +52 -0
- data/test/test_http_client_connection.rb +43 -0
- data/test/{test_connection.rb → test_http_server_connection.rb} +29 -29
- data/test/test_json_api.rb +4 -4
- data/test/{test_request_extensions.rb → test_request.rb} +150 -18
- data/test/test_routing_tree.rb +15 -3
- metadata +41 -29
- data/test/test_request_info.rb +0 -90
- /data/examples/{bad.rb → basic/bad.rb} +0 -0
- /data/examples/{card.rb → basic/card.rb} +0 -0
- /data/examples/{counter.js → basic/counter.js} +0 -0
- /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
- /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
- /data/examples/{index.md → basic/index.md} +0 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
AuthStore = import '../_lib/auth_store'
|
|
2
|
+
|
|
3
|
+
# https://datatracker.ietf.org/doc/html/rfc6749#section-5
|
|
4
|
+
export ->(req) do
|
|
5
|
+
req.validate_http_method('post')
|
|
6
|
+
params = req.get_form_data
|
|
7
|
+
req.validate(
|
|
8
|
+
params['redirect_uri'], String,
|
|
9
|
+
message: 'invalid_request'
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
req.validate(
|
|
13
|
+
params['grant_type'], 'authorization_code',
|
|
14
|
+
message: 'unsupported_grant_type'
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
code = params['code']
|
|
18
|
+
auth_info = AuthStore.fetch(code)
|
|
19
|
+
req.validate(
|
|
20
|
+
auth_info, Hash,
|
|
21
|
+
message: 'invalid_request'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
req.validate(
|
|
25
|
+
params['redirect_uri'], auth_info['redirect_uri'],
|
|
26
|
+
message: 'invalid_request'
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
client_id = params['client_id']
|
|
30
|
+
client_info = AuthStore.fetch(client_id)
|
|
31
|
+
req.validate(
|
|
32
|
+
client_info, Hash,
|
|
33
|
+
message: 'invalid_client'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
code_verifier = params['code_verifier']
|
|
37
|
+
hashed = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
|
38
|
+
req.validate(
|
|
39
|
+
hashed, auth_info['code_challenge'],
|
|
40
|
+
message: 'invalid_grant'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
session_info = AuthStore.fetch(auth_info['sid'])
|
|
44
|
+
req.validate(
|
|
45
|
+
session_info, Hash,
|
|
46
|
+
message: 'invalid_grant'
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
token_info = session_info.merge(
|
|
50
|
+
# some app-specific metadata
|
|
51
|
+
type: 'oauth',
|
|
52
|
+
ttl: 86400 * 30
|
|
53
|
+
)
|
|
54
|
+
token = AuthStore.store(token_info)
|
|
55
|
+
|
|
56
|
+
req.respond_json({
|
|
57
|
+
access_token: token,
|
|
58
|
+
token_type: 'Bearer',
|
|
59
|
+
expires_in: token_info[:ttl]
|
|
60
|
+
})
|
|
61
|
+
rescue ValidationError => e
|
|
62
|
+
req.respond_json(
|
|
63
|
+
{
|
|
64
|
+
error: e.message
|
|
65
|
+
},
|
|
66
|
+
':status' => Syntropy::HTTP::BAD_REQUEST
|
|
67
|
+
)
|
|
68
|
+
rescue => e
|
|
69
|
+
status = Syntropy::Error.http_status(e)
|
|
70
|
+
raise if status == HTTP::INTERNAL_SERVER_ERROR
|
|
71
|
+
|
|
72
|
+
req.respond_json(
|
|
73
|
+
{
|
|
74
|
+
error: 'invalid_request',
|
|
75
|
+
error_description: e.message
|
|
76
|
+
},
|
|
77
|
+
':status' => Syntropy::HTTP::BAD_REQUEST
|
|
78
|
+
)
|
|
79
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
AuthStore = import '../_lib/auth_store'
|
|
2
|
+
|
|
3
|
+
export ->(req) {
|
|
4
|
+
case req.method
|
|
5
|
+
when 'get'
|
|
6
|
+
render_signin_form(req)
|
|
7
|
+
when 'post'
|
|
8
|
+
validate_signin(req)
|
|
9
|
+
else
|
|
10
|
+
raise Syntropy::Error.method_not_allowed
|
|
11
|
+
end
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
def render_signin_form(req)
|
|
15
|
+
req.respond_html(@signin_form.render)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def validate_signin(req)
|
|
19
|
+
creds = req.get_form_data
|
|
20
|
+
|
|
21
|
+
if !valid_creds?(creds)
|
|
22
|
+
req.respond_html(
|
|
23
|
+
@signin_form.render,
|
|
24
|
+
':status' => Syntropy::HTTP::UNAUTHORIZED
|
|
25
|
+
)
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sid = AuthStore.store({
|
|
30
|
+
username: creds['username'],
|
|
31
|
+
timestamp: Time.now.to_i
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
oauth_signin_id = req.cookies['oauth_signin_id']
|
|
35
|
+
if oauth_signin_id
|
|
36
|
+
auth_info = AuthStore.fetch(oauth_signin_id)
|
|
37
|
+
AuthStore.update(oauth_signin_id, auth_info.merge('sid' => sid))
|
|
38
|
+
req.validate(auth_info, Hash)
|
|
39
|
+
req.respond(
|
|
40
|
+
nil,
|
|
41
|
+
':status' => Syntropy::HTTP::SEE_OTHER,
|
|
42
|
+
'Location' => '/oauth/consent',
|
|
43
|
+
)
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
req.respond(
|
|
48
|
+
nil,
|
|
49
|
+
':status' => Syntropy::HTTP::SEE_OTHER,
|
|
50
|
+
'Location' => '/',
|
|
51
|
+
'Set-Cookie' => "sid=#{sid}"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def valid_creds?(creds)
|
|
56
|
+
(creds['username'] == 'foobar') && (creds['password'] == 'foobar')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@signin_form = template {
|
|
60
|
+
html {
|
|
61
|
+
head {
|
|
62
|
+
title 'My awesome site'
|
|
63
|
+
}
|
|
64
|
+
body {
|
|
65
|
+
h1 'Sign in:'
|
|
66
|
+
|
|
67
|
+
form(method: 'post') {
|
|
68
|
+
div {
|
|
69
|
+
label 'username:', for: 'username'
|
|
70
|
+
input type: 'text', name: 'username', required: true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
div {
|
|
74
|
+
label 'password:', for: 'password'
|
|
75
|
+
input type: 'password', name: 'password', required: true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
div {
|
|
79
|
+
input type: 'submit', value: 'Submit'
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
auto_refresh!
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'helper'
|
|
4
|
+
|
|
5
|
+
class AppTest < Minitest::Test
|
|
6
|
+
APP_ROOT = File.expand_path(File.join(__dir__, '../app'))
|
|
7
|
+
HTTP = Syntropy::HTTP
|
|
8
|
+
|
|
9
|
+
def setup
|
|
10
|
+
@machine = UM.new
|
|
11
|
+
@app = Syntropy::App.new(
|
|
12
|
+
root_dir: APP_ROOT,
|
|
13
|
+
mount_path: '/',
|
|
14
|
+
machine: @machine
|
|
15
|
+
)
|
|
16
|
+
@test_harness = Syntropy::TestHarness.new(@app)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_root
|
|
20
|
+
req = @test_harness.request(
|
|
21
|
+
':method' => 'GET',
|
|
22
|
+
':path' => '/'
|
|
23
|
+
)
|
|
24
|
+
assert_equal HTTP::OK, req.response_status
|
|
25
|
+
assert_match /Syntropy/, req.response_body
|
|
26
|
+
end
|
|
27
|
+
end
|