Token-authenticated embedded builder (no cross-origin cookies)

Adds a JWT-authenticated entry point so the embedding app can mount
<docuseal-builder data-token="…"> without the host pre-establishing a
DocuSeal session via cross-origin cookies + an external auth gate.

- EmbedBuilderController (GET /embed/builder?token=…): verifies a short-lived
  HS256 JWT against the owner account's API access token (the same key the
  JSON API uses; raw value is recoverable via the encrypted `token` column),
  signs that user in (a first-party session inside the iframe), records a
  template-scoped grant in the session, and redirects into the regular
  builder — /templates/:id/edit for an existing template, or /new?url=… to
  download + create from `document_urls`. Requires `exp` and caps token
  lifetime (replay bound). Only opens template_ids the account owns.
- EmbedScoped concern: confines an embed session to its own template (by id,
  or by external_id for the /new→create→edit redirect) plus the create +
  builder-support paths; refuses enumeration / other templates / the JSON
  template API. Fails closed. Path rules mirror the allow-list the host app
  enforced at the edge, so the builder keeps working while the cross-origin
  cookie + gate machinery on the host side can be removed.
- builder.js shim now iframes /embed/builder?token=… (the server decides
  edit vs. new) instead of a bare authenticated /new that 404s without a
  pre-set session cookie.
- Request specs for token verification, ownership, exp/lifetime, and scope.
pull/697/head
shipeasy-ai 2 months ago
parent b66ccdc206
commit 3c05712819

@ -15,6 +15,11 @@ class ApplicationController < ActionController::Base
before_action :set_csp, if: -> { request.get? && !request.headers['HTTP_X_TURBO'] } before_action :set_csp, if: -> { request.get? && !request.headers['HTTP_X_TURBO'] }
# Confines token-bootstrapped embed sessions to their own template. No-op for
# ordinary sessions. Included last so `enforce_embed_scope!` runs after
# `authenticate_user!`.
include EmbedScoped
helper_method :button_title, helper_method :button_title,
:current_account, :current_account,
:true_ability, :true_ability,

@ -0,0 +1,82 @@
# frozen_string_literal: true
# Confines a token-bootstrapped embed session (see EmbedBuilderController) to
# the single template it was issued for.
#
# A valid first-party DocuSeal session is necessary but not sufficient: the
# session may only reach its own template (by id, or by the immutable
# external_id for the /new -> create -> edit redirect) plus the create +
# builder-support paths the editor needs. Listing / enumeration / other
# templates / submissions / the JSON template API are refused even with a
# valid session, so one embedder can't browse another's documents in the
# shared DocuSeal account. Fails closed.
#
# The path rules mirror the allow-list the embedding app previously enforced
# at the edge via Caddy `forward_auth`; moving them in-process lets the embed
# drop the cross-origin cookie + gate machinery entirely.
module EmbedScoped
extend ActiveSupport::Concern
SESSION_KEY = 'embed_scope'
SESSION_TTL = 2.hours
TEMPLATE_PATH = %r{\A/templates/(\d+)(?:[/?]|\z)}
# Paths an embed session may hit regardless of which template it owns: the
# create/upload flow, the signing/asset surfaces, and the few endpoints the
# builder's own browser code calls. None expose another account's templates.
UNSCOPED_ALLOW = [
%r{\A/new(?:[/?]|\z)},
%r{\A/templates/new(?:[/?]|\z)},
%r{\A/templates_uploads?(?:[/?]|\z)},
%r{\A/(?:s|d|p|e)(?:[/?]|\z)},
%r{\A/(?:preview|file|blobs_proxy|submit_form)/},
%r{\A/verify_pdf_signature(?:[/?]|\z)},
%r{\A/api/(?:attachments|submitter_email_clicks|submitter_form_views)(?:[/?]|\z)}
].freeze
included do
before_action :enforce_embed_scope!
end
private
def enforce_embed_scope!
scope = session[SESSION_KEY]
return if scope.blank?
if embed_scope_expired?(scope)
reset_session
return redirect_to(new_user_session_path)
end
head(:forbidden) unless embed_path_authorized?(request.path, scope)
end
def embed_scope_expired?(scope)
exp = scope['exp']
exp.present? && exp.to_i <= Time.now.to_i
end
def embed_path_authorized?(path, scope)
if (match = TEMPLATE_PATH.match(path))
return embed_template_in_scope?(match[1].to_i, scope)
end
UNSCOPED_ALLOW.any? { |re| re.match?(path) }
end
# A specific /templates/:id is in scope when the session names that template
# directly, or when the template's immutable external_id matches the one the
# token was issued for (covers /new -> create -> redirect to /templates/:id).
def embed_template_in_scope?(id, scope)
return true if scope['template_id'].present? && scope['template_id'].to_i == id
external_id = scope['external_id']
return false if external_id.blank?
Template.where(id:).pick(:external_id) == external_id
end
end

