Module: BetterAuth::Plugins::MCP

Defined in:
lib/better_auth/plugins/mcp.rb,
lib/better_auth/plugins/mcp/token.rb,
lib/better_auth/plugins/mcp/config.rb,
lib/better_auth/plugins/mcp/schema.rb,
lib/better_auth/plugins/mcp/consent.rb,
lib/better_auth/plugins/mcp/metadata.rb,
lib/better_auth/plugins/mcp/userinfo.rb,
lib/better_auth/plugins/mcp/registration.rb,
lib/better_auth/plugins/mcp/authorization.rb,
lib/better_auth/plugins/mcp/legacy_aliases.rb,
lib/better_auth/plugins/mcp/resource_handler.rb

Defined Under Namespace

Modules: ResourceHandler

Constant Summary collapse

DEFAULT_SCOPES =
%w[openid profile email offline_access].freeze
DEFAULT_GRANT_TYPES =
[OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::REFRESH_GRANT, OAuthProtocol::CLIENT_CREDENTIALS_GRANT].freeze

Class Method Summary collapse

Class Method Details

.authorization_redirect_uri(ctx, config, query, session) ⇒ Object



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/better_auth/plugins/mcp/authorization.rb', line 40

def authorization_redirect_uri(ctx, config, query, session)
  query = OAuthProtocol.stringify_keys(query)
  prompts = OAuthProtocol.parse_scopes(query["prompt"])
  raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client") if query["client_id"].to_s.empty?
  unless query["response_type"]
    raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(ctx.context.base_url + "/error", error: "invalid_request", error_description: "response_type is required"))
  end

  client = OAuthProtocol.find_client(ctx, "oauthClient", query["client_id"])
  raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client") unless client
  OAuthProtocol.validate_redirect_uri!(client, query["redirect_uri"])
  client_data = OAuthProtocol.stringify_keys(client)
  raise ctx.redirect("#{ctx.context.base_url}/error?error=client_disabled") if client_data["disabled"]
  raise ctx.redirect("#{ctx.context.base_url}/error?error=unsupported_response_type") unless query["response_type"] == "code"

  scopes = OAuthProtocol.parse_scopes(query["scope"] || "openid")
  allowed_scopes = OAuthProtocol.parse_scopes(client_data["scopes"])
  allowed_scopes = OAuthProtocol.parse_scopes(config[:scopes]) if allowed_scopes.empty?
  invalid_scopes = scopes.reject { |scope| config[:scopes].include?(scope) && allowed_scopes.include?(scope) }
  unless invalid_scopes.empty?
    raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_scope", error_description: "The following scopes are invalid: #{invalid_scopes.join(", ")}", state: query["state"]))
  end

  pkce_error = OAuthProtocol.validate_authorize_pkce(client_data, scopes, query["code_challenge"], query["code_challenge_method"])
  if pkce_error
    description = (pkce_error == "PKCE is required") ? "pkce is required" : pkce_error
    raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: description, state: query["state"]))
  end

  if prompts.include?("consent")
    consent_code = Crypto.random_string(32)
    config[:store][:consents][consent_code] = {
      query: query,
      session: session,
      client: client,
      scopes: scopes,
      expires_at: Time.now + config[:code_expires_in].to_i
    }
    raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:consent_page], consent_code: consent_code, client_id: client_data["clientId"], scope: OAuthProtocol.scope_string(scopes)))
  end

  code = Crypto.random_string(32)
  OAuthProtocol.store_code(
    config[:store],
    code: code,
    client_id: query["client_id"],
    redirect_uri: query["redirect_uri"],
    session: session,
    scopes: scopes,
    code_challenge: query["code_challenge"],
    code_challenge_method: query["code_challenge_method"],
    nonce: query["nonce"],
    reference_id: client_data["referenceId"]
  )
  OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: validate_issuer_url(OAuthProtocol.issuer(ctx)))
end

.authorize(ctx, config) ⇒ Object



8
9
10
11
12
13
14
15
16
17
18
# File 'lib/better_auth/plugins/mcp/authorization.rb', line 8

def authorize(ctx, config)
  set_cors_headers(ctx)
  query = OAuthProtocol.stringify_keys(ctx.query)
  session = Routes.current_session(ctx, allow_nil: true)
  unless session
    ctx.set_signed_cookie("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax")
    raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], query))
  end

  redirect_with_code(ctx, config, query, session)
