Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/oauthcore/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package oauthcore

type Config struct {
Issuer string
Resource string
ScopesSupported []string
}
38 changes: 38 additions & 0 deletions internal/oauthcore/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package oauthcore

import (
"net/http"
"strings"
)

// MapAuthorizeError maps internal authorization errors to RFC 6749 §4.1.2.1
// error codes. The HTTP adapter decides whether to redirect or write directly.
func MapAuthorizeError(err error) (code, description string) {
msg := err.Error()
switch {
case strings.HasPrefix(msg, "unsupported_response_type"):
return "unsupported_response_type", ""
case strings.HasPrefix(msg, "invalid_request"):
return "invalid_request", strings.TrimPrefix(msg, "invalid_request: ")
default:
return "invalid_request", msg
}
}

// MapTokenError maps internal token exchange errors to RFC 6749 §5.2 error
// codes and HTTP status values.
func MapTokenError(err error) (code string, status int) {
msg := err.Error()
switch {
case strings.HasPrefix(msg, "unsupported_grant_type"):
return "unsupported_grant_type", http.StatusBadRequest
case strings.HasPrefix(msg, "invalid_client"):
return "invalid_client", http.StatusUnauthorized
case strings.HasPrefix(msg, "invalid_grant"):
return "invalid_grant", http.StatusBadRequest
case strings.HasPrefix(msg, "server_error"):
return "server_error", http.StatusInternalServerError
default:
return "invalid_request", http.StatusBadRequest
}
}
53 changes: 53 additions & 0 deletions internal/oauthcore/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package oauthcore

import (
"errors"
"net/http"
"testing"
)

func TestMapAuthorizeError(t *testing.T) {
tests := []struct {
name string
err error
wantCode string
wantDesc string
}{
{"unsupported response type", errors.New("unsupported_response_type"), "unsupported_response_type", ""},
{"invalid request keeps detail", errors.New("invalid_request: missing state parameter"), "invalid_request", "missing state parameter"},
{"unknown error becomes invalid request", errors.New("unexpected"), "invalid_request", "unexpected"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, desc := MapAuthorizeError(tt.err)
if code != tt.wantCode || desc != tt.wantDesc {
t.Fatalf("MapAuthorizeError() = (%q, %q), want (%q, %q)", code, desc, tt.wantCode, tt.wantDesc)
}
})
}
}

func TestMapTokenError(t *testing.T) {
tests := []struct {
name string
err error
wantCode string
wantStatus int
}{
{"unsupported grant", errors.New("unsupported_grant_type"), "unsupported_grant_type", http.StatusBadRequest},
{"invalid client", errors.New("invalid_client"), "invalid_client", http.StatusUnauthorized},
{"invalid grant", errors.New("invalid_grant: bad code"), "invalid_grant", http.StatusBadRequest},
{"server error", errors.New("server_error"), "server_error", http.StatusInternalServerError},
{"unknown error", errors.New("unexpected"), "invalid_request", http.StatusBadRequest},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, status := MapTokenError(tt.err)
if code != tt.wantCode || status != tt.wantStatus {
t.Fatalf("MapTokenError() = (%q, %d), want (%q, %d)", code, status, tt.wantCode, tt.wantStatus)
}
})
}
}
73 changes: 73 additions & 0 deletions internal/oauthcore/mcp_acl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package oauthcore

import "encoding/json"

type AnonymousMCPPolicy struct {
PublicTools []string
}

type jsonRPCRequest struct {
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}

type toolCallParams struct {
Name string `json:"name"`
}

func (p AnonymousMCPPolicy) PayloadAllowed(body []byte) (allowed bool, toolsList bool, reason string) {
var batch []json.RawMessage
if err := json.Unmarshal(body, &batch); err == nil {
if len(batch) == 0 {
return false, false, "empty_batch"
}
containsToolsList := false
for _, raw := range batch {
ok, isToolsList, reason := p.messageAllowed(raw)
if !ok {
return false, false, reason
}
containsToolsList = containsToolsList || isToolsList
}
return true, containsToolsList, ""
}

return p.messageAllowed(body)
}

func (p AnonymousMCPPolicy) messageAllowed(raw []byte) (allowed bool, toolsList bool, reason string) {
var msg jsonRPCRequest
if err := json.Unmarshal(raw, &msg); err != nil {
return false, false, "invalid_json"
}

switch msg.Method {
case "initialize", "notifications/initialized", "ping":
return true, false, ""
case "tools/list":
return true, true, ""
case "tools/call":
var params toolCallParams
if err := json.Unmarshal(msg.Params, &params); err != nil {
return false, false, "invalid_tool_call_params"
}
if params.Name == "" {
return false, false, "missing_tool_name"
}
if p.IsPublicTool(params.Name) {
return true, false, ""
}
return false, false, "tool_not_public"
default:
return false, false, "method_not_public"
}
}

func (p AnonymousMCPPolicy) IsPublicTool(name string) bool {
for _, tool := range p.PublicTools {
if tool == name {
return true
}
}
return false
}
34 changes: 34 additions & 0 deletions internal/oauthcore/mcp_acl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package oauthcore

import "testing"

