Module: Basecamp::Oauth

Defined in:
lib/basecamp/oauth.rb,
lib/basecamp/oauth/pkce.rb,
lib/basecamp/oauth/token.rb,
lib/basecamp/oauth/config.rb,
lib/basecamp/oauth/fetcher.rb,
lib/basecamp/oauth/exchange.rb,
lib/basecamp/oauth/resource.rb,
lib/basecamp/oauth/discovery.rb,
lib/basecamp/oauth/oauth_error.rb,
lib/basecamp/oauth/refresh_request.rb,
lib/basecamp/oauth/discovery_result.rb,
lib/basecamp/oauth/exchange_request.rb,
lib/basecamp/oauth/discovery_selection_error.rb,
lib/basecamp/oauth/protected_resource_metadata.rb

Overview

OAuth 2 module for Basecamp SDK.

Provides OAuth discovery, token exchange, and token refresh functionality. Supports both standard OAuth 2 and Basecamp's Launchpad legacy format.

Examples:

Complete OAuth flow

# 1. Discover OAuth configuration
config = Basecamp::Oauth.discover_launchpad

# 2. Build authorization URL (redirect user here)
auth_url = "#{config.authorization_endpoint}?" + URI.encode_www_form(
  type: "web_server",
  client_id: ENV["BASECAMP_CLIENT_ID"],
  redirect_uri: "https://myapp.com/callback"
)

# 3. Exchange authorization code for tokens (in callback handler)
token = Basecamp::Oauth.exchange_code(
  token_endpoint: config.token_endpoint,
  code: params[:code],
  redirect_uri: "https://myapp.com/callback",
  client_id: ENV["BASECAMP_CLIENT_ID"],
  client_secret: ENV["BASECAMP_CLIENT_SECRET"],
  use_legacy_format: true  # Required for Launchpad
)

# 4. Use the token
client = Basecamp.client(
  access_token: token.access_token,
  account_id: "12345"
)

# 5. Refresh when needed
if token.expired?
  token = Basecamp::Oauth.refresh_token(
    token_endpoint: config.token_endpoint,
    refresh_token: token.refresh_token,
    use_legacy_format: true
  )
end

See Also:

Defined Under Namespace

Modules: Fetcher, Pkce Classes: Config, Discovery, DiscoveryResult, DiscoverySelectionError, Exchange, ExchangeRequest, OauthError, ProtectedResourceMetadata, RefreshRequest, Resource, Token

Constant Summary collapse

LAUNCHPAD_BASE_URL =
"https://launchpad.37signals.com"
FALLBACK_RESOURCE_DISCOVERY_FAILED =

Soft fallback reasons — the ONLY two outcomes under which discover_from_resource yields a fallback (Launchpad) rather than a selected config. Every other failure raises DiscoverySelectionError.

"resource_discovery_failed"
FALLBACK_NO_AS_ADVERTISED =
"no_as_advertised"

Class Method Summary collapse

Class Method Details

.discover(base_url, timeout: 10) ⇒ Config

Discovers RFC 8414 Authorization Server Metadata and binds issuer to base_url by code-point.

Parameters:

  • base_url (String)

    the OAuth server's issuer origin

  • timeout (Integer) (defaults to: 10)

    request timeout in seconds

Returns:



61
62
63
# File 'lib/basecamp/oauth.rb', line 61

def self.discover(base_url, timeout: 10)
  Discovery.new(timeout: timeout).discover(base_url)
end

.discover_from_resource(resource_origin, expected_issuer: nil, timeout: 10) ⇒ DiscoveryResult

Resource-first discovery orchestrator (SPEC.md §16). Composes RFC 9728 (resource metadata) and RFC 8414 (AS metadata) and applies the stage-sensitive fallback state machine.

Returns a DiscoveryResult that is either selected (a bound AS config) or a soft fallback whose reason is resource_discovery_failed or no_as_advertised ONLY. Every hard failure raises DiscoverySelectionError — callers MUST NOT convert a raise into a Launchpad request.