end

Raises:



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# File 'lib/better_auth/plugins/mcp/consent.rb', line 8

def consent(ctx, config)
  current_session = Routes.current_session(ctx)
  body = OAuthProtocol.stringify_keys(ctx.body)
  pending = config[:store][:consents].delete(body["consent_code"].to_s)
  raise APIError.new("BAD_REQUEST", message: "invalid consent_code") unless pending
  raise APIError.new("BAD_REQUEST", message: "expired consent_code") if pending[:expires_at] <= Time.now

  query = pending[:query]
  if body["accept"] == false || body["accept"].to_s == "false"
    return {redirectURI: OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "access_denied", state: query["state"], iss: validate_issuer_url(OAuthProtocol.issuer(ctx)))}
  end

  granted_scopes = OAuthProtocol.parse_scopes(body["scope"] || body["scopes"])
  granted_scopes = pending[:scopes] if granted_scopes.empty?
  unless granted_scopes.all? { |scope| pending[:scopes].include?(scope) }
    raise APIError.new("BAD_REQUEST", message: "invalid_scope")
  end
  pending[:session] = current_session if current_session
  query = query.merge("scope" => OAuthProtocol.scope_string(granted_scopes)).except("prompt")
  {redirectURI: authorization_redirect_uri(ctx, config, query, pending[:session])}
end

.inactive_token_responseObject



103
104
105
# File 'lib/better_auth/plugins/mcp/token.rb', line 103

def inactive_token_response
  {active: false}
end

.introspect(ctx, config) ⇒ Object



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/better_auth/plugins/mcp/token.rb', line 75

def introspect(ctx, config)
  OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix])
  body = OAuthProtocol.stringify_keys(ctx.body)
  token_record = OAuthProtocol.find_token_by_hint(config[:store], body["token"].to_s, body["token_type_hint"], prefix: config[:prefix])
  return inactive_token_response if token_record.nil? || token_record["revoked"] || (token_record["expiresAt"] && token_record["expiresAt"] <= Time.now)

  {
    active: true,
    client_id: token_record["clientId"],
    scope: OAuthProtocol.scope_string(token_record["scope"] || token_record["scopes"]),
    sub: token_record["subject"] || token_record.dig("user", "id"),
    iss: token_record["issuer"],
    iat: token_record["issuedAt"]&.to_i,
    exp: token_record["expiresAt"]&.to_i,
    sid: token_record["sessionId"],
    aud: token_record["audience"]
  }.compact
end

.jwks(ctx, config) ⇒ Object



74
75
76
77
78
# File 'lib/better_auth/plugins/mcp/metadata.rb', line 74

def jwks(ctx, config)
  jwt_config = config[:jwt] || {}
  BetterAuth::Plugins.create_jwk(ctx, jwt_config) if BetterAuth::Plugins.all_jwks(ctx, jwt_config).empty?
  {keys: BetterAuth::Plugins.public_jwks(ctx, jwt_config).map { |key| BetterAuth::Plugins.public_jwk(key, jwt_config) }}
end

.legacy_authorize_endpoint(config) ⇒ Object



14
15
16
17
18
# File 'lib/better_auth/plugins/mcp/legacy_aliases.rb', line 14

def legacy_authorize_endpoint(config)
  Endpoint.new(path: "/mcp/authorize", method: "GET") do |ctx|
    authorize(ctx, config)
  end
end

.legacy_jwks_endpoint(config) ⇒ Object



32
33
34
35
36
# File 'lib/better_auth/plugins/mcp/legacy_aliases.rb', line 32

def legacy_jwks_endpoint(config)
  Endpoint.new(path: "/mcp/jwks", method: "GET") do |ctx|
    ctx.json(jwks(ctx, config))
  end
end

.legacy_register_endpoint(config) ⇒ Object



8
9
10
11
12
# File 'lib/better_auth/plugins/mcp/legacy_aliases.rb', line 8

def legacy_register_endpoint(config)
  Endpoint.new(path: "/mcp/register", method: "POST") do |ctx|
    ctx.json(register_client(ctx, config), status: 201, headers: no_store_headers)
  end
end

.legacy_token_endpoint(config) ⇒ Object



