syntropy 0.30.0 → 0.32.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/.gitignore +2 -0
- data/CHANGELOG.md +30 -0
- data/TODO.md +46 -1
- data/bin/syntropy +8 -86
- data/cmd/_banner.rb +16 -0
- data/cmd/console.rb +77 -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/blog/app/_layout/default.rb +11 -0
- data/examples/blog/app/_lib/post_store.rb +47 -0
- data/examples/blog/app/_schema/2026-01-01-initial.rb +9 -0
- data/examples/blog/app/_setup.rb +4 -0
- data/examples/blog/app/index.rb +7 -0
- data/examples/blog/app/posts/[id]/edit.rb +33 -0
- data/examples/blog/app/posts/[id]/index.rb +58 -0
- data/examples/blog/app/posts/index.rb +38 -0
- data/examples/blog/app/posts/new.rb +29 -0
- 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 +85 -0
- data/examples/mcp-oauth/app/oauth/authorize.rb +18 -0
- data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
- data/examples/mcp-oauth/app/oauth/register.rb +14 -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 +34 -9
- data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
- data/lib/syntropy/applets/builtin/req.rb +1 -1
- data/lib/syntropy/db/connection_pool.rb +71 -0
- data/lib/syntropy/db/schema.rb +92 -0
- data/lib/syntropy/db/store.rb +31 -0
- 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 +176 -0
- data/lib/syntropy/http/server.rb +5 -5
- data/lib/syntropy/http/{connection.rb → server_connection.rb} +15 -91
- data/lib/syntropy/http.rb +3 -1
- data/lib/syntropy/logger.rb +5 -1
- data/lib/syntropy/{module.rb → module_loader.rb} +47 -8
- data/lib/syntropy/papercraft_extensions.rb +1 -1
- data/lib/syntropy/request/mock_adapter.rb +2 -0
- data/lib/syntropy/request/request_info.rb +22 -4
- data/lib/syntropy/request/response.rb +2 -2
- data/lib/syntropy/request/validation.rb +11 -5
- data/lib/syntropy/routing_tree.rb +2 -1
- data/lib/syntropy/test.rb +77 -0
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +5 -23
- data/syntropy.gemspec +3 -3
- data/test/app/.well-known/foo.rb +3 -0
- data/test/app/_hook.rb +1 -1
- data/test/app/by_method.rb +9 -0
- data/test/app_setup/_setup.rb +7 -0
- data/test/app_setup/index.rb +1 -0
- data/test/app_with_schema/_schema/2026-01-02-foo.rb +12 -0
- data/test/app_with_schema/_schema/2026-05-30-bar.rb +7 -0
- data/test/helper.rb +1 -25
- data/test/schema/2026-01-02-foo.rb +12 -0
- data/test/schema/2026-05-30-bar.rb +7 -0
- data/test/test_app.rb +110 -70
- data/test/test_caching.rb +1 -1
- data/test/{test_connection_pool.rb → test_db_connection_pool.rb} +7 -2
- data/test/test_db_schema.rb +96 -0
- data/test/test_db_store.rb +24 -0
- data/test/test_http_client.rb +52 -0
- data/test/test_http_client_connection.rb +43 -0
- data/test/test_http_protocol.rb +250 -0
- data/test/{test_connection.rb → test_http_server_connection.rb} +39 -48
- data/test/test_json_api.rb +5 -5
- data/test/{test_module.rb → test_module_loader.rb} +31 -0
- data/test/{test_request_extensions.rb → test_request.rb} +153 -18
- data/test/test_routing_tree.rb +15 -3
- data/test/test_server.rb +9 -13
- metadata +84 -36
- data/lib/syntropy/connection_pool.rb +0 -61
- 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,38 @@
|
|
|
1
|
+
@post_store = import '_lib/post_store'
|
|
2
|
+
@layout = import '_layout/default'
|
|
3
|
+
|
|
4
|
+
export http_methods
|
|
5
|
+
|
|
6
|
+
def get(req)
|
|
7
|
+
posts = @post_store.get_all
|
|
8
|
+
req.respond_html(
|
|
9
|
+
@template.render(posts:)
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def post(req)
|
|
14
|
+
data = req.get_form_data
|
|
15
|
+
title = req.validate(data['title'], String, /.+/)
|
|
16
|
+
body = req.validate(data['body'], String, /.+/)
|
|
17
|
+
id = @post_store.create(title, body)
|
|
18
|
+
|
|
19
|
+
req.redirect("posts/#{id}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
@template = @layout.apply { |**props|
|
|
23
|
+
h1 "My blog"
|
|
24
|
+
props[:posts].each { |post|
|
|
25
|
+
div {
|
|
26
|
+
h2 {
|
|
27
|
+
a post[:title], href: "/posts/#{post[:id]}"
|
|
28
|
+
}
|
|
29
|
+
p post[:body]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
div {
|
|
34
|
+
p {
|
|
35
|
+
a "New post", href: '/posts/new'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
@post_store = import '/_lib/post_store'
|
|
2
|
+
@layout = import '/_layout/default'
|
|
3
|
+
|
|
4
|
+
export http_methods
|
|
5
|
+
|
|
6
|
+
def get(req)
|
|
7
|
+
req.respond_html(
|
|
8
|
+
@template.render
|
|
9
|
+
)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
@template = @layout.apply { |**props|
|
|
13
|
+
h1 "Create blog post"
|
|
14
|
+
div {
|
|
15
|
+
form(action: "/posts", method: 'post') {
|
|
16
|
+
div {
|
|
17
|
+
label 'Title', for: 'title'
|
|
18
|
+
input name: 'title', type: 'text'
|
|
19
|
+
}
|
|
20
|
+
div {
|
|
21
|
+
label 'Body', for: 'body'
|
|
22
|
+
textarea '', name: 'body', rows: 5
|
|
23
|
+
}
|
|
24
|
+
div {
|
|
25
|
+
button 'Submit', type: 'submit'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.0.3
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Syntropy OAuth 2.1 Example App
|
|
2
|
+
|
|
3
|
+
This app implements a site that includes an MCP server with OAuth 2.1 authorization.
|
|
4
|
+
|
|
5
|
+
## Authorization workflow:
|
|
6
|
+
|
|
7
|
+
### Phase 1: Discovery
|
|
8
|
+
|
|
9
|
+
- MCP client accesses the mcp endpoint:
|
|
10
|
+
|
|
11
|
+
`GET /mcp`
|
|
12
|
+
|
|
13
|
+
Response headers:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
HTTP/1.1 401 Unauthorized
|
|
17
|
+
WWW-Authenticate: Bearer realm="mcp", resource_metadata="http://localhost:1234/.well-known/oauth-protected-resource"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- MCP client makes a request to the protected resource endpoint in order to the
|
|
21
|
+
protected resource metadata:
|
|
22
|
+
|
|
23
|
+
`GET /.well-known/oauth-protected-resource`
|
|
24
|
+
|
|
25
|
+
Response JSON:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
{
|
|
29
|
+
resource: "http://localhost:1234/",
|
|
30
|
+
authorization_servers: ["http://localhost:1234/"],
|
|
31
|
+
scopes_supported: ["mcp:read", "mcp:write"]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- MCP client makes a request to the authorization server:
|
|
36
|
+
|
|
37
|
+
`GET /.well-known/oauth-authorization-server`
|
|
38
|
+
|
|
39
|
+
Response JSON:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
{
|
|
43
|
+
issuer: "http://localhost:1234/",
|
|
44
|
+
registration_endpoint: "http://localhost:1234/oauth/register",
|
|
45
|
+
authorization_endpoint: "http://localhost:1234/oauth/authorize",
|
|
46
|
+
token_endpoint: "http://localhost:1234/oauth/token",
|
|
47
|
+
scopes_supported: ["mcp:read", "mcp:write"],
|
|
48
|
+
response_types_supported: ["code"]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Phase 2: Dynamic Client Registration (DCR)
|
|
53
|
+
|
|
54
|
+
- MCP client makes a request to the register endpoint
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
POST /oauth/register
|
|
58
|
+
Content-Type: application/json
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
"client_name": "Cursor AI Agent",
|
|
62
|
+
"redirect_uris": ["http://localhost:8400/callback"],
|
|
63
|
+
"grant_types": ["authorization_code", "refresh_token"]
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Response:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
HTTP/1.1 201 Created
|
|
71
|
+
Content-Type: application/json
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
"client_id": "mcp_client_xyz789",
|
|
75
|
+
"client_name": "Cursor AI Agent",
|
|
76
|
+
"redirect_uris": ["http://localhost:8400/callback"],
|
|
77
|
+
"grant_types": ["authorization_code", "refresh_token"]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Phase 3: Authorization Request with PKCE
|
|
82
|
+
|
|
83
|
+
- MCP client opens a browser with a URL pointing to the authorization endpoint:
|
|
84
|
+
|
|
85
|
+
`GET /oauth/authorize?response_type=code&client_id=mcp_client_xyz789&redirect_uri=http%3A%2F%2Flocalhost%3A8400%2Fcallback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=random_state_string HTTP/1.1`
|
|
86
|
+
|
|
87
|
+
the server leads the user through signin and consent workflow. Finally it
|
|
88
|
+
generates a temporary auth code and redirects to the client's callback URL:
|
|
89
|
+
|
|
90
|
+
Response:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
HTTP/1.1 302 Found
|
|
94
|
+
Location: http://localhost:8400/callback?code=splat-auth-code-123&state=random_state_string
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Phase 4: Token Exchange
|
|
98
|
+
|
|
99
|
+
- MCP client grabs the code and exchanges it with the token endpoint:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
POST /oauth/token HTTP/1.1
|
|
103
|
+
Host: auth.example.com
|
|
104
|
+
Content-Type: application/x-www-form-urlencoded
|
|
105
|
+
|
|
106
|
+
grant_type=authorization_code&code=splat-auth-code-123&redirect_uri=http%3A%2F%2Flocalhost%3A8400%2Fcallback&client_id=mcp_client_xyz789&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The authorization server hashes the code_verifier and verifies it matches the
|
|
110
|
+
challenge submitted in Phase 3. If it does, it returns an access token:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
HTTP/1.1 200 OK
|
|
114
|
+
Content-Type: application/json
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
"access_token": "mcp_access_token_abc123",
|
|
118
|
+
"token_type": "Bearer",
|
|
119
|
+
"expires_in": 3600,
|
|
120
|
+
"refresh_token": "mcp_refresh_token_def456"
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### And we're done
|
|
125
|
+
|
|
126
|
+
The client can now seamlessly access the MCP resources by attaching the header:
|
|
127
|
+
|
|
128
|
+
`Authorization: Bearer mcp_access_token_abc123`
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# https://datatracker.ietf.org/doc/html/rfc8414#section-2
|
|
2
|
+
export ->(req) {
|
|
3
|
+
req.respond_json(
|
|
4
|
+
{
|
|
5
|
+
issuer: "http://localhost:1234/",
|
|
6
|
+
registration_endpoint: "http://localhost:1234/oauth/register",
|
|
7
|
+
authorization_endpoint: "http://localhost:1234/oauth/authorize",
|
|
8
|
+
token_endpoint: "http://localhost:1234/oauth/token",
|
|
9
|
+
scopes_supported: ["mcp:read", "mcp:write"],
|
|
10
|
+
response_types_supported: ["code"]
|
|
11
|
+
# jwks_uri: "http://localhost:1234/.well-known/jwks.json",
|
|
12
|
+
# grant_types_supported: ["authorization_code", "refresh_token"],
|
|
13
|
+
# code_challenge_methods_supported: ["S256"],
|
|
14
|
+
# claims_supported: ["aud", "iss", "exp", "scope", "sub"],
|
|
15
|
+
# client_id_metadata_document_supported: true
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# https://datatracker.ietf.org/doc/html/rfc9728#name-protected-resource-metadata
|
|
2
|
+
export ->(req) {
|
|
3
|
+
req.respond_json(
|
|
4
|
+
{
|
|
5
|
+
resource: "http://localhost:1234/",
|
|
6
|
+
authorization_servers: ["http://localhost:1234/"],
|
|
7
|
+
scopes_supported: ["mcp:read", "mcp:write"]
|
|
8
|
+
}
|
|
9
|
+
)
|
|
10
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export self
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
@store = {}
|
|
6
|
+
|
|
7
|
+
def store(params)
|
|
8
|
+
key = SecureRandom.hex(16)
|
|
9
|
+
@store[key] = params
|
|
10
|
+
key
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fetch(key)
|
|
14
|
+
@store[key]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def update(key, value)
|
|
18
|
+
@store[key] = value
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fetch_and_remove(key)
|
|
22
|
+
@store.delete(key)
|
|
23
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Syntropy OAuth 2.1 Example
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
AuthStore = import './_lib/auth_store'
|
|
2
|
+
|
|
3
|
+
export ->(req) {
|
|
4
|
+
req.validate_http_method('post')
|
|
5
|
+
req.validate_content_type('application/json')
|
|
6
|
+
|
|
7
|
+
if !(token_info = valid_token?(req))
|
|
8
|
+
respond_unauthorized(req)
|
|
9
|
+
return
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
handle(req, token_info)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# @param req [Syntropy::Request]
|
|
16
|
+
def valid_token?(req)
|
|
17
|
+
token = req.auth_bearer_token
|
|
18
|
+
return false if !token
|
|
19
|
+
|
|
20
|
+
token_info = AuthStore.fetch(token)
|
|
21
|
+
return false if !token_info
|
|
22
|
+
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def respond_unauthorized(req)
|
|
27
|
+
req.respond_json(
|
|
28
|
+
{
|
|
29
|
+
error: 'unauthorized',
|
|
30
|
+
message: 'Authentication required'
|
|
31
|
+
},
|
|
32
|
+
':status' => HTTP::UNAUTHORIZED,
|
|
33
|
+
'WWW-Authenticate' => <<~EOF.tr("\n", ' ')
|
|
34
|
+
Bearer realm="mcp",
|
|
35
|
+
resource_metadata="http://localhost:1234/.well-known/oauth-protected-resource"
|
|
36
|
+
EOF
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def handle(req, token_info)
|
|
41
|
+
req.validate_http_method('post')
|
|
42
|
+
req.validate_content_type('application/json')
|
|
43
|
+
json = JSON.parse(req.read)
|
|
44
|
+
|
|
45
|
+
req.validate(json['jsonrpc'], '2.0')
|
|
46
|
+
req.validate(json['id'], [Integer, String])
|
|
47
|
+
|
|
48
|
+
method = req.validate(json['method'], String)
|
|
49
|
+
sym = :"handle_#{method}"
|
|
50
|
+
raise Syntropy::ValidationError, 'METHOD_NOT_FOUND: method not found' if !respond_to?(sym)
|
|
51
|
+
|
|
52
|
+
send(sym, req, json, token_info)
|
|
53
|
+
rescue Syntropy::ValidationError => e
|
|
54
|
+
if (m = e.message.match(/(.+)\: (.+)/))
|
|
55
|
+
type, message = m[1], m[2]
|
|
56
|
+
else
|
|
57
|
+
type, message = 'INVALID_REQUEST', e.message
|
|
58
|
+
end
|
|
59
|
+
respond_error(req, json, type, message)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
ERROR_CODES = {
|
|
63
|
+
'INVALID_REQUEST' => -32600,
|
|
64
|
+
'METHOD_NOT_FOUND' => -32601,
|
|
65
|
+
'INVALID_PARAMS' => -32602,
|
|
66
|
+
'INTERNAL_ERROR' => -32603,
|
|
67
|
+
'PARSE_ERROR' => -32700
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def respond_error(req, json, error_type, error_message)
|
|
71
|
+
error_code = ERROR_CODES[type] || ERROR_CODES['INTERNAL_ERROR']
|
|
72
|
+
req.respond_json(
|
|
73
|
+
{
|
|
74
|
+
jsonrpc: '2.0',
|
|
75
|
+
id: json['id'],
|
|
76
|
+
error: {
|
|
77
|
+
code: error_code,
|
|
78
|
+
message: error_message
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def handle_initialize()
|
|
85
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
AuthStore = import '../_lib/auth_store'
|
|
2
|
+
|
|
3
|
+
# https://datatracker.ietf.org/doc/html/rfc6749#section-3.1
|
|
4
|
+
export ->(req) {
|
|
5
|
+
req.validate_http_method('get')
|
|
6
|
+
params = req.query
|
|
7
|
+
req.validate(params['response_type'], 'code')
|
|
8
|
+
|
|
9
|
+
client_info = AuthStore.fetch(params['client_id'])
|
|
10
|
+
req.validate(client_info, Hash)
|
|
11
|
+
|
|
12
|
+
key = AuthStore.store(req.query)
|
|
13
|
+
req.respond(nil, {
|
|
14
|
+
':status' => Syntropy::HTTP::FOUND,
|
|
15
|
+
'Location' => '/signin',
|
|
16
|
+
'Set-Cookie' => "oauth_signin_id=#{key}"
|
|
17
|
+
})
|
|
18
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
AuthStore = import '../_lib/auth_store'
|
|
2
|
+
|
|
3
|
+
export ->(req) {
|
|
4
|
+
case req.method
|
|
5
|
+
when 'get'
|
|
6
|
+
render_consent_form(req)
|
|
7
|
+
when 'post'
|
|
8
|
+
validate_consent(req)
|
|
9
|
+
else
|
|
10
|
+
raise Syntropy::Error.method_not_allowed
|
|
11
|
+
end
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
def render_consent_form(req)
|
|
15
|
+
oauth_signin_id = req.cookies['oauth_signin_id']
|
|
16
|
+
auth_info = AuthStore.fetch(oauth_signin_id)
|
|
17
|
+
req.validate(auth_info, Hash)
|
|
18
|
+
|
|
19
|
+
client_id = auth_info['client_id']
|
|
20
|
+
req.validate(client_id, String)
|
|
21
|
+
client_info = AuthStore.fetch(client_id)
|
|
22
|
+
|
|
23
|
+
sid = auth_info['sid']
|
|
24
|
+
req.validate(sid, String)
|
|
25
|
+
session_info = AuthStore.fetch(sid)
|
|
26
|
+
|
|
27
|
+
req.respond_html(@consent_form.render(client_info, session_info))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_consent(req)
|
|
31
|
+
data = req.get_form_data
|
|
32
|
+
decision = data['decision']
|
|
33
|
+
req.validate(decision, ['deny', 'allow'])
|
|
34
|
+
|
|
35
|
+
oauth_signin_id = req.cookies['oauth_signin_id']
|
|
36
|
+
auth_info = AuthStore.fetch(oauth_signin_id)
|
|
37
|
+
req.validate(auth_info, Hash)
|
|
38
|
+
|
|
39
|
+
callback_query = case decision
|
|
40
|
+
when 'deny'
|
|
41
|
+
{
|
|
42
|
+
error: 'access_denied',
|
|
43
|
+
state: auth_info['state']
|
|
44
|
+
}
|
|
45
|
+
when 'allow'
|
|
46
|
+
auth_code = AuthStore.store(auth_info)
|
|
47
|
+
{
|
|
48
|
+
code: auth_code,
|
|
49
|
+
state: auth_info['state']
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
uri = format(
|
|
54
|
+
'%s?%s', auth_info['redirect_uri'],
|
|
55
|
+
URI.encode_www_form(callback_query)
|
|
56
|
+
)
|
|
57
|
+
req.respond(
|
|
58
|
+
nil,
|
|
59
|
+
':status' => Syntropy::HTTP::FOUND,
|
|
60
|
+
'Location' => uri
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@consent_form = template { |client_info, session_info|
|
|
65
|
+
html {
|
|
66
|
+
head {
|
|
67
|
+
title 'My awesome site'
|
|
68
|
+
}
|
|
69
|
+
body {
|
|
70
|
+
h2 client_info['client_name']
|
|
71
|
+
p {
|
|
72
|
+
span 'wants to access my awesome site on behalf of '
|
|
73
|
+
em session_info[:username]
|
|
74
|
+
span '.'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
form(action: '') {
|
|
78
|
+
div {
|
|
79
|
+
button 'Deny', type: 'submit', name: 'decision', value: 'deny'
|
|
80
|
+
button 'Allow', type: 'submit', name: 'decision', value: 'allow'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
auto_refresh!
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
AuthStore = import '../_lib/auth_store'
|
|
2
|
+
|
|
3
|
+
# https://datatracker.ietf.org/doc/html/rfc7591
|
|
4
|
+
export ->(req) {
|
|
5
|
+
req.validate_http_method('post')
|
|
6
|
+
req.validate_content_type('application/json')
|
|
7
|
+
client_info = JSON.parse(req.read)
|
|
8
|
+
client_id = AuthStore.store(client_info)
|
|
9
|
+
|
|
10
|
+
req.respond_json(
|
|
11
|
+
{ client_id: }.merge(client_info),
|
|
12
|
+
':status' => HTTP::CREATED
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -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
|