Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/controllers/books/manifests_controller.rb
Original file line number Diff line number Diff line change
@@ -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
11 changes: 8 additions & 3 deletions app/controllers/books_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 26 additions & 5 deletions app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions app/controllers/concerns/authentication/token_lookup.rb
Original file line number Diff line number Diff line change
@@ -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
30 changes: 25 additions & 5 deletions app/controllers/leafables_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,36 @@ 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

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

Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions app/controllers/oauth/base_controller.rb
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions app/controllers/oauth/device_authorizations_controller.rb
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions app/controllers/oauth/device_verifications_controller.rb
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions app/controllers/oauth/revocations_controller.rb
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions app/controllers/oauth/tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions app/models/book.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions app/models/leaf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions app/models/oauth.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading