Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0521fe1
OAuth 2.1 support
jeremy Jan 5, 2026
7b43efc
OAuth discovery and dynamic client registration
jeremy Jan 5, 2026
13ae0cb
Connected Apps UI for OAuth grant management
jeremy Jan 5, 2026
62797f0
Accept form-encoded requests at the OAuth protocol endpoints
jeremy Aug 28, 2026
130bd79
Redeem each authorization code once, for the client it was issued to
jeremy Oct 6, 2026
14bce34
Let consent redirect past CSP, name the issuer, and grant canonical s…
jeremy Oct 6, 2026
d36b5c2
Describe revocation and response modes honestly, bound client names, …
jeremy Oct 6, 2026
266e948
Ship the OAuth authorization server dark behind availability switches
jeremy Oct 6, 2026
8c8eb93
Keep registration dark when a request names a pilot client
jeremy Oct 6, 2026
3e6cc4a
Reject non-string token request parameters as malformed
jeremy Oct 6, 2026
86d5744
Vary the port only for http loopback redirects
jeremy Oct 6, 2026
ca19332
Point a client's access_tokens at oauth_client_id, and drop the stray…
jeremy Oct 6, 2026
0c91659
Refuse personal access token management to OAuth grants
jeremy Oct 6, 2026
8d7750d
Refuse a PKCE verifier outside 43 to 128 unreserved characters
jeremy Oct 6, 2026
3d75b99
Keep OAuth grants out of every credential and consent surface
jeremy Oct 6, 2026
6971938
Keep OAuth grants out of email changes too
jeremy Oct 6, 2026
910e88a
Keep credentials that outlive a grant away from OAuth apps
jeremy Oct 6, 2026
f7304f2
Filter authorization codes, PKCE verifiers and DPoP proofs from logs
jeremy Oct 17, 2026
0a2b7e1
Accept an authorization request without state, and bound one that has it
jeremy Oct 17, 2026
5075bac
Reach Connected Apps without an account, as personal access tokens are
jeremy Oct 17, 2026
76024c1
Bound what open registration stores: ten redirect URIs of 2000 charac…
jeremy Oct 17, 2026
f64ab63
Issue a code only for an S256-shaped code_challenge
jeremy Oct 17, 2026
07108bb
Make the OAuth migrations safe to run again after stopping partway
jeremy Oct 25, 2026
e0c0cf0
Answer 404 from a dark endpoint however many times it's asked
jeremy Oct 25, 2026
77618dc
Merge main into oauth
jeremy Oct 26, 2026
1938037
Bring main's tests up to this branch: Ruby 4 and the access token finder
jeremy Oct 26, 2026
a035b2f
Merge main into oauth
jeremy Oct 26, 2026
0083f54
Filter the code parameter by its whole name, so code_challenge stays …
jeremy Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/controllers/account/exports_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Account::ExportsController < ApplicationController
disallow_oauth_grants

before_action :ensure_admin_or_owner
before_action :ensure_export_limit_not_exceeded, only: :create
before_action :set_export, only: :show
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/account/join_codes_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
class Account::JoinCodesController < ApplicationController
wrap_parameters :account_join_code, include: %i[ usage_limit ]

disallow_oauth_grants

before_action :set_join_code
before_action :ensure_admin, only: %i[ update destroy ]

Expand Down
28 changes: 25 additions & 3 deletions app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module Authentication
included do
before_action :require_account # Checking and setting account must happen first
before_action :require_authentication
helper_method :authenticated?
helper_method :authenticated?, :oauth_grant?
helper_method :email_address_pending_authentication

etag { Current.identity.id if authenticated? }
Expand All @@ -24,6 +24,19 @@ def allow_unauthenticated_access(**options)
allow_unauthorized_access **options
end

# An OAuth grant acts for an app on the user's data. It may not manage the
# user's credentials or consent to apps: anything it minted there would
# outlive disconnecting the app, and anything it removed would be another
# app's. Declared on every controller that does either: personal access
# tokens, Connected Apps, passkeys, transfer links, email changes and OAuth
# consent. For the same reason it may not read a credential that would
# outlive the grant: the account join code, and account exports, which carry
# that code and every webhook's credentials. Webhooks themselves stay
# readable but withhold their credentials (see webhooks/_webhook.json).
def disallow_oauth_grants(**options)
before_action :forbid_oauth_grant, **options
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
end

def disallow_account_scope(**options)
skip_before_action :require_account, **options
before_action :redirect_tenanted_request, **options
Expand All @@ -35,6 +48,14 @@ def authenticated?
Current.identity.present?
end

def oauth_grant?
Current.access_token&.oauth_client_id?
end

def forbid_oauth_grant
head :forbidden if oauth_grant?
end

