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
21 changes: 17 additions & 4 deletions app/models/identity/access_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -126,17 +126,30 @@ def cleanup
end
end

# An authorization code redeems at most once (RFC 6749 §4.1.2): the grant it
# mints is stamped with the code's jti, and the unique index refuses a second
# stamp. A replay also revokes the grant the first redemption issued.
# An authorization code redeems at most once (RFC 6749 §4.1.2). Redeeming
# records its jti (Oauth::RedeemedAuthorizationCode) in the same transaction
# as the grant it mints, and the unique index refuses a second record. A
# replay also revokes the grant the first redemption issued, and the record
# outlives that grant, so a third attempt mints nothing either.
def redeem(authorization_code, **attributes)
create! authorization_code_jti: authorization_code.jti, **attributes
transaction requires_new: true do
Oauth::RedeemedAuthorizationCode.create! jti: authorization_code.jti
create! authorization_code_jti: authorization_code.jti, **attributes
Comment thread
jeremy marked this conversation as resolved.
end
rescue ActiveRecord::RecordNotUnique
where(authorization_code_jti: authorization_code.jti).destroy_all
remember_redeemed authorization_code
nil
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
# to write. Write it now, so the code mints nothing once that grant is gone.
def remember_redeemed(authorization_code)
Oauth::RedeemedAuthorizationCode.create_or_find_by!(jti: authorization_code.jti)
end

# MySQL compares `token` case-insensitively, so a match is confirmed
# byte for byte: a differently cased token neither authenticates nor
# installs a digest that the real token would then never match.
Expand Down
17 changes: 17 additions & 0 deletions app/models/oauth/redeemed_authorization_code.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# An authorization code that has been redeemed, by its jti. Codes are
# stateless, and the grant a code minted carries its jti too, but that grant
# can be destroyed: by a replay of the code, by revocation, by disconnecting
# the app. This record outlives it, so the code still can't be redeemed again
# for as long as it could otherwise be presented (RFC 6749 §4.1.2; rubric
# GP-4, GP-5).
class Oauth::RedeemedAuthorizationCode < ApplicationRecord
# Well past the code's 60-second lifetime, as the rubric asks (24 hours past
# expiry), so a skewed clock or a slow replay is still recognized.
RETENTION = 25.hours

scope :stale, -> { where(created_at: ...RETENTION.ago) }

def self.cleanup
stale.in_batches.delete_all
end
end
3 changes: 3 additions & 0 deletions config/recurring.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ production: &production
cleanup_lapsed_oauth_grants:
command: "Identity::AccessToken.cleanup"
schedule: every day at 04:32
cleanup_oauth_redeemed_authorization_codes:
command: "Oauth::RedeemedAuthorizationCode.cleanup"
schedule: every day at 04:42
cleanup_exports:
command: "Export.cleanup"
schedule: every hour at minute 20
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# A record of every authorization code redeemed, kept past the code's own
# lifetime, so a code can't be redeemed again once the grant it minted is gone
# (revoked by a replay, by the person, or by its app).
class CreateOauthRedeemedAuthorizationCodes < ActiveRecord::Migration[8.2]
def change
create_table :oauth_redeemed_authorization_codes, id: :uuid do |t|
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
t.string :jti, null: false, limit: 36
t.datetime :created_at, null: false

t.index :jti, unique: true
t.index :created_at
end
end
end
9 changes: 8 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion db/schema_sqlite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.2].define(version: 2026_10_15_120200) do
ActiveRecord::Schema[8.2].define(version: 2026_10_17_140000) do
create_table "accesses", id: :uuid, force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
Expand Down Expand Up @@ -454,6 +454,13 @@
t.index ["client_id"], name: "index_oauth_clients_on_client_id", unique: true
end

create_table "oauth_redeemed_authorization_codes", id: :uuid, force: :cascade do |t|
t.string "jti", limit: 36, null: false
t.datetime "created_at", null: false
t.index ["created_at"], name: "index_oauth_redeemed_authorization_codes_on_created_at"
t.index ["jti"], name: "index_oauth_redeemed_authorization_codes_on_jti", unique: true
end

create_table "oauth_retired_refresh_tokens", id: :uuid, force: :cascade do |t|
t.uuid "access_token_id", null: false
t.string "refresh_token_digest", limit: 80, null: false
Expand Down
2 changes: 1 addition & 1 deletion docs/oauth/spec_conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Status key:

| Requirement | Status |
|---|---|
| Authorization codes are single-use; reuse is refused and the grant minted from that code is revoked (§4.1.2, §10.5; OAuth 2.1 §4.1.3) | **Fixed** #2296 `bd447dd7a` "Redeem each authorization code once…". A `jti` in the code plus a unique `identity_access_tokens.authorization_code_jti`. Reuse is checked only after PKCE, `redirect_uri` and `client_id` pass, so a leaked code without its verifier can't revoke the grant. Limit: if the grant is revoked within the code's 60s life, the row is gone and one more redemption is possible. No tombstones were added. |
| Authorization codes are single-use; reuse is refused and the grant minted from that code is revoked (§4.1.2, §10.5; OAuth 2.1 §4.1.3) | **Fixed** #2296 `bd447dd7a` "Redeem each authorization code once…". A `jti` in the code plus a unique `identity_access_tokens.authorization_code_jti`. Reuse is checked only after PKCE, `redirect_uri` and `client_id` pass, so a leaked code without its verifier can't revoke the grant. Since #3206, each redemption also records the code's `jti` in `oauth_redeemed_authorization_codes`, in the same transaction as the grant, kept 25 hours (rubric GP-4, GP-5: at least 24 hours past expiry). So once a replay, the person or the app has revoked the grant, the code still mints nothing. |
| `client_id` required and matched to the code for public clients (§4.1.3) | **Fixed** #2296 `bd447dd7a`; one check shared by both grants in #3081 `f9a1885ff` |
| Missing or malformed parameters answer `invalid_request`, not `invalid_grant` or `unsupported_grant_type` (§5.2) | **Fixed** #2296 `bd447dd7a`, `ac884df5f` "Reject non-string token request parameters as malformed" (an array `refresh_token` had been accepted as an `IN` list; a hash had raised a 500); #3081 `f9a1885ff`, `2b2b7f2d6` |
| Exact `redirect_uri` equality with the code (§4.1.3) | **Conformant** |
Expand Down
53 changes: 53 additions & 0 deletions test/integration/oauth_flow_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,59 @@ class OauthFlowTest < ActionDispatch::IntegrationTest
assert_response :success
end

test "a code replayed again after its grant was revoked mints nothing" do
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
client = oauth_clients(:mcp_client)
exchange = { grant_type: "authorization_code", client_id: client.client_id, code: authorization_code_for(client, code_verifier: code_verifier),
redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }

untenanted { post oauth_token_path, params: exchange, as: :json }
assert_response :success

untenanted { post oauth_token_path, params: exchange, as: :json }
assert_equal "invalid_grant", response.parsed_body["error"]

assert_no_difference "Identity::AccessToken.count" do
untenanted { post oauth_token_path, params: exchange, as: :json }
end
assert_response :bad_request
assert_equal "invalid_grant", response.parsed_body["error"]
end

test "a code redeemed before redemptions were recorded is remembered when it's replayed" do
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
client = oauth_clients(:mcp_client)
code = authorization_code_for(client, code_verifier: code_verifier)
exchange = { grant_type: "authorization_code", client_id: client.client_id, code: code,
redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }
identities(:david).access_tokens.create!(oauth_client: client, authorization_code_jti: Oauth::AuthorizationCode.parse(code).jti)

untenanted { post oauth_token_path, params: exchange, as: :json }
assert_equal "invalid_grant", response.parsed_body["error"]

assert_no_difference "Identity::AccessToken.count" do
untenanted { post oauth_token_path, params: exchange, as: :json }
end
assert_equal "invalid_grant", response.parsed_body["error"]
end

test "a code can't be redeemed again once the person disconnects the app" do
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
client = oauth_clients(:mcp_client)
exchange = { grant_type: "authorization_code", client_id: client.client_id, code: authorization_code_for(client, code_verifier: code_verifier),
redirect_uri: "http://127.0.0.1:8888/callback", code_verifier: code_verifier }

untenanted { post oauth_token_path, params: exchange, as: :json }
assert_response :success
identities(:david).access_tokens.oauth.where(oauth_client: client).destroy_all

assert_no_difference "Identity::AccessToken.count" do
untenanted { post oauth_token_path, params: exchange, as: :json }
end
assert_response :bad_request
assert_equal "invalid_grant", response.parsed_body["error"]
end

test "a replayed code that fails PKCE does not revoke the grant" do
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
client = oauth_clients(:mcp_client)
Expand Down
21 changes: 21 additions & 0 deletions test/models/oauth/redeemed_authorization_code_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
require "test_helper"

class Oauth::RedeemedAuthorizationCodeTest < ActiveSupport::TestCase
test "a jti is recorded once" do
Oauth::RedeemedAuthorizationCode.create!(jti: "a5f1c0de-0000-4000-8000-000000000001")

assert_raises(ActiveRecord::RecordNotUnique) do
Oauth::RedeemedAuthorizationCode.create!(jti: "a5f1c0de-0000-4000-8000-000000000001")
end
end

test "cleanup keeps records well past the code's lifetime, then removes them" do
kept = travel_to(24.hours.ago) { Oauth::RedeemedAuthorizationCode.create!(jti: "a5f1c0de-0000-4000-8000-000000000002") }
stale = travel_to(26.hours.ago) { Oauth::RedeemedAuthorizationCode.create!(jti: "a5f1c0de-0000-4000-8000-000000000003") }

Oauth::RedeemedAuthorizationCode.cleanup

assert Oauth::RedeemedAuthorizationCode.exists?(kept.id)
assert_not Oauth::RedeemedAuthorizationCode.exists?(stale.id)
end
end
Loading