Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 16 additions & 2 deletions app/controllers/admin/oauth_applications_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def new
def create
@application = Doorkeeper::Application.new(application_params)

if @application.save
if introspect_change_allowed? && @application.save
flash[:notice] = "OAuth application was successfully created."
redirect_to admin_oauth_application_path(@application)
else
Expand All @@ -34,7 +34,9 @@ def edit
end

def update
if @application.update(application_params)
@application.assign_attributes(application_params)

if introspect_change_allowed? && @application.save
redirect_to admin_oauth_application_path(@application), notice: "OAuth application was successfully updated."
else
render :edit, status: :unprocessable_entity
Expand Down Expand Up @@ -106,6 +108,18 @@ def require_superadmin
redirect_to admin_oauth_application_path(@application), alert: "Only a superadmin can change who can use an app."
end

# The `introspect` scope lets an app read the tokens of other apps, so only a
# superadmin may add or remove it. Other scopes stay open to any admin.
def introspect_change_allowed?
return true if current_user.superadmin?

before = Doorkeeper::OAuth::Scopes.from_string(@application.scopes_was.to_s).exists?("introspect")
return true if before == @application.scopes.exists?("introspect")

@application.errors.add(:base, "Only a superadmin can add or remove the introspect scope.")
false
end

def set_application
@application = Doorkeeper::Application.find(params[:id])
end
Expand Down
1 change: 1 addition & 0 deletions app/views/admin/oauth_applications/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
placeholder: "profile email" %>
<p class="mt-2 text-sm text-ink-faint">
Space-separated list of scopes. Available: <code>profile</code>, <code>email</code>, <code>admin</code>, <code>slack</code>, <code>groups</code>, <code>roles</code>.
App tokens only: <code>directory</code>, <code>quilt</code>, <code>introspect</code> (superadmin only).
Leave empty to use defaults.
</p>
</div>
Expand Down
4 changes: 4 additions & 0 deletions app/views/admin/oauth_applications/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@
<code>slack</code> (Slack membership and Slack ID),
<code>groups</code> (the user's groups that are linked to this app),
<code>roles</code> (the user's roles in this app).
App tokens only (<code>client_credentials</code>):
<code>directory</code> (the directory API),
<code>quilt</code> (the Quilt patch API),
<code>introspect</code> (check the app tokens of other apps, superadmin only).
Leave empty to use defaults.
</p>
</div>
Expand Down
50 changes: 36 additions & 14 deletions config/initializers/doorkeeper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,6 @@
# For more information go to
# https://doorkeeper.gitbook.io/guides/ruby-on-rails/scopes
#
# OAuth scopes are for user authorization flows, not API access
#
# `openid` is what turns an authorization request into an OpenID Connect
# request: doorkeeper-openid_connect only adds an `id_token` to the token
# response when the granted scopes include it. It has to be registered here or
Expand All @@ -289,18 +287,26 @@
#
# Every scope listed here is published in `scopes_supported` on both discovery
# documents, and each of the non-openid scopes maps to a claim block in
# config/initializers/doorkeeper_openid_connect.rb.
default_scopes :profile
optional_scopes :openid, :email, :phone, :admin, :slack, :groups, :roles, :directory

# Allows to restrict only certain scopes for grant_type.
# By default, all the scopes will be available for all the grant types.
#
# Keys to this hash should be the name of grant_type and
# values should be the array of scopes for that grant type.
# Note: scopes should be from configured_scopes (i.e. default or optional)
#
# scopes_by_grant_type password: [:write], client_credentials: [:update]
# config/initializers/doorkeeper_openid_connect.rb. The exceptions are the
# app scopes below, which have no claims.
#
# App scopes are for an app's own `client_credentials` token, not for a user:
# - `directory`: call the directory API (Api::V1::Directory).
# - `quilt`: call Quilt's patch API. Quilt checks the token at introspection.
# - `introspect`: introspect the `client_credentials` tokens of other apps
# (see `allow_token_introspection` below). Only a superadmin may allow it
# on an app (see Admin::OauthApplicationsController).
user_scopes = %i[profile openid email phone admin slack groups roles]
app_scopes = %i[directory quilt introspect]

default_scopes :profile
optional_scopes(*(user_scopes - [:profile]), *app_scopes)

# A user can't consent to an app scope, so the authorization endpoint gives
# `invalid_scope` for one. The other grant types are not limited:
# `client_credentials` asks for app scopes, and a refresh keeps the scopes of
# its token.
scopes_by_grant_type authorization_code: user_scopes

# Forbids creating/updating applications with arbitrary scopes that are
# not in configuration, i.e. +default_scopes+ or +optional_scopes+.
Expand Down Expand Up @@ -585,6 +591,22 @@
#
# If you need to block the request at all, then configure your routes.rb or web-server
# like nginx to forbid the request.
#
# Weave keeps Doorkeeper's default rule (an app introspects only its own
# tokens) and adds one case for resource servers such as Quilt: a client that
# authenticates with its credentials, and whose app is allowed the
# `introspect` scope, may introspect another app's token when that token has
# no user. A user's token stays private to the app it was issued to.
allow_token_introspection do |token, authorized_client, authorized_token|
if authorized_token
authorized_token.application == token&.application
elsif token&.application
authorized_client == token.application ||
(token.resource_owner_id.nil? && authorized_client&.scopes&.exists?("introspect"))
else
true
end
end

# WWW-Authenticate Realm (default: "Doorkeeper").
#
Expand Down
2 changes: 2 additions & 0 deletions config/locales/doorkeeper.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ en:
groups: 'See which of your Weave groups apply to this app'
roles: 'See your roles in this app'
directory: 'Look up users of this app (app tokens only)'
quilt: 'Call the Quilt patch API (app tokens only)'
introspect: 'Check the app tokens of other apps (app tokens only)'

applications:
confirmations:
Expand Down
63 changes: 63 additions & 0 deletions docs/OAUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ The OAuth provider supports the following scopes:
- `groups`: the user's groups that are linked to this app (`groups`)
- `roles`: the user's roles in this app (`roles`)
- `directory`: the directory API, for `client_credentials` tokens only (see "Directory API")
- `quilt`: the Quilt patch API, for `client_credentials` tokens only (see "Resource servers")
- `introspect`: introspect the `client_credentials` tokens of other apps (see "Resource servers")

`directory`, `quilt` and `introspect` are app scopes. A user can't grant them:
the authorization endpoint answers `invalid_scope` for each one. Any admin can
allow `directory` and `quilt` on an app. Only a superadmin can add or remove
`introspect`.

The `profile` scope also includes `pronouns`, a non-standard claim (a free-text
string such as `they/them`). It is in the ID token and the userinfo response.
Expand Down Expand Up @@ -321,6 +328,62 @@ Rules:
- Pass exactly one of `role` and `group`, or the API answers `400`.
- A token with a user, a token without the `directory` scope, or a token of an app whose scopes do not list `directory` gives `403`.

### Resource servers

A resource server is an app that other apps call with their own Weave token.
Quilt, the funding service, is one: Krater calls Quilt's patch API with a
`client_credentials` token that has the `quilt` scope. Weave tokens are opaque,
so the resource server checks each token at the introspection endpoint.

An app may introspect its own tokens. To introspect the tokens of other apps,
the resource server's app must be allowed the `introspect` scope. Even then,
Weave shows it only tokens that have no user (`client_credentials` tokens). It
gets `{"active": false}` for a user's token of another app.

Setup, in `/admin/oauth_applications`:

1. A superadmin adds `introspect` to the scopes of the resource server's app (Quilt).
2. An admin adds the resource server's scope (`quilt`) to each app that calls it (Krater).

```mermaid
sequenceDiagram
participant K as Krater
participant W as Weave
participant Q as Quilt
K->>W: POST /oauth/token (client_credentials, scope=quilt)
W-->>K: access_token
K->>Q: request with Authorization: Bearer access_token
Q->>W: POST /oauth/introspect (Basic auth with Quilt's client ID and secret)
W-->>Q: {"active": true, "client_id": "<Krater uid>", "scope": "quilt", ...}
Q-->>K: response
```

The resource server sends the token with its own client credentials:

```bash
curl -u "$QUILT_CLIENT_ID:$QUILT_CLIENT_SECRET" -d token="$TOKEN" \
https://weave.patchworklabs.org/oauth/introspect
```

```json
{
"active": true,
"client_id": "Krater's client ID",
"scope": "quilt",
"token_type": "Bearer",
"exp": 1791234567,
"iat": 1791227367
}
```

Rules for the resource server:

- Refuse the request unless `active` is `true`. An inactive, revoked, expired, unknown or hidden token gives only `{"active": false}`.
- Refuse the request unless `scope` (a space-separated list) includes `quilt`.
- Use `client_id` to find the calling app, for example the patch it belongs to. Keep this mapping in the resource server.
- Keep a result for up to 60 seconds, keyed by a hash of the token, and never past `exp`. A revoked token can then work for up to 60 seconds.
- Authenticate with HTTP Basic. A bearer token can introspect only tokens of its own app.

## Security Considerations

1. **HTTPS Required**: OAuth endpoints enforce HTTPS in production
Expand Down
80 changes: 80 additions & 0 deletions spec/requests/admin_oauth_application_scopes_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true

require "rails_helper"

# The `introspect` scope lets an app read the app tokens of other apps, so only
# a superadmin may add or remove it. Any admin may change the other scopes.
RSpec.describe "Admin OAuth application scopes", type: :request do
let(:admin) { create(:user, :admin, :verified) }
let(:superadmin) { create(:user, :superadmin, :verified) }
let(:application) do
Doorkeeper::Application.create!(name: "Quilt", redirect_uri: "https://quilt.example.com/cb", scopes: "profile")
end

def update_scopes(scopes)
patch admin_oauth_application_path(application), params: { doorkeeper_application: { scopes: scopes } }
end

def create_app(scopes)
post admin_oauth_applications_path, params: {
doorkeeper_application: { name: "New", redirect_uri: "https://new.example.com/cb", scopes: scopes }
}
end

context "when signed in as a plain admin" do
before { sign_in_via_magic_link(admin) }

it "can allow the quilt and directory scopes" do
update_scopes("profile quilt directory")

expect(response).to redirect_to(admin_oauth_application_path(application))
expect(application.reload.scopes.to_a).to contain_exactly("profile", "quilt", "directory")
end

it "can't allow the introspect scope" do
update_scopes("profile introspect")

expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include("Only a superadmin can add or remove the introspect scope.")
expect(application.reload.scopes.to_a).to eq(["profile"])
end

it "can't remove the introspect scope" do
application.update!(scopes: "profile introspect")

update_scopes("profile")

expect(response).to have_http_status(:unprocessable_entity)
expect(application.reload.scopes.to_a).to contain_exactly("profile", "introspect")
end

it "can change other scopes of an app that has the introspect scope" do
application.update!(scopes: "profile introspect")

update_scopes("profile email introspect")

expect(application.reload.scopes.to_a).to contain_exactly("profile", "email", "introspect")
end

it "can't create an app with the introspect scope" do
expect { create_app("introspect") }.not_to change(Doorkeeper::Application, :count)
expect(response).to have_http_status(:unprocessable_entity)
end
end

context "when signed in as a superadmin" do
before { sign_in_via_magic_link(superadmin) }

it "can allow and remove the introspect scope" do
update_scopes("profile introspect")
expect(application.reload.scopes.to_a).to contain_exactly("profile", "introspect")

update_scopes("profile")
expect(application.reload.scopes.to_a).to eq(["profile"])
end

it "can create an app with the introspect scope" do
expect { create_app("introspect") }.to change(Doorkeeper::Application, :count).by(1)
end
end
end
Loading
Loading