Parameters:

  • resource_origin (String)

    the API/resource host origin

  • expected_issuer (String, nil) (defaults to: nil)

    explicit, authoritative issuer selection. When provided, the advertised member equal by code-point is selected; if none matches, expected_issuer_unavailable is raised (never a fallback). Omit to use the Basecamp-profile exclusion heuristic.

  • timeout (Integer) (defaults to: 10)

    request timeout in seconds

Returns:

Raises:



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/basecamp/oauth.rb', line 97

def self.discover_from_resource(resource_origin, expected_issuer: nil, timeout: 10)
  # Origin-root validation of the *caller's* input is a usage error — let it
  # propagate as-is (not a soft fallback).
  # Hop 1: resource metadata. Any failure here is soft (before selection).
  # Pass the RAW resource_origin so binding is code-point-exact against the
  # caller's identifier (SPEC.md §16); discover_protected_resource normalizes
  # only its fetch URL. A malformed caller origin raises UsageError → re-raised.
  resource = begin
    discover_protected_resource(resource_origin, timeout: timeout)
  rescue Basecamp::UsageError
    raise
  rescue OauthError
    nil
  end

  if resource.nil?
    DiscoveryResult.fallback(FALLBACK_RESOURCE_DISCOVERY_FAILED)
  else
    select_and_bind(resource.authorization_servers || [], expected_issuer, timeout)
  end
end

.discover_launchpad(timeout: 10) ⇒ Object



65
66
67
# File 'lib/basecamp/oauth.rb', line 65

def self.discover_launchpad(timeout: 10)
  discover(LAUNCHPAD_BASE_URL, timeout: timeout)
end

.discover_protected_resource(resource_origin, timeout: 10) ⇒ ProtectedResourceMetadata

Discovers RFC 9728 protected-resource metadata for a resource origin.

Parameters:

  • resource_origin (String)

    the API/resource host origin

  • timeout (Integer) (defaults to: 10)

    request timeout in seconds

Returns:



74
75
76
# File 'lib/basecamp/oauth.rb', line 74

def self.discover_protected_resource(resource_origin, timeout: 10)
  Resource.new(timeout: timeout).discover(resource_origin)
end

.exchange_code(token_endpoint:, code:, redirect_uri:, client_id:, client_secret: nil, code_verifier: nil, use_legacy_format: false, timeout: 30) ⇒ Object



119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/basecamp/oauth.rb', line 119

def self.exchange_code(
  token_endpoint:, code:, redirect_uri:, client_id:,
  client_secret: nil, code_verifier: nil,
  use_legacy_format: false, timeout: 30
)
  request = ExchangeRequest.new(
    token_endpoint: token_endpoint, code: code,
    redirect_uri: redirect_uri, client_id: client_id,
    client_secret: client_secret, code_verifier: code_verifier,
    use_legacy_format: use_legacy_format
  )
  Exchange.new(timeout: timeout).exchange(request)
end

.refresh_token(token_endpoint:, refresh_token:, client_id: nil, client_secret: nil, use_legacy_format: false, timeout: 30) ⇒ Object



133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/basecamp/oauth.rb', line 133

def self.refresh_token(
  token_endpoint:, refresh_token:,
  client_id: nil, client_secret: nil,
  use_legacy_format: false, timeout: 30
)
  request = RefreshRequest.new(
    token_endpoint: token_endpoint, refresh_token: refresh_token,
    client_id: client_id, client_secret: client_secret,
    use_legacy_format: use_legacy_format
  )
  Exchange.new(timeout: timeout).refresh(request)
end

.token_expired?(token, buffer_seconds = 60) ⇒ Boolean

Returns:

  • (Boolean)


146
147
148
# File 'lib/basecamp/oauth.rb', line 146

def self.token_expired?(token, buffer_seconds = 60)
  token.expired?(buffer_seconds)
end