mirror of https://github.com/docusealco/docuseal
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
parent
b66ccdc206
commit
3c05712819
@ -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
|
||||
@ -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…
Reference in new issue