diff --git a/app/controllers/books/manifests_controller.rb b/app/controllers/books/manifests_controller.rb new file mode 100644 index 00000000..fb12ee50 --- /dev/null +++ b/app/controllers/books/manifests_controller.rb @@ -0,0 +1,10 @@ +# The sync manifest: everything a CLI needs to decide what changed remotely — +# book metadata plus every active leaf with its position and fingerprint — +# without downloading any content. +class Books::ManifestsController < ApplicationController + include BookScoped + + def show + @leaves = @book.leaves.active.with_leafables.positioned + end +end diff --git a/app/controllers/books_controller.rb b/app/controllers/books_controller.rb index eda26210..1e09d0c7 100644 --- a/app/controllers/books_controller.rb +++ b/app/controllers/books_controller.rb @@ -34,11 +34,16 @@ def edit end def update - @book.update(book_params) - update_accesses(@book) + @book.update(book_params) if params[:book].present? remove_cover if params[:remove_cover] == "true" - redirect_to book_slug_url(@book) + respond_to do |format| + format.html do + update_accesses(@book) + redirect_to book_slug_url(@book) + end + format.json { render json: { id: @book.id, fingerprint: @book.fingerprint } } + end end def destroy diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 96c7ee74..fc6ae2ca 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -1,12 +1,12 @@ module Authentication extend ActiveSupport::Concern - include SessionLookup + include SessionLookup, TokenLookup included do before_action :require_authentication helper_method :signed_in? - protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? } + protect_from_forgery with: :exception, unless: -> { authenticated_by.oauth_token? } end class_methods do @@ -31,14 +31,35 @@ def require_authentication end def restore_authentication - if session = find_session_by_cookie + if bearer_token.present? + resume_token_access + elsif session = find_session_by_cookie resume_session session end end + # A presented-but-invalid bearer token must answer 401 rather than fall + # through to cookie authentication or the sign-in page. def request_authentication - session[:return_to_after_authenticating] = request.url - redirect_to new_session_url + if bearer_token.present? + request_token_authentication + else + session[:return_to_after_authenticating] = request.url + redirect_to new_session_url + end + end + + def resume_token_access + if access_token = find_access_token_by_header + access_token.record_use + Current.user = access_token.user + set_authenticated_by(:oauth_token) + end + end + + def request_token_authentication + response.headers["WWW-Authenticate"] = 'Bearer error="invalid_token"' + head :unauthorized end def redirect_signed_in_user_to_root diff --git a/app/controllers/concerns/authentication/token_lookup.rb b/app/controllers/concerns/authentication/token_lookup.rb new file mode 100644 index 00000000..6a832912 --- /dev/null +++ b/app/controllers/concerns/authentication/token_lookup.rb @@ -0,0 +1,9 @@ +module Authentication::TokenLookup + def find_access_token_by_header + Oauth::AccessToken.find_active_by_raw_token bearer_token + end + + def bearer_token + request.authorization.to_s[/\ABearer (.+)\z/i, 1] + end +end diff --git a/app/controllers/leafables_controller.rb b/app/controllers/leafables_controller.rb index c1aa3915..3997e337 100644 --- a/app/controllers/leafables_controller.rb +++ b/app/controllers/leafables_controller.rb @@ -13,12 +13,19 @@ def new def create @leaf = @book.press new_leafable, leaf_params position_new_leaf @leaf + + respond_to do |format| + format.turbo_stream { render } + format.html { head :no_content } + format.json { render :show, status: :created } + end end def show respond_to do |format| format.html format.md + format.json end end @@ -26,11 +33,16 @@ def edit end def update - @leaf.edit leafable_params: leafable_params, leaf_params: leaf_params - - respond_to do |format| - format.turbo_stream { render } - format.html { head :no_content } + if stale_write? + render json: { error: "stale_write", fingerprint: @leaf.fingerprint }, status: :conflict + else + @leaf.edit leafable_params: leafable_params, leaf_params: leaf_params + + respond_to do |format| + format.turbo_stream { render } + format.html { head :no_content } + format.json { render :show } + end end end @@ -40,10 +52,18 @@ def destroy respond_to do |format| format.turbo_stream { render } format.html { redirect_to book_slug_url(@book) } + format.json { head :no_content } end end private + # A sync client sends the fingerprint its copy is based on; refusing a + # mismatch keeps it from clobbering an edit it hasn't seen. Requests + # without one (the web editor) keep last-write-wins. + def stale_write? + params[:base_fingerprint].present? && params[:base_fingerprint] != @leaf.fingerprint + end + def leaf_params default_leaf_params.merge params.fetch(:leaf, {}).permit(:title) end diff --git a/app/controllers/oauth/base_controller.rb b/app/controllers/oauth/base_controller.rb new file mode 100644 index 00000000..557b80ed --- /dev/null +++ b/app/controllers/oauth/base_controller.rb @@ -0,0 +1,33 @@ +# Device-facing OAuth endpoints. These speak the OAuth protocol to programs, +# not browsers, so they sit outside ApplicationController: no cookie +# authentication, no CSRF (clients authenticate per request), no browser +# guard, and errors render as OAuth JSON error responses. +class Oauth::BaseController < ActionController::Base + skip_forgery_protection + + rate_limit to: 30, within: 1.minute, with: -> { render_rate_limit_exceeded } + + before_action :prevent_response_caching + + rescue_from Oauth::Error, with: :render_oauth_error + + private + def require_recognized_client + unless params[:client_id] == Oauth::CLIENT_ID + raise Oauth::Error.invalid_client + end + end + + def prevent_response_caching + response.headers["Cache-Control"] = "no-store" + end + + def render_oauth_error(error) + render json: error, status: error.http_status + end + + def render_rate_limit_exceeded + response.headers["Retry-After"] = "60" + render json: Oauth::Error.slow_down("Too many requests"), status: :too_many_requests + end +end diff --git a/app/controllers/oauth/device_authorizations_controller.rb b/app/controllers/oauth/device_authorizations_controller.rb new file mode 100644 index 00000000..aeada0ab --- /dev/null +++ b/app/controllers/oauth/device_authorizations_controller.rb @@ -0,0 +1,19 @@ +# RFC 8628 §3.1-3.2: a CLI requests a device code and user code pair here, +# displays the user code, and polls the token endpoint while the user +# approves at the verification URL. +class Oauth::DeviceAuthorizationsController < Oauth::BaseController + before_action :require_recognized_client + + def create + grant = Oauth::DeviceGrant.generate! + + render json: { + device_code: grant.raw_device_code, + user_code: grant.formatted_user_code, + verification_uri: oauth_device_verification_url, + verification_uri_complete: oauth_device_verification_url(user_code: grant.formatted_user_code), + expires_in: Oauth::DeviceGrant::EXPIRES_IN.to_i, + interval: Oauth::DeviceGrant::POLLING_INTERVAL.to_i + } + end +end diff --git a/app/controllers/oauth/device_verifications_controller.rb b/app/controllers/oauth/device_verifications_controller.rb new file mode 100644 index 00000000..fb30856d --- /dev/null +++ b/app/controllers/oauth/device_verifications_controller.rb @@ -0,0 +1,71 @@ +# RFC 8628 §3.3: the browser side of the device flow, where a signed-in user +# enters the code their CLI displayed and approves or denies it. Approval is +# update, denial is destroy — either way the CLI's next poll learns the answer. +class Oauth::DeviceVerificationsController < ApplicationController + # The coarse limit on the device-facing endpoints doesn't cover this + # controller, which would otherwise be an unthrottled brute-force surface + # for guessing user codes. + rate_limit to: 30, within: 1.minute, with: -> { render_rate_limit_exceeded } + + before_action :set_grant, except: :show + + def show + if params[:user_code].present? + if @grant = Oauth::DeviceGrant.find_pending_by_user_code(params[:user_code]) + render :confirm + else + reject_code + end + end + end + + def create + if @grant + render :confirm + else + reject_code + end + end + + def update + if @grant.nil? + reject_code + # RFC 8628 §5.4: a device code doesn't authenticate the program that + # requested it — anyone can start a device flow and lure a signed-in user + # to its verification link. Approval requires the user's explicit + # affirmation that they initiated this sign-in. Denial stays + # affirmation-free: declining is always safe. + elsif params[:initiation_confirmed].blank? + @error = "To connect, confirm that you requested this code." + render :confirm + elsif @grant.approve!(Current.user) + render :approved + else + reject_code + end + end + + def destroy + if @grant&.deny! + render :denied + else + reject_code + end + end + + private + def set_grant + @grant = Oauth::DeviceGrant.find_pending_by_user_code(params[:user_code]) + end + + def reject_code + @error = "That code is invalid or has expired. Check your terminal and try again." + render :show, status: :unprocessable_entity + end + + def render_rate_limit_exceeded + response.headers["Retry-After"] = "60" + @error = "Too many attempts. Please wait a minute and try again." + render :show, status: :too_many_requests + end +end diff --git a/app/controllers/oauth/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb new file mode 100644 index 00000000..3fc9962d --- /dev/null +++ b/app/controllers/oauth/revocations_controller.rb @@ -0,0 +1,16 @@ +# RFC 7009: revoke a token the client no longer needs. Revoking a refresh +# token revokes its whole family. Always answers 200 — whether the token +# existed is not the caller's business. +class Oauth::RevocationsController < Oauth::BaseController + before_action :require_recognized_client + + def create + if refresh_token = Oauth::RefreshToken.find_by_raw_token(params[:token]) + refresh_token.revoke_family + elsif access_token = Oauth::AccessToken.find_by_raw_token(params[:token]) + access_token.revoke + end + + head :ok + end +end diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb new file mode 100644 index 00000000..c760ba07 --- /dev/null +++ b/app/controllers/oauth/tokens_controller.rb @@ -0,0 +1,44 @@ +class Oauth::TokensController < Oauth::BaseController + before_action :require_recognized_client + + def create + case params[:grant_type] + when Oauth::DEVICE_CODE_GRANT_TYPE + redeem_device_code + when "refresh_token" + rotate_refresh_token + else + raise Oauth::Error.unsupported_grant_type + end + end + + private + def redeem_device_code + grant = Oauth::DeviceGrant.find_by_raw_device_code(params[:device_code]) + + if grant.nil? + raise Oauth::Error.invalid_grant("Invalid device code") + end + + grant.judge_poll! + access_token, refresh_token = grant.redeem! + + render json: token_response(access_token, refresh_token) + end + + def rotate_refresh_token + refresh_token = Oauth::RefreshToken.find_presented!(params[:refresh_token]) + successor = refresh_token.rotate! + + render json: token_response(successor.issue_access_token!, successor) + end + + def token_response(access_token, refresh_token) + { + access_token: access_token.raw_token, + token_type: "Bearer", + expires_in: Oauth::AccessToken::EXPIRES_IN.to_i, + refresh_token: refresh_token.raw_token + } + end +end diff --git a/app/models/book.rb b/app/models/book.rb index 371b7f11..a2768704 100644 --- a/app/models/book.rb +++ b/app/models/book.rb @@ -16,4 +16,8 @@ def press(leafable, leaf_params) def markable leaves.active.positioned.map { it.leafable.markable }.join("\n\n") end + + def fingerprint + Digest::SHA256.hexdigest [ title, subtitle, author, theme, cover.attached? ? cover.blob.checksum : nil ].join("\0") + end end diff --git a/app/models/leaf.rb b/app/models/leaf.rb index fb7e68be..0a84a101 100644 --- a/app/models/leaf.rb +++ b/app/models/leaf.rb @@ -14,4 +14,11 @@ class Leaf < ApplicationRecord def slug title.parameterize.presence || "-" end + + # A content-derived identity for sync clients: comparing fingerprints tells + # them whether a leaf changed, and sending one back lets the server refuse + # a write based on a stale copy. + def fingerprint + Digest::SHA256.hexdigest "#{title}\0#{leafable.fingerprintable_content}" + end end diff --git a/app/models/oauth.rb b/app/models/oauth.rb new file mode 100644 index 00000000..338ad5a7 --- /dev/null +++ b/app/models/oauth.rb @@ -0,0 +1,9 @@ +module Oauth + CLIENT_ID = "writebook-cli" + DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" + GRANT_TYPES = [ DEVICE_CODE_GRANT_TYPE, "refresh_token" ].freeze + + def self.table_name_prefix + "oauth_" + end +end diff --git a/app/models/oauth/access_token.rb b/app/models/oauth/access_token.rb new file mode 100644 index 00000000..72190c40 --- /dev/null +++ b/app/models/oauth/access_token.rb @@ -0,0 +1,57 @@ +class Oauth::AccessToken < ApplicationRecord + EXPIRES_IN = 1.hour + ACTIVITY_REFRESH_RATE = 1.hour + + belongs_to :user + belongs_to :refresh_token, class_name: "Oauth::RefreshToken", optional: true + + validates :token_digest, presence: true, uniqueness: true + + scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) } + + attr_accessor :raw_token + + class << self + def generate!(user:, refresh_token: nil) + raw_token = Oauth::TokenGenerator.access_token + + create!(user: user, refresh_token: refresh_token, + token_digest: Oauth::TokenDigester.digest(raw_token), expires_at: EXPIRES_IN.from_now) + .tap { it.raw_token = raw_token } + end + + def find_by_raw_token(raw_token) + if raw_token.is_a?(String) && raw_token.present? + find_by token_digest: Oauth::TokenDigester.digest(raw_token) + end + end + + def find_active_by_raw_token(raw_token) + if token = find_by_raw_token(raw_token) + token if token.active? + end + end + end + + def active? + !expired? && !revoked? + end + + def expired? + expires_at < Time.current + end + + def revoked? + revoked_at.present? + end + + def revoke + update! revoked_at: Time.current unless revoked? + end + + def record_use + if last_used_at.nil? || last_used_at.before?(ACTIVITY_REFRESH_RATE.ago) + update_column :last_used_at, Time.current + end + end +end diff --git a/app/models/oauth/device_grant.rb b/app/models/oauth/device_grant.rb new file mode 100644 index 00000000..93b7d15d --- /dev/null +++ b/app/models/oauth/device_grant.rb @@ -0,0 +1,85 @@ +# RFC 8628 Device Authorization Grant. A CLI requests a device code and user +# code pair, shows the user code, and polls the token endpoint while the user +# approves the code at the verification URL in any signed-in browser. +# +# Device codes are stored as digests. User codes are stored plaintext (they +# must be looked up by user input) and are protected by rate limiting, a short +# TTL, and an unambiguous 8-character alphabet. +class Oauth::DeviceGrant < ApplicationRecord + include Redemption + + EXPIRES_IN = 10.minutes + POLLING_INTERVAL = 5.seconds + + USER_CODE_ALPHABET = ("A".."Z").to_a + ("2".."9").to_a - %w[ O I L ] + USER_CODE_LENGTH = 8 + + belongs_to :user, optional: true + + validates :device_code_digest, presence: true, uniqueness: true + validates :user_code, presence: true, uniqueness: true + validates :status, inclusion: { in: %w[ pending approved denied ] } + validates :user, presence: true, if: :approved? + + attr_accessor :raw_device_code + + class << self + def generate! + raw_device_code = Oauth::TokenGenerator.device_code + + create!(device_code_digest: Oauth::TokenDigester.digest(raw_device_code), + user_code: generate_user_code, expires_at: EXPIRES_IN.from_now) + .tap { it.raw_device_code = raw_device_code } + end + + def find_by_raw_device_code(raw_device_code) + if raw_device_code.is_a?(String) && raw_device_code.present? + find_by device_code_digest: Oauth::TokenDigester.digest(raw_device_code) + end + end + + def find_pending_by_user_code(user_code) + if user_code.is_a?(String) && user_code.present? + where(status: "pending").where("expires_at > ?", Time.current) + .find_by(user_code: user_code.strip.delete("-").upcase) + end + end + + private + def generate_user_code + USER_CODE_LENGTH.times.map { USER_CODE_ALPHABET[SecureRandom.random_number(USER_CODE_ALPHABET.size)] }.join + end + end + + # Atomic UPDATE WHERE, so a raced approval or denial can't overwrite the + # transition that got there first. + def approve!(user) + self.class.where(id: id, status: "pending") + .update_all(status: "approved", user_id: user.id, updated_at: Time.current) == 1 + end + + def deny! + self.class.where(id: id, status: "pending") + .update_all(status: "denied", updated_at: Time.current) == 1 + end + + def expired? + expires_at < Time.current + end + + def pending? + status == "pending" + end + + def approved? + status == "approved" + end + + def denied? + status == "denied" + end + + def formatted_user_code + "#{user_code[0..3]}-#{user_code[4..7]}" + end +end diff --git a/app/models/oauth/device_grant/redemption.rb b/app/models/oauth/device_grant/redemption.rb new file mode 100644 index 00000000..32215696 --- /dev/null +++ b/app/models/oauth/device_grant/redemption.rb @@ -0,0 +1,53 @@ +# Token-endpoint redemption of a device grant: RFC 8628 poll judgment, +# atomic consumption, and token minting. +module Oauth::DeviceGrant::Redemption + # Judge a token-endpoint poll, raising in RFC 8628 §3.5 order. Returns + # quietly only when the grant is approved and ready to redeem. + def judge_poll! + if expired? + raise Oauth::Error.expired_token + end + + if polled_too_fast? + record_poll + raise Oauth::Error.slow_down + end + + record_poll + + if denied? + raise Oauth::Error.access_denied("The user denied the authorization request") + end + + if pending? + raise Oauth::Error.authorization_pending + end + end + + def redeem! + transaction do + unless consume! + raise Oauth::Error.invalid_grant("Device code has already been used") + end + + refresh_token = Oauth::RefreshToken.generate!(user: user) + [ refresh_token.issue_access_token!, refresh_token ] + end + end + + private + # UPDATE WHERE consumed_at IS NULL, so a device code can never be + # redeemed twice. + def consume! + self.class.where(id: id, consumed_at: nil, status: "approved") + .update_all(consumed_at: Time.current) == 1 + end + + def polled_too_fast? + last_polled_at.present? && last_polled_at > Oauth::DeviceGrant::POLLING_INTERVAL.ago + end + + def record_poll + update_column :last_polled_at, Time.current + end +end diff --git a/app/models/oauth/error.rb b/app/models/oauth/error.rb new file mode 100644 index 00000000..1f8679d6 --- /dev/null +++ b/app/models/oauth/error.rb @@ -0,0 +1,65 @@ +# OAuth error responses per RFC 6749 §5.2 and RFC 8628 §3.5, rendered as +# {"error": "invalid_grant", "error_description": "..."}. Token endpoint +# errors answer 400 except invalid_client, which answers 401. +class Oauth::Error < StandardError + attr_reader :code, :description + + HTTP_STATUS_MAP = { + invalid_request: :bad_request, + invalid_client: :unauthorized, + invalid_grant: :bad_request, + unsupported_grant_type: :bad_request, + access_denied: :bad_request, + authorization_pending: :bad_request, + slow_down: :bad_request, + expired_token: :bad_request + }.freeze + + class << self + def invalid_request(description = nil) + new :invalid_request, description || "The request is missing a required parameter or is otherwise malformed" + end + + def invalid_client(description = nil) + new :invalid_client, description || "Client authentication failed" + end + + def invalid_grant(description = nil) + new :invalid_grant, description || "The provided authorization grant is invalid, expired, or revoked" + end + + def unsupported_grant_type(description = nil) + new :unsupported_grant_type, description || "The grant type is not supported" + end + + def access_denied(description = nil) + new :access_denied, description || "The resource owner denied the request" + end + + def authorization_pending(description = nil) + new :authorization_pending, description || "The authorization request is still pending" + end + + def slow_down(description = nil) + new :slow_down, description || "Polling too frequently, please slow down" + end + + def expired_token(description = nil) + new :expired_token, description || "The device code has expired" + end + end + + def initialize(code, description = nil) + @code = code.to_sym + @description = description + super(description || code.to_s) + end + + def http_status + HTTP_STATUS_MAP.fetch(@code, :bad_request) + end + + def as_json(*) + { error: @code.to_s, error_description: @description }.compact + end +end diff --git a/app/models/oauth/refresh_token.rb b/app/models/oauth/refresh_token.rb new file mode 100644 index 00000000..06d9869d --- /dev/null +++ b/app/models/oauth/refresh_token.rb @@ -0,0 +1,81 @@ +# Long-lived tokens used to obtain new access tokens, rotated on every use. +# Tokens minted from one another share a family_id, so detected theft can +# revoke the whole lineage at once. +class Oauth::RefreshToken < ApplicationRecord + include Rotation + + EXPIRES_IN = 90.days + + belongs_to :user + belongs_to :replaced_by, class_name: "Oauth::RefreshToken", optional: true + has_many :access_tokens, class_name: "Oauth::AccessToken", dependent: :destroy + + validates :token_digest, presence: true, uniqueness: true + validates :family_id, presence: true + + attr_accessor :raw_token + + class << self + def generate!(user:, family_id: nil) + raw_token = Oauth::TokenGenerator.refresh_token + + create!(user: user, family_id: family_id || SecureRandom.uuid, + token_digest: Oauth::TokenDigester.digest(raw_token), expires_at: EXPIRES_IN.from_now) + .tap { it.raw_token = raw_token } + end + + def find_by_raw_token(raw_token) + if raw_token.is_a?(String) && raw_token.present? + find_by token_digest: Oauth::TokenDigester.digest(raw_token) + end + end + + # Look up a token presented to the token endpoint and verify it, in + # order: unknown token, expiry, revocation. A rotated token that has + # since expired still reaches rotate!'s reuse adjudication — presenting + # it is the same theft signal as presenting it fresh, and must revoke + # the family rather than answer as a mere expiry. + def find_presented!(raw_token) + refresh_token = find_by_raw_token(raw_token) + + if refresh_token.nil? + raise Oauth::Error.invalid_grant("Invalid refresh token") + end + + if refresh_token.expired? && !refresh_token.rotated? + raise Oauth::Error.invalid_grant("Token has expired") + end + + if refresh_token.revoked? + raise Oauth::Error.invalid_grant("Token has been revoked") + end + + refresh_token + end + end + + def issue_access_token! + Oauth::AccessToken.generate! user: user, refresh_token: self + end + + def expired? + expires_at < Time.current + end + + def revoked? + revoked_at.present? + end + + def rotated? + rotated_at.present? + end + + def revoke_family + self.class.where(family_id: family_id, revoked_at: nil).update_all(revoked_at: Time.current) + + Oauth::AccessToken.joins(:refresh_token) + .where(oauth_refresh_tokens: { family_id: family_id }) + .where(revoked_at: nil) + .update_all(revoked_at: Time.current) + end +end diff --git a/app/models/oauth/refresh_token/rotation.rb b/app/models/oauth/refresh_token/rotation.rb new file mode 100644 index 00000000..64f61ecf --- /dev/null +++ b/app/models/oauth/refresh_token/rotation.rb @@ -0,0 +1,100 @@ +# Refresh-token rotation (OAuth 2.1 §6.1): every refresh mints a successor +# and retires its predecessor atomically, with a bounded grace window for +# idempotent retries and family revocation as the theft response. +module Oauth::RefreshToken::Rotation + extend ActiveSupport::Concern + + REPLAY_GRACE = 1.minute + + # Four outcomes: + # - A fresh rotation mints and links a new successor. + # - A grace-window replay is an idempotent retry: return the successor the + # first rotation minted, so a client that lost the response doesn't get + # revoked as a thief. + # - Reuse outside the grace window is a theft signal: kill the family. + # - A lost rotation race reloads and serves the winner's successor via the + # same grace path. + def rotate! + if rotated? + if within_grace_window? + successor_for_retry! + else + revoke_family + raise Oauth::Error.invalid_grant("Token reuse detected, session terminated") + end + else + rotate_freshly! + end + end + + private + def rotate_freshly! + successor = self.class.generate!(user: user, family_id: family_id) + + if rotate_to?(successor) + successor + else + reload + + if within_grace_window? + successor_for_retry! + else + raise Oauth::Error.invalid_grant("Token rotation conflict") + end + end + end + + # UPDATE WHERE rotated_at IS NULL, so concurrent refreshes can't fork + # the family. The successor's raw token is stored encrypted for grace + # window retries; the race loser destroys its speculative successor. + def rotate_to?(successor) + rotated = self.class.where(id: id, rotated_at: nil, revoked_at: nil).update_all( + rotated_at: Time.current, + replaced_by_id: successor.id, + successor_raw_token: encryptor.encrypt_and_sign(successor.raw_token)) == 1 + + if rotated + reload + true + else + successor.destroy + false + end + end + + def within_grace_window? + rotated? && rotated_at > REPLAY_GRACE.ago + end + + def successor_for_retry! + successor = replaced_by + + # Once the successor has itself been rotated, the client demonstrably + # received it and moved on — serving it again would talk the client + # backwards onto a spent credential. + if successor.nil? || successor.rotated? + raise Oauth::Error.invalid_grant("Refresh token superseded") + end + + successor.raw_token = decrypted_successor_raw_token + + if successor.raw_token.nil? + raise Oauth::Error.invalid_grant("Grace window token not available") + end + + successor + end + + def decrypted_successor_raw_token + if successor_raw_token.present? + encryptor.decrypt_and_verify(successor_raw_token) + end + rescue ActiveSupport::MessageEncryptor::InvalidMessage + nil + end + + def encryptor + @encryptor ||= ActiveSupport::MessageEncryptor.new \ + Rails.application.key_generator.generate_key("oauth/refresh_token_successor", 32) + end +end diff --git a/app/models/oauth/token_digester.rb b/app/models/oauth/token_digester.rb new file mode 100644 index 00000000..febb0662 --- /dev/null +++ b/app/models/oauth/token_digester.rb @@ -0,0 +1,16 @@ +# Tokens are never stored in plain text. We store HMAC-SHA256(pepper, raw_token), +# which allows lookup by raw token while preventing token extraction from a +# database leak. The pepper derives from secret_key_base, so rotating that +# invalidates every outstanding token. +module Oauth::TokenDigester + extend self + + def digest(raw_token) + OpenSSL::HMAC.hexdigest("SHA256", pepper, raw_token) + end + + private + def pepper + @pepper ||= Rails.application.key_generator.generate_key("oauth/token_digester") + end +end diff --git a/app/models/oauth/token_generator.rb b/app/models/oauth/token_generator.rb new file mode 100644 index 00000000..8f8ef2bb --- /dev/null +++ b/app/models/oauth/token_generator.rb @@ -0,0 +1,30 @@ +# Tokens carry a prefix for quick identification in logs and error reports +# without exposing their contents. +module Oauth::TokenGenerator + extend self + + PREFIXES = { + access_token: "wb_at_", + refresh_token: "wb_rt_", + device_code: "wb_dc_" + }.freeze + + TOKEN_BYTES = 32 + + def access_token + generate :access_token + end + + def refresh_token + generate :refresh_token + end + + def device_code + generate :device_code + end + + private + def generate(type) + "#{PREFIXES.fetch(type)}#{SecureRandom.hex(TOKEN_BYTES)}" + end +end diff --git a/app/models/page.rb b/app/models/page.rb index 18af8b80..8cfbeff9 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -25,6 +25,10 @@ def markable body.content.to_s end + def fingerprintable_content + markable + end + private def plain_text html_body = rendered_html(markdown_source) diff --git a/app/models/picture.rb b/app/models/picture.rb index 6573a97b..fdd7658b 100644 --- a/app/models/picture.rb +++ b/app/models/picture.rb @@ -12,4 +12,8 @@ def large_image def markable caption end + + def fingerprintable_content + "#{caption}\0#{image.attached? ? image.blob.checksum : nil}" + end end diff --git a/app/models/section.rb b/app/models/section.rb index 29aa9686..aa55eda2 100644 --- a/app/models/section.rb +++ b/app/models/section.rb @@ -8,4 +8,8 @@ def searchable_content def markable body end + + def fingerprintable_content + "#{body}\0#{theme}" + end end diff --git a/app/views/books/manifests/show.json.jbuilder b/app/views/books/manifests/show.json.jbuilder new file mode 100644 index 00000000..436d0f9e --- /dev/null +++ b/app/views/books/manifests/show.json.jbuilder @@ -0,0 +1,35 @@ +json.book do + json.extract! @book, :id, :title, :subtitle, :author, :theme, :slug, :published + json.fingerprint @book.fingerprint + + if @book.cover.attached? + json.cover do + json.checksum @book.cover.blob.checksum + json.filename @book.cover.filename.to_s + json.url rails_blob_path(@book.cover) + end + else + json.cover nil + end +end + +json.leaves @leaves do |leaf| + json.extract! leaf, :id, :title + json.type leaf.leafable_type + json.fingerprint leaf.fingerprint + + case leaf.leafable + when Section + json.theme leaf.leafable.theme + when Picture + if leaf.leafable.image.attached? + json.image do + json.checksum leaf.leafable.image.blob.checksum + json.filename leaf.leafable.image.filename.to_s + json.url rails_blob_path(leaf.leafable.image) + end + else + json.image nil + end + end +end diff --git a/app/views/leafables/show.json.jbuilder b/app/views/leafables/show.json.jbuilder new file mode 100644 index 00000000..1ad30a61 --- /dev/null +++ b/app/views/leafables/show.json.jbuilder @@ -0,0 +1,27 @@ +json.extract! @leaf, :id, :title +json.type @leaf.leafable_type +json.fingerprint @leaf.fingerprint + +case @leaf.leafable +when Page + json.body @leaf.leafable.markable + json.record_gid @leaf.leafable.to_signed_global_id( + expires_in: ActionText::Markdown::UPLOADS_SIGNED_ID_EXPIRY, + for: ActionText::Markdown::UPLOADS_SIGNED_ID_PURPOSE + ).to_s +when Section + json.body @leaf.leafable.body + json.theme @leaf.leafable.theme +when Picture + json.caption @leaf.leafable.caption + + if @leaf.leafable.image.attached? + json.image do + json.checksum @leaf.leafable.image.blob.checksum + json.filename @leaf.leafable.image.filename.to_s + json.url rails_blob_path(@leaf.leafable.image) + end + else + json.image nil + end +end diff --git a/app/views/oauth/device_verifications/approved.html.erb b/app/views/oauth/device_verifications/approved.html.erb new file mode 100644 index 00000000..14391509 --- /dev/null +++ b/app/views/oauth/device_verifications/approved.html.erb @@ -0,0 +1,6 @@ +<% content_for(:title) { "Device connected" } %> + +
+

