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/device_flow.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/device_flow_error.rb,
lib/basecamp/oauth/device_authorization.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: DeviceFlow, Fetcher, Pkce Classes: Config, DeviceAuthorization, DeviceFlowError, 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

.perform_device_login(config:, client_id:, display:, scope: nil, clock: DeviceFlow::DEFAULT_CLOCK, sleeper: DeviceFlow::DEFAULT_SLEEPER, cancelled: DeviceFlow::DEFAULT_CANCELLED, timeout: DeviceFlow::DEVICE_REQUEST_TIMEOUT) ⇒ Token

Runs the full device authorization grant against an already-selected Config (RFC 8628). Thin delegator to Basecamp::Oauth::DeviceFlow.perform_device_login.

Returns:



184
185
186
187
188
189
190
191
192
193
# File 'lib/basecamp/oauth.rb', line 184

def self.(
  config:, client_id:, display:, scope: nil,
  clock: DeviceFlow::DEFAULT_CLOCK, sleeper: DeviceFlow::DEFAULT_SLEEPER,
  cancelled: DeviceFlow::DEFAULT_CANCELLED, timeout: DeviceFlow::DEVICE_REQUEST_TIMEOUT
)
  DeviceFlow.(
    config: config, client_id: client_id, display: display, scope: scope,
    clock: clock, sleeper: sleeper, cancelled: cancelled, timeout: timeout
  )
end

.poll_device_token(token_endpoint:, client_id:, device_code:, interval:, expires_in:, clock: DeviceFlow::DEFAULT_CLOCK, sleeper: DeviceFlow::DEFAULT_SLEEPER, cancelled: DeviceFlow::DEFAULT_CANCELLED, timeout: DeviceFlow::DEVICE_REQUEST_TIMEOUT, deadline_at: nil) ⇒ Token

Polls the token endpoint for a device grant (RFC 8628 §3.5). Thin delegator to Basecamp::Oauth::DeviceFlow.poll_device_token; clock, sleeper, and cancelled are injectable for tests.

Returns:



166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/basecamp/oauth.rb', line 166

def self.poll_device_token(
  token_endpoint:, client_id:, device_code:, interval:, expires_in:,
  clock: DeviceFlow::DEFAULT_CLOCK, sleeper: DeviceFlow::DEFAULT_SLEEPER,
  cancelled: DeviceFlow::DEFAULT_CANCELLED, timeout: DeviceFlow::DEVICE_REQUEST_TIMEOUT,
  deadline_at: nil
)
  DeviceFlow.poll_device_token(
    token_endpoint: token_endpoint, client_id: client_id,
    device_code: device_code, interval: interval, expires_in: expires_in,
    clock: clock, sleeper: sleeper, cancelled: cancelled, timeout: timeout,
    deadline_at: deadline_at
  )
end

.refresh_token(token_endpoint:, refresh_token:, client_id: nil, client_secret: nil, use_legacy_format: false, timeout: 30, resource: nil) ⇒ 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, resource: nil
)
  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, resource: resource
  )
  Exchange.new(timeout: timeout).refresh(request)
end

.request_device_authorization(device_authorization_endpoint:, client_id:, scope: nil, timeout: DeviceFlow::DEVICE_REQUEST_TIMEOUT) ⇒ DeviceAuthorization

Requests an RFC 8628 device/user code pair. Thin delegator to Basecamp::Oauth::DeviceFlow.request_device_authorization.

Returns:



154
155
156
157
158
159
# File 'lib/basecamp/oauth.rb', line 154

def self.request_device_authorization(device_authorization_endpoint:, client_id:, scope: nil, timeout: DeviceFlow::DEVICE_REQUEST_TIMEOUT)
  DeviceFlow.request_device_authorization(
    device_authorization_endpoint: device_authorization_endpoint,
    client_id: client_id, scope: scope, timeout: timeout
  )
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