Skip to content
Open
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
2 changes: 1 addition & 1 deletion app/controllers/oauth/clients_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def dynamic_client_registration_response(client)
client_name: client.name,
redirect_uris: client.redirect_uris,
token_endpoint_auth_method: "none",
grant_types: %w[ authorization_code ],
grant_types: %w[ authorization_code refresh_token ],
Comment thread
jeremy marked this conversation as resolved.
response_types: %w[ code ],
scope: client.scopes.join(" ")
}
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/oauth/metadata_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def show
registration_endpoint: oauth_clients_url,
revocation_endpoint: oauth_revocation_url,
response_types_supported: %w[ code ],
grant_types_supported: %w[ authorization_code ],
grant_types_supported: %w[ authorization_code refresh_token ],
Comment thread
jeremy marked this conversation as resolved.
token_endpoint_auth_methods_supported: %w[ none ],
code_challenge_methods_supported: %w[ S256 ],
scopes_supported: %w[ read write ]
Expand Down
4 changes: 3 additions & 1 deletion app/controllers/oauth/revocations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ def create

private
def set_access_token
@access_token = Identity::AccessToken.find_by(token: params.require(:token))
token = params.require(:token)
@access_token = Identity::AccessToken.find_by(token: token) ||
Identity::AccessToken.find_by(refresh_token: token)
end
end
97 changes: 81 additions & 16 deletions app/controllers/oauth/tokens_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,45 @@ class Oauth::TokensController < Oauth::BaseController
rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded

before_action :validate_grant_type
before_action :set_auth_code
before_action :set_client
before_action :validate_pkce
before_action :validate_redirect_uri
before_action :set_identity

with_options if: :authorization_code_grant? do
before_action :set_auth_code
before_action :set_client
before_action :validate_pkce
before_action :validate_redirect_uri
before_action :set_identity
end

with_options unless: :authorization_code_grant? do
before_action :set_refreshable_access_token
before_action :validate_refresh_client
before_action :set_refresh_scope
end

def create
granted = @auth_code.scope.to_s.split
permission = granted.include?("write") ? "write" : "read"
access_token = @identity.access_tokens.create! oauth_client: @client, permission: permission

render json: {
access_token: access_token.token,
token_type: "Bearer",
scope: granted.join(" ")
}
if authorization_code_grant?
granted = @auth_code.scope.to_s.split
permission = granted.include?("write") ? "write" : "read"
access_token = @identity.access_tokens.create! oauth_client: @client, permission: permission

render json: token_response(access_token, scope: granted.join(" "))
else
if @access_token.refresh(permission: @refresh_permission)
render json: token_response(@access_token, scope: scope_for(@access_token.permission))
else
oauth_error "invalid_grant", "Invalid refresh token"
end
end
end

private
def authorization_code_grant?
params[:grant_type] == "authorization_code"
end

def validate_grant_type
unless params[:grant_type] == "authorization_code"
oauth_error "unsupported_grant_type", "Only authorization_code grant is supported"
unless params[:grant_type].in?(%w[ authorization_code refresh_token ])
oauth_error "unsupported_grant_type", "Only authorization_code and refresh_token grants are supported"
end
end

Expand Down Expand Up @@ -59,4 +76,52 @@ def set_identity
oauth_error "invalid_grant", "Identity not found"
end
end

def set_refreshable_access_token
unless params[:refresh_token].present? &&
@access_token = Identity::AccessToken.oauth.find_by(refresh_token: params[:refresh_token])
oauth_error "invalid_grant", "Invalid refresh token"
end
end

def validate_refresh_client
unless @access_token.oauth_client.client_id == params[:client_id]
oauth_error "invalid_grant", "Refresh token was not issued to this client"
end
end

# A refresh request may narrow scope but never widen it (RFC 6749 §6). An
# omitted scope keeps the original grant; a requested subset narrows the
# rotated token; anything beyond the grant is invalid_scope. Only an absent
# parameter means "keep the grant" — a blank one names no scope, and the
# empty scope list below rejects it, since no scope-token is a malformed
# scope (RFC 6749 §3.3), not a request for the full grant.
def set_refresh_scope
granted = granted_scopes(@access_token.permission)
requested = params[:scope].nil? ? granted : params[:scope].to_s.split

