Skip to content

Commit 13c6f6c

Browse files
committed
Implement OAuth workflow phase 4 (token endpoint)
1 parent 8f3354d commit 13c6f6c

2 files changed

Lines changed: 286 additions & 3 deletions

File tree

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,79 @@
1+
AuthStore = import '../_lib/auth_store'
2+
13
# https://datatracker.ietf.org/doc/html/rfc6749#section-5
2-
export ->(req) {
3-
req.respond()
4-
}
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

examples/mcp-oauth/test/test_oauth.rb

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# frozen_string_literal: true
22

33
require_relative 'helper'
4+
require 'base64'
5+
require 'digest'
46

57
class OAuthBaseTest < Minitest::Test
68
APP_ROOT = File.expand_path(File.join(__dir__, '../app'))
@@ -418,3 +420,209 @@ def test_oauth_consent_endpoint_post_allow
418420
assert_equal auth_params, code_info
419421
end
420422
end
423+
424+
class OAuthPhase4AuthorizationTest < OAuthBaseTest
425+
def setup
426+
super
427+
@client_info = {
428+
"client_name" => "My AI Agent",
429+
"redirect_uris" => ["http://localhost:8400/callback"],
430+
"grant_types" => ["authorization_code", "refresh_token"]
431+
}
432+
@client_id = @store.store(@client_info)
433+
434+
@session_info = {
435+
username: 'foobar'
436+
}
437+
@sid = @store.store(@session_info)
438+
439+
@code_verifier = SecureRandom.hex(16)
440+
@code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(@code_verifier), padding: false)
441+
442+
@auth_params = {
443+
'response_type' => 'code',
444+
'client_id' => @client_id,
445+
'redirect_uri' => 'http://localhost:4321/callback',
446+
'code_challenge' => @code_challenge,
447+
'code_challenge_method' => 'S256',
448+
'state' => 'my_state',
449+
'sid' => @sid
450+
}
451+
@auth_code = @store.store(@auth_params)
452+
end
453+
454+
def test_oauth_token_exchange
455+
req = @test_harness.request(
456+
{
457+
':method' => 'POST',
458+
':path' => '/oauth/token',
459+
'content-type' => 'application/x-www-form-urlencoded'
460+
},
461+
URI.encode_www_form(
462+
grant_type: 'authorization_code',
463+
code: @auth_code,
464+
redirect_uri: 'http://localhost:4321/callback',
465+
client_id: @client_id,
466+
code_verifier: @code_verifier
467+
)
468+
)
469+
470+
assert_equal HTTP::OK, req.response_status
471+
assert_equal 'application/json', req.response_content_type
472+
json = req.response_json
473+
474+
at = json['access_token']
475+
assert_kind_of String, at
476+
token_info = @store.fetch(at)
477+
refute_nil token_info
478+
assert_equal @session_info[:username], token_info[:username]
479+
assert_equal 'oauth', token_info[:type]
480+
481+
assert_equal 'Bearer', json['token_type']
482+
assert_kind_of Integer, json['expires_in']
483+
end
484+
485+
def test_oauth_token_exchange_missing_params
486+
req = @test_harness.request(
487+
{
488+
':method' => 'POST',
489+
':path' => '/oauth/token',
490+
'content-type' => 'application/x-www-form-urlencoded'
491+
},
492+
URI.encode_www_form({}
493+
# grant_type: 'authorization_code',
494+
# code: auth_code,
495+
# redirect_uri: 'http://localhost:4321/callback',
496+
# client_id: client_id,
497+
# code_verifier: code_verifier
498+
)
499+
)
500+
501+
assert_equal HTTP::BAD_REQUEST, req.response_status
502+
assert_equal 'application/json', req.response_content_type
503+
json = req.response_json
504+
505+
error = json['error']
506+
assert_equal 'invalid_request', error
507+
end
508+
509+
def test_oauth_token_exchange_invalid_grant_type
510+
req = @test_harness.request(
511+
{
512+
':method' => 'POST',
513+
':path' => '/oauth/token',
514+
'content-type' => 'application/x-www-form-urlencoded'
515+
},
516+
URI.encode_www_form(
517+
grant_type: 'foo',
518+
code: @auth_code,
519+
redirect_uri: 'http://localhost:4321/callback',
520+
client_id: @client_id,
521+
code_verifier: @code_verifier
522+
)
523+
)
524+
525+
assert_equal HTTP::BAD_REQUEST, req.response_status
526+
assert_equal 'application/json', req.response_content_type
527+
json = req.response_json
528+
529+
error = json['error']
530+
assert_equal 'unsupported_grant_type', error
531+
end
532+
533+
def test_oauth_token_exchange_invalid_code
534+
req = @test_harness.request(
535+
{
536+
':method' => 'POST',
537+
':path' => '/oauth/token',
538+
'content-type' => 'application/x-www-form-urlencoded'
539+
},
540+
URI.encode_www_form(
541+
grant_type: 'authorization_code',
542+
code: @auth_code + '!',
543+
redirect_uri: 'http://localhost:4321/callback',
544+
client_id: @client_id,
545+
code_verifier: @code_verifier
546+
)
547+
)
548+
549+
assert_equal HTTP::BAD_REQUEST, req.response_status
550+
assert_equal 'application/json', req.response_content_type
551+
json = req.response_json
552+
553+
error = json['error']
554+
assert_equal 'invalid_request', error
555+
end
556+
557+
def test_oauth_token_exchange_invalid_redirect_uri
558+
req = @test_harness.request(
559+
{
560+
':method' => 'POST',
561+
':path' => '/oauth/token',
562+
'content-type' => 'application/x-www-form-urlencoded'
563+
},
564+
URI.encode_www_form(
565+
grant_type: 'authorization_code',
566+
code: @auth_code,
567+
redirect_uri: 'http://localhost:4321/foo',
568+
client_id: @client_id,
569+
code_verifier: @code_verifier
570+
)
571+
)
572+
573+
assert_equal HTTP::BAD_REQUEST, req.response_status
574+
assert_equal 'application/json', req.response_content_type
575+
json = req.response_json
576+
577+
error = json['error']
578+
assert_equal 'invalid_request', error
579+
end
580+
581+
def test_oauth_token_exchange_invalid_client_id
582+
req = @test_harness.request(
583+
{
584+
':method' => 'POST',
585+
':path' => '/oauth/token',
586+
'content-type' => 'application/x-www-form-urlencoded'
587+
},
588+
URI.encode_www_form(
589+
grant_type: 'authorization_code',
590+
code: @auth_code + '!',
591+
redirect_uri: 'http://localhost:4321/callback',
592+
client_id: @client_id + 'foo',
593+
code_verifier: @code_verifier
594+
)
595+
)
596+
597+
assert_equal HTTP::BAD_REQUEST, req.response_status
598+
assert_equal 'application/json', req.response_content_type
599+
json = req.response_json
600+
601+
error = json['error']
602+
assert_equal 'invalid_request', error
603+
end
604+
605+
def test_oauth_token_exchange_invalid_code_verifier
606+
req = @test_harness.request(
607+
{
608+
':method' => 'POST',
609+
':path' => '/oauth/token',
610+
'content-type' => 'application/x-www-form-urlencoded'
611+
},
612+
URI.encode_www_form(
613+
grant_type: 'authorization_code',
614+
code: @auth_code + '!',
615+
redirect_uri: 'http://localhost:4321/callback',
616+
client_id: @client_id,
617+
code_verifier: @code_verifier + 'abc'
618+
)
619+
)
620+
621+
assert_equal HTTP::BAD_REQUEST, req.response_status
622+
assert_equal 'application/json', req.response_content_type
623+
json = req.response_json
624+
625+
error = json['error']
626+
assert_equal 'invalid_request', error
627+
end
628+
end

0 commit comments

Comments
 (0)