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" } %> + +
You can close this window and return to your terminal.
+<%= @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" %> +The device was not connected. You can close this window.
+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 %> +