Module: BetterAuth::Plugins

Defined in:
lib/better_auth/plugins/oauth_provider.rb,
lib/better_auth/plugins/oauth_provider/mcp.rb,
lib/better_auth/plugins/oauth_provider/token.rb,
lib/better_auth/plugins/oauth_provider/client.rb,
lib/better_auth/plugins/oauth_provider/logout.rb,
lib/better_auth/plugins/oauth_provider/revoke.rb,
lib/better_auth/plugins/oauth_provider/schema.rb,
lib/better_auth/plugins/oauth_provider/consent.rb,
lib/better_auth/plugins/oauth_provider/continue.rb,
lib/better_auth/plugins/oauth_provider/metadata.rb,
lib/better_auth/plugins/oauth_provider/register.rb,
lib/better_auth/plugins/oauth_provider/userinfo.rb,
lib/better_auth/plugins/oauth_provider/authorize.rb,
lib/better_auth/plugins/oauth_provider/types/zod.rb,
lib/better_auth/plugins/oauth_provider/introspect.rb,
lib/better_auth/plugins/oauth_provider/rate_limit.rb,
lib/better_auth/plugins/oauth_provider/types/index.rb,
lib/better_auth/plugins/oauth_provider/types/oauth.rb,
lib/better_auth/plugins/oauth_provider/utils/index.rb,
lib/better_auth/plugins/oauth_provider/types/helpers.rb,
lib/better_auth/plugins/oauth_provider/client_resource.rb,
lib/better_auth/plugins/oauth_provider/middleware/index.rb,
lib/better_auth/plugins/oauth_provider/oauth_client/index.rb,
lib/better_auth/plugins/oauth_provider/oauth_consent/index.rb,
lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb,
lib/better_auth/plugins/oauth_provider/oauth_consent/endpoints.rb

Defined Under Namespace

Modules: OAuthProvider

Class Method Summary collapse

Class Method Details

.oauth_access_token_expires_in(config, scopes, machine:) ⇒ Object



127
128
129
130
131
132
133
134
135
# File 'lib/better_auth/plugins/oauth_provider/token.rb', line 127

def oauth_access_token_expires_in(config, scopes, machine:)
  base = machine ? config[:m2m_access_token_expires_in] : config[:access_token_expires_in]
  expirations = normalize_hash(config[:scope_expirations] || {})
  matches = OAuthProtocol.parse_scopes(scopes).filter_map do |scope|
    value = expirations[scope.to_sym] || expirations[scope]
    oauth_duration_seconds(value) if value
  end
  ([base.to_i] + matches).compact.min
end

.oauth_active_authorization_session!(ctx, stored_session) ⇒ Object

Raises:

  • (APIError)


95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/better_auth/plugins/oauth_provider/token.rb', line 95

def oauth_active_authorization_session!(ctx, stored_session)
  data = OAuthProtocol.stringify_keys(stored_session || {})
  session_snapshot = OAuthProtocol.stringify_keys(data["session"] || data[:session] || {})
  user_snapshot = OAuthProtocol.stringify_keys(data["user"] || data[:user] || {})
  session_id = session_snapshot["id"]
  stored = session_id && ctx.context.adapter.find_one(model: "session", where: [{field: "id", value: session_id}])
  raise APIError.new("BAD_REQUEST", message: "session no longer exists") unless stored
  raise APIError.new("BAD_REQUEST", message: "session no longer exists") if stored["expiresAt"] && stored["expiresAt"] <= Time.now

  user = ctx.context.internal_adapter.find_user_by_id(stored["userId"] || user_snapshot["id"])
  raise APIError.new("BAD_REQUEST", message: "missing user, user may have been deleted") unless user

  {"user" => user, "session" => stored}
end

.oauth_admin_create_client_endpoint(config) ⇒ Object



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 123

def oauth_admin_create_client_endpoint(config)
  Endpoint.new(path: "/admin/oauth2/create-client", method: "POST", metadata: {server_only: true}) do |ctx|
    session = nil
    if config[:client_privileges].respond_to?(:call)
      session = Routes.current_session(ctx)
      oauth_assert_client_privilege!(ctx, config, session, "create")
    elsif config[:client_reference].respond_to?(:call)
      session = Routes.current_session(ctx, allow_nil: true)
    end
    body = OAuthProtocol.stringify_keys(ctx.body)
    client = OAuthProtocol.create_client(
      ctx,
      model: "oauthClient",
      body: body,
      owner_session: nil,
      default_scopes: config[:client_registration_default_scopes] || config[:scopes],
      allowed_scopes: config[:client_registration_allowed_scopes] || config[:scopes],
      store_client_secret: config[:store_client_secret],
      prefix: config[:prefix],
      dynamic_registration: false,
      admin: true,
      pairwise_secret: config[:pairwise_secret],
      strip_client_metadata: true,
      reference_id: oauth_client_reference(config, session)
    )
    ctx.json(client, status: 201, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"})
  end
end

.oauth_admin_update_client_endpoint(config) ⇒ Object



152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 152

def oauth_admin_update_client_endpoint(config)
  Endpoint.new(path: "/admin/oauth2/update-client", method: "PATCH", metadata: {server_only: true}) do |ctx|
    body = OAuthProtocol.stringify_keys(ctx.body)
    client = OAuthProtocol.find_client(ctx, "oauthClient", body["client_id"])
    raise APIError.new("NOT_FOUND", message: "client not found") unless client

    update_source = OAuthProtocol.stringify_keys(body["update"] || {})
    oauth_validate_client_update!(client, update_source, config, admin: true)
    update = oauth_client_update_data(update_source, admin: true)
    updated = update.empty? ? client : ctx.context.adapter.update(model: "oauthClient", where: [{field: "clientId", value: body["client_id"]}], update: update.merge(updatedAt: Time.now))
    ctx.json(OAuthProtocol.client_response(updated, include_secret: false))
  end
end

.oauth_assert_client_privilege!(ctx, config, session, action) ⇒ Object

Raises:

  • (APIError)


19
20
21
22
23
24
25
26
27
28
29
30
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/index.rb', line 19

def oauth_assert_client_privilege!(ctx, config, session, action)
  callback = config[:client_privileges]
  return unless callback.respond_to?(:call)

  allowed = callback.call({
    headers: ctx.headers,
    action: action,
    session: session[:session],
    user: session[:user]
  })
  raise APIError.new("UNAUTHORIZED") unless allowed
end

.oauth_assert_owned_client!(client, session, config = nil) ⇒ Object

Raises:

  • (APIError)


7
8
9
10
11
12
13
14
15
16
17
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/index.rb', line 7

def oauth_assert_owned_client!(client, session, config = nil)
  data = OAuthProtocol.stringify_keys(client)
  return if data["userId"] && data["userId"] == session[:user]["id"]

  if data["referenceId"] && config && config[:client_reference].respond_to?(:call)
    reference_id = config[:client_reference].call({user: session[:user], session: session[:session]})
    return if data["referenceId"] == reference_id
  end

  raise APIError.new("NOT_FOUND", message: "client not found")
end

.oauth_authorization_redirect(ctx, config, query, session, client, scopes, reference_id: nil) ⇒ Object



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/better_auth/plugins/oauth_provider/consent.rb', line 38

def oauth_authorization_redirect(ctx, config, query, session, client, scopes, reference_id: nil)
  code = Crypto.random_string(32)
  client_reference_id = OAuthProtocol.stringify_keys(client)["referenceId"]
  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: reference_id || client_reference_id,
    expires_in: config[:code_expires_in],
    store_tokens: config[:store_tokens]
  )
  OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)))
end

.oauth_authorize_endpoint(config) ⇒ Object



23
24
25
26
27
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 23

def oauth_authorize_endpoint(config)
  Endpoint.new(path: "/oauth2/authorize", method: "GET") do |ctx|
    oauth_authorize_flow(ctx, config, OAuthProtocol.stringify_keys(ctx.query))
  end
end

.oauth_authorize_error_redirect(ctx, query, error, description) ⇒ Object



203
204
205
206
207
208
209
210
211
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 203

def oauth_authorize_error_redirect(ctx, query, error, description)
  OAuthProtocol.redirect_uri_with_params(
    query["redirect_uri"],
    error: error,
    error_description: description,
    state: query["state"],
    iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))
  )
end

.oauth_authorize_flow(ctx, config, query, continue_post_login: false) ⇒ Object

Raises:

  • (APIError)


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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 29

def oauth_authorize_flow(ctx, config, query, continue_post_login: false)
  query = oauth_resolve_request_uri!(ctx, config, query)
  response_type = query["response_type"].to_s

  client = OAuthProtocol.find_client(ctx, "oauthClient", query["client_id"])
  raise APIError.new("BAD_REQUEST", message: "invalid_client") unless client
  OAuthProtocol.validate_redirect_uri!(client, query["redirect_uri"])
  if response_type != "code"
    raise ctx.redirect(oauth_authorize_error_redirect(ctx, query, "unsupported_response_type", "response_type must be code"))
  end

  scopes = OAuthProtocol.parse_scopes(query["scope"])
  scopes = OAuthProtocol.parse_scopes(OAuthProtocol.stringify_keys(client)["scopes"] || config[:scopes]) if scopes.empty?
  prompts = OAuthProtocol.parse_scopes(query["prompt"])
  if prompts.include?("none") && (prompts - ["none"]).any?
    raise ctx.redirect(oauth_authorize_error_redirect(ctx, query, "invalid_request", "prompt none cannot be combined with other prompts"))
  end
  client_data = OAuthProtocol.stringify_keys(client)
  if client_data["disabled"]
    raise ctx.redirect(oauth_authorize_error_redirect(ctx, query, "invalid_client", "client is disabled"))
  end
  allowed_scopes = OAuthProtocol.parse_scopes(client_data["scopes"])
  allowed_scopes = OAuthProtocol.parse_scopes(config[:scopes]) if allowed_scopes.empty?
  unless scopes.all? { |scope| allowed_scopes.include?(scope) }
    raise ctx.redirect(oauth_authorize_error_redirect(ctx, query, "invalid_scope", "invalid scope"))
  end
  pkce_error = OAuthProtocol.validate_authorize_pkce(client_data, scopes, query["code_challenge"], query["code_challenge_method"])
  raise ctx.redirect(oauth_authorize_error_redirect(ctx, query, "invalid_request", pkce_error)) if pkce_error

  session = Routes.current_session(ctx, allow_nil: true)
  unless session
    if prompts.include?("none")
      raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "login_required", state: query["state"], iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))))
    end

    if prompts.include?("create")
      raise ctx.redirect(oauth_prompt_redirect(ctx, config, query, "create"))
    end

    raise ctx.redirect(oauth_prompt_redirect(ctx, config, query, "login"))
  end

  if oauth_requires_login?(session, prompts, query) && !
    raise ctx.redirect(oauth_prompt_redirect(ctx, config, query, "login"))
  end

  if prompts.include?("select_account") && !
    if prompts.include?("none")
      raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "account_selection_required", state: query["state"], iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))))
    end

    raise ctx.redirect(oauth_prompt_redirect(ctx, config, query, "select_account"))
  end

  if config.dig(:post_login, :should_redirect).respond_to?(:call) && !
    should_redirect = config.dig(:post_login, :should_redirect).call({user: session[:user], session: session[:session], client: client_data, scopes: scopes})
    if should_redirect
      if prompts.include?("none")
        raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "interaction_required", state: query["state"], iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))))
      end

      raise ctx.redirect(oauth_prompt_redirect(ctx, config, query, "post_login", page: should_redirect.is_a?(String) ? should_redirect : nil))
    end
  end

  consent_reference_id = oauth_consent_reference(config, session, scopes)
  requires_consent = !client_data["skipConsent"] && (prompts.include?("consent") || !oauth_consent_granted?(ctx, client_data["clientId"], session[:user]["id"], scopes, consent_reference_id))

  if requires_consent
    if prompts.include?("none")
      raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "consent_required", state: query["state"], iss: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))))
    end

    consent_code = Crypto.random_string(32)
    config[:store][:consents][consent_code] = {
      query: query,
      session: session,
      client: client,
      scopes: scopes,
      reference_id: consent_reference_id,
      expires_at: Time.now + 600
    }
    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

  oauth_redirect_with_code(ctx, config, query, session, client, scopes, reference_id: consent_reference_id)
end

.oauth_client_id_body_schemaObject



511
512
513
514
515
516
517
518
# File 'lib/better_auth/plugins/oauth_provider.rb', line 511

def oauth_client_id_body_schema
  OpenAPI.object_schema(
    {
      client_id: {type: "string", description: "OAuth2 client ID"}
    },
    required: ["client_id"]
  )
