diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 4cdbbdb69..b01bc8c0d 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -46,7 +46,7 @@ module Authentication end def resume_session - if session = find_session_by_cookie + if session = find_session_by_cookie || find_or_start_session_by_bearer_token set_current_session session end end @@ -55,12 +55,30 @@ module Authentication Session.find_signed(cookies.signed[:session_token]) end - def request_authentication - if Current.account.present? - session[:return_to_after_authenticating] = request.url + def find_or_start_session_by_bearer_token + if request_authorized_by_bearer_token? + Identity::AccessToken.find_by(token: authorization_bearer_token)&.session end + end - redirect_to_login_url + def request_authorized_by_bearer_token? + request.authorization.to_s.starts_with? "Bearer" + end + + def authorization_bearer_token + request.authorization.to_s.split(" ", 2).second + end + + def request_authentication + if request_authorized_by_bearer_token? + head :unauthorized + else + if Current.account.present? + session[:return_to_after_authenticating] = request.url + end + + redirect_to_login_url + end end def after_authentication_url diff --git a/app/models/identity.rb b/app/models/identity.rb index bb69734b4..f6dd7d433 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,6 +1,7 @@ class Identity < ApplicationRecord include Joinable, Transferable + has_many :access_tokens, dependent: :destroy has_many :magic_links, dependent: :destroy has_many :sessions, dependent: :destroy has_many :users, dependent: :nullify diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb new file mode 100644 index 000000000..c5e3d6705 --- /dev/null +++ b/app/models/identity/access_token.rb @@ -0,0 +1,17 @@ +class Identity::AccessToken < ApplicationRecord + belongs_to :identity + + has_secure_token + enum :permission, %w[ read write ].index_by(&:itself), default: :read + + def session + identity.sessions.find_or_create_by! user_agent: session_user_agent + end + + private + # Overload the user_agent identification for access token session reuse. + # This allows us to easily reuse a single session record per access token. + def session_user_agent + "access-token-#{id}" + end +end diff --git a/db/migrate/20251201132341_create_identity_access_tokens.rb b/db/migrate/20251201132341_create_identity_access_tokens.rb new file mode 100644 index 000000000..0e455104d --- /dev/null +++ b/db/migrate/20251201132341_create_identity_access_tokens.rb @@ -0,0 +1,14 @@ +class CreateIdentityAccessTokens < ActiveRecord::Migration[8.2] + def change + create_table :identity_access_tokens, id: :uuid do |t| + t.uuid :identity_id, null: false + t.string :token + t.string :permission + t.text :description + + t.timestamps + + t.index ["identity_id"], name: "index_access_token_on_identity_id" + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 42458db17..85a8f8064 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -321,6 +321,16 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do t.index ["email_address"], name: "index_identities_on_email_address", unique: true end + create_table "identity_access_tokens", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description" + t.uuid "identity_id", null: false + t.string "permission" + t.string "token" + t.datetime "updated_at", null: false + t.index ["identity_id"], name: "index_access_token_on_identity_id" + end + create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "code", null: false t.datetime "created_at", null: false diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 1dd2b3e00..e2f65dccc 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -321,6 +321,16 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do t.index ["email_address"], name: "index_identities_on_email_address", unique: true end + create_table "identity_access_tokens", id: :uuid, force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description", limit: 65535 + t.uuid "identity_id", null: false + t.string "permission", limit: 255 + t.string "token", limit: 255 + t.datetime "updated_at", null: false + t.index ["identity_id"], name: "index_access_token_on_identity_id" + end + create_table "magic_links", id: :uuid, force: :cascade do |t| t.string "code", limit: 255, null: false t.datetime "created_at", null: false diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb new file mode 100644 index 000000000..51d0367f2 --- /dev/null +++ b/test/controllers/api_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +class ApiTest < ActionDispatch::IntegrationTest + test "authenticate with valid access token" do + get boards_path(format: :json), env: bearer_token_env(identity_access_tokens(:jasons_api_token).token) + assert_response :success + end + + test "fail to authenticate with invalid access token" do + get boards_path(format: :json), env: bearer_token_env("nonsense") + assert_response :unauthorized + end + + private + def bearer_token_env(token) + { "HTTP_AUTHORIZATION" => "Bearer #{token}" } + end +end diff --git a/test/fixtures/identity/access_tokens.yml b/test/fixtures/identity/access_tokens.yml new file mode 100644 index 000000000..17089b6c9 --- /dev/null +++ b/test/fixtures/identity/access_tokens.yml @@ -0,0 +1,5 @@ +jasons_api_token: + identity: jason + token: 018cf1425682700098f24f0799e3fe20 + description: My Superscript + permission: read diff --git a/test/models/identity/access_token_test.rb b/test/models/identity/access_token_test.rb new file mode 100644 index 000000000..49f1c57ce --- /dev/null +++ b/test/models/identity/access_token_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class Identity::AccessTokenTest < ActiveSupport::TestCase + test "only one session at the time" do + assert_changes -> { Session.count }, +1 do + identity_access_tokens(:jasons_api_token).session + end + + assert_no_changes -> { Session.count } do + identity_access_tokens(:jasons_api_token).session + end + end +end