Device connected

+

You can close this window and return to your terminal.

+
diff --git a/app/views/oauth/device_verifications/confirm.html.erb b/app/views/oauth/device_verifications/confirm.html.erb new file mode 100644 index 00000000..41278963 --- /dev/null +++ b/app/views/oauth/device_verifications/confirm.html.erb @@ -0,0 +1,30 @@ +<% content_for(:title) { "Connect a device" } %> + +
"> +

Connect this device?

+ +

<%= @grant.formatted_user_code %>

+ +

+ A device showing this code will get full access to Writebook as + <%= Current.user.name %>. +

+ + <% if @error %> +

<%= @error %>

+ <% end %> + + <%= form_with url: oauth_device_verification_url, method: :patch, class: "flex flex-column gap" do |form| %> + <%= form.hidden_field :user_code, value: @grant.user_code %> + + + + + <% end %> + + <%= button_to "Deny", oauth_device_verification_url(user_code: @grant.user_code), + method: :delete, class: "btn center margin-block" %> +
diff --git a/app/views/oauth/device_verifications/denied.html.erb b/app/views/oauth/device_verifications/denied.html.erb new file mode 100644 index 00000000..2810ccc5 --- /dev/null +++ b/app/views/oauth/device_verifications/denied.html.erb @@ -0,0 +1,6 @@ +<% content_for(:title) { "Device denied" } %> + +
+