20
21
22
23
24
# File 'lib/better_auth/plugins/mcp/legacy_aliases.rb', line 20

def legacy_token_endpoint(config)
  Endpoint.new(path: "/mcp/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
    ctx.json(token(ctx, config), headers: no_store_headers)
  end
end

.legacy_userinfo_endpoint(config) ⇒ Object



26
27
28
29
30
# File 'lib/better_auth/plugins/mcp/legacy_aliases.rb', line 26

def legacy_userinfo_endpoint(config)
  Endpoint.new(path: "/mcp/userinfo", method: "GET") do |ctx|
    ctx.json(userinfo(ctx, config))
  end
end

.mcp_jwks_uri(ctx, config) ⇒ Object



61
62
63
64
65
# File 'lib/better_auth/plugins/mcp/metadata.rb', line 61

def mcp_jwks_uri(ctx, config)
  config.dig(:oidc_config, :metadata, :jwks_uri) ||
    config.dig(:advertised_metadata, :jwks_uri) ||
    "#{OAuthProtocol.endpoint_base(ctx)}/oauth2/jwks"
end

.mcp_signing_algs(ctx, config) ⇒ Object



67
68
69
70
71
72
# File 'lib/better_auth/plugins/mcp/metadata.rb', line 67

def mcp_signing_algs(ctx, config)
  jwt_plugin = ctx.context.options.plugins.find { |plugin| plugin.id == "jwt" }
  alg = config.dig(:jwt, :jwks, :key_pair_config, :alg) ||
    jwt_plugin&.options&.dig(:jwks, :key_pair_config, :alg)
  [alg || "EdDSA"]
end

.no_store_headersObject



46
47
48
# File 'lib/better_auth/plugins/mcp/config.rb', line 46

def no_store_headers
  {"Cache-Control" => "no-store", "Pragma" => "no-cache"}
end

.normalize_config(options) ⇒ Object



11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/better_auth/plugins/mcp/config.rb', line 11

def normalize_config(options)
  incoming = BetterAuth::Plugins.normalize_hash(options || {})
  oidc = BetterAuth::Plugins.normalize_hash(incoming[:oidc_config] || {})
  base = {
    login_page: "/login",
    consent_page: "/oauth2/consent",
    resource: nil,
    scopes: DEFAULT_SCOPES,
    grant_types: DEFAULT_GRANT_TYPES,
    allow_dynamic_client_registration: true,
    allow_unauthenticated_client_registration: true,
    require_pkce: true,
    code_expires_in: 600,
    access_token_expires_in: 3600,
    refresh_token_expires_in: 604_800,
    m2m_access_token_expires_in: 3600,
    store_client_secret: "plain",
    prefix: {},
    store: OAuthProtocol.stores
  }
  config = base.merge(oidc.except(:metadata)).merge(incoming)
  config[:oidc_config] = oidc
  config[:scopes] = (Array(base[:scopes]) + Array(oidc[:scopes]) + Array(incoming[:scopes])).compact.map(&:to_s).uniq
  config[:grant_types] = Array(config[:grant_types]).map(&:to_s)
  config[:prefix] = BetterAuth::Plugins.normalize_hash(config[:prefix] || {})
  config
end

.oauth_metadata(ctx, config) ⇒ Object



22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/better_auth/plugins/mcp/metadata.rb', line 22

def (ctx, config)
  base = OAuthProtocol.endpoint_base(ctx)
  {
    issuer: validate_issuer_url(OAuthProtocol.issuer(ctx)),
    authorization_endpoint: "#{base}/oauth2/authorize",
    token_endpoint: "#{base}/oauth2/token",
    userinfo_endpoint: "#{base}/oauth2/userinfo",
    registration_endpoint: "#{base}/oauth2/register",
    introspection_endpoint: "#{base}/oauth2/introspect",
    revocation_endpoint: "#{base}/oauth2/revoke",
    jwks_uri: mcp_jwks_uri(ctx, config),
    scopes_supported: config[:scopes],
    response_types_supported: ["code"],
    response_modes_supported: ["query"],
    grant_types_supported: config[:grant_types],
    subject_types_supported: ["public"],
    id_token_signing_alg_values_supported: mcp_signing_algs(ctx, config),
    token_endpoint_auth_methods_supported: ["none", "client_secret_basic", "client_secret_post"],
    introspection_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
    revocation_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
    code_challenge_methods_supported: ["S256"],
    authorization_response_iss_parameter_supported: true,
    claims_supported: %w[sub iss aud exp iat sid scope azp email email_verified name picture family_name given_name]
  }.merge(BetterAuth::Plugins.normalize_hash(config.dig(:oidc_config, :metadata) || {}))
end

.parse_login_prompt(value) ⇒ Object



103
104
105
106
107
108
# File 'lib/better_auth/plugins/mcp/authorization.rb', line 103

def (value)
  parsed = JSON.parse(value.to_s)
  parsed.is_a?(Hash) ? parsed : nil
rescue JSON::ParserError
  nil
end

.prompt_without_login(value) ⇒ Object



97
98
99
100
101
# File 'lib/better_auth/plugins/mcp/authorization.rb', line 97

def (value)
  prompts = OAuthProtocol.parse_scopes(value)
  prompts.delete("login")
  OAuthProtocol.scope_string(prompts)
end

.protected_resource_metadata(ctx, config) ⇒ Object



48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/better_auth/plugins/mcp/metadata.rb', line 48

def (ctx, config)
  base = OAuthProtocol.endpoint_base(ctx)
  origin = OAuthProtocol.origin_for(base)
  {
    resource: config[:resource] || origin,
    authorization_servers: [origin],
    jwks_uri: mcp_jwks_uri(ctx, config),
    scopes_supported: config[:scopes],
    bearer_methods_supported: ["header"],
    resource_signing_alg_values_supported: mcp_signing_algs(ctx, config)
  }
end

.redirect_with_code(ctx, config, query, session) ⇒ Object



36
37
38
# File 'lib/better_auth/plugins/mcp/authorization.rb', line 36

def redirect_with_code(ctx, config, query, session)
  raise ctx.redirect(authorization_redirect_uri(ctx, config, query, session))
end

.register_client(ctx, config) ⇒ Object



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# File 'lib/better_auth/plugins/mcp/registration.rb', line 8

def register_client(ctx, config)
  set_cors_headers(ctx)
  body = OAuthProtocol.stringify_keys(ctx.body)
  body["token_endpoint_auth_method"] ||= "none"
  body["grant_types"] ||= [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::REFRESH_GRANT]
  body["response_types"] ||= ["code"]
  body["require_pkce"] = true unless body.key?("require_pkce") || body.key?("requirePKCE")

  OAuthProtocol.create_client(
    ctx,
    model: "oauthClient",
    body: body,
    default_auth_method: "none",
    store_client_secret: config[:store_client_secret],
    default_scopes: config[:scopes],
    allowed_scopes: config[:scopes],
    prefix: config[:prefix],
    dynamic_registration: true,
    strip_client_metadata: true
  )
end

.restore_login_prompt(ctx, config) ⇒ Object



20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/better_auth/plugins/mcp/authorization.rb', line 20

def (ctx, config)
  cookie = ctx.get_signed_cookie("oidc_login_prompt", ctx.context.secret)
  return unless cookie

  session = ctx.context.new_session
  return unless session && session[:session] && ctx.response_headers["set-cookie"].to_s.include?(ctx.context.auth_cookies[:session_token].name)

  query = (cookie)
  return unless query

  query["prompt"] = (query["prompt"]) if query.key?("prompt")
  ctx.set_cookie("oidc_login_prompt", "", path: "/", max_age: 0)
  ctx.context.set_current_session(session) if ctx.context.respond_to?(:set_current_session)
  [302, ctx.response_headers.merge("location" => authorization_redirect_uri(ctx, config, query, session)), [""]]
end

.revoke(ctx, config) ⇒ Object



94
95
96
97
98
99
100
101
# File 'lib/better_auth/plugins/mcp/token.rb', line 94

def revoke(ctx, config)
  OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix])
  body = OAuthProtocol.stringify_keys(ctx.body)
  if (token_record = OAuthProtocol.find_token_by_hint(config[:store], body["token"].to_s, body["token_type_hint"], prefix: config[:prefix]))
    token_record["revoked"] = Time.now
  end
  {revoked: true}