func TestAnonymousMCPPayloadAllowed(t *testing.T) {
policy := AnonymousMCPPolicy{PublicTools: []string{"list_pages", "get_page"}}

tests := []struct {
name string
body string
wantAllowed bool
wantToolsList bool
wantReason string
}{
{"initialize allowed", `{"jsonrpc":"2.0","id":1,"method":"initialize"}`, true, false, ""},
{"tools list allowed", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, true, true, ""},
{"public tool allowed", `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_page"}}`, true, false, ""},
{"private tool rejected", `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"publish_post"}}`, false, false, "tool_not_public"},
{"unknown method rejected", `{"jsonrpc":"2.0","id":1,"method":"resources/read"}`, false, false, "method_not_public"},
{"batch detects tools list", `[{"jsonrpc":"2.0","id":1,"method":"initialize"},{"jsonrpc":"2.0","id":2,"method":"tools/list"}]`, true, true, ""},
{"empty batch rejected", `[]`, false, false, "empty_batch"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotAllowed, gotToolsList, gotReason := policy.PayloadAllowed([]byte(tt.body))
if gotAllowed != tt.wantAllowed || gotToolsList != tt.wantToolsList || gotReason != tt.wantReason {
t.Fatalf("PayloadAllowed() = (%v, %v, %q), want (%v, %v, %q)",
gotAllowed, gotToolsList, gotReason,
tt.wantAllowed, tt.wantToolsList, tt.wantReason)
}
})
}
}
28 changes: 28 additions & 0 deletions internal/oauthcore/metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package oauthcore

import "fmt"

func AuthorizationServerMetadata(cfg Config) map[string]interface{} {
return map[string]interface{}{
"issuer": cfg.Issuer,
"authorization_endpoint": fmt.Sprintf("%s/authorize", cfg.Issuer),
"token_endpoint": fmt.Sprintf("%s/token", cfg.Issuer),
"registration_endpoint": fmt.Sprintf("%s/register", cfg.Issuer),
"response_types_supported": []string{"code"},
"grant_types_supported": []string{"authorization_code"},
"code_challenge_methods_supported": []string{"S256"},
"token_endpoint_auth_methods_supported": []string{"none", "client_secret_post"},
"scopes_supported": cfg.ScopesSupported,
"service_documentation": cfg.Resource,
}
}

func ProtectedResourceMetadata(cfg Config) map[string]interface{} {
return map[string]interface{}{
"resource": cfg.Resource,
"authorization_servers": []string{cfg.Issuer},
"bearer_methods_supported": []string{"header"},
"scopes_supported": cfg.ScopesSupported,
"resource_documentation": cfg.Resource,
}
}
50 changes: 50 additions & 0 deletions internal/oauthcore/metadata_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package oauthcore

import "testing"

func TestAuthorizationServerMetadata(t *testing.T) {
cfg := Config{
Issuer: "https://mcp.example.test",
Resource: "https://mcp.example.test/mcp",
ScopesSupported: []string{"mcp"},
}

got := AuthorizationServerMetadata(cfg)

if got["issuer"] != cfg.Issuer {
t.Fatalf("issuer = %v, want %q", got["issuer"], cfg.Issuer)
}
if got["authorization_endpoint"] != "https://mcp.example.test/authorize" {
t.Fatalf("authorization_endpoint = %v", got["authorization_endpoint"])
}
if got["token_endpoint"] != "https://mcp.example.test/token" {
t.Fatalf("token_endpoint = %v", got["token_endpoint"])
}
if got["registration_endpoint"] != "https://mcp.example.test/register" {
t.Fatalf("registration_endpoint = %v", got["registration_endpoint"])
}
if got["service_documentation"] != cfg.Resource {
t.Fatalf("service_documentation = %v, want %q", got["service_documentation"], cfg.Resource)
}
}

func TestProtectedResourceMetadata(t *testing.T) {
cfg := Config{
Issuer: "https://mcp.example.test",
Resource: "https://mcp.example.test/mcp",
ScopesSupported: []string{"mcp"},
}

got := ProtectedResourceMetadata(cfg)

if got["resource"] != cfg.Resource {
t.Fatalf("resource = %v, want %q", got["resource"], cfg.Resource)
}
servers, ok := got["authorization_servers"].([]string)
if !ok || len(servers) != 1 || servers[0] != cfg.Issuer {
t.Fatalf("authorization_servers = %#v", got["authorization_servers"])
}
if got["resource_documentation"] != cfg.Resource {
t.Fatalf("resource_documentation = %v, want %q", got["resource_documentation"], cfg.Resource)
}
}
50 changes: 50 additions & 0 deletions internal/oauthcore/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package oauthcore

import "time"

type AuthCode struct {
RedirectURI string
ExpiresAt time.Time
CodeChallenge string
CodeChallengeMethod string
}

type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in,omitempty"`
Scope string `json:"scope,omitempty"`
}

type RegistrationRequest struct {
RedirectURIs []string `json:"redirect_uris"`
}

type RegistrationResponse struct {
ClientID string `json:"client_id"`
ClientIDIssuedAt int64 `json:"client_id_issued_at"`
RedirectURIs []string `json:"redirect_uris"`
GrantTypes []string `json:"grant_types"`
ResponseTypes []string `json:"response_types"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
Scope string `json:"scope"`
}

type AuthorizeRequest struct {
ResponseType string
ClientID string
RedirectURI string
State string
CodeChallenge string
CodeChallengeMethod string
}

type TokenExchangeRequest struct {
GrantType string
ClientID string
ClientSecret string
RedirectURI string
Code string
CodeVerifier string
}
18 changes: 18 additions & 0 deletions internal/oauthcore/tokens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package oauthcore

import (
"crypto/sha256"
"encoding/hex"
)

type TokenStore interface {
Load() (map[string]float64, error)
Save(map[string]float64) error
Close() error
}

func HashToken(token string) string {
h := sha256.New()
h.Write([]byte(token))
return hex.EncodeToString(h.Sum(nil))
}
11 changes: 11 additions & 0 deletions internal/oauthcore/tokens_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package oauthcore

import "testing"

func TestHashToken(t *testing.T) {
got := HashToken("token")
want := "3c469e9d6c5875d37a43f353d4f88e61fcf812c66eee3457465a40b0da4153e0"
if got != want {
t.Fatalf("HashToken() = %q, want %q", got, want)
}
}
Loading
Loading