You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
docuseal/app/controllers/embed_builder_controller.rb

115 lines
3.5 KiB

# frozen_string_literal: true
require 'jwt'
# Token-authenticated entry point for the embedded template builder.
#
# The embedding app mints a short-lived JWT signed HS256 with the account's
# API access token — the same key the JSON API authenticates with — and
# renders `<docuseal-builder data-token="…">`. The self-hosted embed shim
# (`public/js/builder.js`) iframes THIS endpoint, passing the token.
#
# We verify the token against the owner's access token, sign that user in
# (establishing a first-party DocuSeal session inside the iframe), record a
# template-scoped grant in the session (enforced by EmbedScoped), and redirect
# into the regular builder — `/templates/:id/edit` for an existing template, or
# `/new` (download + create from `document_urls`) for a fresh one. The JWT is
# the only credential the embedder handles: no cross-origin cookies, no shared
# admin grant.
class EmbedBuilderController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
# Refuse tokens whose lifetime exceeds this even if `exp` allows more — an
# embed bootstrap is near-instant, so a long-lived token is pure replay risk.
MAX_TOKEN_TTL = 10.minutes
def show
payload = verified_payload(params[:token].to_s)
return reject unless payload
user = User.active.find_by(email: owner_email(payload))
return reject unless user
sign_in(user)
template_id = resolve_template_id(user, payload)
session[EmbedScoped::SESSION_KEY] = {
'external_id' => payload['external_id'].presence,
'template_id' => template_id,
'exp' => (Time.now + EmbedScoped::SESSION_TTL).to_i
}.compact
redirect_to(builder_target(template_id, payload))
end
private
# Read the owner email from the UNVERIFIED payload only to look up which
# access token should have signed the token, then verify the signature
# against that key. A token forged for someone else's email fails — the
# attacker doesn't hold that account's key.
def verified_payload(token)
return if token.blank?
claims = JWT.decode(token, nil, false).first
return unless claims.is_a?(Hash)
user = User.active.find_by(email: owner_email(claims))
return unless user
payload, = JWT.decode(token, user.access_token.token, true, algorithm: 'HS256', verify_expiration: true)
payload if fresh_enough?(payload)
rescue JWT::DecodeError
nil
end
# Require an `exp` claim and cap the accepted lifetime — bounds replay
# regardless of what the issuer set.
def fresh_enough?(payload)
exp = payload['exp']
return false if exp.blank?
now = Time.now.to_i
now <= exp.to_i && (exp.to_i - now) <= MAX_TOKEN_TTL.to_i
end
def owner_email(claims)
claims['user_email'].presence || claims['integration_email'].presence
end
# Only an id the signed-in user's account actually owns — never trust the
# token's template_id blindly.
def resolve_template_id(user, payload)
id = payload['template_id']
return if id.blank?
user.account.templates.where(id:).pick(:id)
end
def builder_target(template_id, payload)
return edit_template_path(template_id) if template_id
query = {
url: payload.dig('document_urls', 0),
filename: filename_for(payload),
external_id: payload['external_id'].presence
}.compact
"/new?#{query.to_query}"
end
def filename_for(payload)
name = payload['name'].presence || 'Untitled'
"#{name.gsub(/[^A-Za-z0-9_-]+/, '_')}.pdf"
end
def reject
redirect_to(new_user_session_path, alert: 'Not authorized')
end
end