Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a8bdf48
Limit OAuth to 37signals staff while it's piloted
jeremy Oct 25, 2026
a533581
Stop an app's push delivery once its owner isn't staff, while OAuth i…
jeremy Oct 25, 2026
331f364
Merge oauth-token-exchange into oauth-staff-only
jeremy Oct 26, 2026
e4ae2a2
Merge oauth-consent-design into oauth-staff-only, so they land on mai…
jeremy Oct 26, 2026
99c193b
Merge oauth-consent-design into oauth-staff-only
jeremy Oct 26, 2026
2271dee
Let personal access tokens through the staff gate whatever their client
jeremy Oct 26, 2026
12a605e
Merge oauth-consent-design into oauth-staff-only
jeremy Oct 26, 2026
b974057
Merge oauth-consent-design into oauth-staff-only
jeremy Oct 26, 2026
8ac6ff8
Merge oauth-consent-design into oauth-staff-only
jeremy Oct 26, 2026
51915e8
Merge oauth-consent-design into oauth-staff-only
jeremy Oct 26, 2026
202aafe
Merge oauth-consent-design into oauth-staff-only
jeremy Oct 26, 2026
8e5dde1
Merge oauth-consent-design into oauth-staff-only
jeremy Oct 26, 2026
74c3a81
Merge oauth-consent-design into oauth-staff-only
jeremy Oct 26, 2026
1d14e30
Check the staff-only switch before a token's client
jeremy Oct 26, 2026
5514e07
Merge oauth-consent-design into oauth-staff-only
jeremy Oct 26, 2026
69d8318
Merge oauth-consent-design into oauth-staff-only
jeremy Oct 26, 2026
9184573
Merge oauth-consent-design into oauth-staff-only
jeremy Oct 26, 2026
e6d0ddc
Merge oauth-consent-design into oauth-staff-only
jeremy Oct 26, 2026
49c1ee1
Merge oauth-consent-design: the review fixes from below
jeremy Oct 26, 2026
94344d1
Merge oauth-consent-design: review fixes from below
jeremy Oct 26, 2026
d8ba673
Merge oauth-consent-design: review fixes from below
jeremy Oct 26, 2026
4a96a44
Let a replayed refresh token revoke its grant whoever owns it, and lo…
jeremy Oct 26, 2026
a62f789
Merge oauth-consent-design: review fixes from below
jeremy Oct 26, 2026
c32b732
Answer a replayed code before weighing its owner, and refuse non-staf…
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
13 changes: 13 additions & 0 deletions app/controllers/oauth/authorizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ class Oauth::AuthorizationsController < Oauth::BaseController
before_action :require_authentication
disallow_oauth_grants

before_action :require_admitted_identity, unless: :denial?

before_action :set_client
before_action :validate_redirect_uri
before_action :validate_single_valued_parameters
Expand Down Expand Up @@ -82,6 +84,17 @@ def denial?
request.post? && params[:error] == "access_denied"
end

# While OAuth is staff only, nobody else is asked to consent. They're told
# why, rather than bounced back to an app that can't say.
def require_admitted_identity
unless Oauth::Availability.admits?(Current.identity)
respond_to do |format|
format.html { render :staff_only, status: :forbidden }
format.json { render_oauth_error "access_denied", "OAuth apps are limited to 37signals staff for now", status: :forbidden }
end
end
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
Expand Down
29 changes: 29 additions & 0 deletions app/controllers/oauth/tokens_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,16 @@ class Oauth::TokensController < Oauth::BaseController
with_options if: :authorization_code_grant? do
before_action :validate_pkce
before_action :validate_redirect_uri
before_action :answer_code_replay
before_action :set_identity
before_action :require_admitted_identity
Comment thread
jeremy marked this conversation as resolved.
before_action :validate_code_resource
before_action :validate_bound_account
end

# A replay that can't be a retry earns its revocation whoever owns the grant,
# so it isn't stopped here: it issues nothing either way.
before_action :require_admitted_grant_owner, unless: -> { authorization_code_grant? || unretryable_replay? }
before_action :validate_refresh_resource, unless: -> { authorization_code_grant? || unretryable_replay? }
before_action :validate_refresh_bound_account, unless: -> { authorization_code_grant? || unretryable_replay? }

Expand Down Expand Up @@ -155,6 +160,30 @@ def set_identity
end
end

# While OAuth is staff only, a code or refresh token mints nothing for
# someone who isn't staff, even one issued while they were.
# A code presented again, by the client it was issued to with its verifier,
# is answered as a replay before anything about the person or account is
# weighed: the revocation it earns can't hinge on what has changed since.
def answer_code_replay
if Identity::AccessToken.code_redeemed?(@auth_code)
Identity::AccessToken.answer_code_replay(@auth_code, client: @client)
oauth_error "invalid_grant", "Authorization code already used"
end
end

def require_admitted_identity
unless Oauth::Availability.admits?(@identity)
oauth_error "invalid_grant", "OAuth apps are limited to 37signals staff for now"
end
end

