# Firefly Services Auth

> Production-grade OAuth Server-to-Server authentication for Adobe Firefly Services — token retrieval, refresh-before-expiry, scope selection, multi-tenant credential isolation, and the JWT-to-OAuth migration path. Use whenever the user needs to obtain or refresh a Firefly Services access token, mentions "token expired", "401 Unauthorized", "auth", "IMS", "scopes", "client credentials", "S2S", "Server-to-Server", "JWT migration", or wires authentication into a service that calls `firefly-api.adobe.io` or `image.adobe.io`. Also handles JWT deprecation — JWT reached end-of-life June 30, 2025 and all certificates expired March 1, 2026; any remaining JWT integration is already broken and this skill covers the rebuild path. Do NOT use for first-time project setup; use `firefly-services-bootstrap` instead.

- Skill: `focus-gts/firefly-services-auth` (Agent Skill)
- Install (CLI): `npx skillmds@latest add focus-gts/firefly-services-auth`
- Raw SKILL.md: https://api.skillmd.com/api/skills/focus-gts/firefly-services-auth/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- License: Apache-2.0
- Author: Focus-GTS (https://skillmd.com/u/focus-gts)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/focus-gts/firefly-services-auth

---


# Firefly Services Authentication

Obtains and refreshes IMS access tokens for Firefly Services using OAuth Server-to-Server credentials. Covers the token round-trip, the refresh-before-expiry pattern, scope selection per API surface, and the migration path off deprecated JWT credentials.

If credentials do not exist yet, run `firefly-services-bootstrap` first. If a 401/403 is being thrown despite a valid-looking token, use `firefly-services-troubleshoot`.

## When to Use This Skill

Use this skill when:
- A Firefly Services API call needs an `Authorization: Bearer <token>` header
- A long-running service needs to refresh tokens before they expire (~24h validity)
- The user is migrating from deprecated JWT credentials to OAuth Server-to-Server
- A new endpoint needs different scopes added to the token request
- Auth is being wired into a backend service (Node, Python, Go) calling Firefly APIs

Do **NOT** use this skill when:
- A valid `FIREFLY_SERVICES_ACCESS_TOKEN` is already in scope and not near expiry — reuse it
- The user is doing end-user delegated auth (the documented Firefly Services auth model is server-to-server; as of this writing there is no user-delegated path)
- Credentials do not yet exist — run `firefly-services-bootstrap` first

## Critical: JWT Credentials Have Reached End-of-Life

Service Account (JWT) credentials reached **end-of-life on June 30, 2025**. They continued to function only until their issuing certificate expired, and the final certificate expiry was **March 1, 2026**. That date has passed — every JWT-based integration is now non-functional, and there is no path to renew a JWT certificate.

If you see code like this anywhere, it must be migrated:

```js
// Legacy JWT path — no longer functional (do not use)
const jwt = require('jsonwebtoken');
const token = jwt.sign({...}, privateKey, {algorithm: 'RS256'});
```

The migration is a one-time rebuild — you cannot incrementally upgrade JWT to OAuth. See **JWT → OAuth Migration** below.

## Prerequisites

| Requirement | How to verify |
|---|---|
| OAuth S2S credentials provisioned | `$FIREFLY_SERVICES_CLIENT_ID` and `$FIREFLY_SERVICES_CLIENT_SECRET` exist as env vars |
| Workspace subscribed to the right API | Token must include the relevant Firefly scope (see scope table below) |
| Network access to IMS | `curl -I https://ims-na1.adobelogin.com/ims/token/v3` returns 405 (POST-only) |

## Scope Selection — Pick Only What You Need

The scope string in the token request controls what the token can access. Over-scoping is a security smell — request only the scopes the calling service actually needs.

| Scope | Required for |
|---|---|
| `openid` | Recommended baseline (Adobe getting-started default) |
| `AdobeID` | Recommended baseline (Adobe getting-started default) |
| `session` | Recommended baseline (Adobe getting-started default) |
| `additional_info` | Recommended baseline (Adobe getting-started default) |
| `read_organizations` | Recommended baseline for server-to-server; SDK-documented requirement for Photoshop/Lightroom |
| `firefly_api` | Firefly v1 / legacy endpoints |
| `ff_apis` | Firefly v2 / v3 endpoints (most current workloads) |
| `firefly_enterprise` | Custom Models API |
| `creative_sdk` | Legacy Photoshop/Lightroom scope — the SDK documents `openid, AdobeID, read_organizations` as the current requirement; including `creative_sdk` is harmless |
| `AdobeID,additional_info.projectedProductContext` | Some product-profile-gated services |

The SDK-documented per-API minimums are narrower than the baseline: Firefly is `firefly_api, ff_apis`; Photoshop and Lightroom are `openid, AdobeID, read_organizations`. The baseline rows are Adobe's getting-started default and are safe to include.

**Always request both `firefly_api` and `ff_apis`** when uncertain — the two scopes cover different endpoint generations, and the cost of including both is zero. Include `firefly_enterprise` only for projects using Custom Models. For Photoshop/Lightroom workloads, `openid, AdobeID, read_organizations` covers the SDK-documented requirement; `creative_sdk` is a legacy scope that remains harmless to include.

The canonical scope string for an FDE engagement that uses Firefly + Photoshop + custom models:

```
openid,AdobeID,session,additional_info,read_organizations,firefly_api,ff_apis,firefly_enterprise,creative_sdk
```

## Step 1 — Retrieve an Access Token (Bash)

The IMS token endpoint accepts `application/x-www-form-urlencoded`. Tokens are valid for ~24 hours (`expires_in: 86399` seconds).

```bash
curl --silent --location 'https://ims-na1.adobelogin.com/ims/token/v3' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode "client_id=$FIREFLY_SERVICES_CLIENT_ID" \
  --data-urlencode "client_secret=$FIREFLY_SERVICES_CLIENT_SECRET" \
  --data-urlencode 'scope=openid,AdobeID,session,additional_info,read_organizations,firefly_api,ff_apis'
```

Successful response:

```json
{"access_token": "eyJhbGc...", "token_type": "bearer", "expires_in": 86399}
```

Capture the token and `expires_in` together — the refresh-before-expiry pattern depends on knowing absolute expiry time.

## Step 2 — Retrieve an Access Token (Node.js with SDK)

```js
import { ServerToServerTokenProvider } from '@adobe/firefly-services-common-apis';

const tokenProvider = new ServerToServerTokenProvider(
  {
    clientId: process.env.FIREFLY_SERVICES_CLIENT_ID,
    clientSecret: process.env.FIREFLY_SERVICES_CLIENT_SECRET,
    // scopes is a single comma-separated string, not an array
    scopes: 'openid,AdobeID,session,additional_info,read_organizations,firefly_api,ff_apis',
  },
  // autoRefresh defaults to false, and with it off getToken() throws until
  // you call authenticate() yourself — pass true so getToken() fetches the
  // initial token for you.
  { autoRefresh: true },
);

const accessToken = await tokenProvider.getToken();
```

`ServerToServerTokenProvider` caches the token in memory. With `autoRefresh: true`, `getToken()` fetches the initial token automatically. Note the shipped behavior: once a token is cached, `getToken()` returns the cached value without re-fetching — a token held past its ~24h lifetime is returned as-is. This makes the SDK path a good fit for scripts and short-lived jobs. For a long-running service, use the refresh-before-expiry cache in Step 3, which owns the token lifecycle explicitly.

## Step 3 — Refresh Before Expiry (Production Pattern)

Tokens are valid for ~24 hours. A production service must refresh proactively, not reactively. Reactive refresh (on 401) means in-flight requests fail at the boundary.

Pattern:

```js
class FireflyTokenCache {
  constructor({ clientId, clientSecret, scopes, safetyBufferSec = 300 }) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.scopes = scopes.join(',');
    this.safetyBuffer = safetyBufferSec * 1000;
    this.token = null;
    this.expiresAt = 0;
  }

  async getToken() {
    if (this.token && Date.now() < this.expiresAt - this.safetyBuffer) {
      return this.token;
    }
    await this.refresh();
    return this.token;
  }

  async refresh() {
    const body = new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: this.clientId,
      client_secret: this.clientSecret,
      scope: this.scopes,
    });
    const res = await fetch('https://ims-na1.adobelogin.com/ims/token/v3', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body,
    });
    if (!res.ok) {
      throw new Error(`IMS token refresh failed: ${res.status} ${await res.text()}`);
    }
    const json = await res.json();
    this.token = json.access_token;
    this.expiresAt = Date.now() + json.expires_in * 1000;
  }
}
```

Set `safetyBufferSec` to 5 minutes (300s) at minimum. For high-throughput services, use 15 minutes — the latency of a refresh under load is non-trivial and you do not want it on the request path.

## Step 4 — Multi-Tenant Credential Isolation

When a single service calls Firefly Services on behalf of multiple customers, do **not** share a single set of credentials. Each customer engagement should have its own OAuth S2S credential pair, scoped to that customer's IMS org.

Why: a leaked or rotated credential should not affect other customers. Logs, rate limits, and audit trails are also per-credential — sharing credentials destroys attribution.

Pattern:

```js
const tokenCaches = new Map();  // customerId → FireflyTokenCache

function getTokenCache(customerId) {
  if (!tokenCaches.has(customerId)) {
    const creds = loadCredsForCustomer(customerId); // from your secrets manager
    tokenCaches.set(customerId, new FireflyTokenCache(creds));
  }
  return tokenCaches.get(customerId);
}
```

In multi-tenant FDE deployments (multiple enterprise customers all served by the same service), this pattern is mandatory.

## JWT → OAuth Migration

If a project still carries JWT credentials, it is already broken — the final certificate expiry was March 1, 2026. The migration below is a rebuild of a dead integration, not a live cutover: there is no working JWT path to run in parallel.

### Step A — Provision OAuth S2S credentials in the same workspace

Credential creation is a Developer Console UI step — the `aio console` plugin manages projects, workspaces, and API subscriptions, but has no credential-creation command. Deep-link straight to the existing project/workspace:

```bash
aio console open
```

In the Console UI, open the workspace that holds the JWT credential and add a new credential: **Credentials → Add Credential** (or **Add API** if prompted for a credential type), then choose **OAuth Server-to-Server** and name it `<name>-oauth`. The credential screen presents the `client_id` and `client_secret` — capture both immediately (the secret is not retrievable later, only rotatable) and store them in the secrets manager.

The expired JWT credential can coexist in the same workspace while you stand up OAuth — it no longer functions, so there is no traffic to cut over. Revoke it once the OAuth path is verified (Step C).

### Step B — Replace JWT-signing code with OAuth client-credentials

Every place that signs a JWT and exchanges it at IMS is replaced with a single client-credentials request as shown in Step 1. The Node SDK does this for you (`ServerToServerTokenProvider`).

### Step C — Cut traffic over and decommission JWT

After the OAuth path is verified in production, revoke the JWT credentials in Developer Console. Do not leave them active — abandoned credentials are an audit liability.

## Troubleshooting & Edge Cases

- **`invalid_client`:** Wrong `client_secret`. Re-export `$FIREFLY_SERVICES_CLIENT_SECRET` from the secrets manager.
- **`unauthorized_client`:** The workspace owning these credentials is not subscribed to the API surface the scope is requesting. Re-run `aio console workspace api add` for the missing service.
- **200 OK but empty `access_token`:** Scope string is malformed or includes a scope the credential is not entitled to. Strip the scope list down to the required core set (`openid,AdobeID,session,additional_info,read_organizations,firefly_api,ff_apis`) and reintroduce one scope at a time.
- **Token valid but Firefly API returns 401:** The token is real but does not include `firefly_api` or `ff_apis` scope. Re-issue with the correct scope set.
- **Token valid but Firefly API returns 403:** The token has the scope but the IMS org does not have the entitlement. Customer needs Adobe to provision Firefly Services for that org.
- **Token works locally but fails in production:** Different `client_id`/`client_secret`. Multi-environment deployments need per-environment credentials — never share a credential between Stage and Prod.
- **Token works but in-flight requests sporadically 401:** Refresh-on-expiry race. Increase the safety buffer (5 → 15 minutes) and make the refresh function idempotent (only one refresh in-flight per cache instance at a time).
- **JWT certificate expired:** The integration is non-functional until rebuilt on OAuth. There is no path to renew the JWT certificate.

## Validate

Auth is correctly configured when:

1. `curl` against `/ims/token/v3` returns a non-empty `access_token` and `expires_in: 86399`
2. The token can be used to call at least one Firefly endpoint without 401
3. The refresh-before-expiry pattern is in place — no service depends on reactive 401-and-retry
4. Credentials are loaded from a secrets manager, not from `.env` files in source control
5. Multi-tenant services have per-customer credentials

## Chaining with Other Skills

- `firefly-services-troubleshoot` — Decode auth errors deeper than this skill covers
- `firefly-generate-image-v3-async` — First workload after auth is wired
- `firefly-services-rate-limits` — Token-aware retry and backoff

## References

- [Firefly API Authentication](https://developer.adobe.com/firefly-services/docs/firefly-api/guides/get-started/)
- [IMS OAuth Server-to-Server overview](https://developer.adobe.com/developer-console/docs/guides/authentication/ServerToServerAuthentication/)
- [JWT EOL migration guide](https://developer.adobe.com/developer-console/docs/guides/authentication/ServerToServerAuthentication/migration/)
- [`@adobe/firefly-services-common-apis` SDK](https://www.npmjs.com/package/@adobe/firefly-services-common-apis)