@ -0,0 +1,114 @@
# 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

@ -89,6 +89,10 @@ Rails.application.routes.draw do
authenticated do authenticated do
resource :templates_upload, only: %i[show], path: 'new' resource :templates_upload, only: %i[show], path: 'new'
end end
# Token-authenticated entry for the embedded builder (verifies a JWT signed
# with the account API key, signs the owner in, then redirects into the
# builder). Public on purpose — the token IS the credential.
get 'embed/builder', to: 'embed_builder#show', as: :embed_builder
resources :templates_archived, only: %i[index], path: 'templates/archived' resources :templates_archived, only: %i[index], path: 'templates/archived'
resources :folders, only: %i[show edit update destroy], controller: 'template_folders' resources :folders, only: %i[show edit update destroy], controller: 'template_folders'
resources :template_sharings_testing, only: %i[create] resources :template_sharings_testing, only: %i[create]

@ -5,32 +5,17 @@
// //
// What this does: // What this does:
// 1. Reads the JWT minted by the embedding app from `data-token`. // 1. Reads the JWT minted by the embedding app from `data-token`.
// 2. Decodes its payload to discover `document_urls`, `name`, // 2. Iframes the token-authenticated entry point `/embed/builder?token=…`.
// `external_id`, and `template_id`. // That endpoint verifies the JWT against the account API key, signs the
// 3. Iframes either `/templates/:id/edit` (existing template) or // owner in (a first-party DocuSeal session, scoped to one template), and
// `/new?url=...&filename=...&external_id=...` (fresh upload flow). // redirects into the builder — `/templates/:id/edit` for an existing
// 4. Relays the inner builder's `save` postMessage up as a DOM // template, or `/new?url=…` for a fresh upload. The token is the only
// `CustomEvent('save')` on the outer element so `@docuseal/react`'s // credential; no cross-origin cookies are involved.
// `onSave` callback fires. // 3. Relays the inner builder's `save` postMessage up as a DOM
// // `CustomEvent('save')` so `@docuseal/react`'s `onSave` callback fires.
// JWT signature is NOT verified here — host DocuSeal session cookies carry
// auth, and the calling app signs with the shared secret it controls.
(function () { (function () {
function decodeJwtPayload(token) { // Discover the DocuSeal host from this script's own src so embedders don't
try { // have to set data-host on every element.
var part = token.split('.')[1]
if (!part) return null
var b64 = part.replace(/-/g, '+').replace(/_/g, '/')
var pad = b64.length % 4
if (pad) b64 += '='.repeat(4 - pad)
return JSON.parse(decodeURIComponent(escape(atob(b64))))
} catch (e) {
return null
}
}
// Discover the DocuSeal host from this script's own src so embedders
// don't have to set data-host on every element.
var SCRIPT_HOST = (function () { var SCRIPT_HOST = (function () {
try { try {
var current = document.currentScript var current = document.currentScript
@ -50,10 +35,9 @@
} }
})() })()
// Relay `save` postMessage from the iframe (templates_builder fires it // Relay `save` postMessage from the iframe (the builder fires it after a
// after a successful manual SAVE) up as a DOM `save` CustomEvent on the // successful manual SAVE) up as a DOM `save` CustomEvent on the outer
// outer <docuseal-builder> element so React listeners can close the // <docuseal-builder> element so React listeners can close the embedding sheet.
// embedding sheet.
if (!window.__docusealEmbedListenerInstalled) { if (!window.__docusealEmbedListenerInstalled) {
window.__docusealEmbedListenerInstalled = true window.__docusealEmbedListenerInstalled = true
window.addEventListener('message', function (ev) { window.addEventListener('message', function (ev) {
@ -81,27 +65,12 @@
if (!token) return if (!token) return
this._mounted = true this._mounted = true
var host = this.getAttribute('data-host') || SCRIPT_HOST || window.location.host var host = this.getAttribute('data-host') || SCRIPT_HOST || window.location.host
var payload = decodeJwtPayload(token) || {}
var docUrl = (payload.document_urls && payload.document_urls[0]) || ''
var name = payload.name || 'Untitled'
var filename = name.replace(/[^A-Za-z0-9_\-]+/g, '_') + '.pdf'
// Match parent page scheme. Both ends run TLS locally (mkcert), so // Match parent page scheme. Both ends run TLS, so https parent -> https
// https parent -> https iframe with no mixed-content blocking. // iframe with no mixed-content blocking.
var origin = window.location.protocol + '//' + host var origin = window.location.protocol + '//' + host
var src var src = origin + '/embed/builder?token=' + encodeURIComponent(token)
if (payload.template_id) {
// Existing template: open the editor directly so prior field
// edits are preserved. Posting to /new would create another
// template and orphan the original.
src = origin + '/templates/' + encodeURIComponent(payload.template_id) + '/edit'
} else {
var qs = new URLSearchParams()
if (docUrl) qs.set('url', docUrl)
qs.set('filename', filename)
if (payload.external_id) qs.set('external_id', payload.external_id)
src = origin + '/new?' + qs.toString()
}
var iframe = document.createElement('iframe') var iframe = document.createElement('iframe')
iframe.src = src iframe.src = src
iframe.style.cssText = 'width:100%;height:100%;min-height:600px;border:0;display:block' iframe.style.cssText = 'width:100%;height:100%;min-height:600px;border:0;display:block'

@ -0,0 +1,106 @@
# frozen_string_literal: true
require 'jwt'
describe 'Embed builder' do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:api_key) { user.access_token.token }
def token(claims = {})
JWT.encode({ user_email: user.email, exp: 5.minutes.from_now.to_i }.merge(claims), api_key, 'HS256')
end
describe 'GET /embed/builder' do
it 'signs the owner in and redirects to /new for a fresh template' do
get embed_builder_path, params: {
token: token(document_urls: ['https://example.com/doc.pdf'], name: 'Consent Form', external_id: 'ext-123')
}
expect(response).to have_http_status(:found)
expect(response.location).to include('/new')
expect(response.location).to include('external_id=ext-123')
expect(response.location).to include('Consent_Form.pdf')
end
it 'redirects to the editor for an existing owned template' do
template = create(:template, account:, external_id: 'ext-xyz')
get embed_builder_path, params: { token: token(template_id: template.id) }
expect(response).to redirect_to(edit_template_path(template))
end
it 'never opens a template the account does not own — falls back to create' do
other = create(:template, account: create(:account), external_id: 'foreign')
get embed_builder_path, params: { token: token(template_id: other.id) }
expect(response.location).to include('/new')
expect(response.location).not_to include("/templates/#{other.id}")
end
it 'rejects a token signed with the wrong key' do
bad = JWT.encode({ user_email: user.email, exp: 5.minutes.from_now.to_i }, 'not-the-key', 'HS256')
get embed_builder_path, params: { token: bad }
expect(response).to redirect_to(new_user_session_path)
end
it 'rejects an expired token' do
get embed_builder_path, params: { token: token(exp: 1.minute.ago.to_i) }
expect(response).to redirect_to(new_user_session_path)
end
it 'rejects a token with no exp claim' do
bare = JWT.encode({ user_email: user.email }, api_key, 'HS256')
get embed_builder_path, params: { token: bare }
expect(response).to redirect_to(new_user_session_path)
end
it 'rejects a token whose lifetime exceeds the cap' do
get embed_builder_path, params: { token: token(exp: 1.hour.from_now.to_i) }
expect(response).to redirect_to(new_user_session_path)
end
it 'rejects a token for an unknown owner' do
foreign = JWT.encode({ user_email: 'nobody@example.com', exp: 5.minutes.from_now.to_i }, api_key, 'HS256')
get embed_builder_path, params: { token: foreign }
expect(response).to redirect_to(new_user_session_path)
end
end
describe 'scope enforcement (EmbedScoped)' do
it 'confines the embed session to its own template' do
mine = create(:template, account:, external_id: 'mine')
theirs = create(:template, account:, external_id: 'theirs')
get embed_builder_path, params: { token: token(template_id: mine.id) }
expect(response).to redirect_to(edit_template_path(mine))
# Cookies persist across requests within a request spec, so the next
# call rides the embed session established above.
get edit_template_path(mine)
expect(response).to have_http_status(:ok)
get edit_template_path(theirs)
expect(response).to have_http_status(:forbidden)
end
it 'refuses template enumeration even with a valid embed session' do
mine = create(:template, account:, external_id: 'mine')
get embed_builder_path, params: { token: token(template_id: mine.id) }
get '/templates'
expect(response).to have_http_status(:forbidden)
end
end
end
Loading…
Cancel
Save