end

.oauth_client_reference(config, session) ⇒ Object



32
33
34
35
36
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/index.rb', line 32

def oauth_client_reference(config, session)
  return nil unless session && config[:client_reference].respond_to?(:call)

  config[:client_reference].call({user: session[:user], session: session[:session]})
end

.oauth_client_registration_openapi(description, include_secret:) ⇒ Object



252
253
254
255
256
257
258
259
260
261
262
# File 'lib/better_auth/plugins/oauth_provider.rb', line 252

def oauth_client_registration_openapi(description, include_secret:)
  {
    openapi: {
      description: description,
      requestBody: OpenAPI.json_request_body(oauth_client_registration_schema),
      responses: {
        "201" => OpenAPI.json_response("OAuth2 client created successfully", oauth_client_response_schema(include_secret: include_secret))
      }
    }
  }
end

.oauth_client_registration_schemaObject



477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
# File 'lib/better_auth/plugins/oauth_provider.rb', line 477

def oauth_client_registration_schema
  OpenAPI.object_schema(
    {
      redirect_uris: {type: "array", items: {type: "string", format: "uri"}, description: "Allowed redirect URIs"},
      post_logout_redirect_uris: {type: "array", items: {type: "string", format: "uri"}, description: "Allowed post logout redirect URIs"},
      client_name: {type: "string", description: "OAuth2 client name"},
      client_uri: {type: "string", format: "uri"},
      logo_uri: {type: "string", format: "uri"},
      contacts: {type: "array", items: {type: "string"}},
      tos_uri: {type: "string", format: "uri"},
      policy_uri: {type: "string", format: "uri"},
      software_id: {type: "string"},
      software_version: {type: "string"},
      software_statement: {type: "string"},
      token_endpoint_auth_method: {type: "string", enum: ["client_secret_basic", "client_secret_post", "none"]},
      grant_types: {type: "array", items: {type: "string", enum: [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::CLIENT_CREDENTIALS_GRANT, OAuthProtocol::REFRESH_GRANT]}},
      response_types: {type: "array", items: {type: "string", enum: ["code"]}},
      scope: {type: "string"},
      scopes: {type: "array", items: {type: "string"}},
      type: {type: "string", enum: ["web", "native", "user-agent-based"]},
      require_pkce: {type: "boolean"},
      requirePKCE: {type: "boolean"},
      subject_type: {type: "string", enum: ["public", "pairwise"]},
      subjectType: {type: "string", enum: ["public", "pairwise"]},
      enable_end_session: {type: "boolean"},
      enableEndSession: {type: "boolean"},
      skip_consent: {type: "boolean"},
      skipConsent: {type: "boolean"},
      metadata: {type: "object", additionalProperties: true}
    },
    required: ["redirect_uris"]
  )
end

.oauth_client_response_schema(include_secret:) ⇒ Object



520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
# File 'lib/better_auth/plugins/oauth_provider.rb', line 520

def oauth_client_response_schema(include_secret:)
  properties = oauth_public_client_schema[:properties].merge(
    redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
    post_logout_redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
    token_endpoint_auth_method: {type: "string"},
    grant_types: {type: "array", items: {type: "string"}},
    response_types: {type: "array", items: {type: "string"}},
    scope: {type: "string"},
    public: {type: "boolean"},
    type: {type: ["string", "null"]},
    user_id: {type: ["string", "null"]},
    reference_id: {type: ["string", "null"]},
    require_pkce: {type: ["boolean", "null"]},
    subject_type: {type: ["string", "null"]},
    metadata: {type: "object", additionalProperties: true},
    client_id_issued_at: {type: "number"},
    client_secret_expires_at: {type: "number"}
  )
  properties[:client_secret] = {type: "string", description: "OAuth2 client secret"} if include_secret
  OpenAPI.object_schema(properties)
end

.oauth_client_update_data(source, admin: false) ⇒ Object



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/index.rb', line 38

def oauth_client_update_data(source, admin: false)
  update = {}
  update["name"] = source["client_name"] || source["name"] if source.key?("client_name") || source.key?("name")
  update["uri"] = source["client_uri"] if source.key?("client_uri")
  update["icon"] = source["logo_uri"] if source.key?("logo_uri")
  if source.key?("redirect_uris")
    redirects = Array(source["redirect_uris"]).map(&:to_s)
    update["redirectUris"] = redirects
    update["redirectUrls"] = redirects.join(",")
  end
  update["postLogoutRedirectUris"] = Array(source["post_logout_redirect_uris"]).map(&:to_s) if source.key?("post_logout_redirect_uris")
  update["tokenEndpointAuthMethod"] = source["token_endpoint_auth_method"] || source["tokenEndpointAuthMethod"] if admin && (source.key?("token_endpoint_auth_method") || source.key?("tokenEndpointAuthMethod"))
  update["grantTypes"] = Array(source["grant_types"]).map(&:to_s) if source.key?("grant_types")
  update["responseTypes"] = Array(source["response_types"]).map(&:to_s) if source.key?("response_types")
  update["scopes"] = OAuthProtocol.parse_scopes(source["scope"] || source["scopes"]) if source.key?("scope") || source.key?("scopes")
  update["type"] = source["type"] if admin && source.key?("type")
  update["public"] = !!source["public"] if admin && source.key?("public")
  update["enableEndSession"] = !!(source["enable_end_session"] || source["enableEndSession"]) if source.key?("enable_end_session") || source.key?("enableEndSession")
  update["skipConsent"] = !!(source["skip_consent"] || source["skipConsent"]) if admin && (source.key?("skip_consent") || source.key?("skipConsent"))
  update["clientSecretExpiresAt"] = source["client_secret_expires_at"] if admin && source.key?("client_secret_expires_at")
  update["subjectType"] = source["subject_type"] || source["subjectType"] if admin && (source.key?("subject_type") || source.key?("subjectType"))
  update["metadata"] = source["metadata"] if source.key?("metadata")
  update
end


7
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
# File 'lib/better_auth/plugins/oauth_provider/consent.rb', line 7

def oauth_consent_endpoint(config)
  Endpoint.new(path: "/oauth2/consent", method: "POST", metadata: oauth_openapi_for(:consent)) do |ctx|
    current_session = Routes.current_session(ctx, allow_nil: true)
    body = OAuthProtocol.stringify_keys(ctx.body)
    consent = config[:store][:consents].delete(body["consent_code"].to_s)
    raise APIError.new("BAD_REQUEST", message: "invalid consent_code") unless consent
    raise APIError.new("BAD_REQUEST", message: "expired consent_code") if consent[:expires_at] <= Time.now
    raise APIError.new("UNAUTHORIZED", message: "session required") unless current_session
    unless current_session[:user]["id"].to_s == consent[:session][:user]["id"].to_s
      raise APIError.new("FORBIDDEN", message: "consent session mismatch")
    end

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

    granted_scopes = OAuthProtocol.parse_scopes(body["scope"] || body["scopes"])
    granted_scopes = consent[:scopes] if granted_scopes.empty?
    unless granted_scopes.all? { |scope| consent[:scopes].include?(scope) }
      raise APIError.new("BAD_REQUEST", message: "invalid_scope")
    end

    reference_id = consent[:reference_id]
    oauth_store_consent(ctx, consent[:client], consent[:session], granted_scopes, reference_id)
    redirect = oauth_authorization_redirect(ctx, config, query, consent[:session], consent[:client], granted_scopes, reference_id: reference_id)
    ctx.json({redirectURI: redirect})
  end
end

Returns:

  • (Boolean)


62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/better_auth/plugins/oauth_provider/consent.rb', line 62

def oauth_consent_granted?(ctx, client_id, user_id, scopes, reference_id = nil)
  where = [
    {field: "clientId", value: client_id},
    {field: "userId", value: user_id}
  ]
  where << {field: "referenceId", value: reference_id} if reference_id
  consent = ctx.context.adapter.find_one(
    model: "oauthConsent",
    where: where
  )
  return false unless consent

  granted = OAuthProtocol.parse_scopes(consent["scopes"])
  scopes.all? { |scope| granted.include?(scope) }
end


556
557
558
559
560
561
562
563
# File 'lib/better_auth/plugins/oauth_provider.rb', line 556

def oauth_consent_identifier_schema
  OpenAPI.object_schema(
    {
      id: {type: "string", description: "OAuth2 consent ID"},
      client_id: {type: "string", description: "OAuth2 client ID"}
    }
  )
end


565
566
567
568
569
570
571
572
573
574
# File 'lib/better_auth/plugins/oauth_provider.rb', line 565

def oauth_consent_mutation_schema(required_update:)
  OpenAPI.object_schema(
    oauth_consent_identifier_schema[:properties].merge(
      update: OpenAPI.object_schema({scopes: {type: "array", items: {type: "string"}}}),
      scope: {type: "string"},
      scopes: {type: "array", items: {type: "string"}}
    ),
    required: required_update ? ["update"] : []
  )
end


375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
# File 'lib/better_auth/plugins/oauth_provider.rb', line 375

def oauth_consent_openapi
  {
    openapi: {
      description: "Submit an OAuth2 consent decision",
      requestBody: OpenAPI.json_request_body(
        OpenAPI.object_schema(
          {
            consent_code: {type: "string", description: "Consent code issued by the authorization flow"},
            accept: {type: "boolean", description: "Whether the user accepted the consent request"},
            scope: {type: "string", description: "Granted scopes as a space-delimited string"},
            scopes: {type: "array", items: {type: "string"}, description: "Granted scopes"}
          },
          required: ["consent_code"]
        )
      ),
      responses: {
        "200" => OpenAPI.json_response("OAuth2 consent redirect", OpenAPI.object_schema({redirectURI: {type: "string"}}, required: ["redirectURI"]))
      }
    }
  }
end


99
100
101
102
103
104
# File 'lib/better_auth/plugins/oauth_provider/consent.rb', line 99

def oauth_consent_reference(config, session, scopes)
  callback = config.dig(:post_login, :consent_reference_id) || config.dig(:post_login, :consentReferenceId)
  return nil unless callback.respond_to?(:call)

  callback.call({user: session[:user], session: session[:session], scopes: scopes})
end


17
18
19
20
21
22
23
24
25
26
# File 'lib/better_auth/plugins/oauth_provider/oauth_consent/index.rb', line 17

def oauth_consent_response(consent)
  data = OAuthProtocol.stringify_keys(consent)
  {
    id: data["id"],
    client_id: data["clientId"],
    user_id: data["userId"],
    scope: OAuthProtocol.scope_string(data["scopes"]),
    scopes: OAuthProtocol.parse_scopes(data["scopes"])
  }.compact
end


576
577
578
579
580
581
582
583
584
585
586
587
# File 'lib/better_auth/plugins/oauth_provider.rb', line 576

def oauth_consent_response_schema
  OpenAPI.object_schema(
    {
      id: {type: "string"},
      clientId: {type: "string"},
      userId: {type: "string"},
      scopes: {type: "array", items: {type: "string"}},
      createdAt: {type: "string", format: "date-time"},
      updatedAt: {type: "string", format: "date-time"}
    }
  )
end

.oauth_continue_endpoint(config) ⇒ Object



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# File 'lib/better_auth/plugins/oauth_provider/continue.rb', line 7

def oauth_continue_endpoint(config)
  Endpoint.new(path: "/oauth2/continue", method: "POST", metadata: oauth_openapi_for(:continue)) do |ctx|
    Routes.current_session(ctx)
    body = OAuthProtocol.stringify_keys(ctx.body)
    action = if body["selected"] == true
      "select_account"
    elsif body["created"] == true
      "create"
    elsif body["postLogin"] == true || body["post_login"] == true
      "post_login"
    end
    raise APIError.new("BAD_REQUEST", message: "Missing parameters") unless action

    query = oauth_verified_query!(ctx, body["oauth_query"])
    oauth_delete_prompt!(query, action) unless action == "post_login"
    url = oauth_redirect_location { oauth_authorize_flow(ctx, config, query, continue_post_login: action == "post_login") }
    ctx.json({redirect: true, url: url})
  end
end

.oauth_continue_openapiObject



352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/better_auth/plugins/oauth_provider.rb', line 352

def oauth_continue_openapi
  {
    openapi: {
      description: "Continue an OAuth2 authorization interaction",
      requestBody: OpenAPI.json_request_body(
        OpenAPI.object_schema(
          {
            oauth_query: {type: "string", description: "Signed OAuth query string"},
            selected: {type: "boolean", description: "Continue after account selection"},
            created: {type: "boolean", description: "Continue after account creation"},
            postLogin: {type: "boolean", description: "Continue after post-login flow"},
            post_login: {type: "boolean", description: "Continue after post-login flow"}
          },
          required: ["oauth_query"]
        )
      ),
      responses: {
        "200" => OpenAPI.json_response("OAuth2 authorization redirect", oauth_redirect_response_schema)
      }
    }
  }
end

.oauth_create_client_endpoint(config) ⇒ Object



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

def oauth_create_client_endpoint(config)
  Endpoint.new(path: "/oauth2/create-client", method: "POST", metadata: oauth_openapi_for(:create_client)) do |ctx|
    session = Routes.current_session(ctx)
    oauth_assert_client_privilege!(ctx, config, session, "create")
    body = OAuthProtocol.stringify_keys(ctx.body)
    client = OAuthProtocol.create_client(
      ctx,
      model: "oauthClient",
      body: body,
      owner_session: session,
      default_scopes: config[:client_registration_default_scopes] || config[:scopes],
      allowed_scopes: config[:client_registration_allowed_scopes] || config[:scopes],
      store_client_secret: config[:store_client_secret],
      prefix: config[:prefix],
      dynamic_registration: false,
      admin: false,
      pairwise_secret: config[:pairwise_secret],
      strip_client_metadata: true,
      reference_id: oauth_client_reference(config, session)
    )
    ctx.json(client, status: 201, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"})
  end
end

.oauth_default_jwks_uri(ctx, config) ⇒ Object



81
82
83
84
85
86
87
88
89
# File 'lib/better_auth/plugins/oauth_provider/metadata.rb', line 81

def oauth_default_jwks_uri(ctx, config)
  return nil if config[:disable_jwt_plugin]

  jwt_plugin = ctx.context.options.plugins.find { |plugin| plugin.id == "jwt" }
  return nil unless jwt_plugin

  path = jwt_plugin.options&.dig(:jwks, :jwks_path) || "/jwks"
  "#{OAuthProtocol.endpoint_base(ctx)}#{path}"
end

.oauth_delete_client_endpoint(config) ⇒ Object



93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 93

def oauth_delete_client_endpoint(config)
  Endpoint.new(path: "/oauth2/delete-client", method: "POST", metadata: oauth_openapi_for(:delete_client)) do |ctx|
    session = Routes.current_session(ctx)
    oauth_assert_client_privilege!(ctx, config, session, "delete")
    body = OAuthProtocol.stringify_keys(ctx.body)
    client = OAuthProtocol.find_client(ctx, "oauthClient", body["client_id"])
    raise APIError.new("NOT_FOUND", message: "client not found") unless client
    oauth_assert_owned_client!(client, session, config)
    ctx.context.adapter.delete(model: "oauthClient", where: [{field: "clientId", value: body["client_id"]}])
    ctx.json({deleted: true})
  end
end

.oauth_delete_client_openapiObject



284
285
286
287
288
289
290
291
292
293
294
# File 'lib/better_auth/plugins/oauth_provider.rb', line 284

def oauth_delete_client_openapi
  {
    openapi: {
      description: "Delete an OAuth2 client",
      requestBody: OpenAPI.json_request_body(oauth_client_id_body_schema),
      responses: {
        "200" => OpenAPI.json_response("OAuth2 client deleted", OpenAPI.object_schema({deleted: {type: "boolean"}}, required: ["deleted"]))
      }
    }
  }
end


63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/better_auth/plugins/oauth_provider/oauth_consent/endpoints.rb', line 63

def oauth_delete_consent_endpoint
  Endpoint.new(path: "/oauth2/delete-consent", method: "POST", metadata: oauth_openapi_for(:delete_consent)) do |ctx|
    session = Routes.current_session(ctx)
    body = OAuthProtocol.stringify_keys(ctx.body)
    id = body["id"]
    consent = if id.to_s.empty?
      oauth_find_user_consent(ctx, session, body["client_id"])
    else
      ctx.context.adapter.find_one(model: "oauthConsent", where: [{field: "id", value: id}])
    end
    raise APIError.new("NOT_FOUND", message: "missing id") if id.to_s.empty? && body["client_id"].to_s.empty?
    raise APIError.new("NOT_FOUND", message: "consent not found") unless consent
    raise APIError.new("UNAUTHORIZED") unless OAuthProtocol.stringify_keys(consent)["userId"] == session[:user]["id"]

    ctx.context.adapter.delete(model: "oauthConsent", where: [{field: "id", value: OAuthProtocol.stringify_keys(consent)["id"]}])
    ctx.json({deleted: true})
  end
end


340
341
342
343
344
345
346
347
348
349
350
# File 'lib/better_auth/plugins/oauth_provider.rb', line 340

def oauth_delete_consent_openapi
  {
    openapi: {
      description: "Delete OAuth2 consent",
      requestBody: OpenAPI.json_request_body(oauth_consent_identifier_schema),
      responses: {
        "200" => OpenAPI.json_response("OAuth2 consent deleted", OpenAPI.object_schema({deleted: {type: "boolean"}}, required: ["deleted"]))
      }
    }
  }
end

.oauth_delete_prompt!(query, prompt) ⇒ Object



184
185
186
187
188
189
190
191
192
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 184

def oauth_delete_prompt!(query, prompt)
  prompts = OAuthProtocol.parse_scopes(query["prompt"])
  prompts.delete(prompt)
  if prompts.empty?
    query.delete("prompt")
  else
    query["prompt"] = OAuthProtocol.scope_string(prompts)
  end
end

.oauth_duration_seconds(value) ⇒ Object



137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/better_auth/plugins/oauth_provider/token.rb', line 137

def oauth_duration_seconds(value)
  return value.to_i if value.is_a?(Numeric)

  match = value.to_s.match(/\A(\d+)([smhd])?\z/)
  return value.to_i unless match

  amount = match[1].to_i
  case match[2]
  when "m" then amount * 60
  when "h" then amount * 3600
  when "d" then amount * 86_400
  else amount
  end
end

.oauth_encrypted_secret_storage?(value) ⇒ Boolean

Returns:

  • (Boolean)


112
113
114
115
# File 'lib/better_auth/plugins/oauth_provider.rb', line 112

def oauth_encrypted_secret_storage?(value)
  mode = value.is_a?(Hash) ? normalize_hash(value) : value.to_s
  mode == "encrypted" || (mode.is_a?(Hash) && (mode[:encrypt].respond_to?(:call) || mode[:decrypt].respond_to?(:call)))
end

.oauth_end_session_body_schemaObject



636
637
638
639
640
641
642
643
644
645
# File 'lib/better_auth/plugins/oauth_provider.rb', line 636

def oauth_end_session_body_schema
  OpenAPI.object_schema(
    {
      id_token_hint: {type: "string"},
      client_id: {type: "string"},
      post_logout_redirect_uri: {type: "string", format: "uri"},
      state: {type: "string"}
    }
  )
end

.oauth_end_session_endpointObject



7
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
# File 'lib/better_auth/plugins/oauth_provider/logout.rb', line 7

def oauth_end_session_endpoint
  Endpoint.new(path: "/oauth2/end-session", method: ["GET", "POST"], metadata: oauth_openapi_for(:end_session).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])) do |ctx|
    input = OAuthProtocol.stringify_keys((ctx.method == "GET") ? ctx.query : ctx.body)
    id_token_hint = input["id_token_hint"].to_s
    raise APIError.new("UNAUTHORIZED", message: "invalid id token") if id_token_hint.empty?

    decoded = ::JWT.decode(id_token_hint, nil, false).first
    client_id = input["client_id"] || decoded["aud"]
    client = OAuthProtocol.find_client(ctx, "oauthClient", client_id)
    raise APIError.new("BAD_REQUEST", message: "invalid_client") unless client

    client_data = OAuthProtocol.stringify_keys(client)
    raise APIError.new("BAD_REQUEST", message: "invalid_client") if client_data["disabled"]
    raise APIError.new("UNAUTHORIZED", message: "client unable to logout") unless client_data["enableEndSession"]

    payload = OAuthProtocol.verify_oauth_jwt(
      ctx,
      id_token_hint,
      issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)),
      hs256_secret: OAuthProtocol.id_token_hs256_key(ctx, client_data["clientId"], client_data["clientSecret"])
    )
    raise APIError.new("UNAUTHORIZED", message: "invalid id token") unless payload
    raise APIError.new("BAD_REQUEST", message: "audience mismatch") if input["client_id"] && payload["aud"] != input["client_id"]

    if payload["sid"]
      ctx.context.adapter.delete(model: "session", where: [{field: "id", value: payload["sid"]}])
    end

    if input["post_logout_redirect_uri"]
      unless OAuthProtocol.client_logout_redirect_uris(client_data).include?(input["post_logout_redirect_uri"])
        raise APIError.new("BAD_REQUEST", message: "invalid post_logout_redirect_uri")
      end

      redirect = OAuthProtocol.redirect_uri_with_params(input["post_logout_redirect_uri"], state: input["state"])
      raise ctx.redirect(redirect)
    end

    ctx.json({status: true})
  rescue ::JWT::DecodeError
    raise APIError.new("UNAUTHORIZED", message: "invalid id token")
  end
end

.oauth_end_session_openapiObject



464
465
466
467
468
469
470
471
472
473
474
475
# File 'lib/better_auth/plugins/oauth_provider.rb', line 464

def oauth_end_session_openapi
  {
    openapi: {
      description: "End an OpenID Connect session",
      parameters: oauth_end_session_parameters,
      requestBody: OpenAPI.json_request_body(oauth_end_session_body_schema, required: false),
      responses: {
        "200" => OpenAPI.json_response("OpenID Connect session ended", OpenAPI.status_response_schema)
      }
    }
  }
end

.oauth_end_session_parametersObject



630
631
632
633
634
# File 'lib/better_auth/plugins/oauth_provider.rb', line 630

def oauth_end_session_parameters
  oauth_end_session_body_schema[:properties].keys.map do |name|
    OpenAPI.query_parameter(name.to_s, required: false, schema: oauth_end_session_body_schema[:properties][name])
  end
end


7
8
9
10
11
12
13
14
15
# File 'lib/better_auth/plugins/oauth_provider/oauth_consent/index.rb', line 7

def oauth_find_user_consent(ctx, session, client_id)
  ctx.context.adapter.find_one(
    model: "oauthConsent",
    where: [
      {field: "clientId", value: client_id},
      {field: "userId", value: session[:user]["id"]}
    ]
  )
end

.oauth_get_client_endpoint(config) ⇒ Object



31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 31

def oauth_get_client_endpoint(config)
  Endpoint.new(path: "/oauth2/get-client", method: "GET") do |ctx|
    session = Routes.current_session(ctx)
    oauth_assert_client_privilege!(ctx, config, session, "read")
    query = OAuthProtocol.stringify_keys(ctx.query)
    client = OAuthProtocol.find_client(ctx, "oauthClient", query["client_id"])
    raise APIError.new("NOT_FOUND", message: "client not found") unless client
    oauth_assert_owned_client!(client, session, config)

    ctx.json(OAuthProtocol.client_response(client, include_secret: false))
  end
end

.oauth_get_client_public_endpoint(_config) ⇒ Object



44
45
46
47
48
49
50
51
52
53
54
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 44

def oauth_get_client_public_endpoint(_config)
  Endpoint.new(path: "/oauth2/public-client", method: "GET") do |ctx|
    Routes.current_session(ctx, allow_nil: true)
    query = OAuthProtocol.stringify_keys(ctx.query)
    client = OAuthProtocol.find_client(ctx, "oauthClient", query["client_id"])
    raise APIError.new("NOT_FOUND", message: "client not found") unless client
    raise APIError.new("NOT_FOUND", message: "client not found") if OAuthProtocol.stringify_keys(client)["disabled"]

    ctx.json(oauth_public_client_response(client))
  end
end

.oauth_get_client_public_prelogin_endpoint(config) ⇒ Object



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 56

def oauth_get_client_public_prelogin_endpoint(config)
  Endpoint.new(
    path: "/oauth2/public-client-prelogin",
    method: "POST",
    body_schema: ->(value) { value },
    metadata: oauth_openapi_for(:public_client_prelogin)
  ) do |ctx|
    input = OAuthProtocol.stringify_keys(ctx.body).merge(OAuthProtocol.stringify_keys(ctx.query))
    unless config[:allow_public_client_prelogin] || config[:allowPublicClientPrelogin]
      raise APIError.new("BAD_REQUEST")
    end
    unless OAuthProvider::Utils.verify_oauth_query_params(input["oauth_query"], ctx.context.secret)
      raise APIError.new("UNAUTHORIZED", body: {error: "invalid_signature"})
    end

    client = OAuthProtocol.find_client(ctx, "oauthClient", input["client_id"])
    raise APIError.new("NOT_FOUND", message: "client not found") unless client
    raise APIError.new("NOT_FOUND", message: "client not found") if OAuthProtocol.stringify_keys(client)["disabled"]

    ctx.json(oauth_public_client_response(client))
  end
