Skip to content

Commit 1fa953e

Browse files
dblockCopilot
andcommitted
Support opt-in active OAuth v2 reinstallation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 976a7c6 commit 1fa953e

7 files changed

Lines changed: 122 additions & 23 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
### 2.3.0 (Next)
44

5+
* [#190](https://github.com/slack-ruby/slack-ruby-bot-server/pull/190): Add opt-in active OAuth v2 reinstallation with credential refresh and a `reinstalled` callback, preserving default rejection and legacy RTM protection; rejected duplicate registrations no longer overwrite OAuth metadata - [@dblock](https://github.com/dblock), [@Copilot](https://github.com/apps/copilot-swe-agent).
56
* [#188](https://github.com/slack-ruby/slack-ruby-bot-server/pull/188): Reduce the database CI matrices from 22 to 12 jobs while retaining representative legacy ORM majors and modern Ruby/Grape 3/4 coverage - [@dblock](https://github.com/dblock), [@Copilot](https://github.com/apps/copilot-swe-agent).
67
* [#186](https://github.com/slack-ruby/slack-ruby-bot-server/pull/186): Document persisted custom Team attributes for both adapters and Rails concern loading/reloading - [@dblock](https://github.com/dblock), [@Copilot](https://github.com/apps/copilot-swe-agent).
78
* [#183](https://github.com/slack-ruby/slack-ruby-bot-server/pull/183): Add a `config.server_pages` block to configure page middleware options centrally without changing the installed library's defaults - [@dblock](https://github.com/dblock), [@Copilot](https://github.com/apps/copilot-swe-agent).

‎README.md‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Build a complete Slack bot service with Slack button integration, in Ruby.
2222
- [Active Record Attributes](#active-record-attributes)
2323
- [Rails Loading and Reloading](#rails-loading-and-reloading)
2424
- [OAuth Version and Scopes](#oauth-version-and-scopes)
25+
- [Active Reinstallation](#active-reinstallation)
2526
- [Slack App](#slack-app)
2627
- [API](#api)
2728
- [Custom Endpoints](#custom-endpoints)
@@ -197,6 +198,26 @@ end
197198

198199
The "Add to Slack" button uses the standard OAuth code grant flow as described in the [Slack docs](https://api.slack.com/docs/oauth#flow). Once clicked, the user is taken through the authorization process at Slack's site. Upon successful completion, a callback containing a temporary code is sent to the redirect URL you specified. The endpoint at that URL contains code that persists the bot token each time a Slack client is instantiated for the specific team.
199200

201+
#### Active Reinstallation
202+
203+
By default, installing an already active bot returns an "already registered" error. Stateless OAuth v2 applications, such as Events API bots, can opt into refreshing an existing installation:
204+
205+
```ruby
206+
SlackRubyBotServer.configure do |config|
207+
config.allow_reinstall = true
208+
end
209+
```
210+
211+
Both the existing installation and the new authorization must use OAuth v2. Successful active reinstalls update the stored token, scopes, and authorizing-user metadata, then call `Service#reinstall!` and emit `:reinstalled` with the optional OAuth `state`. They do not create another team or invoke `:creating`, `:created`, `:starting`, or `:started`, so no second worker is started:
212+
213+
```ruby
214+
SlackRubyBotServer::Service.instance.on :reinstalled do |team, error, options|
215+
# Apply application-specific installation settings using options[:state].
216+
end
217+
```
218+
219+
Only enable this for applications that do not need an existing connection or worker restarted when credentials change. Active OAuth v1 installations and active OAuth-version migrations still return an error, even with this option enabled, preserving legacy RTM protection. New installations and previously inactive installations retain the normal creation/start lifecycle. `Config.reset!` restores the default `false`.
220+
200221
### Slack App
201222

202223
Create a new Slack App [here](https://api.slack.com/applications/new).
@@ -339,6 +360,7 @@ The following callbacks are supported. All callbacks receive a `team`, except `e
339360
| error | an error has occurred |
340361
| creating | a new team is being registered |
341362
| created | a new team has been registered |
363+
| reinstalled | an active OAuth v2 installation has been refreshed without starting a worker |
342364
| booting | the service is starting and is connecting a team to Slack |
343365
| booted | the service is starting and has connected a team to Slack |
344366
| stopping | the service is about to disconnect a team from Slack |
@@ -348,7 +370,7 @@ The following callbacks are supported. All callbacks receive a `team`, except `e
348370
| deactivating | a team is being deactivated |
349371
| deactivated | a team has been deactivated |
350372

351-
The [Add to Slack button](https://api.slack.com/docs/slack-button) also allows for an optional `state` parameter that will be returned on completion of the request. The `creating` and `created` callbacks include an options hash where this value can be accessed (to check for forgery attacks for instance).
373+
The [Add to Slack button](https://api.slack.com/docs/slack-button) also allows for an optional `state` parameter that will be returned on completion of the request. The `creating`, `created`, and `reinstalled` callbacks include an options hash where this value can be accessed (to check for forgery attacks for instance).
352374
```ruby
353375
auth = OpenSSL::HMAC.hexdigest("SHA256", "key", "data")
354376
```

‎lib/slack-ruby-bot-server/api/endpoints/teams_endpoint.rb‎

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -88,36 +88,42 @@ class Create < Grape::API
8888
team ||= Team.where(team_id: team_id, oauth_version: oauth_version).first
8989
team ||= Team.where(team_id: team_id).first
9090

91+
attributes = {
92+
oauth_version: oauth_version,
93+
oauth_scope: oauth_scope,
94+
activated_user_id: user_id,
95+
activated_user_access_token: access_token,
96+
bot_user_id: bot_user_id
97+
}
98+
reinstall = false
99+
91100
if team
92101
team.ping_if_active!
93102

94-
team.update!(
95-
oauth_version: oauth_version,
96-
oauth_scope: oauth_scope,
97-
activated_user_id: user_id,
98-
activated_user_access_token: access_token,
99-
bot_user_id: bot_user_id
100-
)
103+
if team.active?
104+
raise "Team #{team.name} is already registered." unless SlackRubyBotServer::Config.allow_reinstall && team.oauth_version == 'v2' && oauth_version == :v2
101105

102-
raise "Team #{team.name} is already registered." if team.active?
106+
reinstall = true
107+
attributes[:token] = token
108+
end
103109

104-
team.activate!(token)
110+
team.update!(attributes)
111+
team.activate!(token) unless reinstall
105112
else
106-
team = Team.create!(
107-
token: token,
108-
oauth_version: oauth_version,
109-
oauth_scope: oauth_scope,
110-
team_id: team_id,
111-
name: team_name,
112-
activated_user_id: user_id,
113-
activated_user_access_token: access_token,
114-
bot_user_id: bot_user_id
115-
)
113+
team = Team.create!(attributes.merge(
114+
token: token,
115+
team_id: team_id,
116+
name: team_name
117+
))
116118
end
117119

118120
options = params.slice(:state)
119121

120-
Service.instance.create!(team, options)
122+
if reinstall
123+
Service.instance.reinstall!(team, options)
124+
else
125+
Service.instance.create!(team, options)
126+
end
121127
present team, with: Presenters::TeamPresenter
122128
end
123129
end

‎lib/slack-ruby-bot-server/config.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ module Config
88
attr_accessor :view_paths
99
attr_accessor :oauth_scope
1010
attr_accessor :oauth_version
11+
attr_accessor :allow_reinstall
1112
attr_accessor :api
1213

1314
def reset!
1415
self.logger = nil
1516
self.service_class = SlackRubyBotServer::Service
1617
self.oauth_scope = nil
1718
self.oauth_version = :v2
19+
self.allow_reinstall = false
1820
@server_pages = nil
1921
self.api = nil
2022

‎lib/slack-ruby-bot-server/service.rb‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ def start!(team)
5151
logger.error e
5252
end
5353

54+
def reinstall!(team, options = {})
55+
logger.info "Reinstalled team #{team}."
56+
run_callbacks :reinstalled, team, nil, options
57+
end
58+
5459
def restart!(team)
5560
logger.info "Restarting team #{team}."
5661
run_callbacks :restarting, team

‎spec/api/endpoints/teams_endpoint_spec.rb‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,13 @@
127127
end
128128
it 'returns a useful error when team already exists' do
129129
expect_any_instance_of(Team).to receive(:ping_if_active!)
130-
existing_team = Fabricate(:team, token: 'access_token')
130+
existing_team = Fabricate(:team, token: 'access_token', oauth_scope: 'original-scope', activated_user_id: 'original-user')
131131
expect { client.teams._post(code: 'code') }.to raise_error Faraday::ClientError do |e|
132132
json = JSON.parse(e.response[:body])
133133
expect(json['message']).to eq "Team #{existing_team.name} is already registered."
134134
end
135+
expect(existing_team.reload.oauth_scope).to eq 'original-scope'
136+
expect(existing_team.activated_user_id).to eq 'original-user'
135137
end
136138
it 'reactivates a deactivated team with a different code' do
137139
expect(SlackRubyBotServer::Service.instance).to receive(:start!)
@@ -151,6 +153,58 @@
151153
expect(team.bot_user_id).to be nil
152154
end.to_not change(Team, :count)
153155
end
156+
157+
context 'with active reinstallation enabled' do
158+
before do
159+
SlackRubyBotServer.config.allow_reinstall = true
160+
allow_any_instance_of(Team).to receive(:ping_if_active!)
161+
end
162+
163+
it 'refreshes credentials without creating a record or starting another worker' do
164+
existing_team = Fabricate(:team, token: 'old-token', team_id: 'team_id', oauth_version: 'v2')
165+
expect(SlackRubyBotServer::Service.instance).not_to receive(:start!)
166+
expect(SlackRubyBotServer::Service.instance).not_to receive(:create!)
167+
expect(SlackRubyBotServer::Service.instance).to receive(:reinstall!).with(
168+
instance_of(Team), { state: 'application-state' }
169+
).and_call_original
170+
171+
expect do
172+
response = client.teams._post(code: 'code', state: 'application-state')
173+
expect(response.id.to_s).to eq existing_team.id.to_s
174+
end.not_to change(Team, :count)
175+
existing_team.reload
176+
expect(existing_team.token).to eq 'access_token'
177+
expect(existing_team.active?).to be true
178+
expect(existing_team.oauth_scope).to eq 'commands,incoming-webhook'
179+
expect(existing_team.activated_user_access_token).to eq 'access_token'
180+
expect(existing_team.activated_user_id).to eq 'user_id'
181+
end
182+
183+
it 'does not bypass the active legacy installation guard' do
184+
existing_team = Fabricate(:team, token: 'old-token', team_id: 'team_id', oauth_version: 'v1', oauth_scope: 'original-scope')
185+
expect(SlackRubyBotServer::Service.instance).not_to receive(:reinstall!)
186+
expect { client.teams._post(code: 'code') }.to raise_error(Faraday::ClientError)
187+
existing_team.reload
188+
expect(existing_team.token).to eq 'old-token'
189+
expect(existing_team.oauth_version).to eq 'v1'
190+
expect(existing_team.oauth_scope).to eq 'original-scope'
191+
end
192+
193+
it 'still starts newly created installations normally' do
194+
expect(SlackRubyBotServer::Service.instance).to receive(:start!).and_call_original
195+
expect(SlackRubyBotServer::Service.instance).not_to receive(:reinstall!)
196+
expect { client.teams._post(code: 'code') }.to change(Team, :count).by(1)
197+
end
198+
199+
it 'still starts previously deactivated installations normally' do
200+
existing_team = Fabricate(:team, token: 'old-token', team_id: 'team_id', oauth_version: 'v2', active: false)
201+
expect(SlackRubyBotServer::Service.instance).to receive(:start!).and_call_original
202+
expect(SlackRubyBotServer::Service.instance).not_to receive(:reinstall!)
203+
client.teams._post(code: 'code')
204+
expect(existing_team.reload.token).to eq 'access_token'
205+
expect(existing_team.active?).to be true
206+
end
207+
end
154208
end
155209

156210
context 'register a bot via oauth v1' do
@@ -235,8 +289,9 @@
235289
end.to_not change(Team, :count)
236290
end
237291
it 'returns a useful error when team already exists' do
292+
SlackRubyBotServer.config.allow_reinstall = true
238293
expect_any_instance_of(Team).to receive(:ping_if_active!)
239-
existing_team = Fabricate(:team, token: 'access_token')
294+
existing_team = Fabricate(:team, token: 'access_token', oauth_version: 'v2')
240295
expect { client.teams._post(code: 'code') }.to raise_error Faraday::ClientError do |e|
241296
json = JSON.parse(e.response[:body])
242297
expect(json['message']).to eq "Team #{existing_team.name} is already registered."

‎spec/slack-ruby-bot-server/service_spec.rb‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@
77
end
88
context 'callbacks' do
99
let(:instance) { SlackRubyBotServer::Service.instance }
10+
it 'notifies applications of reinstallation without starting a worker' do
11+
expect(instance).not_to receive(:start!)
12+
expect do |callback|
13+
instance.on(:reinstalled, &callback)
14+
instance.reinstall!(team, state: 'application-state')
15+
end.to yield_with_args(team, nil, { state: 'application-state' })
16+
end
17+
1018
context 'single' do
1119
before do
1220
@events = []

0 commit comments

Comments
 (0)