end

.schemaObject



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/better_auth/plugins/mcp/schema.rb', line 8

def schema
  {
    oauthClient: {
      modelName: "oauthClient",
      fields: {
        clientId: {type: "string", unique: true, required: true},
        clientSecret: {type: "string", required: false},
        disabled: {type: "boolean", default_value: false, required: false},
        skipConsent: {type: "boolean", required: false},
        enableEndSession: {type: "boolean", required: false},
        clientSecretExpiresAt: {type: "number", required: false},
        scopes: {type: "string[]", required: false},
        userId: {type: "string", required: false},
        createdAt: {type: "date", required: true, default_value: -> { Time.now }},
        updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }},
        name: {type: "string", required: false},
        uri: {type: "string", required: false},
        icon: {type: "string", required: false},
        contacts: {type: "string[]", required: false},
        tos: {type: "string", required: false},
        policy: {type: "string", required: false},
        softwareId: {type: "string", required: false},
        softwareVersion: {type: "string", required: false},
        softwareStatement: {type: "string", required: false},
        redirectUris: {type: "string[]", required: true},
        postLogoutRedirectUris: {type: "string[]", required: false},
        tokenEndpointAuthMethod: {type: "string", required: false},
        grantTypes: {type: "string[]", required: false},
        responseTypes: {type: "string[]", required: false},
        public: {type: "boolean", required: false},
        type: {type: "string", required: false},
        requirePKCE: {type: "boolean", required: false},
        subjectType: {type: "string", required: false},
        referenceId: {type: "string", required: false},
        metadata: {type: "json", required: false}
      }
    },
    oauthRefreshToken: {
      fields: {
        token: {type: "string", required: true},
        clientId: {type: "string", required: true},
        sessionId: {type: "string", required: false},
        userId: {type: "string", required: false},
        referenceId: {type: "string", required: false},
        authTime: {type: "date", required: false},
        expiresAt: {type: "date", required: false},
        createdAt: {type: "date", required: true, default_value: -> { Time.now }},
        revoked: {type: "date", required: false},
        scopes: {type: "string[]", required: true}
      }
    },
    oauthAccessToken: {
      modelName: "oauthAccessToken",
      fields: {
        token: {type: "string", unique: true, required: true},
        expiresAt: {type: "date", required: true},
        clientId: {type: "string", required: true},
        userId: {type: "string", required: false},
        sessionId: {type: "string", required: false},
        scopes: {type: "string[]", required: true},
        revoked: {type: "date", required: false},
        referenceId: {type: "string", required: false},
        authTime: {type: "date", required: false},
        refreshId: {type: "string", required: false},
        createdAt: {type: "date", required: true, default_value: -> { Time.now }},
        updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
      }
    },
    oauthConsent: {
      modelName: "oauthConsent",
      fields: {
        clientId: {type: "string", required: true},
        userId: {type: "string", required: false},
        referenceId: {type: "string", required: false},
        scopes: {type: "string[]", required: true},
        createdAt: {type: "date", required: true, default_value: -> { Time.now }},
        updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
      }
    }
  }
end

.session_from_token(ctx, config) ⇒ Object



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/better_auth/plugins/mcp/userinfo.rb', line 14

def session_from_token(ctx, config)
  authorization = ctx.headers["authorization"].to_s
  token_value = authorization.start_with?("Bearer ") ? authorization.delete_prefix("Bearer ").strip : authorization.strip
  return nil if token_value.empty?

  token_record = OAuthProtocol.token_record(config[:store], token_value, prefix: config[:prefix])
  return token_record if token_record

  payload = ::JWT.decode(token_value, ctx.context.secret, true, algorithm: "HS256").first
  {
    "clientId" => payload["azp"],
    "userId" => payload["sub"],
    "sessionId" => payload["sid"],
    "scopes" => OAuthProtocol.parse_scopes(payload["scope"]),
    "audience" => payload["aud"],
    "subject" => payload["sub"],
    "expiresAt" => payload["exp"] ? Time.at(payload["exp"].to_i) : nil
  }.compact
rescue ::JWT::DecodeError
  nil
end

.set_cors_headers(ctx) ⇒ Object



39
40
41
42
43
44
# File 'lib/better_auth/plugins/mcp/config.rb', line 39

def set_cors_headers(ctx)
  ctx.set_header("Access-Control-Allow-Origin", "*")
  ctx.set_header("Access-Control-Allow-Methods", "POST, OPTIONS")
  ctx.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
  ctx.set_header("Access-Control-Max-Age", "86400")