end


15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# File 'lib/better_auth/plugins/oauth_provider/oauth_consent/endpoints.rb', line 15

def oauth_get_consent_endpoint
  Endpoint.new(path: "/oauth2/get-consent", method: "GET") do |ctx|
    session = Routes.current_session(ctx)
    query = OAuthProtocol.stringify_keys(ctx.query)
    consent = if query["id"].to_s.empty?
      oauth_find_user_consent(ctx, session, query["client_id"])
    else
      ctx.context.adapter.find_one(model: "oauthConsent", where: [{field: "id", value: query["id"]}])
    end
    raise APIError.new("NOT_FOUND", message: "missing id") unless query["id"] || query["client_id"]
    raise APIError.new("NOT_FOUND", message: "consent not found") unless consent
    raise APIError.new("UNAUTHORIZED") unless OAuthProtocol.stringify_keys(consent)["userId"] == session[:user]["id"]

    ctx.json(oauth_consent_response(consent))
  end
end

.oauth_hashed_secret_storage?(value) ⇒ Boolean

Returns:

  • (Boolean)


107
108
109
110
# File 'lib/better_auth/plugins/oauth_provider.rb', line 107

def oauth_hashed_secret_storage?(value)
  mode = value.is_a?(Hash) ? normalize_hash(value) : value.to_s
  mode == "hashed" || (mode.is_a?(Hash) && mode[:hash].respond_to?(:call))
end

.oauth_id_token_signing_algs(ctx, config) ⇒ Object



97
98
99
100
101
102
103
104
105
106
# File 'lib/better_auth/plugins/oauth_provider/metadata.rb', line 97

def oauth_id_token_signing_algs(ctx, config)
  return ["HS256"] if config[:disable_jwt_plugin]

  jwt_plugin = ctx.context.options.plugins.find { |plugin| plugin.id == "jwt" }
  return ["HS256"] unless jwt_plugin

  alg = config.dig(:jwt, :jwks, :key_pair_config, :alg) ||
    jwt_plugin&.options&.dig(:jwks, :key_pair_config, :alg)
  alg ? [alg] : ["EdDSA"]
end

.oauth_introspect_endpoint(config) ⇒ Object



7
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
# File 'lib/better_auth/plugins/oauth_provider/introspect.rb', line 7

def oauth_introspect_endpoint(config)
  Endpoint.new(path: "/oauth2/introspect", method: "POST", metadata: oauth_openapi_for(:introspect).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])) do |ctx|
    client = OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix], require_confidential: true)
    client_id = OAuthProtocol.stringify_keys(client)["clientId"]
    body = OAuthProtocol.stringify_keys(ctx.body)
    token_value = body["token"].to_s.sub(/\ABearer\s+/i, "")
    token = OAuthProtocol.find_token_by_hint(config[:store], token_value, body["token_type_hint"], prefix: config[:prefix])
    active = token && token["clientId"].to_s == client_id.to_s && !token["revoked"] && (!token["expiresAt"] || token["expiresAt"] > Time.now)
    if active
      next ctx.json({
        active: true,
        client_id: token["clientId"],
        scope: OAuthProtocol.scope_string(token["scope"] || token["scopes"]),
        sub: token["subject"] || token.dig("user", "id"),
        iss: token["issuer"],
        iat: token["issuedAt"]&.to_i,
        exp: token["expiresAt"]&.to_i,
        sid: token["sessionId"],
        aud: token["audience"]
      })
    end

    jwt = oauth_introspect_jwt_access_token(ctx, client, token_value)
    ctx.json(jwt || {active: false})
  end
end

.oauth_introspect_jwt_access_token(ctx, client, token) ⇒ Object



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/better_auth/plugins/oauth_provider/introspect.rb', line 38

def oauth_introspect_jwt_access_token(ctx, client, token)
  payload = OAuthProtocol.verify_oauth_jwt(ctx, token, issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)), hs256_secret: ctx.context.secret)
  client_data = OAuthProtocol.stringify_keys(client)
  return nil unless payload["azp"] == client_data["clientId"]

  {
    active: true,
    client_id: payload["azp"],
    scope: payload["scope"],
    sub: payload["sub"],
    aud: payload["aud"],
    exp: payload["exp"]
  }.compact
rescue ::JWT::DecodeError
  nil
end

.oauth_introspect_openapiObject



424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/better_auth/plugins/oauth_provider.rb', line 424

def oauth_introspect_openapi
  {
    openapi: {
      description: "Introspect an OAuth2 token",
      requestBody: OpenAPI.json_request_body(
        OpenAPI.object_schema(
          {
            token: {type: "string", description: "Token to introspect"},
            token_type_hint: {type: "string", enum: ["access_token", "refresh_token"]}
          },
          required: ["token"]
        )
      ),
      responses: {
        "200" => OpenAPI.json_response("OAuth2 token introspection result", oauth_introspection_response_schema)
      }
    }
  }
end

.oauth_introspection_response_schemaObject



613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
# File 'lib/better_auth/plugins/oauth_provider.rb', line 613

def oauth_introspection_response_schema
  OpenAPI.object_schema(
    {
      active: {type: "boolean"},
      client_id: {type: "string"},
      scope: {type: "string"},
      sub: {type: "string"},
      iss: {type: "string"},
      iat: {type: "number"},
      exp: {type: "number"},
      sid: {type: "string"},
      aud: {oneOf: [{type: "string"}, {type: "array", items: {type: "string"}}]}
    },
    required: ["active"]
  )
end

.oauth_invalid_request_uri!(ctx, query, description) ⇒ Object

Raises:

  • (APIError)


230
231
232
233
234
235
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 230

def oauth_invalid_request_uri!(ctx, query, description)
  redirect_uri = query["redirect_uri"]
  raise APIError.new("BAD_REQUEST", message: "invalid_request_uri") if redirect_uri.to_s.empty?

  raise ctx.redirect(oauth_authorize_error_redirect(ctx, query, "invalid_request_uri", description))
end

.oauth_jwks_uri(ctx, config) ⇒ Object



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

def oauth_jwks_uri(ctx, config)
  config.dig(:advertised_metadata, :jwks_uri) ||
    config[:jwks_uri] ||
    config.dig(:jwks, :remote_url) ||
    oauth_default_jwks_uri(ctx, config)
end

.oauth_jwt_access_token?(config, audience) ⇒ Boolean

Returns:

  • (Boolean)


34
35
36
# File 'lib/better_auth/plugins/oauth_provider/introspect.rb', line 34

def oauth_jwt_access_token?(config, audience)
  !!audience && !config[:disable_jwt_plugin] && !config[:disable_jwt_access_tokens]
end

.oauth_legacy_delete_client_endpoint(config) ⇒ Object



231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 231

def oauth_legacy_delete_client_endpoint(config)
  Endpoint.new(path: "/oauth2/client", method: "DELETE") do |ctx|
    session = Routes.current_session(ctx)
    oauth_assert_client_privilege!(ctx, config, session, "delete")
    body = OAuthProtocol.stringify_keys(ctx.body)
    client = OAuthProtocol.find_client(ctx, "oauthClient", body["client_id"])
    raise APIError.new("NOT_FOUND", message: "client not found") unless client
    oauth_assert_owned_client!(client, session, config)
    ctx.context.adapter.delete(model: "oauthClient", where: [{field: "clientId", value: body["client_id"]}])
    ctx.json({deleted: true})
  end
end


119
120
121
122
123
124
125
126
127
128
# File 'lib/better_auth/plugins/oauth_provider/oauth_consent/endpoints.rb', line 119

def oauth_legacy_delete_consent_endpoint
  Endpoint.new(path: "/oauth2/consent", method: "DELETE") do |ctx|
    session = Routes.current_session(ctx)
    body = OAuthProtocol.stringify_keys(ctx.body)
    consent = oauth_find_user_consent(ctx, session, body["client_id"])
    raise APIError.new("NOT_FOUND", message: "consent not found") unless consent
    ctx.context.adapter.delete(model: "oauthConsent", where: [{field: "id", value: OAuthProtocol.stringify_keys(consent)["id"]}])
    ctx.json({deleted: true})
  end
end

.oauth_legacy_get_client_endpoint(config) ⇒ Object



188
189
190
191
192
193
194
195
196
197
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 188

def oauth_legacy_get_client_endpoint(config)
  Endpoint.new(path: "/oauth2/client/:id", method: "GET") do |ctx|
    session = Routes.current_session(ctx)
    oauth_assert_client_privilege!(ctx, config, session, "read")
    client = OAuthProtocol.find_client(ctx, "oauthClient", ctx.params["id"] || ctx.params[:id])
    raise APIError.new("NOT_FOUND", message: "client not found") unless client
    oauth_assert_owned_client!(client, session, config)
    ctx.json(OAuthProtocol.client_response(client, include_secret: false))
  end
end

.oauth_legacy_get_client_public_endpoint(_config) ⇒ Object



199
200
201
202
203
204
205
206
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 199

def oauth_legacy_get_client_public_endpoint(_config)
  Endpoint.new(path: "/oauth2/client", method: "GET") do |ctx|
    query = OAuthProtocol.stringify_keys(ctx.query)
    client = OAuthProtocol.find_client(ctx, "oauthClient", query["client_id"])
    raise APIError.new("NOT_FOUND", message: "client not found") unless client
    ctx.json(OAuthProtocol.client_response(client, include_secret: false))
  end
end


90
91
92
93
94
95
96
97
98
# File 'lib/better_auth/plugins/oauth_provider/oauth_consent/endpoints.rb', line 90

def oauth_legacy_get_consent_endpoint
  Endpoint.new(path: "/oauth2/consent", method: "GET") do |ctx|
    session = Routes.current_session(ctx)
    query = OAuthProtocol.stringify_keys(ctx.query)
    consent = oauth_find_user_consent(ctx, session, query["client_id"])
    raise APIError.new("NOT_FOUND", message: "consent not found") unless consent
    ctx.json(oauth_consent_response(consent))
  end
end

.oauth_legacy_list_clients_endpoint(config) ⇒ Object



208
209
210
211
212
213
214
215
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 208

def oauth_legacy_list_clients_endpoint(config)
  Endpoint.new(path: "/oauth2/clients", method: "GET") do |ctx|
    session = Routes.current_session(ctx)
    oauth_assert_client_privilege!(ctx, config, session, "list")
    clients = ctx.context.adapter.find_many(model: "oauthClient", where: [{field: "userId", value: session[:user]["id"]}])
    ctx.json(clients.map { |client| OAuthProtocol.client_response(client, include_secret: false) })
  end
end

.oauth_legacy_list_consents_endpointObject



82
83
84
85
86
87
88
# File 'lib/better_auth/plugins/oauth_provider/oauth_consent/endpoints.rb', line 82

def oauth_legacy_list_consents_endpoint
  Endpoint.new(path: "/oauth2/consents", method: "GET") do |ctx|
    session = Routes.current_session(ctx)
    consents = ctx.context.adapter.find_many(model: "oauthConsent", where: [{field: "userId", value: session[:user]["id"]}])
    ctx.json(consents.map { |consent| oauth_consent_response(consent) })
  end
end

.oauth_legacy_update_client_endpoint(config) ⇒ Object



217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 217

def oauth_legacy_update_client_endpoint(config)
  Endpoint.new(path: "/oauth2/client", method: "PATCH", metadata: oauth_openapi_for(:update_client)) do |ctx|
    session = Routes.current_session(ctx)
    oauth_assert_client_privilege!(ctx, config, session, "update")
    body = OAuthProtocol.stringify_keys(ctx.body)
    client = OAuthProtocol.find_client(ctx, "oauthClient", body["client_id"])
    raise APIError.new("NOT_FOUND", message: "client not found") unless client
    oauth_assert_owned_client!(client, session, config)
    update = oauth_client_update_data(OAuthProtocol.stringify_keys(body["update"] || body))
    updated = update.empty? ? client : ctx.context.adapter.update(model: "oauthClient", where: [{field: "clientId", value: body["client_id"]}], update: update.merge(updatedAt: Time.now))
    ctx.json(OAuthProtocol.client_response(updated, include_secret: false))
  end
end


100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/better_auth/plugins/oauth_provider/oauth_consent/endpoints.rb', line 100