def require_account
unless Current.account.present?
redirect_to main_app.session_menu_path(script_name: nil)
Expand All @@ -59,8 +80,9 @@ def authenticate_by_bearer_token
if request.authorization.to_s.include?("Bearer")
if bearer_token_authenticatable_request?
authenticate_or_request_with_http_token do |token|
if identity = Identity.find_by_permissable_access_token(token, method: request.method)
Current.identity = identity
if access_token = Identity::AccessToken.find_permissable(token, method: request.method)
Current.access_token = access_token
Current.identity = access_token.identity
end
end
else
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/my/access_tokens_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ class My::AccessTokensController < ApplicationController
wrap_parameters :access_token, include: %i[ description permission ]

skip_before_action :require_account
disallow_oauth_grants

def index
@access_tokens = my_access_tokens.order(created_at: :desc)
Expand Down Expand Up @@ -45,7 +46,7 @@ def destroy

private
def my_access_tokens
Current.identity.access_tokens
Current.identity.access_tokens.personal
Comment thread
jeremy marked this conversation as resolved.
end

def access_token_params
Expand Down
34 changes: 34 additions & 0 deletions app/controllers/my/connected_apps_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class My::ConnectedAppsController < ApplicationController
# Grants belong to the identity, not to an account, so revoking them must not
# depend on still having one: an identity whose only membership has ended
# keeps its grants, and could regain their reach by joining again.
skip_before_action :require_account
disallow_oauth_grants
Comment thread
jeremy marked this conversation as resolved.

before_action :set_connected_apps, only: :index
before_action :set_oauth_client, only: :destroy

def index
end

def destroy
Comment thread
jeremy marked this conversation as resolved.
@tokens.destroy_all

redirect_to my_connected_apps_path, notice: "#{@client.name} has been disconnected"
end

private
def set_connected_apps
tokens = oauth_tokens.includes(:oauth_client).order(:created_at)
@connected_apps = tokens.group_by(&:oauth_client).sort_by { |client, _| client.name.downcase }
end

def set_oauth_client
@tokens = oauth_tokens.where(oauth_client_id: params.require(:id))
@client = @tokens.first&.oauth_client or raise ActiveRecord::RecordNotFound
end

def oauth_tokens
Current.identity.access_tokens.oauth
end
end
2 changes: 2 additions & 0 deletions app/controllers/my/passkeys_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class My::PasskeysController < ApplicationController
disallow_oauth_grants

include ActionPack::Passkey::Request