end

.token(ctx, config) ⇒ Object



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/better_auth/plugins/mcp/token.rb', line 8

def token(ctx, config)
  set_cors_headers(ctx)
  body = OAuthProtocol.stringify_keys(ctx.body)
  client = OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix])
  audience = validate_resource!(config, body)

  case body["grant_type"]
  when OAuthProtocol::AUTH_CODE_GRANT
    code = OAuthProtocol.consume_code!(
      config[:store],
      body["code"],
      client_id: OAuthProtocol.stringify_keys(client)["clientId"],
      redirect_uri: body["redirect_uri"],
      code_verifier: body["code_verifier"]
    )
    OAuthProtocol.issue_tokens(
      ctx,
      config[:store],
      model: "oauthAccessToken",
      client: client,
      session: code[:session],
      scopes: code[:scopes],
      include_refresh: code[:scopes].include?("offline_access"),
      issuer: validate_issuer_url(OAuthProtocol.issuer(ctx)),
      prefix: config[:prefix],
      refresh_token_expires_in: config[:refresh_token_expires_in],
      access_token_expires_in: config[:access_token_expires_in],
      audience: audience,
      grant_type: OAuthProtocol::AUTH_CODE_GRANT,
      jwt_access_token: !audience.nil?,
      nonce: code[:nonce],
      auth_time: code[:auth_time],
      reference_id: code[:reference_id],
      filter_id_token_claims_by_scope: true
    )
  when OAuthProtocol::REFRESH_GRANT
    OAuthProtocol.refresh_tokens(
      ctx,
      config[:store],
      model: "oauthAccessToken",
      client: client,
      refresh_token: body["refresh_token"],
      scopes: body["scope"],
      issuer: validate_issuer_url(OAuthProtocol.issuer(ctx)),
      prefix: config[:prefix],
      refresh_token_expires_in: config[:refresh_token_expires_in],
      access_token_expires_in: config[:access_token_expires_in],
      audience: audience,
      jwt_access_token: !audience.nil?,
      filter_id_token_claims_by_scope: true
    )
  else
    raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
  end
end

.userinfo(ctx, config) ⇒ Object



10
11
12
# File 'lib/better_auth/plugins/mcp/userinfo.rb', line 10

def userinfo(ctx, config)
  OAuthProtocol.userinfo(config[:store], ctx.headers["authorization"], prefix: config[:prefix], jwt_secret: ctx.context.secret)
end

.validate_issuer_url(value) ⇒ Object



10
11
12
13
14
15
16
17
18
19
20
# File 'lib/better_auth/plugins/mcp/metadata.rb', line 10

def validate_issuer_url(value)
  uri = URI.parse(value.to_s)
  uri.query = nil
  uri.fragment = nil
  if uri.scheme == "http" && !["localhost", "127.0.0.1", "::1"].include?(uri.hostname || uri.host)
    uri.scheme = "https"
  end
  uri.to_s.sub(%r{/+\z}, "")
rescue URI::InvalidURIError
  value.to_s.split(/[?#]/).first.sub(%r{/+\z}, "")
end

.validate_resource!(config, body) ⇒ Object



64
65
66
67
68
69
70
71
72
73
# File 'lib/better_auth/plugins/mcp/token.rb', line 64

def validate_resource!(config, body)
  resources = Array(body["resource"]).compact.map(&:to_s)
  return nil if resources.empty?

  valid = Array(config[:valid_audiences]).map(&:to_s)
  resources.each do |resource|
    raise APIError.new("BAD_REQUEST", message: "requested resource invalid") unless valid.empty? || valid.include?(resource)
  end
  (resources.length == 1) ? resources.first : resources
end

.with_mcp_auth(app, resource_metadata_url:, auth: nil, resource_metadata_mappings: {}) ⇒ Object



20
21
22
23
24
25
26
27
# File 'lib/better_auth/plugins/mcp.rb', line 20

def with_mcp_auth(app, resource_metadata_url:, auth: nil, resource_metadata_mappings: {})
  ResourceHandler.with_mcp_auth(
    app,
    resource_metadata_url: ,
    auth: auth,
    resource_metadata_mappings: 
  )
end