def oauth_legacy_update_consent_endpoint
  Endpoint.new(path: "/oauth2/consent", method: "PATCH", metadata: oauth_openapi_for(:update_consent)) do |ctx|
    session = Routes.current_session(ctx)
    body = OAuthProtocol.stringify_keys(ctx.body)
    consent = oauth_find_user_consent(ctx, session, body["client_id"])
    raise APIError.new("NOT_FOUND", message: "consent not found") unless consent
    scopes = OAuthProtocol.parse_scopes(body["scope"] || body["scopes"])
    existing = OAuthProtocol.parse_scopes(OAuthProtocol.stringify_keys(consent)["scopes"])
    raise APIError.new("BAD_REQUEST", message: "invalid_scope") unless scopes.all? { |scope| existing.include?(scope) }

    updated = ctx.context.adapter.update(
      model: "oauthConsent",
      where: [{field: "id", value: OAuthProtocol.stringify_keys(consent)["id"]}],
      update: {scopes: scopes, updatedAt: Time.now}
    )
    ctx.json(oauth_consent_response(updated))
  end
end

.oauth_list_clients_endpoint(config) ⇒ Object



79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 79

def oauth_list_clients_endpoint(config)
  Endpoint.new(path: "/oauth2/get-clients", method: "GET") do |ctx|
    session = Routes.current_session(ctx)
    oauth_assert_client_privilege!(ctx, config, session, "list")
    reference_id = config[:client_reference]&.call({user: session[:user], session: session[:session]})
    clients = if reference_id
      ctx.context.adapter.find_many(model: "oauthClient", where: [{field: "referenceId", value: reference_id}])
    else
      ctx.context.adapter.find_many(model: "oauthClient", where: [{field: "userId", value: session[:user]["id"]}])
    end
    ctx.json(clients.map { |client| OAuthProtocol.client_response(client, include_secret: false) })
  end
end

.oauth_list_consents_endpointObject



7
8
9
10
11
12
13
# File 'lib/better_auth/plugins/oauth_provider/oauth_consent/endpoints.rb', line 7

def oauth_list_consents_endpoint
  Endpoint.new(path: "/oauth2/get-consents", method: "GET") do |ctx|
    session = Routes.current_session(ctx)
    consents = ctx.context.adapter.find_many(model: "oauthConsent", where: [{field: "userId", value: session[:user]["id"]}])
    ctx.json(consents.map { |consent| oauth_consent_response(consent) })
  end
end

.oauth_metadata_headersObject



70
71
72
# File 'lib/better_auth/plugins/oauth_provider/metadata.rb', line 70

def 
  {"Cache-Control" => "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400"}
end

.oauth_no_store_headersObject



91
92
93
# File 'lib/better_auth/plugins/oauth_provider/token.rb', line 91

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

.oauth_openapi_for(route) ⇒ Object



233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/better_auth/plugins/oauth_provider.rb', line 233

def oauth_openapi_for(route)
  {
    register_client: oauth_client_registration_openapi("Register a new OAuth2 client", include_secret: true),
    create_client: oauth_client_registration_openapi("Create a new OAuth2 client", include_secret: true),
    public_client_prelogin: oauth_public_client_prelogin_openapi,
    delete_client: oauth_delete_client_openapi,
    update_client: oauth_update_client_openapi,
    rotate_client_secret: oauth_rotate_client_secret_openapi,
    update_consent: oauth_update_consent_openapi,
    delete_consent: oauth_delete_consent_openapi,
    continue: oauth_continue_openapi,
    consent: oauth_consent_openapi,
    token: oauth_token_openapi,
    introspect: oauth_introspect_openapi,
    revoke: oauth_revoke_openapi,
    end_session: oauth_end_session_openapi
  }.fetch(route)
end

.oauth_openid_metadata_endpoint(config) ⇒ Object



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
# File 'lib/better_auth/plugins/oauth_provider/metadata.rb', line 33

def (config)
  Endpoint.new(path: "/.well-known/openid-configuration", method: "GET", metadata: {hide: true}) do |ctx|
    unless OAuthProtocol.parse_scopes(config[:scopes]).include?("openid")
      raise APIError.new("NOT_FOUND", message: "openid is not enabled")
    end

    base = OAuthProtocol.endpoint_base(ctx)
     = {
      issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)),
      authorization_endpoint: "#{base}/oauth2/authorize",
      token_endpoint: "#{base}/oauth2/token",
      introspection_endpoint: "#{base}/oauth2/introspect",
      revocation_endpoint: "#{base}/oauth2/revoke",
      response_types_supported: ["code"],
      response_modes_supported: ["query"],
      grant_types_supported: config[:grant_types],
      token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
      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,
      scopes_supported: config.dig(:advertised_metadata, :scopes_supported) || config[:scopes],
      userinfo_endpoint: "#{base}/oauth2/userinfo",
      subject_types_supported: config[:pairwise_secret] ? ["public", "pairwise"] : ["public"],
      id_token_signing_alg_values_supported: oauth_id_token_signing_algs(ctx, config),
      end_session_endpoint: "#{base}/oauth2/end-session",
      acr_values_supported: ["urn:mace:incommon:iap:bronze"],
      prompt_values_supported: oauth_prompt_values,
      claims_supported: config.dig(:advertised_metadata, :claims_supported) || config[:claims] || []
    }
    [:registration_endpoint] = "#{base}/oauth2/register" if config[:allow_dynamic_client_registration]
    jwks_uri = oauth_jwks_uri(ctx, config)
    [:jwks_uri] = jwks_uri if jwks_uri
    ctx.json(, headers: )
  end
end

.oauth_persist_token_revocation(ctx, config, body, token) ⇒ Object



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/better_auth/plugins/oauth_provider/revoke.rb', line 26

def oauth_persist_token_revocation(ctx, config, body, token)
  return unless token["id"]

  hint = body["token_type_hint"].to_s
  token_value = body["token"].to_s
  access_value = OAuthProtocol.strip_prefix(token_value, config[:prefix], :access_token)
  refresh_value = OAuthProtocol.strip_prefix(token_value, config[:prefix], :refresh_token)
  is_access = hint == "access_token" || (access_value && config[:store][:tokens][access_value].equal?(token))
  is_refresh = hint == "refresh_token" || (refresh_value && config[:store][:refresh_tokens][refresh_value].equal?(token))

  if is_access && OAuthProtocol.schema_model?(ctx, "oauthAccessToken")
    ctx.context.adapter.update(model: "oauthAccessToken", where: [{field: "id", value: token["id"]}], update: {revoked: token["revoked"]})
  end

  if is_refresh && OAuthProtocol.schema_model?(ctx, "oauthRefreshToken")
    ctx.context.adapter.update(model: "oauthRefreshToken", where: [{field: "id", value: token["id"]}], update: {revoked: token["revoked"]})
    oauth_revoke_refresh_access_tokens(ctx, config[:store], token)
  end
end

.oauth_prompt_page(config, type) ⇒ Object



138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 138

def oauth_prompt_page(config, type)
  case type
  when "create"
    config.dig(:signup, :page) || config[:login_page]
  when "select_account"
    config.dig(:select_account, :page) || config[:login_page]
  when "post_login"
    config.dig(:post_login, :page) || config[:login_page]
  when "consent"
    config[:consent_page]
  else
    config[:login_page]
  end
end

.oauth_prompt_redirect(ctx, config, query, type, page: nil) ⇒ Object



132
133
134
135
136
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 132

def oauth_prompt_redirect(ctx, config, query, type, page: nil)
  target = page || oauth_prompt_page(config, type)

  "#{target}?#{oauth_signed_query(ctx, query)}"
end

.oauth_prompt_valuesObject



108
109
110
# File 'lib/better_auth/plugins/oauth_provider/metadata.rb', line 108

def oauth_prompt_values
  ["login", "consent", "create", "select_account", "none"]
end

.oauth_provider(options = {}) ⇒ Object



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
# File 'lib/better_auth/plugins/oauth_provider.rb', line 38

def oauth_provider(options = {})
  raw_options = normalize_hash(options)
  config = {
    login_page: "/login",
    consent_page: "/oauth2/consent",
    scopes: [],
    grant_types: [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::CLIENT_CREDENTIALS_GRANT, OAuthProtocol::REFRESH_GRANT],
    allow_dynamic_client_registration: false,
    allow_unauthenticated_client_registration: false,
    client_registration_default_scopes: nil,
    client_registration_allowed_scopes: nil,
    signup: {},
    select_account: {},
    post_login: {},
    store_client_secret: "hashed",
    store_tokens: "hashed",
    prefix: {},
    code_expires_in: 600,
    id_token_expires_in: 36_000,
    refresh_token_expires_in: 2_592_000,
    access_token_expires_in: 3600,
    m2m_access_token_expires_in: 3600,
    client_credential_grant_default_scopes: nil,
    scope_expirations: {},
    store: OAuthProtocol.stores
  }.merge(raw_options)

  oauth_provider_validate_config!(config, raw_options)

  Plugin.new(
    id: "oauth-provider",
    version: BetterAuth::OAuthProvider::VERSION,
    init: oauth_provider_init(config),
    hooks: oauth_provider_hooks(config),
    endpoints: oauth_provider_endpoints(config),
    schema: oauth_provider_schema,
    rate_limit: oauth_provider_rate_limits(config),
    options: config
  )
end

.oauth_provider_endpoints(config) ⇒ Object



192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/better_auth/plugins/oauth_provider.rb', line 192

def oauth_provider_endpoints(config)
  {
    get_o_auth_server_config: (config),
    get_open_id_config: (config),
    register_o_auth_client: oauth_register_client_endpoint(config),
    create_o_auth_client: oauth_create_client_endpoint(config),
    admin_create_o_auth_client: oauth_admin_create_client_endpoint(config),
    admin_update_o_auth_client: oauth_admin_update_client_endpoint(config),
    get_o_auth_client: oauth_get_client_endpoint(config),
    get_o_auth_client_public: oauth_get_client_public_endpoint(config),
    get_o_auth_client_public_prelogin: oauth_get_client_public_prelogin_endpoint(config),
    get_o_auth_clients: oauth_list_clients_endpoint(config),
    list_o_auth_clients: oauth_list_clients_endpoint(config),
    delete_o_auth_client: oauth_delete_client_endpoint(config),
    update_o_auth_client: oauth_update_client_endpoint(config),
    rotate_o_auth_client_secret: oauth_rotate_client_secret_endpoint(config),
    get_o_auth_consents: oauth_list_consents_endpoint,
    list_o_auth_consents: oauth_list_consents_endpoint,
    get_o_auth_consent: oauth_get_consent_endpoint,
    update_o_auth_consent: oauth_update_consent_endpoint,
    delete_o_auth_consent: oauth_delete_consent_endpoint,
    legacy_get_o_auth_client: oauth_legacy_get_client_endpoint(config),
    legacy_get_o_auth_client_public: oauth_legacy_get_client_public_endpoint(config),
    legacy_list_o_auth_clients: oauth_legacy_list_clients_endpoint(config),
    legacy_update_o_auth_client: oauth_legacy_update_client_endpoint(config),
    legacy_delete_o_auth_client: oauth_legacy_delete_client_endpoint(config),
    legacy_list_o_auth_consents: oauth_legacy_list_consents_endpoint,
    legacy_get_o_auth_consent: oauth_legacy_get_consent_endpoint,
    legacy_update_o_auth_consent: oauth_legacy_update_consent_endpoint,
    legacy_delete_o_auth_consent: oauth_legacy_delete_consent_endpoint,
    o_auth2_authorize: oauth_authorize_endpoint(config),
    o_auth2_continue: oauth_continue_endpoint(config),
    o_auth2_consent: oauth_consent_endpoint(config),
    o_auth2_token: oauth_token_endpoint(config),
    o_auth2_introspect: oauth_introspect_endpoint(config),
    o_auth2_revoke: oauth_revoke_endpoint(config),
    o_auth2_user_info: oauth_userinfo_endpoint(config),
    o_auth2_end_session: oauth_end_session_endpoint
  }
end

.oauth_provider_hooks(config) ⇒ Object



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/better_auth/plugins/oauth_provider.rb', line 117

def oauth_provider_hooks(config)
  {
    before: [
      {
        matcher: ->(ctx) { ctx.path.start_with?("/sign-in/", "/sign-up/") && !!oauth_query_from_body(ctx.body) },
        handler: ->(ctx) { oauth_validate_query_hook!(ctx) }
      }
    ],
    after: [
      {
        matcher: ->(ctx) { oauth_resume_after_session_cookie?(ctx) },
        handler: ->(ctx) { oauth_resume_after_session_cookie(ctx, config) }
      }
    ]
  }