def require_admitted_grant_owner
unless @access_token.personal? || Oauth::Availability.admits?(@access_token.identity)
oauth_error "invalid_grant", "OAuth apps are limited to 37signals staff for now"
end
end

# RFC 8707 §2.2: a resource on redemption must name what the code was
# issued for. A code for the hosted MCP server answers to that resource
# alone; any other code, to Fizzy's own API. Neither is widened or moved
Expand Down
33 changes: 28 additions & 5 deletions app/models/identity/access_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -206,13 +206,28 @@ def redeem(authorization_code, **attributes)
create! authorization_code_jti: authorization_code.jti, **attributes
end
rescue ActiveRecord::RecordNotUnique
replayed = where(authorization_code_jti: authorization_code.jti).each { |grant| grant.revoke(reason: "code_replayed", actor: "system") }
# The code names its identity even when no grant is left to.
Oauth::Audit.record "code.replayed", grant: replayed.first, client: attributes[:oauth_client], identity_id: authorization_code.identity_id, actor: "client"
remember_redeemed authorization_code
answer_code_replay authorization_code, client: attributes[:oauth_client]
nil
end

# Whether a code has been redeemed before: recorded, or, for one redeemed
# before redemptions were recorded, stamped on the grant it minted.
def code_redeemed?(authorization_code)
Oauth::RedeemedAuthorizationCode.exists?(jti: authorization_code.jti) || unscoped.exists?(authorization_code_jti: authorization_code.jti)
end

# A replayed code revokes the grant its first redemption issued, whoever
# presents it and whatever has changed since, and mints nothing.
# The revocations report as they commit, after the replay that caused them.
def answer_code_replay(authorization_code, client:)
transaction do
replayed = unscoped.where(authorization_code_jti: authorization_code.jti).each { |grant| grant.revoke(reason: "code_replayed", actor: "system") }
# The code names its identity even when no grant is left to.
Oauth::Audit.record "code.replayed", grant: replayed.first, client: client, identity_id: authorization_code.identity_id, actor: "client"
remember_redeemed authorization_code
end
end

private
# A code redeemed before redemptions were recorded has a grant but no
# record, and the failed transaction above rolled back the one it tried
Expand Down Expand Up @@ -268,7 +283,7 @@ def find_and_digest_undigested(raw)
# write once that grant can't: a refresh that narrows the grant to read
# narrows the tokens delegated from it too.
def honored?
honored_client? && (!delegated? || honored_delegation?)
honored_client? && honored_identity? && (!delegated? || honored_delegation?)
Comment thread
jeremy marked this conversation as resolved.
end

def personal?
Expand Down Expand Up @@ -390,6 +405,14 @@ def honored_delegation?
grant.present? && grant.honored_client? && (read? || grant.write?)
end

# While OAuth is staff only, an OAuth token works only for staff, so a
# grant stops working once its owner isn't. A personal access token isn't
# OAuth and always works. The switch is checked first, so an install that
# isn't staff only doesn't load each token's client to tell.
def honored_identity?
!Oauth::Availability.staff_only? || personal? || identity&.staff? == true
end

def honored_client?
oauth_client_id.nil? || Oauth::Availability.acceptance_enabled? || personal? || Oauth::Availability.acceptance_enabled?(oauth_client&.client_id)
end
Expand Down
18 changes: 18 additions & 0 deletions app/models/oauth/availability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@
#
# An incident pause must therefore empty pilot_client_ids as well as throwing
# the switch, or listed clients keep minting through it.
#
# == Staff only
#
# The switches above choose clients. staff_only chooses people: while it's on,
# only 37signals staff (Identity#staff?) can connect an app, redeem a code,
# refresh, or use an OAuth token, whichever client asks. Someone who stops
# being staff loses the access they granted on their next request. Personal
# access tokens are not OAuth credentials, so it doesn't touch them, and
# management stays open to everyone, so anyone can still shed a grant.
module Oauth::Availability
extend self

Expand All @@ -53,6 +62,15 @@ def metadata_documents_enabled?(client_id = nil)
issuance_enabled?(client_id) && config.cimd_enabled == true
end

def staff_only?
config.staff_only == true
end

# Whether OAuth is open to this person: everyone, unless staff_only.
def admits?(identity)
!staff_only? || identity&.staff? == true
Comment thread
jeremy marked this conversation as resolved.
end

# The clients whose tokens are honored, as a relation, for code that weighs
# many grants at once rather than one presented token.
def accepted_clients
Expand Down
8 changes: 5 additions & 3 deletions app/models/push/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,18 @@ class Push::Subscription < ApplicationRecord
# moving to another identity, a claim racing the last grant's end), where
# the cleanup on grant destroy only finds what the identity still reaches.
# The grant must also be one Fizzy honors (Oauth::Availability): while
# OAuth is dark, an app whose tokens are refused gets no notifications
# either.
# OAuth is dark, or staff only and the user isn't staff, an app whose tokens
# are refused gets no notifications either.
#
# An account-bound grant reaches only its own account, so only it, or a grant
# bound to no account, keeps a subscription in that account delivering: once
# the app's grant for one account ends, a grant it holds for another account
# doesn't carry that account's notifications.
scope :deliverable_to, ->(user) do
grants = Identity::AccessToken.grants.unlapsed.where(identity_id: user.identity_id, account_id: [ nil, user.account_id ], oauth_client: Oauth::Availability.accepted_clients)

where(oauth_client_id: nil).or \
where(oauth_client_id: Identity::AccessToken.grants.unlapsed.where(identity_id: user.identity_id, account_id: [ nil, user.account_id ], oauth_client: Oauth::Availability.accepted_clients).select(:oauth_client_id))
where(oauth_client_id: (!Oauth::Availability.staff_only? || Oauth::Availability.admits?(user.identity) ? grants : grants.none).select(:oauth_client_id))
end

validates :endpoint, presence: true
Expand Down
11 changes: 11 additions & 0 deletions app/views/oauth/authorizations/staff_only.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<article class="panel panel--wide shadow center txt-align-start">
<div class="flex flex-column gap">
<h2 class="margin-none">Not available yet</h2>

<p class="txt-muted margin-none">
Only 37signals staff can connect apps to Fizzy for now.
</p>

<%= link_to "Go back", root_path, class: "btn btn--outline center txt-medium" %>
</div>
</article>
3 changes: 3 additions & 0 deletions config/environments/production.rb
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@
config.x.oauth.acceptance_enabled = ENV["OAUTH_ACCEPTANCE_ENABLED"] == "true"
config.x.oauth.issuance_enabled = ENV["OAUTH_ISSUANCE_ENABLED"] != "false"
config.x.oauth.pilot_client_ids = ENV.fetch("OAUTH_PILOT_CLIENT_IDS", "").split(",").map(&:strip).compact_blank
# OAUTH_STAFF_ONLY=true limits OAuth to 37signals staff, whichever client
# asks; personal access tokens are unaffected (Oauth::Availability).
config.x.oauth.staff_only = ENV["OAUTH_STAFF_ONLY"] == "true"

# Client ID Metadata Documents are off unless OAUTH_CIMD_ENABLED is "true",
# and then fetched only from the hosts OAUTH_CIMD_ALLOWED_HOSTS names
Expand Down
1 change: 1 addition & 0 deletions saas/config/deploy.beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ env:
CACHE_NAMESPACE: <%= @beta_number %>
RAILS_ENV: beta
RAILS_LOG_LEVEL: fatal # suppress unstructured log lines
OAUTH_STAFF_ONLY: "true" # OAuth is for 37signals staff until we open it up
MYSQL_DATABASE_HOST: fizzy-mysql-primary
MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica
MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary
Expand Down
1 change: 1 addition & 0 deletions saas/config/deploy.production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ env:
clear:
RAILS_ENV: production
RAILS_LOG_LEVEL: fatal # suppress unstructured log lines
OAUTH_STAFF_ONLY: "true" # OAuth is for 37signals staff until we open it up
MYSQL_DATABASE_HOST: fizzy-mysql-primary
MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica
MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary
Expand Down
1 change: 1 addition & 0 deletions saas/config/deploy.staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ env:
clear:
RAILS_ENV: staging
RAILS_LOG_LEVEL: fatal # suppress unstructured log lines
OAUTH_STAFF_ONLY: "true" # OAuth is for 37signals staff until we open it up
MYSQL_DATABASE_HOST: fizzy-staging-mysql-primary
MYSQL_DATABASE_REPLICA_HOST: fizzy-staging-mysql-replica
MYSQL_SOLID_CABLE_HOST: fizzy-staging-mysql-primary
Expand Down
23 changes: 23 additions & 0 deletions test/integration/oauth_delegate_resources_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,29 @@ class OauthDelegateResourcesTest < ActionDispatch::IntegrationTest
end
end

test "an app's push subscription stops delivering while OAuth is staff only and the user isn't staff, alongside the browser's" do
subscribe_push bearer(@grant)
users(:kevin).push_subscriptions.create!(push_params(BROWSER))

with_oauth_availability staff_only: true do
assert_equal [ BROWSER, ENDPOINT ].sort, delivered_endpoints.sort

identities(:kevin).update!(staff: false)
assert_equal [ BROWSER ], delivered_endpoints
end
end

test "delivering push looks up no identity while OAuth isn't staff only" do
subscribe_push bearer(@grant)
user = User.find(users(:kevin).id)

with_oauth_availability staff_only: false do
Push::Subscription.deliverable_to(user).load
end

assert_not user.association(:identity).loaded?
end

test "an app's push subscription stops delivering when the user leaves the account" do
subscribe_push bearer(@grant)
users(:kevin).push_subscriptions.create!(push_params(BROWSER))
Expand Down
Loading
Loading