From 2d4671e2eeed9bf6cafe3f2f3a107b027b192986 Mon Sep 17 00:00:00 2001
From: Jasper Mayone
Date: Sat, 10 Oct 2026 15:52:56 -0400
Subject: [PATCH 1/2] feat: let resource servers introspect client_credentials
tokens
---
.../admin/oauth_applications_controller.rb | 18 ++-
.../admin/oauth_applications/edit.html.erb | 1 +
.../admin/oauth_applications/new.html.erb | 4 +
config/initializers/doorkeeper.rb | 50 +++++--
config/locales/doorkeeper.en.yml | 2 +
.../admin_oauth_application_scopes_spec.rb | 80 ++++++++++
spec/requests/oauth/introspection_spec.rb | 139 ++++++++++++++++++
7 files changed, 278 insertions(+), 16 deletions(-)
create mode 100644 spec/requests/admin_oauth_application_scopes_spec.rb
create mode 100644 spec/requests/oauth/introspection_spec.rb
diff --git a/app/controllers/admin/oauth_applications_controller.rb b/app/controllers/admin/oauth_applications_controller.rb
index 5bc0ad8..3a5f25f 100644
--- a/app/controllers/admin/oauth_applications_controller.rb
+++ b/app/controllers/admin/oauth_applications_controller.rb
@@ -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
@@ -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
@@ -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
diff --git a/app/views/admin/oauth_applications/edit.html.erb b/app/views/admin/oauth_applications/edit.html.erb
index bc15e43..3c57ab6 100644
--- a/app/views/admin/oauth_applications/edit.html.erb
+++ b/app/views/admin/oauth_applications/edit.html.erb
@@ -64,6 +64,7 @@
placeholder: "profile email" %>
Space-separated list of scopes. Available: profile, email, admin, slack, groups, roles.
+ App tokens only: directory, quilt, introspect (superadmin only).
Leave empty to use defaults.
diff --git a/app/views/admin/oauth_applications/new.html.erb b/app/views/admin/oauth_applications/new.html.erb
index 8403927..2cac9ab 100644
--- a/app/views/admin/oauth_applications/new.html.erb
+++ b/app/views/admin/oauth_applications/new.html.erb
@@ -72,6 +72,10 @@
slack (Slack membership and Slack ID),
groups (the user's groups that are linked to this app),
roles (the user's roles in this app).
+ App tokens only (client_credentials):
+ directory (the directory API),
+ quilt (the Quilt patch API),
+ introspect (check the app tokens of other apps, superadmin only).
Leave empty to use defaults.
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
index a059017..fd8d377 100644
--- a/config/initializers/doorkeeper.rb
+++ b/config/initializers/doorkeeper.rb
@@ -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
@@ -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+.
@@ -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").
#
diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml
index b91dd74..a19a4e1 100644
--- a/config/locales/doorkeeper.en.yml
+++ b/config/locales/doorkeeper.en.yml
@@ -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:
diff --git a/spec/requests/admin_oauth_application_scopes_spec.rb b/spec/requests/admin_oauth_application_scopes_spec.rb
new file mode 100644
index 0000000..8319c5a
--- /dev/null
+++ b/spec/requests/admin_oauth_application_scopes_spec.rb
@@ -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
diff --git a/spec/requests/oauth/introspection_spec.rb b/spec/requests/oauth/introspection_spec.rb
new file mode 100644
index 0000000..b42e0e3
--- /dev/null
+++ b/spec/requests/oauth/introspection_spec.rb
@@ -0,0 +1,139 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# A resource server such as Quilt gets an app's client_credentials token on
+# each request and checks it at introspection. Doorkeeper lets an app
+# introspect only its own tokens, so an app allowed the `introspect` scope may
+# also introspect app tokens of other apps. A user's token stays private to the
+# app it was issued to.
+RSpec.describe "OAuth token introspection", type: :request do
+ let(:quilt) do
+ Doorkeeper::Application.create!(name: "Quilt", redirect_uri: "https://quilt.example.com/cb", scopes: "introspect")
+ end
+ let(:krater) do
+ Doorkeeper::Application.create!(name: "Krater", redirect_uri: "https://krater.example.com/cb",
+ scopes: "openid profile quilt")
+ end
+ let(:user) { create(:user, :verified, :accepted_code_of_conduct) }
+
+ def basic_auth(app)
+ { "Authorization" => ActionController::HttpAuthentication::Basic.encode_credentials(app.uid, app.plaintext_secret) }
+ end
+
+ def client_credentials_token(app, scope:)
+ post oauth_token_path, params: { grant_type: "client_credentials", scope: scope }, headers: basic_auth(app)
+ expect(response).to have_http_status(:ok)
+ response.parsed_body.fetch("access_token")
+ end
+
+ def introspect(token, as:)
+ post oauth_introspect_path, params: { token: token }, headers: basic_auth(as)
+ response.parsed_body
+ end
+
+ describe "an app allowed the introspect scope" do
+ it "sees another app's client_credentials token as active, with its client and scope" do
+ token = client_credentials_token(krater, scope: "quilt")
+
+ freeze_time do
+ record = Doorkeeper::AccessToken.by_token(token)
+ body = introspect(token, as: quilt)
+
+ expect(response).to have_http_status(:ok)
+ expect(body).to include(
+ "active" => true,
+ "client_id" => krater.uid,
+ "scope" => "quilt",
+ "token_type" => "Bearer",
+ "exp" => record.expires_at.to_i,
+ "iat" => record.created_at.to_i
+ )
+ end
+ end
+
+ it "does not see a user's token of another app" do
+ token = Doorkeeper::AccessToken.create!(application: krater, resource_owner_id: user.id, scopes: "openid profile")
+
+ expect(introspect(token.plaintext_token, as: quilt)).to eq("active" => false)
+ end
+
+ it "sees a revoked token as inactive" do
+ token = client_credentials_token(krater, scope: "quilt")
+ Doorkeeper::AccessToken.by_token(token).revoke
+
+ expect(introspect(token, as: quilt)).to eq("active" => false)
+ end
+
+ it "sees an expired token as inactive" do
+ token = client_credentials_token(krater, scope: "quilt")
+
+ travel(Doorkeeper.config.access_token_expires_in + 1.minute) do
+ expect(introspect(token, as: quilt)).to eq("active" => false)
+ end
+ end
+
+ it "can't use a bearer token to introspect another app's token" do
+ token = client_credentials_token(krater, scope: "quilt")
+ quilt_token = client_credentials_token(quilt, scope: "introspect")
+
+ post oauth_introspect_path, params: { token: token }, headers: { "Authorization" => "Bearer #{quilt_token}" }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ describe "an app not allowed the introspect scope" do
+ let(:other) do
+ Doorkeeper::Application.create!(name: "Other", redirect_uri: "https://other.example.com/cb", scopes: "profile quilt")
+ end
+
+ it "does not see another app's client_credentials token" do
+ token = client_credentials_token(krater, scope: "quilt")
+
+ expect(introspect(token, as: other)).to eq("active" => false)
+ end
+
+ it "still sees its own tokens, as Doorkeeper's default allows" do
+ app_token = client_credentials_token(krater, scope: "quilt")
+ user_token = Doorkeeper::AccessToken.create!(application: krater, resource_owner_id: user.id, scopes: "profile")
+
+ expect(introspect(app_token, as: krater)).to include("active" => true, "client_id" => krater.uid)
+ expect(introspect(user_token.plaintext_token, as: krater)).to include("active" => true, "client_id" => krater.uid)
+ end
+ end
+
+ describe "app scopes" do
+ it "are not issued to an app that is not allowed them" do
+ post oauth_token_path, params: { grant_type: "client_credentials", scope: "quilt" }, headers: basic_auth(quilt)
+
+ expect(response).to have_http_status(:bad_request)
+ expect(response.parsed_body["error"]).to eq("invalid_scope")
+ end
+
+ %w[quilt introspect directory].each do |scope|
+ it "can't be asked for at the authorization endpoint (#{scope})" do
+ app = Doorkeeper::Application.create!(name: "Client", redirect_uri: "https://client.example.com/cb",
+ scopes: "openid profile #{scope}")
+ verifier = SecureRandom.urlsafe_base64(64)
+ sign_in_via_magic_link(user)
+
+ params = {
+ client_id: app.uid, redirect_uri: app.redirect_uri, response_type: "code", scope: "openid #{scope}",
+ code_challenge: Base64.urlsafe_encode64(OpenSSL::Digest::SHA256.digest(verifier), padding: false),
+ code_challenge_method: "S256"
+ }
+
+ # The consent screen shows Weave's error page. A consent POST sends the
+ # error back to the client. Neither one issues a code.
+ get oauth_authorization_path, params: params
+ expect(response).to have_http_status(:bad_request)
+ expect(response.body).to include("The requested scope is invalid")
+
+ post oauth_authorization_path, params: params
+ expect(Rack::Utils.parse_query(URI.parse(response.location).query)).to include("error" => "invalid_scope")
+ expect(Doorkeeper::AccessGrant.where(application: app)).to be_empty
+ end
+ end
+ end
+end
From 6b377689586fcff5a6c980b94f5c295a004259f3 Mon Sep 17 00:00:00 2001
From: Jasper Mayone
Date: Sat, 10 Oct 2026 15:52:56 -0400
Subject: [PATCH 2/2] docs: describe how resource servers check app tokens
---
docs/OAUTH.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 63 insertions(+)
diff --git a/docs/OAUTH.md b/docs/OAUTH.md
index ec22799..96f0696 100644
--- a/docs/OAUTH.md
+++ b/docs/OAUTH.md
@@ -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.
@@ -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": "", "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