end

.oauth_provider_init(config) ⇒ Object



174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/better_auth/plugins/oauth_provider.rb', line 174

def oauth_provider_init(config)
  lambda do |context|
    advertised_scopes = Array(config.dig(:advertised_metadata, :scopes_supported)).map(&:to_s)
    provider_scopes = OAuthProtocol.parse_scopes(config[:scopes])
    missing_scopes = advertised_scopes - provider_scopes
    unless missing_scopes.empty?
      raise APIError.new("BAD_REQUEST", message: "advertised_metadata.scopes_supported #{missing_scopes.first} not found in scopes")
    end
    if config[:pairwise_secret] && config[:pairwise_secret].to_s.length < 32
      raise APIError.new("BAD_REQUEST", message: "pairwise_secret must be at least 32 characters")
    end
    if context.options.secondary_storage && !context.options.session[:store_session_in_database]
      raise APIError.new("BAD_REQUEST", message: "OAuth Provider requires session.store_session_in_database when using secondary storage")
    end
    nil
  end
end

.oauth_provider_rate_limits(config) ⇒ Object



7
8
9
10
11
12
13
14
15
16
17
18
19
20
# File 'lib/better_auth/plugins/oauth_provider/rate_limit.rb', line 7

def oauth_provider_rate_limits(config)
  rate_limit = normalize_hash(config[:rate_limit] || {})
  [
    oauth_rate_limit_rule(rate_limit, :token, "/oauth2/token", window: 60, max: 20),
    oauth_rate_limit_rule(rate_limit, :authorize, "/oauth2/authorize", window: 60, max: 30),
    oauth_rate_limit_rule(rate_limit, :introspect, "/oauth2/introspect", window: 60, max: 100),
    oauth_rate_limit_rule(rate_limit, :revoke, "/oauth2/revoke", window: 60, max: 30),
    oauth_rate_limit_rule(rate_limit, :register, "/oauth2/register", window: 60, max: 5),
    oauth_rate_limit_rule(rate_limit, :userinfo, "/oauth2/userinfo", window: 60, max: 60),
    oauth_rate_limit_rule(rate_limit, :continue, "/oauth2/continue", window: 60, max: 40),
    oauth_rate_limit_rule(rate_limit, :consent, "/oauth2/consent", window: 60, max: 40),
    oauth_rate_limit_rule(rate_limit, :end_session, "/oauth2/end-session", window: 60, max: 30)
  ].compact
end

.oauth_provider_schemaObject



7
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
# File 'lib/better_auth/plugins/oauth_provider/schema.rb', line 7

def oauth_provider_schema
  {
    oauthClient: {
      model_name: "oauth_clients",
      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, index: true, references: {model: "user", field: "id"}},
        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, index: true},
        metadata: {type: "json", required: false}
      }
    },
    oauthRefreshToken: {
      fields: {
        token: {type: "string", required: true},
        clientId: {type: "string", required: true, index: true, references: {model: "oauthClient", field: "clientId"}},
        sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
        userId: {type: "string", required: false, index: true, references: {model: "user", field: "id"}},
        referenceId: {type: "string", required: false, index: true},
        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: {
      model_name: "oauth_access_tokens",
      fields: {
        token: {type: "string", unique: true, required: true},
        expiresAt: {type: "date", required: true},
        clientId: {type: "string", required: true, index: true, references: {model: "oauthClient", field: "clientId"}},
        userId: {type: "string", required: false, index: true, references: {model: "user", field: "id"}},
        sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
        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, index: true, references: {model: "oauthRefreshToken", field: "id"}},
        createdAt: {type: "date", required: true, default_value: -> { Time.now }},
        updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
      }
    },
    oauthConsent: {
      model_name: "oauth_consents",
      fields: {
        clientId: {type: "string", required: true, index: true, references: {model: "oauthClient", field: "clientId"}},
        userId: {type: "string", required: false, index: true, references: {model: "user", field: "id"}},
        referenceId: {type: "string", required: false, index: true},
        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

.oauth_provider_validate_config!(config, raw_options = {}) ⇒ Object



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/better_auth/plugins/oauth_provider.rb', line 79

def oauth_provider_validate_config!(config, raw_options = {})
  provider_scopes = OAuthProtocol.parse_scopes(config[:scopes])
  [
    [:client_registration_allowed_scopes, config[:client_registration_allowed_scopes]],
    [:client_registration_default_scopes, config[:client_registration_default_scopes]]
  ].each do |key, value|
    next if value.nil?

    missing = OAuthProtocol.parse_scopes(value) - provider_scopes
    unless missing.empty?
      raise APIError.new("BAD_REQUEST", message: "#{key} #{missing.first} not found in scopes")
    end
  end

  grant_types = Array(config[:grant_types]).map(&:to_s)
  if grant_types.include?(OAuthProtocol::REFRESH_GRANT) && !grant_types.include?(OAuthProtocol::AUTH_CODE_GRANT)
    raise APIError.new("BAD_REQUEST", message: "refresh_token grant requires authorization_code grant")
  end

  store_client_secret = config[:store_client_secret]
  if config[:disable_jwt_plugin] && raw_options.key?(:store_client_secret) && oauth_hashed_secret_storage?(store_client_secret)
    raise APIError.new("BAD_REQUEST", message: "unable to store hashed secrets because id tokens will be signed with client secret")
  end
  if !config[:disable_jwt_plugin] && oauth_encrypted_secret_storage?(store_client_secret)
    raise APIError.new("BAD_REQUEST", message: "encrypted secret storage is not recommended, please use hashed secret storage with the JWT plugin")
  end
end

.oauth_public_client_prelogin_openapiObject



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/better_auth/plugins/oauth_provider.rb', line 264

def oauth_public_client_prelogin_openapi
  {
    openapi: {
      description: "Get public OAuth2 client metadata before login",
      requestBody: OpenAPI.json_request_body(
        OpenAPI.object_schema(
          {
            client_id: {type: "string", description: "OAuth2 client ID"},
            oauth_query: {type: "string", description: "Signed OAuth query string"}
          },
          required: ["client_id", "oauth_query"]
        )
      ),
      responses: {
        "200" => OpenAPI.json_response("Public OAuth2 client metadata", oauth_public_client_schema)
      }
    }
  }
end

.oauth_public_client_response(client) ⇒ Object



98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/index.rb', line 98

def oauth_public_client_response(client)
  data = OAuthProtocol.stringify_keys(client)
  {
    client_id: data["clientId"],
    client_name: data["name"],
    client_uri: data["uri"],
    logo_uri: data["icon"],
    contacts: data["contacts"] || [],
    tos_uri: data["tos"],
    policy_uri: data["policy"]
  }.compact
end

.oauth_public_client_schemaObject



542
543
544
545
546
547
548
549
550
551
552
553
554
# File 'lib/better_auth/plugins/oauth_provider.rb', line 542

def oauth_public_client_schema
  OpenAPI.object_schema(
    {
      client_id: {type: "string"},
      client_name: {type: "string"},
      client_uri: {type: ["string", "null"], format: "uri"},
      logo_uri: {type: ["string", "null"], format: "uri"},
      contacts: {type: "array", items: {type: "string"}},
      tos_uri: {type: ["string", "null"], format: "uri"},
      policy_uri: {type: ["string", "null"], format: "uri"}
    }
  )
end

.oauth_query_from_body(body) ⇒ Object



167
168
169
170
171
172
# File 'lib/better_auth/plugins/oauth_provider.rb', line 167

def oauth_query_from_body(body)
  return nil unless body.is_a?(Hash)

  data = OAuthProtocol.stringify_keys(body || {})
  data["oauth_query"] || data["oauthQuery"]
end

.oauth_rate_limit_rule(rate_limit, key, path, window:, max:) ⇒ Object



22
23
24
25
26
27
28
29
30
31
32
# File 'lib/better_auth/plugins/oauth_provider/rate_limit.rb', line 22

def oauth_rate_limit_rule(rate_limit, key, path, window:, max:)
  override = rate_limit[key]
  return nil if override == false

  override = normalize_hash(override || {})
  {
    path_matcher: ->(request_path) { request_path == path },
    window: override[:window] || window,
    max: override[:max] || max
  }
end

.oauth_redirect_locationObject



194
195
196
197
198
199
200
201
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 194

def oauth_redirect_location
  yield
rescue APIError => error
  location = error.headers["location"]
  return location if location

  raise
end

.oauth_redirect_response_schemaObject



589
590
591
592
593
594
595
596
597
# File 'lib/better_auth/plugins/oauth_provider.rb', line 589

def oauth_redirect_response_schema
  OpenAPI.object_schema(
    {
      redirect: {type: "boolean", enum: [true]},
      url: {type: "string", format: "uri"}
    },
    required: ["redirect", "url"]
  )
end

.oauth_redirect_with_code(ctx, config, query, session, client, scopes, reference_id: nil) ⇒ Object



58
59
60
# File 'lib/better_auth/plugins/oauth_provider/consent.rb', line 58

def oauth_redirect_with_code(ctx, config, query, session, client, scopes, reference_id: nil)
  raise ctx.redirect(oauth_authorization_redirect(ctx, config, query, session, client, scopes, reference_id: reference_id))
end

.oauth_register_client_endpoint(config) ⇒ Object



7
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
# File 'lib/better_auth/plugins/oauth_provider/register.rb', line 7

def oauth_register_client_endpoint(config)
  Endpoint.new(
    path: "/oauth2/register",
    method: "POST",
    body_schema: ->(value) { value },
    metadata: oauth_openapi_for(:register_client)
  ) do |ctx|
    session = Routes.current_session(ctx, allow_nil: true)
    body = OAuthProtocol.stringify_keys(ctx.body)
    unless config[:allow_dynamic_client_registration]
      raise APIError.new("FORBIDDEN", message: "Client registration is disabled")
    end
    unless session || config[:allow_unauthenticated_client_registration]
      raise APIError.new("UNAUTHORIZED")
    end
    if body.key?("skip_consent") || body.key?("skipConsent")
      raise APIError.new("BAD_REQUEST", message: "skip_consent is not allowed during dynamic client registration")
    end
    body["require_pkce"] = true unless body.key?("require_pkce") || body.key?("requirePKCE")

    client = OAuthProtocol.create_client(
      ctx,
      model: "oauthClient",
      body: body,
      owner_session: session,
      unauthenticated: session.nil?,
      default_scopes: config[:client_registration_default_scopes] || config[:scopes],
      allowed_scopes: config[:client_registration_allowed_scopes] || config[:scopes],
      store_client_secret: config[:store_client_secret],
      prefix: config[:prefix],
      dynamic_registration: true,
      pairwise_secret: config[:pairwise_secret],
      strip_client_metadata: true,
      reference_id: oauth_client_reference(config, session)
    )
    ctx.json(client, status: 201, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"})
  end
end

.oauth_requires_login?(session, prompts, query) ⇒ Boolean

Returns:

  • (Boolean)


117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 117

def oauth_requires_login?(session, prompts, query)
  return true if prompts.include?("login")
  return false unless query.key?("max_age")

  max_age = Integer(query["max_age"])
  return false if max_age.negative?

  auth_time = OAuthProvider::Utils.resolve_session_auth_time(session)
  return false unless auth_time

  (Time.now - auth_time) > max_age
rescue ArgumentError, TypeError
  false
end

.oauth_resolve_request_uri!(ctx, config, query) ⇒ Object



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 213

def oauth_resolve_request_uri!(ctx, config, query)
  query = OAuthProtocol.stringify_keys(query)
  return query if query["request_uri"].to_s.empty?

  resolver = config[:request_uri_resolver]
  unless resolver.respond_to?(:call)
    return oauth_invalid_request_uri!(ctx, query, "request_uri not supported")
  end

  resolved = resolver.call({request_uri: query["request_uri"], client_id: query["client_id"], context: ctx})
  return oauth_invalid_request_uri!(ctx, query, "request_uri is invalid or expired") unless resolved

  resolved_query = OAuthProtocol.stringify_keys(resolved)
  resolved_query["client_id"] = query["client_id"] if query["client_id"]
  resolved_query
end


152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/better_auth/plugins/oauth_provider.rb', line 152

def oauth_resume_after_session_cookie(ctx, config)
  query = oauth_verified_query!(ctx, oauth_query_from_body(ctx.body))
  ctx.context.set_current_session(ctx.context.new_session) if ctx.context.respond_to?(:set_current_session) && ctx.context.new_session
  location = oauth_redirect_location { oauth_authorize_flow(ctx, config, query, continue_post_login: true) }
  [302, Endpoint::Result.merge_headers(ctx.response_headers, {"location" => location}), [""]]
rescue APIError => error
  raise APIError.new(
    error.status,
    message: error.message,
    headers: Endpoint::Result.merge_headers(ctx.response_headers, error.headers),
    code: error.code,
    body: error.body
  )
end

.oauth_resume_after_session_cookie?(ctx) ⇒ Boolean

Returns:

  • (Boolean)


145
146
147
148
149
150
# File 'lib/better_auth/plugins/oauth_provider.rb', line 145

def oauth_resume_after_session_cookie?(ctx)
  return false unless oauth_query_from_body(ctx.body)
  return false unless ctx.path.start_with?("/sign-in/", "/sign-up/")

  ctx.response_headers["set-cookie"].to_s.include?(ctx.context.auth_cookies[:session_token].name)
end

.oauth_revoke_endpoint(config) ⇒ Object



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# File 'lib/better_auth/plugins/oauth_provider/revoke.rb', line 7

def oauth_revoke_endpoint(config)
  Endpoint.new(path: "/oauth2/revoke", method: "POST", metadata: oauth_openapi_for(:revoke).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])) do |ctx|
    client = OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix], require_confidential: true)
    client_id = OAuthProtocol.stringify_keys(client)["clientId"]
    body = OAuthProtocol.stringify_keys(ctx.body)
    if body["token_type_hint"].to_s == "access_token" && OAuthProtocol.find_token_by_hint(config[:store], body["token"].to_s, "refresh_token", prefix: config[:prefix])
      raise APIError.new("BAD_REQUEST", message: "invalid_request")
    end
    if body["token_type_hint"].to_s == "refresh_token" && OAuthProtocol.find_token_by_hint(config[:store], body["token"].to_s, "access_token", prefix: config[:prefix])
      raise APIError.new("BAD_REQUEST", message: "invalid_request")
    end
    if (token = OAuthProtocol.find_token_by_hint(config[:store], body["token"].to_s, body["token_type_hint"], prefix: config[:prefix])) && token["clientId"].to_s == client_id.to_s
      token["revoked"] = Time.now
      oauth_persist_token_revocation(ctx, config, body, token)
    end
    ctx.json({revoked: true})
  end
end

.oauth_revoke_openapiObject



444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# File 'lib/better_auth/plugins/oauth_provider.rb', line 444

def oauth_revoke_openapi
  {
    openapi: {
      description: "Revoke an OAuth2 token",
      requestBody: OpenAPI.json_request_body(
        OpenAPI.object_schema(
          {
            token: {type: "string", description: "Token to revoke"},
            token_type_hint: {type: "string", enum: ["access_token", "refresh_token"]}
          },
          required: ["token"]
        )
      ),
      responses: {
        "200" => OpenAPI.json_response("OAuth2 token revoked", OpenAPI.object_schema({revoked: {type: "boolean"}}, required: ["revoked"]))
      }
    }
  }
end

.oauth_revoke_refresh_access_tokens(ctx, store, refresh_token) ⇒ Object



46
47
48
49
50
51
52
53
54
55
56
# File 'lib/better_auth/plugins/oauth_provider/revoke.rb', line 46

def oauth_revoke_refresh_access_tokens(ctx, store, refresh_token)
  refresh_id = refresh_token["id"]
  return if refresh_id.to_s.empty?

  store[:tokens].each_value do |record|
    record["revoked"] = refresh_token["revoked"] if record["refreshId"].to_s == refresh_id.to_s
  end
  return unless OAuthProtocol.schema_model?(ctx, "oauthAccessToken")

  ctx.context.adapter.update_many(model: "oauthAccessToken", where: [{field: "refreshId", value: refresh_id}], update: {revoked: refresh_token["revoked"]})
end

.oauth_rotate_client_secret_endpoint(config) ⇒ Object



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 166

def oauth_rotate_client_secret_endpoint(config)
  Endpoint.new(path: "/oauth2/client/rotate-secret", method: "POST", metadata: oauth_openapi_for(:rotate_client_secret)) do |ctx|
    session = Routes.current_session(ctx)
    oauth_assert_client_privilege!(ctx, config, session, "rotate")
    body = OAuthProtocol.stringify_keys(ctx.body)
    client = OAuthProtocol.find_client(ctx, "oauthClient", body["client_id"])
    raise APIError.new("NOT_FOUND", message: "client not found") unless client
    oauth_assert_owned_client!(client, session, config)
    client_data = OAuthProtocol.stringify_keys(client)
    raise APIError.new("BAD_REQUEST", message: "public clients cannot rotate secrets") if client_data["public"] || client_data["tokenEndpointAuthMethod"] == "none"

    client_secret = Crypto.random_string(32)
    updated = ctx.context.adapter.update(
      model: "oauthClient",
      where: [{field: "clientId", value: body["client_id"]}],
      update: {clientSecret: OAuthProtocol.store_client_secret_value(ctx, client_secret, config[:store_client_secret]), updatedAt: Time.now}
    )
    response = OAuthProtocol.client_response(updated, include_secret: false)
    ctx.json(response.merge(client_secret: OAuthProtocol.apply_prefix(client_secret, config[:prefix], :client_secret), client_secret_expires_at: client_data["clientSecretExpiresAt"] || 0))
  end
end

.oauth_rotate_client_secret_openapiObject



316
317
318
319
320
321
322
323
324
325
326
# File 'lib/better_auth/plugins/oauth_provider.rb', line 316

def oauth_rotate_client_secret_openapi
  {
    openapi: {
      description: "Rotate an OAuth2 client secret",
      requestBody: OpenAPI.json_request_body(oauth_client_id_body_schema),
      responses: {
        "200" => OpenAPI.json_response("OAuth2 client secret rotated", oauth_client_response_schema(include_secret: true))
      }
    }
  }
end

.oauth_server_metadata_endpoint(config) ⇒ Object



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

def (config)
  Endpoint.new(path: "/.well-known/oauth-authorization-server", method: "GET", metadata: {hide: true}) do |ctx|
    base = OAuthProtocol.endpoint_base(ctx)
     = {
      issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)),
      authorization_endpoint: "#{base}/oauth2/authorize",
      token_endpoint: "#{base}/oauth2/token",
      introspection_endpoint: "#{base}/oauth2/introspect",
      revocation_endpoint: "#{base}/oauth2/revoke",
      response_types_supported: ["code"],
      response_modes_supported: ["query"],
      grant_types_supported: config[:grant_types],
      token_endpoint_auth_methods_supported: oauth_token_auth_methods(config),
      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,
      scopes_supported: config.dig(:advertised_metadata, :scopes_supported) || config[:scopes]
    }
    [:registration_endpoint] = "#{base}/oauth2/register" if config[:allow_dynamic_client_registration]
    jwks_uri = oauth_jwks_uri(ctx, config)
    [:jwks_uri] = jwks_uri if jwks_uri
    ctx.json(, headers: )
  end
end

.oauth_signed_query(ctx, query) ⇒ Object



153
154
155
156
157
158
159
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 153

def oauth_signed_query(ctx, query)
  data = OAuthProtocol.stringify_keys(query).compact
  data["exp"] = (Time.now.to_i + 600).to_s
  unsigned = URI.encode_www_form(data)
  signature = Crypto.hmac_signature(unsigned, ctx.context.secret, encoding: :base64url)
  "#{unsigned}&#{URI.encode_www_form("sig" => signature)}"
end


78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/better_auth/plugins/oauth_provider/consent.rb', line 78

def oauth_store_consent(ctx, client, session, scopes, reference_id = nil)
  client_id = OAuthProtocol.stringify_keys(client)["clientId"]
  user_id = session[:user]["id"]
  where = [
    {field: "clientId", value: client_id},
    {field: "userId", value: user_id}
  ]
  where << {field: "referenceId", value: reference_id} if reference_id
  existing = ctx.context.adapter.find_one(
    model: "oauthConsent",
    where: where
  )
  data = {clientId: client_id, userId: user_id, scopes: scopes}
  data[:referenceId] = reference_id if reference_id
  if existing
    ctx.context.adapter.update(model: "oauthConsent", where: [{field: "id", value: existing.fetch("id")}], update: data)
  else
    ctx.context.adapter.create(model: "oauthConsent", data: data)
  end
end

.oauth_token_auth_methods(config) ⇒ Object



91
92
93
94
95
# File 'lib/better_auth/plugins/oauth_provider/metadata.rb', line 91

def oauth_token_auth_methods(config)
  methods = ["client_secret_basic", "client_secret_post"]
  methods.unshift("none") if config[:allow_unauthenticated_client_registration]
  methods
end

.oauth_token_endpoint(config) ⇒ Object



7
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
89
# File 'lib/better_auth/plugins/oauth_provider/token.rb', line 7

def oauth_token_endpoint(config)
  Endpoint.new(
    path: "/oauth2/token",
    method: "POST",
    body_schema: ->(value) { value },
    metadata: oauth_openapi_for(:token).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])
  ) do |ctx|
    body = OAuthProtocol.request_body!(ctx.body)
    client = OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix])
    client_id = OAuthProtocol.stringify_keys(client)["clientId"]
    client_grants = OAuthProtocol.parse_scopes(OAuthProtocol.stringify_keys(client)["grantTypes"])
    if client_grants.any? && !client_grants.include?(body["grant_type"].to_s)
      raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
    end
    response = case body["grant_type"]
    when OAuthProtocol::AUTH_CODE_GRANT
      code = OAuthProtocol.consume_code!(
        config[:store],
        body["code"],
        client_id: client_id,
        redirect_uri: body["redirect_uri"],
        code_verifier: body["code_verifier"],
        store_tokens: config[:store_tokens]
      )
      session = oauth_active_authorization_session!(ctx, code[:session])
      audience = oauth_validate_resource!(ctx, config, body, code[:scopes])
      OAuthProtocol.issue_tokens(
        ctx,
        config[:store],
        model: "oauthAccessToken",
        client: client,
        session: session,
        scopes: code[:scopes],
        include_refresh: code[:scopes].include?("offline_access"),
        issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)),
        prefix: config[:prefix],
        refresh_token_expires_in: config[:refresh_token_expires_in],
        access_token_expires_in: oauth_access_token_expires_in(config, code[:scopes], machine: false),
        id_token_expires_in: config[:id_token_expires_in],
        audience: audience,
        grant_type: OAuthProtocol::AUTH_CODE_GRANT,
        custom_token_response_fields: config[:custom_token_response_fields],
        custom_access_token_claims: config[:custom_access_token_claims],
        custom_id_token_claims: config[:custom_id_token_claims],
        jwt_access_token: oauth_jwt_access_token?(config, audience),
        use_jwt_plugin: !config[:disable_jwt_plugin],
        pairwise_secret: config[:pairwise_secret],
        nonce: code[:nonce],
        auth_time: code[:auth_time],
        reference_id: code[:reference_id],
        filter_id_token_claims_by_scope: true,
        store_tokens: config[:store_tokens]
      )
    when OAuthProtocol::CLIENT_CREDENTIALS_GRANT
      requested = OAuthProtocol.parse_scopes(body["scope"])
      oidc_scopes = %w[openid profile email offline_access]
      unless (requested & oidc_scopes).empty?
        raise APIError.new("BAD_REQUEST", message: "invalid_scope")
      end
      client_data = OAuthProtocol.stringify_keys(client)
      allowed = if client_data.key?("scopes") && !client_data["scopes"].nil?
        OAuthProtocol.parse_scopes(client_data["scopes"])
      else
        OAuthProtocol.parse_scopes(config[:client_credential_grant_default_scopes] || config[:scopes])
      end
      requested = allowed if requested.empty?
      unless requested.all? { |scope| allowed.include?(scope) }
        raise APIError.new("BAD_REQUEST", message: "invalid_scope")
      end

      audience = oauth_validate_resource!(ctx, config, body, requested)
      OAuthProtocol.issue_tokens(ctx, config[:store], model: "oauthAccessToken", client: client, session: {"user" => {}, "session" => {}}, scopes: requested, include_refresh: false, issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)), prefix: config[:prefix], audience: audience, grant_type: OAuthProtocol::CLIENT_CREDENTIALS_GRANT, custom_token_response_fields: config[:custom_token_response_fields], custom_access_token_claims: config[:custom_access_token_claims], custom_id_token_claims: config[:custom_id_token_claims], jwt_access_token: oauth_jwt_access_token?(config, audience), use_jwt_plugin: !config[:disable_jwt_plugin], pairwise_secret: config[:pairwise_secret], access_token_expires_in: oauth_access_token_expires_in(config, requested, machine: true), id_token_expires_in: config[:id_token_expires_in], filter_id_token_claims_by_scope: true, store_tokens: config[:store_tokens])
    when OAuthProtocol::REFRESH_GRANT
      refresh_record = OAuthProtocol.find_token_by_hint(config[:store], body["refresh_token"].to_s, "refresh_token", prefix: config[:prefix])
      refresh_scopes = OAuthProtocol.parse_scopes(body["scope"] || refresh_record&.fetch("scopes", nil))
      audience = oauth_validate_resource!(ctx, config, body, refresh_scopes)
      OAuthProtocol.refresh_tokens(ctx, config[:store], model: "oauthAccessToken", client: client, refresh_token: body["refresh_token"], scopes: body["scope"], issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx)), prefix: config[:prefix], refresh_token_expires_in: config[:refresh_token_expires_in], audience: audience, custom_token_response_fields: config[:custom_token_response_fields], custom_access_token_claims: config[:custom_access_token_claims], custom_id_token_claims: config[:custom_id_token_claims], jwt_access_token: oauth_jwt_access_token?(config, audience), use_jwt_plugin: !config[:disable_jwt_plugin], pairwise_secret: config[:pairwise_secret], access_token_expires_in: oauth_access_token_expires_in(config, refresh_scopes, machine: false), id_token_expires_in: config[:id_token_expires_in], filter_id_token_claims_by_scope: true, store_tokens: config[:store_tokens])
    else
      raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
    end
    ctx.json(response, headers: oauth_no_store_headers)
  end
