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



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

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_admin_create_client_endpoint(config) ⇒ Object



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 118

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



147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 147

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
# 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]
  )
  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



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

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
# 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"])
  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 prompts.include?("login") && !
    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_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_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") 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)


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

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


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

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

.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") 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_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") 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_delete_client_endpoint(config) ⇒ Object



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

def oauth_delete_client_endpoint(config)
  Endpoint.new(path: "/oauth2/delete-client", method: "POST") 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


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") 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

.oauth_delete_prompt!(query, prompt) ⇒ Object



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

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



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

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_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: {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


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
# 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") 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_id_token_signing_algs(ctx, config) ⇒ Object



84
85
86
87
88
89
90
91
92
93
# File 'lib/better_auth/plugins/oauth_provider/metadata.rb', line 84

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: {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])
    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_invalid_request_uri!(ctx, query, description) ⇒ Object

Raises:

  • (APIError)


210
211
212
213
214
215
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 210

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(config) ⇒ Object



72
73
74
75
76
# File 'lib/better_auth/plugins/oauth_provider/metadata.rb', line 72

def oauth_jwks_uri(config)
  config.dig(:advertised_metadata, :jwks_uri) ||
    config[:jwks_uri] ||
    config.dig(:jwks, :remote_url)
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



226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 226

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



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

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



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

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



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

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



212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 212

def oauth_legacy_update_client_endpoint(config)
  Endpoint.new(path: "/oauth2/client", method: "PATCH") 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") 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



74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/better_auth/plugins/oauth_provider/oauth_client/endpoints.rb', line 74

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



68
69
70
# File 'lib/better_auth/plugins/oauth_provider/metadata.rb', line 68

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

.oauth_openid_metadata_endpoint(config) ⇒ Object



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

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(config) if oauth_jwks_uri(config)
    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
# 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"]})
  end
end

.oauth_prompt_page(config, type) ⇒ Object



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

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



114
115
116
117
118
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 114

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



95
96
97
# File 'lib/better_auth/plugins/oauth_provider/metadata.rb', line 95

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

def oauth_provider(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: "plain",
    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(normalize_hash(options))

  Plugin.new(
    id: "oauth-provider",
    version: BetterAuth::OAuthProvider::VERSION,
    init: oauth_provider_init(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



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/better_auth/plugins/oauth_provider.rb', line 92

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_init(config) ⇒ Object



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

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

.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_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



176
177
178
179
180
181
182
183
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 176

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

  raise
end

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



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

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

def oauth_register_client_endpoint(config)
  Endpoint.new(path: "/oauth2/register", method: "POST") 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_resolve_request_uri!(ctx, config, query) ⇒ Object



195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 195

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

  OAuthProtocol.stringify_keys(resolved)
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: {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])
    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_rotate_client_secret_endpoint(config) ⇒ Object



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

def oauth_rotate_client_secret_endpoint(config)
  Endpoint.new(path: "/oauth2/client/rotate-secret", method: "POST") 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_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
# 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(config) if oauth_jwks_uri(config)
    ctx.json(, headers: )
  end
end

.oauth_signed_query(ctx, query) ⇒ Object



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

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


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/consent.rb', line 77

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



78
79
80
81
82
# File 'lib/better_auth/plugins/oauth_provider/metadata.rb', line 78

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

def oauth_token_endpoint(config)
  Endpoint.new(path: "/oauth2/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
    body = OAuthProtocol.stringify_keys(ctx.body)
    client = OAuthProtocol.authenticate_client!(ctx, "oauthClient", store_client_secret: config[:store_client_secret], prefix: config[:prefix])
    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: body["client_id"],
        redirect_uri: body["redirect_uri"],
        code_verifier: body["code_verifier"]
      )
      audience = oauth_validate_resource!(ctx, config, body, code[:scopes])
      OAuthProtocol.issue_tokens(
        ctx,
        config[:store],
        model: "oauthAccessToken",
        client: client,
        session: code[:session],
        scopes: code[:scopes],
        include_refresh: code[:scopes].include?("offline_access") || OAuthProtocol.parse_scopes(OAuthProtocol.stringify_keys(client)["grantTypes"]).include?(OAuthProtocol::REFRESH_GRANT),
        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
      )
    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)
    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)
    else
      raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
    end
    ctx.json(response)
  end
end

.oauth_update_client_endpoint(config) ⇒ Object



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

def oauth_update_client_endpoint(config)
  Endpoint.new(path: "/oauth2/update-client", method: "POST") 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


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") 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

.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_resource!(ctx, config, body, scopes) ⇒ Object



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

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)


143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/better_auth/plugins/oauth_provider/authorize.rb', line 143

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