if requested.present? && requested.all? { |scope| granted.include?(scope) }
@refresh_permission = requested.include?("write") ? "write" : "read"
else
oauth_error "invalid_scope", "Requested scope exceeds the original grant"
end
end

def granted_scopes(permission)
permission == "write" ? %w[ read write ] : %w[ read ]
end

def scope_for(permission)
granted_scopes(permission).join(" ")
end

def token_response(access_token, scope: nil)
{
access_token: access_token.token,
token_type: "Bearer",
expires_in: access_token.expires_in,
refresh_token: access_token.refresh_token,
scope: scope
}.compact
end
end
2 changes: 1 addition & 1 deletion app/models/identity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class Identity < ApplicationRecord
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }

def self.find_by_permissable_access_token(token, method:)
if (access_token = AccessToken.find_by(token: token)) && access_token.allows?(method)
if (access_token = AccessToken.active.find_by(token: token)) && access_token.allows?(method)
access_token.identity
end
end
Expand Down
32 changes: 32 additions & 0 deletions app/models/identity/access_token.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,46 @@
class Identity::AccessToken < ApplicationRecord
EXPIRES_IN = 1.hour

belongs_to :identity
belongs_to :oauth_client, class_name: "Oauth::Client", optional: true

scope :personal, -> { where oauth_client_id: nil }
scope :oauth, -> { where.not oauth_client_id: nil }
scope :active, -> { where(expires_at: nil).or(where(expires_at: Time.current..)) }

has_secure_token
enum :permission, %w[ read write ].index_by(&:itself), default: :read

before_create :set_expiry_and_refresh_token, if: :oauth_client_id?

def allows?(method)
method.in?(%w[ GET HEAD ]) || write?
end

def expired?
expires_at? && expires_at.past?
end

def expires_in
(expires_at - Time.current).to_i if expires_at?
end

# Rotates atomically on the presented refresh token, so a concurrent
# rotation wins the row and the loser comes up empty-handed.
def refresh(permission: self.permission)
rotated = { token: self.class.generate_unique_secure_token,
refresh_token: self.class.generate_unique_secure_token,
expires_at: EXPIRES_IN.from_now, permission: permission, updated_at: Time.current }

if self.class.where(id: id, refresh_token: refresh_token).update_all(rotated) == 1
assign_attributes rotated
true
end
end

private
def set_expiry_and_refresh_token
self.expires_at ||= EXPIRES_IN.from_now
self.refresh_token ||= self.class.generate_unique_secure_token
end
end
10 changes: 10 additions & 0 deletions db/migrate/20260827100000_add_oauth_token_expiry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class AddOauthTokenExpiry < ActiveRecord::Migration[8.2]
def change
change_table :identity_access_tokens, bulk: true do |t|
t.string :refresh_token
t.datetime :expires_at
Comment thread
jeremy marked this conversation as resolved.

t.index :refresh_token, unique: true
end
end
end
3 changes: 3 additions & 0 deletions db/schema.rb

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

3 changes: 3 additions & 0 deletions db/schema_sqlite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,16 @@
create_table "identity_access_tokens", id: :uuid, force: :cascade do |t|
t.datetime "created_at", null: false
t.text "description", limit: 65535
t.datetime "expires_at"
t.uuid "identity_id", null: false
t.uuid "oauth_client_id"
t.string "permission", limit: 255
t.string "refresh_token", limit: 255
t.string "token", limit: 255
t.datetime "updated_at", null: false
t.index ["identity_id"], name: "index_access_token_on_identity_id"
t.index ["oauth_client_id"], name: "index_identity_access_tokens_on_oauth_client_id"
t.index ["refresh_token"], name: "index_identity_access_tokens_on_refresh_token", unique: true
end

create_table "identity_transfers", id: :uuid, force: :cascade do |t|
Expand Down
Loading