end

.oauth_token_openapiObject



397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
# File 'lib/better_auth/plugins/oauth_provider.rb', line 397

def oauth_token_openapi
  {
    openapi: {
      description: "Exchange an OAuth2 grant for tokens",
      requestBody: OpenAPI.json_request_body(
        OpenAPI.object_schema(
          {
            grant_type: {type: "string", enum: [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::CLIENT_CREDENTIALS_GRANT, OAuthProtocol::REFRESH_GRANT]},
            code: {type: "string", description: "Authorization code"},
            redirect_uri: {type: "string", format: "uri"},
            code_verifier: {type: "string"},
            client_id: {type: "string"},
            client_secret: {type: "string"},
            refresh_token: {type: "string"},
            scope: {type: "string"},
            resource: {oneOf: [{type: "string"}, {type: "array", items: {type: "string"}}]}
          },
          required: ["grant_type"]
        )
      ),
      responses: {
        "200" => OpenAPI.json_response("OAuth2 tokens issued", oauth_token_response_schema)
      }
    }
  }
end

.oauth_token_response_schemaObject



599
600
601
602
603
604
605
606
607
608
609
610
611
# File 'lib/better_auth/plugins/oauth_provider.rb', line 599

def oauth_token_response_schema
  OpenAPI.object_schema(
    {
      access_token: {type: "string"},
      token_type: {type: "string"},
      expires_in: {type: "number"},
      refresh_token: {type: "string"},
      scope: {type: "string"},
      id_token: {type: "string"}
    },
    required: ["access_token", "token_type"]
  )
end

.oauth_update_client_endpoint(config) ⇒ Object



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 106

def oauth_update_client_endpoint(config)
  Endpoint.new(path: "/oauth2/update-client", method: "POST", metadata: oauth_openapi_for(:update_client)) do |ctx|
    session = Routes.current_session(ctx)
    oauth_assert_client_privilege!(ctx, config, session, "update")
    body = OAuthProtocol.stringify_keys(ctx.body)
    client = OAuthProtocol.find_client(ctx, "oauthClient", body["client_id"])
    raise APIError.new("NOT_FOUND", message: "client not found") unless client
    oauth_assert_owned_client!(client, session, config)

    update_source = OAuthProtocol.stringify_keys(body["update"] || {})
    oauth_validate_client_update!(client, update_source, config, admin: false)
    update = oauth_client_update_data(update_source)
    updated = update.empty? ? client : ctx.context.adapter.update(model: "oauthClient", where: [{field: "clientId", value: body["client_id"]}], update: update.merge(updatedAt: Time.now))
    ctx.json(OAuthProtocol.client_response(updated, include_secret: false))
  end
end

.oauth_update_client_openapiObject



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/better_auth/plugins/oauth_provider.rb', line 296

def oauth_update_client_openapi
  {
    openapi: {
      description: "Update an OAuth2 client",
      requestBody: OpenAPI.json_request_body(
        OpenAPI.object_schema(
          {
            client_id: {type: "string", description: "OAuth2 client ID"},
            update: oauth_client_registration_schema.merge(description: "Client metadata to update")
          },
          required: ["client_id", "update"]
        )
      ),
      responses: {
        "200" => OpenAPI.json_response("OAuth2 client updated", oauth_client_response_schema(include_secret: false))
      }
    }
  }
end


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
# File 'lib/better_auth/plugins/oauth_provider/oauth_consent/endpoints.rb', line 32

def oauth_update_consent_endpoint
  Endpoint.new(path: "/oauth2/update-consent", method: "POST", metadata: oauth_openapi_for(:update_consent)) do |ctx|
    session = Routes.current_session(ctx)
    body = OAuthProtocol.stringify_keys(ctx.body)
    id = body["id"]
    consent = if id.to_s.empty?
      oauth_find_user_consent(ctx, session, body["client_id"])
    else
      ctx.context.adapter.find_one(model: "oauthConsent", where: [{field: "id", value: id}])
    end
    raise APIError.new("NOT_FOUND", message: "missing id") if id.to_s.empty? && body["client_id"].to_s.empty?
    raise APIError.new("NOT_FOUND", message: "consent not found") unless consent
    consent_data = OAuthProtocol.stringify_keys(consent)
    raise APIError.new("UNAUTHORIZED") unless consent_data["userId"] == session[:user]["id"]

    client = OAuthProtocol.find_client(ctx, "oauthClient", consent_data["clientId"])
    allowed = OAuthProtocol.parse_scopes(OAuthProtocol.stringify_keys(client || {})["scopes"])
    scopes = OAuthProtocol.parse_scopes(OAuthProtocol.stringify_keys(body["update"] || {})["scopes"] || body["scope"] || body["scopes"])
    unless scopes.all? { |scope| allowed.include?(scope) }
      raise APIError.new("BAD_REQUEST", message: "invalid_scope")
    end

    updated = ctx.context.adapter.update(
      model: "oauthConsent",
      where: [{field: "id", value: consent_data["id"]}],
      update: {scopes: scopes, updatedAt: Time.now}
    )
    ctx.json(oauth_consent_response(updated))
  end
end


328
329
330
331
332
333
334
335
336
337
338
# File 'lib/better_auth/plugins/oauth_provider.rb', line 328

def oauth_update_consent_openapi
  {
    openapi: {
      description: "Update OAuth2 consent scopes",
      requestBody: OpenAPI.json_request_body(oauth_consent_mutation_schema(required_update: false)),
      responses: {
        "200" => OpenAPI.json_response("OAuth2 consent updated", oauth_consent_response_schema)
      }
    }
  }
end

.oauth_userinfo_endpoint(config) ⇒ Object



7
8
9
10
11
# File 'lib/better_auth/plugins/oauth_provider/userinfo.rb', line 7

def oauth_userinfo_endpoint(config)
  Endpoint.new(path: "/oauth2/userinfo", method: "GET") do |ctx|
    ctx.json(OAuthProtocol.userinfo(config[:store], ctx.headers["authorization"], additional_claim: config[:custom_user_info_claims] || config[:additional_claim], prefix: config[:prefix], jwt_secret: ctx.context.secret, ctx: ctx, issuer: OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))))
  end
end

.oauth_validate_client_update!(client, source, config, admin:) ⇒ Object



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
96
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/index.rb', line 63

def oauth_validate_client_update!(client, source, config, admin:)
  return if source.empty?

  current = OAuthProtocol.stringify_keys(client)
  source = source.except("public", "token_endpoint_auth_method", "tokenEndpointAuthMethod", "client_secret", "clientSecret", "type") unless admin
  return if source.empty?

  redirects = source.key?("redirect_uris") ? Array(source["redirect_uris"]).map(&:to_s) : OAuthProtocol.client_redirect_uris(current)
  redirects.each { |uri| OAuthProtocol.validate_safe_url!(uri, field: "redirect_uris") }
  if source.key?("post_logout_redirect_uris")
    Array(source["post_logout_redirect_uris"]).map(&:to_s).each { |uri| OAuthProtocol.validate_safe_url!(uri, field: "post_logout_redirect_uris") }
  end

  auth_method = source["token_endpoint_auth_method"] || source["tokenEndpointAuthMethod"] || current["tokenEndpointAuthMethod"] || "client_secret_basic"
  body = {
    "token_endpoint_auth_method" => auth_method,
    "grant_types" => source.key?("grant_types") ? Array(source["grant_types"]).map(&:to_s) : Array(current["grantTypes"]).map(&:to_s),
    "response_types" => source.key?("response_types") ? Array(source["response_types"]).map(&:to_s) : Array(current["responseTypes"]).map(&:to_s),
    "type" => source.key?("type") ? source["type"] : current["type"],
    "subject_type" => source["subject_type"] || source["subjectType"] || current["subjectType"]
  }.compact
  OAuthProtocol.(auth_method, body)
  OAuthProtocol.validate_admin_only_fields!(source, admin: admin)
  OAuthProtocol.validate_client_registration!(auth_method, body["grant_types"], body["response_types"], body, unauthenticated: false, dynamic_registration: false)
  OAuthProtocol.validate_pairwise_client!(body, redirects, config[:pairwise_secret])

  return unless source.key?("scope") || source.key?("scopes")

  scopes = OAuthProtocol.parse_scopes(source["scope"] || source["scopes"])
  allowed = OAuthProtocol.parse_scopes(config[:client_registration_allowed_scopes] || config[:scopes])
  unless allowed.empty? || scopes.all? { |scope| allowed.include?(scope) }
    raise APIError.new("BAD_REQUEST", message: "invalid_scope")
  end
end

.oauth_validate_query_hook!(ctx) ⇒ Object



134
135
136
137
138
139
140
141
142
143
# File 'lib/better_auth/plugins/oauth_provider.rb', line 134

def oauth_validate_query_hook!(ctx)
  oauth_query = oauth_query_from_body(ctx.body)
  return unless oauth_query

  unless OAuthProvider::Utils.verify_oauth_query_params(oauth_query, ctx.context.secret)
    raise APIError.new("BAD_REQUEST", message: "invalid_signature", body: {error: "invalid_signature"})
  end

  nil
end

.oauth_validate_resource!(ctx, config, body, scopes) ⇒ Object



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/better_auth/plugins/oauth_provider/token.rb', line 110

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

  userinfo_audience = "#{OAuthProtocol.endpoint_base(ctx)}/oauth2/userinfo"
  requested = resources.dup
  requested << userinfo_audience if OAuthProtocol.parse_scopes(scopes).include?("openid") && !requested.include?(userinfo_audience)
  valid = Array(config[:valid_audiences]).map(&:to_s)
  valid = [OAuthProvider.validate_issuer_url(OAuthProtocol.issuer(ctx))] if valid.empty?
  valid << userinfo_audience if OAuthProtocol.parse_scopes(scopes).include?("openid") && !valid.include?(userinfo_audience)

  requested.each do |resource|
    raise APIError.new("BAD_REQUEST", message: "requested resource invalid") unless valid.include?(resource)
  end
  (requested.length == 1) ? requested.first : requested
end

.oauth_verified_query!(ctx, oauth_query) ⇒ Object

Raises:

  • (APIError)


161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 161

def oauth_verified_query!(ctx, oauth_query)
  raise APIError.new("BAD_REQUEST", message: "missing oauth query") if oauth_query.to_s.empty?

  pairs = URI.decode_www_form(oauth_query.to_s)
  signature = pairs.reverse_each.find { |key, _value| key == "sig" }&.last
  unsigned_pairs = pairs.filter_map { |key, value| [key, value] unless key == "sig" }
  unsigned = URI.encode_www_form(unsigned_pairs)
  exp = unsigned_pairs.reverse_each.find { |key, _value| key == "exp" }&.last.to_i
  unless signature && exp >= Time.now.to_i && Crypto.verify_hmac_signature(unsigned, signature, ctx.context.secret, encoding: :base64url)
    raise APIError.new("BAD_REQUEST", message: "invalid oauth query")
  end

  unsigned_pairs.each_with_object({}) do |(key, value), result|
    next if key == "exp"

    result[key] = if result.key?(key)
      Array(result[key]) << value
    else
      value
    end
  end
end