<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://tosborne89.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://tosborne89.github.io/" rel="alternate" type="text/html" /><updated>2026-11-08T19:01:04+00:00</updated><id>https://tosborne89.github.io/feed.xml</id><title type="html">Tim Osborne, Atlassian Forge migrations</title><subtitle>Moving Jira and Confluence Connect apps to Forge before Connect end of support on 31 January 2027.</subtitle><entry><title type="html">Porting a 48,000-install Jira app off Atlassian Connect: what moved, what didn’t, and the OAuth gap nobody warns you about</title><link href="https://tosborne89.github.io/figma-for-jira-forge-port/" rel="alternate" type="text/html" title="Porting a 48,000-install Jira app off Atlassian Connect: what moved, what didn’t, and the OAuth gap nobody warns you about" /><published>2026-11-08T00:00:00+00:00</published><updated>2026-11-08T00:00:00+00:00</updated><id>https://tosborne89.github.io/figma-for-jira-forge-port</id><content type="html" xml:base="https://tosborne89.github.io/figma-for-jira-forge-port/"><![CDATA[<p>Atlassian Connect reaches end of support on 31 January 2027. Connect apps keep running after that date, but Atlassian “will only address critical security patches”, and some changes “will cause apps to stop functioning without advance notice” (<a href="https://www.atlassian.com/blog/development/getting-ready-for-connect-end-of-support">Atlassian</a>).</p>

<p>To see what a real migration involves, I took an open-source Connect app and ported it to Forge. I chose <a href="https://github.com/atlassian-labs/figma-for-jira">atlassian-labs/figma-for-jira</a> because it is Apache-2.0 licensed and widely used: the “Figma for Jira” Marketplace listing has 48,052 installs and still has no Forge manifest (Marketplace and forge-apps.com APIs, 7 November 2026). It is also a hard case, which makes it a useful one.</p>

<p>The port runs on a live Jira Cloud site. It is a case study, with no connection to Figma or Atlassian. Code: <a href="https://github.com/tosborne89/figma-forge-port">github.com/tosborne89/figma-forge-port</a>.</p>

<h2 id="what-moved-cleanly">What moved cleanly</h2>

<table>
  <thead>
    <tr>
      <th>Connect</th>
      <th>Forge</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Admin page served from the app’s own server in an iframe</td>
      <td><code class="language-plaintext highlighter-rouge">jira:adminPage</code> in UI Kit, rendered natively by Jira</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">installed</code> webhook storing <code class="language-plaintext highlighter-rouge">clientKey</code> and <code class="language-plaintext highlighter-rouge">sharedSecret</code></td>
      <td>An install trigger with nothing to store, because Forge owns installation identity</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">uninstalled</code> webhook</td>
      <td><code class="language-plaintext highlighter-rouge">preUninstall</code>, which deletes the app’s Figma webhooks and then its data</td>
    </tr>
    <tr>
      <td>Two Express routes for Figma webhooks, with a passcode check</td>
      <td>One web trigger routed by path, with a constant-time passcode check</td>
    </tr>
    <tr>
      <td>Background sync via <code class="language-plaintext highlighter-rouge">setImmediate</code> with no retry</td>
      <td>A Forge Async Events queue (up to 900 s) that retries on failure</td>
    </tr>
    <tr>
      <td>PostgreSQL tables for teams and webhooks</td>
      <td>Forge key-value storage, with passcodes in secret storage</td>
    </tr>
    <tr>
      <td>Connect JWT middleware and an installations table</td>
      <td>Deleted. Forge authenticates requests itself.</td>
    </tr>
  </tbody>
</table>

<p>That last row is the pattern for the whole port: a lot of code simply disappears, because Forge takes over hosting, installation identity and request auth.</p>

<h2 id="what-did-not-move-the-designs-panel">What did not move: the Designs panel</h2>

<p>The app’s core feature is Jira’s native Designs panel, provided through the Connect module <code class="language-plaintext highlighter-rouge">jiraDesignInfoProvider</code>. Forge has no equivalent module, and its scope list has no design scope (<a href="https://developer.atlassian.com/cloud/jira/software/scopes-for-oauth-2-3LO-and-forge-apps/">Forge scopes</a>).</p>

<p>Keeping that one module as Connect inside a Forge manifest doesn’t escape the deadline either: such an app “is considered a Connect app until all Connect modules are removed” (<a href="https://community.developer.atlassian.com/t/how-long-forge-connectmodules-will-be-working/98926">developer community</a>).</p>

<p>For a client in this position, I’d recommend moving everything else now and raising the gap with Atlassian. In parallel, prototype a pure-Forge fallback, the route <a href="https://marketplace.atlassian.com/apps/1221981">Figma for Jira Pro</a> already takes.</p>

<h2 id="the-oauth-gap">The OAuth gap</h2>

<p>Forge has built-in external auth (<code class="language-plaintext highlighter-rouge">withProvider</code>), and the obvious plan is to delete the app’s own Figma OAuth code and use it. That plan fails.</p>

<p><code class="language-plaintext highlighter-rouge">withProvider</code> only works in functions a user invokes from the UI. Atlassian staff say providers “only support online asUser which requires user context” (<a href="https://community.developer.atlassian.com/t/can-forge-webtriggers-access-provider-authentication-like-google/96357">developer community</a>). This app calls Figma in two places where no user is present:</p>

