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