Device denied

+

The device was not connected. You can close this window.

+
diff --git a/app/views/oauth/device_verifications/show.html.erb b/app/views/oauth/device_verifications/show.html.erb new file mode 100644 index 00000000..6414b49a --- /dev/null +++ b/app/views/oauth/device_verifications/show.html.erb @@ -0,0 +1,20 @@ +<% content_for(:title) { "Connect a device" } %> + +
"> +

Connect a device

+ +

Enter the code shown in your terminal to connect it to Writebook.

+ + <% if @error %> +

<%= @error %>

+ <% end %> + + <%= form_with url: oauth_device_verification_url, class: "flex flex-column gap" do |form| %> + + + + <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index 6a759da6..44406173 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,13 @@ end end + scope :oauth, module: "oauth", as: :oauth do + resources :device_authorizations, only: :create + resources :tokens, only: :create + resources :revocations, only: :create + resource :device_verification, only: %i[ show create update destroy ] + end + get "join/:join_code", to: "users#new", as: :join post "join/:join_code", to: "users#create" @@ -20,6 +27,7 @@ end resources :books, except: %i[ index show ] do + resource :manifest, controller: "books/manifests", only: :show resource :publication, controller: "books/publications", only: %i[ show edit update ] resource :bookmark, controller: "books/bookmarks", only: :show diff --git a/db/migrate/20260826000001_create_oauth_tables.rb b/db/migrate/20260826000001_create_oauth_tables.rb new file mode 100644 index 00000000..77cdb1f0 --- /dev/null +++ b/db/migrate/20260826000001_create_oauth_tables.rb @@ -0,0 +1,36 @@ +class CreateOauthTables < ActiveRecord::Migration[8.0] + def change + create_table :oauth_device_grants do |t| + t.string :device_code_digest, null: false, index: { unique: true } + t.string :user_code, null: false, index: { unique: true } + t.string :status, null: false, default: "pending" + t.integer :user_id + t.datetime :consumed_at + t.datetime :last_polled_at + t.datetime :expires_at, null: false + t.timestamps + end + + create_table :oauth_refresh_tokens do |t| + t.string :token_digest, null: false, index: { unique: true } + t.integer :user_id, null: false, index: true + t.string :family_id, null: false, index: true + t.integer :replaced_by_id + t.datetime :rotated_at + t.text :successor_raw_token + t.datetime :revoked_at + t.datetime :expires_at, null: false + t.timestamps + end + + create_table :oauth_access_tokens do |t| + t.string :token_digest, null: false, index: { unique: true } + t.integer :user_id, null: false, index: true + t.integer :refresh_token_id, index: true + t.datetime :last_used_at + t.datetime :revoked_at + t.datetime :expires_at, null: false + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index e0331993..d7a758d0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,42 +10,42 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2024_09_28_005927) do +ActiveRecord::Schema[8.2].define(version: 2026_08_26_000001) do create_table "accesses", force: :cascade do |t| - t.integer "user_id", null: false t.integer "book_id", null: false - t.string "level", null: false t.datetime "created_at", null: false + t.string "level", null: false t.datetime "updated_at", null: false + t.integer "user_id", null: false t.index ["book_id"], name: "index_accesses_on_book_id" t.index ["user_id", "book_id"], name: "index_accesses_on_user_id_and_book_id", unique: true t.index ["user_id"], name: "index_accesses_on_user_id" end create_table "accounts", force: :cascade do |t| - t.string "name", null: false - t.string "join_code", null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.text "custom_styles" + t.string "join_code", null: false + t.string "name", null: false + t.datetime "updated_at", null: false end create_table "action_text_markdowns", force: :cascade do |t| - t.string "record_type", null: false - t.integer "record_id", null: false - t.string "name", null: false t.text "content", default: "", null: false t.datetime "created_at", null: false + t.string "name", null: false + t.integer "record_id", null: false + t.string "record_type", null: false t.datetime "updated_at", null: false t.index ["record_type", "record_id"], name: "index_action_text_markdowns_on_record" end create_table "active_storage_attachments", force: :cascade do |t| - t.string "name", null: false - t.string "record_type", null: false - t.bigint "record_id", null: false t.bigint "blob_id", null: false t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false t.string "slug" t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true @@ -53,14 +53,14 @@ end create_table "active_storage_blobs", force: :cascade do |t| - t.string "key", null: false - t.string "filename", null: false - t.string "content_type" - t.text "metadata" - t.string "service_name", null: false t.bigint "byte_size", null: false t.string "checksum" + t.string "content_type" t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -71,24 +71,24 @@ end create_table "books", force: :cascade do |t| - t.string "title", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "subtitle" t.string "author" + t.datetime "created_at", null: false + t.boolean "everyone_access", default: true, null: false t.boolean "published", default: false, null: false t.string "slug", null: false - t.boolean "everyone_access", default: true, null: false + t.string "subtitle" t.string "theme", default: "blue", null: false + t.string "title", null: false + t.datetime "updated_at", null: false t.index ["published"], name: "index_books_on_published" end create_table "edits", force: :cascade do |t| - t.integer "leaf_id", null: false - t.string "leafable_type", null: false - t.integer "leafable_id", null: false t.string "action", null: false t.datetime "created_at", null: false + t.integer "leaf_id", null: false + t.integer "leafable_id", null: false + t.string "leafable_type", null: false t.datetime "updated_at", null: false t.index ["leaf_id"], name: "index_edits_on_leaf_id" t.index ["leafable_type", "leafable_id"], name: "index_edits_on_leafable" @@ -96,54 +96,98 @@ create_table "leaves", force: :cascade do |t| t.integer "book_id", null: false - t.string "leafable_type", null: false + t.datetime "created_at", null: false t.integer "leafable_id", null: false + t.string "leafable_type", null: false t.float "position_score", null: false t.string "status", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "title", null: false + t.datetime "updated_at", null: false t.index ["book_id"], name: "index_leaves_on_book_id" t.index ["leafable_type", "leafable_id"], name: "index_leafs_on_leafable" end + create_table "oauth_access_tokens", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.datetime "last_used_at" + t.integer "refresh_token_id" + t.datetime "revoked_at" + t.string "token_digest", null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["refresh_token_id"], name: "index_oauth_access_tokens_on_refresh_token_id" + t.index ["token_digest"], name: "index_oauth_access_tokens_on_token_digest", unique: true + t.index ["user_id"], name: "index_oauth_access_tokens_on_user_id" + end + + create_table "oauth_device_grants", force: :cascade do |t| + t.datetime "consumed_at" + t.datetime "created_at", null: false + t.string "device_code_digest", null: false + t.datetime "expires_at", null: false + t.datetime "last_polled_at" + t.string "status", default: "pending", null: false + t.datetime "updated_at", null: false + t.string "user_code", null: false + t.integer "user_id" + t.index ["device_code_digest"], name: "index_oauth_device_grants_on_device_code_digest", unique: true + t.index ["user_code"], name: "index_oauth_device_grants_on_user_code", unique: true + end + + create_table "oauth_refresh_tokens", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "family_id", null: false + t.integer "replaced_by_id" + t.datetime "revoked_at" + t.datetime "rotated_at" + t.text "successor_raw_token" + t.string "token_digest", null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["family_id"], name: "index_oauth_refresh_tokens_on_family_id" + t.index ["token_digest"], name: "index_oauth_refresh_tokens_on_token_digest", unique: true + t.index ["user_id"], name: "index_oauth_refresh_tokens_on_user_id" + end + create_table "pages", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false end create_table "pictures", force: :cascade do |t| + t.string "caption" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "caption" end create_table "sections", force: :cascade do |t| + t.text "body" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "theme" - t.text "body" + t.datetime "updated_at", null: false end create_table "sessions", force: :cascade do |t| - t.integer "user_id", null: false - t.string "token", null: false + t.datetime "created_at", null: false t.string "ip_address" - t.string "user_agent" t.datetime "last_active_at", null: false - t.datetime "created_at", null: false + t.string "token", null: false t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false t.index ["token"], name: "index_sessions_on_token", unique: true t.index ["user_id"], name: "index_sessions_on_user_id" end create_table "users", force: :cascade do |t| - t.string "name", null: false + t.boolean "active", default: true + t.datetime "created_at", null: false t.string "email_address", null: false + t.string "name", null: false t.string "password_digest", null: false t.integer "role", null: false - t.boolean "active", default: true - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["email_address"], name: "index_users_on_email_address", unique: true t.index ["name"], name: "index_users_on_name", unique: true diff --git a/test/controllers/oauth/revocations_controller_test.rb b/test/controllers/oauth/revocations_controller_test.rb new file mode 100644 index 00000000..8f2345f1 --- /dev/null +++ b/test/controllers/oauth/revocations_controller_test.rb @@ -0,0 +1,24 @@ +require "test_helper" + +class Oauth::RevocationsControllerTest < ActionDispatch::IntegrationTest + test "revoking a refresh token kills its whole family" do + post oauth_revocations_url, params: { client_id: Oauth::CLIENT_ID, token: DAVIDS_REFRESH_TOKEN } + assert_response :success + + assert oauth_refresh_tokens(:davids).reload.revoked? + assert oauth_access_tokens(:davids).reload.revoked? + end + + test "revoking an access token leaves the refresh token alone" do + post oauth_revocations_url, params: { client_id: Oauth::CLIENT_ID, token: DAVIDS_ACCESS_TOKEN } + assert_response :success + + assert oauth_access_tokens(:davids).reload.revoked? + assert_not oauth_refresh_tokens(:davids).reload.revoked? + end + + test "an unknown token still answers 200" do + post oauth_revocations_url, params: { client_id: Oauth::CLIENT_ID, token: "wb_rt_nonsense" } + assert_response :success + end +end diff --git a/test/controllers/oauth/tokens_controller_test.rb b/test/controllers/oauth/tokens_controller_test.rb new file mode 100644 index 00000000..02187918 --- /dev/null +++ b/test/controllers/oauth/tokens_controller_test.rb @@ -0,0 +1,64 @@ +require "test_helper" + +class Oauth::TokensControllerTest < ActionDispatch::IntegrationTest + test "refreshing rotates the token and mints a new pair" do + refresh + assert_response :success + + tokens = response.parsed_body + assert_match(/\Awb_at_/, tokens["access_token"]) + assert_match(/\Awb_rt_/, tokens["refresh_token"]) + assert_not_equal DAVIDS_REFRESH_TOKEN, tokens["refresh_token"] + + assert oauth_refresh_tokens(:davids).reload.rotated? + assert_equal oauth_refresh_tokens(:davids).family_id, + Oauth::RefreshToken.find_by_raw_token(tokens["refresh_token"]).family_id + end + + test "replaying a refresh inside the grace window returns the same successor" do + refresh + first = response.parsed_body + + refresh + assert_response :success + assert_equal first["refresh_token"], response.parsed_body["refresh_token"] + end + + test "replaying a refresh after the grace window revokes the whole family" do + refresh + successor_raw_token = response.parsed_body["refresh_token"] + + travel 2.minutes + + refresh + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + + assert Oauth::RefreshToken.find_by_raw_token(successor_raw_token).revoked? + assert oauth_access_tokens(:davids).reload.revoked? + end + + test "an expired refresh token is rejected" do + oauth_refresh_tokens(:davids).update! expires_at: 1.hour.ago + + refresh + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "unknown grant types are rejected" do + post oauth_tokens_url, params: { client_id: Oauth::CLIENT_ID, grant_type: "authorization_code" } + assert_response :bad_request + assert_equal "unsupported_grant_type", response.parsed_body["error"] + end + + test "an unrecognized client is rejected" do + post oauth_tokens_url, params: { client_id: "somebody-else", grant_type: "refresh_token", refresh_token: DAVIDS_REFRESH_TOKEN } + assert_response :unauthorized + end + + private + def refresh(token = DAVIDS_REFRESH_TOKEN) + post oauth_tokens_url, params: { client_id: Oauth::CLIENT_ID, grant_type: "refresh_token", refresh_token: token } + end +end diff --git a/test/fixtures/oauth/access_tokens.yml b/test/fixtures/oauth/access_tokens.yml new file mode 100644 index 00000000..d182f3db --- /dev/null +++ b/test/fixtures/oauth/access_tokens.yml @@ -0,0 +1,21 @@ +davids: + user: david + refresh_token: davids + token_digest: <%= Oauth::TokenDigester.digest("wb_at_#{"d" * 64}") %> + expires_at: <%= 1.hour.from_now %> + +davids_expired: + user: david + token_digest: <%= Oauth::TokenDigester.digest("wb_at_#{"e" * 64}") %> + expires_at: <%= 1.hour.ago %> + +jasons: + user: jason + refresh_token: jasons + token_digest: <%= Oauth::TokenDigester.digest("wb_at_#{"j" * 64}") %> + expires_at: <%= 1.hour.from_now %> + +jzs: + user: jz + token_digest: <%= Oauth::TokenDigester.digest("wb_at_#{"z" * 64}") %> + expires_at: <%= 1.hour.from_now %> diff --git a/test/fixtures/oauth/refresh_tokens.yml b/test/fixtures/oauth/refresh_tokens.yml new file mode 100644 index 00000000..d0103749 --- /dev/null +++ b/test/fixtures/oauth/refresh_tokens.yml @@ -0,0 +1,11 @@ +davids: + user: david + token_digest: <%= Oauth::TokenDigester.digest("wb_rt_#{"d" * 64}") %> + family_id: davids-cli-family + expires_at: <%= 30.days.from_now %> + +jasons: + user: jason + token_digest: <%= Oauth::TokenDigester.digest("wb_rt_#{"j" * 64}") %> + family_id: jasons-cli-family + expires_at: <%= 30.days.from_now %> diff --git a/test/integration/oauth/bearer_authentication_test.rb b/test/integration/oauth/bearer_authentication_test.rb new file mode 100644 index 00000000..0fb1e0f6 --- /dev/null +++ b/test/integration/oauth/bearer_authentication_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class Oauth::BearerAuthenticationTest < ActionDispatch::IntegrationTest + test "a valid bearer token authenticates without setting a session cookie" do + get root_url, headers: bearer_headers + assert_response :success + assert_not cookies[:session_token].present? + end + + test "an invalid bearer token answers 401 instead of falling through to the sign-in page" do + get root_url, headers: bearer_headers("wb_at_nonsense") + assert_response :unauthorized + assert_equal 'Bearer error="invalid_token"', response.headers["WWW-Authenticate"] + end + + test "expired and revoked tokens are rejected" do + get root_url, headers: bearer_headers(DAVIDS_EXPIRED_ACCESS_TOKEN) + assert_response :unauthorized + + oauth_access_tokens(:davids).revoke + get root_url, headers: bearer_headers + assert_response :unauthorized + end + + test "bearer requests skip CSRF protection" do + with_forgery_protection do + assert_difference -> { Book.count }, +1 do + post books_url, params: { book: { title: "From the CLI" } }, headers: bearer_headers + end + end + end + + test "cookie-less browser requests still get CSRF protection" do + with_forgery_protection do + post session_url, params: { email_address: "david@example.com", password: "secret123456" } + assert_response :unprocessable_entity + end + end + + private + def with_forgery_protection + original = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + yield + ensure + ActionController::Base.allow_forgery_protection = original + end +end diff --git a/test/integration/oauth/device_flow_test.rb b/test/integration/oauth/device_flow_test.rb new file mode 100644 index 00000000..fe3eb391 --- /dev/null +++ b/test/integration/oauth/device_flow_test.rb @@ -0,0 +1,125 @@ +require "test_helper" + +class Oauth::DeviceFlowTest < ActionDispatch::IntegrationTest + test "a device signs in through the whole flow" do + post oauth_device_authorizations_url, params: { client_id: Oauth::CLIENT_ID } + assert_response :success + + authorization = response.parsed_body + device_code = authorization["device_code"] + user_code = authorization["user_code"] + assert_match(/\Awb_dc_/, device_code) + assert_match(/\A[A-Z2-9]{4}-[A-Z2-9]{4}\z/, user_code) + assert_equal oauth_device_verification_url, authorization["verification_uri"] + assert_equal 5, authorization["interval"] + + poll device_code + assert_oauth_error :authorization_pending + + travel 6.seconds + + sign_in :david + get oauth_device_verification_url(user_code: user_code) + assert_response :success + assert_select "form input[name=user_code]" + + patch oauth_device_verification_url, params: { user_code: user_code, initiation_confirmed: "1" } + assert_response :success + + poll device_code + assert_response :success + + tokens = response.parsed_body + assert_match(/\Awb_at_/, tokens["access_token"]) + assert_match(/\Awb_rt_/, tokens["refresh_token"]) + assert_equal "Bearer", tokens["token_type"] + + get root_url, headers: { "Authorization" => "Bearer #{tokens["access_token"]}" } + assert_response :success + + travel 6.seconds + poll device_code + assert_oauth_error :invalid_grant + end + + test "polling too fast answers slow_down" do + device_code = requested_device_code + + poll device_code + assert_oauth_error :authorization_pending + + poll device_code + assert_oauth_error :slow_down + end + + test "a denied device gets access_denied" do + device_code = requested_device_code + grant = Oauth::DeviceGrant.find_by_raw_device_code(device_code) + + sign_in :david + delete oauth_device_verification_url, params: { user_code: grant.user_code } + assert_response :success + + poll device_code + assert_oauth_error :access_denied + end + + test "an expired device code gets expired_token" do + device_code = requested_device_code + + travel 11.minutes + + poll device_code + assert_oauth_error :expired_token + end + + test "approval requires confirming the sign-in was user-initiated" do + device_code = requested_device_code + grant = Oauth::DeviceGrant.find_by_raw_device_code(device_code) + + sign_in :david + patch oauth_device_verification_url, params: { user_code: grant.user_code } + assert_response :success + assert grant.reload.pending? + + travel 6.seconds + poll device_code + assert_oauth_error :authorization_pending + end + + test "verification requires signing in first" do + get oauth_device_verification_url + assert_redirected_to new_session_url + end + + test "an unknown user code is rejected" do + sign_in :david + post oauth_device_verification_url, params: { user_code: "XXXX-XXXX" } + assert_response :unprocessable_entity + end + + test "an unknown client cannot start the flow" do + post oauth_device_authorizations_url, params: { client_id: "somebody-else" } + assert_response :unauthorized + assert_equal "invalid_client", response.parsed_body["error"] + end + + private + def requested_device_code + post oauth_device_authorizations_url, params: { client_id: Oauth::CLIENT_ID } + response.parsed_body["device_code"] + end + + def poll(device_code) + post oauth_tokens_url, params: { + client_id: Oauth::CLIENT_ID, + grant_type: Oauth::DEVICE_CODE_GRANT_TYPE, + device_code: device_code + } + end + + def assert_oauth_error(code) + assert_response :bad_request + assert_equal code.to_s, response.parsed_body["error"] + end +end diff --git a/test/integration/sync_api_test.rb b/test/integration/sync_api_test.rb new file mode 100644 index 00000000..89002eff --- /dev/null +++ b/test/integration/sync_api_test.rb @@ -0,0 +1,120 @@ +require "test_helper" + +class SyncApiTest < ActionDispatch::IntegrationTest + test "the manifest lists the book and its active leaves in position order" do + get book_manifest_url(books(:handbook), format: :json), headers: bearer_headers + assert_response :success + + manifest = response.parsed_body + assert_equal "Handbook", manifest["book"]["title"] + assert_equal books(:handbook).fingerprint, manifest["book"]["fingerprint"] + + leaves = manifest["leaves"] + assert_equal %w[ Section Page Page Picture ], leaves.map { it["type"] } + assert_equal leaves(:welcome_page).fingerprint, leaves.second["fingerprint"] + assert_equal "reading.webp", leaves.fourth["image"]["filename"] + end + + test "trashed leaves are left out of the manifest" do + leaves(:summary_page).trashed! + + get book_manifest_url(books(:handbook), format: :json), headers: bearer_headers + assert_not_includes response.parsed_body["leaves"].map { it["id"] }, leaves(:summary_page).id + end + + test "showing a page returns its raw markdown and an upload gid" do + get book_page_url(books(:handbook), leaves(:welcome_page), format: :json), headers: bearer_headers + assert_response :success + + page = response.parsed_body + assert_equal "This is _such_ a great handbook.", page["body"] + assert_equal leaves(:welcome_page).fingerprint, page["fingerprint"] + assert page["record_gid"].present? + end + + test "creating a page at a position" do + assert_difference -> { books(:handbook).leaves.active.count }, +1 do + post book_pages_url(books(:handbook), format: :json), headers: bearer_headers, + params: { leaf: { title: "Epilogue" }, page: { body: "The end." }, position: 1 } + end + + assert_response :created + + created = response.parsed_body + leaf = Leaf.find(created["id"]) + assert_equal "Epilogue", leaf.title + assert_equal "The end.", leaf.leafable.markable + assert_equal leaf, books(:handbook).leaves.active.positioned.second + end + + test "updating a page with the current fingerprint succeeds and returns the new one" do + leaf = leaves(:welcome_page) + + patch book_page_url(books(:handbook), leaf, format: :json), headers: bearer_headers, + params: { leaf: { title: "Welcome!" }, page: { body: "Fresh words." }, base_fingerprint: leaf.fingerprint } + + assert_response :success + assert_equal "Fresh words.", leaf.reload.leafable.markable + assert_equal leaf.fingerprint, response.parsed_body["fingerprint"] + end + + test "updating with a stale fingerprint answers 409 and changes nothing" do + leaf = leaves(:welcome_page) + + patch book_page_url(books(:handbook), leaf, format: :json), headers: bearer_headers, + params: { leaf: { title: "Clobbered" }, page: { body: "Clobbered." }, base_fingerprint: "stale" } + + assert_response :conflict + assert_equal "stale_write", response.parsed_body["error"] + assert_equal leaf.fingerprint, response.parsed_body["fingerprint"] + assert_equal "Welcome to The Handbook!", leaf.reload.title + end + + test "updates without a base fingerprint keep last-write-wins for the web editor" do + leaf = leaves(:welcome_page) + + patch book_page_url(books(:handbook), leaf, format: :json), headers: bearer_headers, + params: { leaf: { title: "No guard" }, page: { body: "Still fine." } } + + assert_response :success + assert_equal "No guard", leaf.reload.title + end + + test "destroying a leaf trashes it" do + delete book_page_url(books(:handbook), leaves(:summary_page), format: :json), headers: bearer_headers + + assert_response :no_content + assert leaves(:summary_page).reload.trashed? + end + + test "one moves call reorders the whole book" do + book = books(:handbook) + reversed = book.leaves.active.positioned.reverse + + post book_leaves_moves_url(book, format: :json), headers: bearer_headers, + params: { id: reversed.map(&:id), position: 0 } + + assert_response :no_content + assert_equal reversed.map(&:id), book.leaves.active.positioned.map(&:id) + end + + test "updating the book's metadata returns its new fingerprint" do + patch book_url(books(:handbook), format: :json), headers: bearer_headers, + params: { book: { title: "The Handbook", author: "David" } } + + assert_response :success + assert_equal "The Handbook", books(:handbook).reload.title + assert_equal books(:handbook).fingerprint, response.parsed_body["fingerprint"] + end + + test "readers can fetch the manifest but not write" do + get book_manifest_url(books(:handbook), format: :json), headers: bearer_headers(JZS_ACCESS_TOKEN) + assert_response :success + + patch book_page_url(books(:handbook), leaves(:welcome_page), format: :json), + headers: bearer_headers(JZS_ACCESS_TOKEN), + params: { leaf: { title: "Nope" }, page: { body: "Nope." } } + assert_response :forbidden + assert_equal "Welcome to The Handbook!", leaves(:welcome_page).reload.title + end +end diff --git a/test/models/leaf_test.rb b/test/models/leaf_test.rb index c38ed293..a081d2a0 100644 --- a/test/models/leaf_test.rb +++ b/test/models/leaf_test.rb @@ -10,4 +10,22 @@ class LeafTest < ActiveSupport::TestCase leaf = Leaf.new(title: "") assert_equal "-", leaf.slug end + + test "fingerprint changes with the title and the content, for every leafable type" do + [ leaves(:welcome_page), leaves(:welcome_section), leaves(:reading_picture) ].each do |leaf| + original = leaf.fingerprint + assert_equal original, leaf.reload.fingerprint + + leaf.update! title: "#{leaf.title} again" + assert_not_equal original, leaf.fingerprint + end + end + + test "fingerprint changes when a page body changes" do + leaf = leaves(:welcome_page) + original = leaf.fingerprint + + leaf.leafable.body.update! content: "Rewritten." + assert_not_equal original, leaf.reload.fingerprint + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 37e5e0a2..9636e3c8 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -10,6 +10,6 @@ class TestCase # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all - include SessionTestHelper + include OauthTestHelper, SessionTestHelper end end diff --git a/test/test_helpers/oauth_test_helper.rb b/test/test_helpers/oauth_test_helper.rb new file mode 100644 index 00000000..c870c4a3 --- /dev/null +++ b/test/test_helpers/oauth_test_helper.rb @@ -0,0 +1,12 @@ +module OauthTestHelper + DAVIDS_ACCESS_TOKEN = "wb_at_#{"d" * 64}" + DAVIDS_EXPIRED_ACCESS_TOKEN = "wb_at_#{"e" * 64}" + DAVIDS_REFRESH_TOKEN = "wb_rt_#{"d" * 64}" + JASONS_ACCESS_TOKEN = "wb_at_#{"j" * 64}" + JASONS_REFRESH_TOKEN = "wb_rt_#{"j" * 64}" + JZS_ACCESS_TOKEN = "wb_at_#{"z" * 64}" + + def bearer_headers(token = DAVIDS_ACCESS_TOKEN) + { "Authorization" => "Bearer #{token}" } + end +end