<ul>
  <li>the queue consumer that reacts to Figma webhooks;</li>
  <li><code class="language-plaintext highlighter-rouge">preUninstall</code>, which deletes the app’s Figma webhooks.</li>
</ul>

<p>So the app has to keep its own OAuth code. The port does it like this:</p>

<ul>
  <li><strong>Start:</strong> the admin clicks Connect Figma. A resolver signs a short-lived state with HMAC and stores a one-time PKCE S256 verifier. It then returns Figma’s authorize URL, which opens in a new tab because Figma blocks its sign-in inside WebViews (<a href="https://developers.figma.com/docs/rest-api/oauth-apps/">Figma OAuth apps</a>).</li>
  <li><strong>Callback:</strong> a dedicated web trigger checks the state and consumes the verifier, so a replayed callback fails. It then exchanges the code straight away, since Figma’s codes expire after 30 seconds. Tokens go into Forge secret storage under the admin’s account ID.</li>
  <li><strong>Use with no user present:</strong> the consumer looks up who connected the team, loads that account’s token, and calls Figma through Forge <code class="language-plaintext highlighter-rouge">fetch</code>, with <code class="language-plaintext highlighter-rouge">api.figma.com</code> as the only declared egress host.</li>
  <li><strong>Refresh:</strong> within an hour of expiry. Figma’s refresh response contains no new refresh token, so the old one is kept (<a href="https://developers.figma.com/docs/rest-api/oauth-apps/">Figma OAuth apps</a>).</li>
</ul>

<p>This is stricter than the original, which used a signed state only. The port adds PKCE and one-time state.</p>

<h2 id="a-finding-that-matters-for-marketplace-apps">A finding that matters for Marketplace apps</h2>

<p>Forge web trigger URLs are stable per app, site and environment (<a href="https://developer.atlassian.com/platform/forge/runtime-reference/web-trigger-api/">web trigger API</a>). On Connect, the app had one base URL, so it could register one OAuth redirect URI with Figma. On Forge, every customer site gets a different callback URL.</p>

<p>For a private app on one site, that doesn’t matter. A Marketplace app needs one of these:</p>

<ul>
  <li>a small fixed relay that forwards the callback to the per-site URL carried in the signed state;</li>
  <li>or one OAuth app per customer.</li>
</ul>

<p>Plan for this before you start, not after.</p>

<h2 id="proof">Proof</h2>

<p>On a live Jira Cloud site, on 8 November 2026:</p>

<ul>
  <li>
    <p><strong>Sign-in works.</strong> The admin page shows “Connected as” with the Figma account’s email, read from Figma’s <code class="language-plaintext highlighter-rouge">/v1/me</code> with the stored token.</p>

    <p><img src="/assets/images/figma-forge-admin-connected.png" alt="The Forge admin page in Jira settings, showing &quot;Connected as&quot; with the Figma account" /></p>
  </li>
  <li><strong>Figma’s paid-plan rule is handled.</strong> Figma answers webhook creation on free plans with 400 “Access Denied”. The port maps that to a clear message, as the original did.</li>
  <li>
    <p><strong>Background calls work with no user present.</strong> A queued job, running in the consumer with no user context, read a Figma file with the stored token and logged <code class="language-plaintext highlighter-rouge">figma-sync read file { name: 'Forge port sync test' }</code>.</p>

    <p><img src="/assets/images/figma-forge-sync-log.png" alt="forge logs output showing the queue consumer reading the Figma file with no user present" /></p>
  </li>
  <li><strong>Tests:</strong> 60 unit tests covering state tampering and expiry, one-time verifiers, redirect mismatches, refresh behaviour, and the callback never echoing input. <code class="language-plaintext highlighter-rouge">npm audit</code> reports 0 vulnerabilities.</li>
</ul>

<h2 id="practical-traps">Practical traps</h2>

<ul>
  <li>Adding an egress host forces a major version, a <code class="language-plaintext highlighter-rouge">forge install --upgrade</code>, and loss of “Runs on Atlassian” eligibility.</li>
  <li>The string form of an egress entry (<code class="language-plaintext highlighter-rouge">'https://api.figma.com'</code>) now gives a lint deprecation warning. Use <code class="language-plaintext highlighter-rouge">address: api.figma.com</code>.</li>
  <li>A <code class="language-plaintext highlighter-rouge">jira:adminPage</code> with <code class="language-plaintext highlighter-rouge">useAsConfig: true</code> lives at <code class="language-plaintext highlighter-rouge">/jira/settings/apps/configure/{appId}/{envId}</code>. The URL without <code class="language-plaintext highlighter-rouge">configure</code> doesn’t work.</li>
  <li>Store client secrets with <code class="language-plaintext highlighter-rouge">forge variables set --encrypt</code>, and type them in your own terminal, never in a shared session.</li>
</ul>

<h2 id="if-you-own-a-connect-app">If you own a Connect app</h2>

<p>If your app still shows LEGACY, I offer a free audit. You get a component map like the one above, with an honest list of what Forge can’t do yet. Details are on the <a href="/">offer page</a>, email <a href="mailto:osborne.tim89@gmail.com?subject=Forge%20migration">osborne.tim89@gmail.com</a>, or <a href="https://www.upwork.com/freelancers/~01c351fe04e5d1ae40">message me on Upwork</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Atlassian Connect reaches end of support on 31 January 2027. Connect apps keep running after that date, but Atlassian “will only address critical security patches”, and some changes “will cause apps to stop functioning without advance notice” (Atlassian).]]></summary></entry></feed>