before_action :set_passkey, only: %i[ edit update destroy ]
Expand Down
170 changes: 170 additions & 0 deletions app/controllers/oauth/authorizations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
class Oauth::AuthorizationsController < Oauth::BaseController
FORM_ACTION_ORIGIN = %r{\Ahttps?://[a-z0-9.-]+(:\d+)?\z}i
FORM_ACTION_SCHEME = /\A[a-z][a-z0-9+.-]*\z/i
STATE_MAXIMUM_LENGTH = 2048
# An S256 challenge is the unpadded base64url SHA-256 of the verifier: 43 characters (RFC 7636 §4.2).
S256_CODE_CHALLENGE = /\A[A-Za-z0-9_-]{43}\z/
RETURN_URL_MAXIMUM_LENGTH = 2000

# Browsers apply form-action to every hop of a form submission, redirects
# included, so the app-wide form-action 'self' would block the consent POST's
# redirect to the client. Allow just this request's validated redirect target.
content_security_policy if: -> { request.content_security_policy } do |policy|
policy.form_action :self, -> { validated_redirect_form_action_source }
end

# Gated ahead of sign-in so a dark server never asks anyone to sign in for
# it. A denial is never gated: it only tells the client no.
before_action :require_issuance_enabled, unless: :denial?
before_action :save_oauth_return_url
before_action :require_authentication
disallow_oauth_grants

before_action :set_client
before_action :validate_redirect_uri
before_action :validate_response_type
before_action :validate_pkce
before_action :validate_scope
before_action :validate_state

def new
@scope = Oauth.canonical_scope(params[:scope].presence || "read")
@redirect_uri = params[:redirect_uri]
@state = params[:state]
@code_challenge = params[:code_challenge]
end

def create
if denial?
redirect_to error_redirect_uri("access_denied", "User denied the request"), allow_other_host: true
else
code = Oauth::AuthorizationCode.generate \
client_id: @client.client_id,
identity_id: Current.identity.id,
code_challenge: params[:code_challenge],
redirect_uri: params[:redirect_uri],
scope: Oauth.canonical_scope(params[:scope].presence || "read")
Comment thread
jeremy marked this conversation as resolved.

redirect_to success_redirect_uri(code), allow_other_host: true
end
end

private
def denial?
request.post? && params[:error] == "access_denied"
end

# Runs before authentication, so a person already signed in is told apart
# by their session cookie. The URL goes into the cookie-stored session,
# which overflows (a 500) past 4KB once encrypted, so a request too long to
# keep isn't kept: the person signs in and starts again from the app.
def save_oauth_return_url
if request.get? && !find_session_by_cookie && request.url.bytesize <= RETURN_URL_MAXIMUM_LENGTH
session[:return_to_after_authenticating] = request.url
end
end

def set_client
@client = Oauth::Client.find_by(client_id: params[:client_id])
oauth_error("invalid_request", "Unknown client") unless @client
end

def validate_redirect_uri
unless performed? || @client.allows_redirect?(params[:redirect_uri])
redirect_with_error "invalid_request", "Invalid redirect_uri"
end
end

def validate_response_type
unless performed? || params[:response_type] == "code"
redirect_with_error "unsupported_response_type", "Only 'code' response_type is supported"
end
end

def validate_pkce
unless performed? || params[:code_challenge].present?
redirect_with_error "invalid_request", "code_challenge is required"
Comment thread
jeremy marked this conversation as resolved.
end

unless performed? || params[:code_challenge].is_a?(String) && params[:code_challenge].match?(S256_CODE_CHALLENGE)
redirect_with_error "invalid_request", "code_challenge must be an S256 challenge"
end

unless performed? || params[:code_challenge_method] == "S256"
redirect_with_error "invalid_request", "code_challenge_method must be S256"
end
end

def validate_scope
unless performed? || @client.allows_scope?(params[:scope].presence || "read")
redirect_with_error "invalid_scope", "Requested scope is not allowed"
end
end

# state is optional: PKCE is the CSRF defence (OAuth 2.1 §7.5.1), so a
# client that doesn't send it isn't refused. One that does gets it back
# exactly, and it must be a single string of bounded length, since it is
# carried through the consent form and into the redirect.
def validate_state
unless performed? || valid_state?
redirect_with_error "invalid_request", "state must be a string of at most #{STATE_MAXIMUM_LENGTH} characters"
end
end

def valid_state?
params[:state].nil? || (params[:state].is_a?(String) && params[:state].length <= STATE_MAXIMUM_LENGTH)
end

def redirect_with_error(error, description)
if params[:redirect_uri].present? && @client&.allows_redirect?(params[:redirect_uri])
redirect_to error_redirect_uri(error, description), allow_other_host: true
else
@error = error
@error_description = description
render :error, status: :bad_request
end
end

def success_redirect_uri(code)
build_redirect_uri params[:redirect_uri],
code: code,
state: params[:state].presence,
iss: oauth_issuer
end

def error_redirect_uri(error, description)
build_redirect_uri params[:redirect_uri],
error: error,
error_description: description,
state: params[:state].presence,
iss: oauth_issuer
end

# A CSP source naming the validated redirect origin, port included, since a
# loopback client may present any port (RFC 8252 §7.3). CSP host-sources
# can't express IPv6 literals, and a registered native redirect has no
# host, so those get their bare scheme. Nothing outside these shapes is
# written into the header.
def validated_redirect_form_action_source
if @client&.allows_redirect?(params[:redirect_uri])
uri = URI.parse(params[:redirect_uri])
origin = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}"

if origin.match?(FORM_ACTION_ORIGIN)
origin
elsif uri.scheme.to_s.match?(FORM_ACTION_SCHEME)
"#{uri.scheme}:"
end
end
rescue URI::InvalidURIError
nil
end

def build_redirect_uri(base, **query_params)
uri = URI.parse(base)
query = URI.decode_www_form(uri.query || "")
query_params.compact.each { |k, v| query << [ k.to_s, v ] }
uri.query = URI.encode_www_form(query)
uri.to_s
end
end
42 changes: 42 additions & 0 deletions app/controllers/oauth/base_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
class Oauth::BaseController < ApplicationController
disallow_account_scope

private
# Discovery follows acceptance (see Oauth::Availability): a dark server
# answers as if it had no authorization server at all.
def require_acceptance_enabled
head :not_found unless Oauth::Availability.acceptance_enabled?
end

# Minting endpoints are 404 while dark and 503 while issuance is paused.
# Revocation, Connected Apps and denials never call this, so people can
# always shed access. The pilot exemption reads the claimed client_id,
# before the client authenticates: this only decides whether the endpoint
# answers at all.
def require_issuance_enabled
if !Oauth::Availability.acceptance_enabled?(piloting_client_id)
head :not_found
elsif !Oauth::Availability.issuance_enabled?(piloting_client_id)
head :service_unavailable
end
end

# The client a request acts for, if it names one.
def piloting_client_id
params[:client_id]
end

# The metadata issuer and the iss authorization response parameter must be
# identical (RFC 9207 §2). script_name: nil keeps an account prefix out of it.
def oauth_issuer
root_url(script_name: nil)
end

def oauth_error(error, description = nil, status: :bad_request)
render json: { error: error, error_description: description }.compact, status: status
end

def oauth_rate_limit_exceeded
oauth_error "slow_down", "Too many requests", status: :too_many_requests
end
end
Loading
Loading