diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
index 71005eaf063..5c4c4c45955 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
@@ -15,6 +15,7 @@ import {
import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
+import { formatDateTime } from '@sim/utils/formatting'
import { useRouter } from 'next/navigation'
import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
@@ -38,6 +39,7 @@ import {
type ServiceAccountProviderId,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
+import { useSavedTokenExpiryLabel } from '@/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import {
useCreateCredentialDraft,
@@ -98,6 +100,7 @@ export function ConnectedCredentialDetail({
})
const connectMicrosoftDataverseOAuthService = useConnectMicrosoftDataverseOAuthService()
const isAdmin = credential?.role === 'admin'
+ const expiryLabel = useSavedTokenExpiryLabel(credential?.expiresAt)
const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false)
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
@@ -330,6 +333,13 @@ export function ConnectedCredentialDetail({
)}
+ {credential.expiresAt && (
+
+
+ {formatDateTime(new Date(credential.expiresAt), 'UTC')} ยท {expiryLabel}
+
+
+ )}
{
+ root.render()
+ })
+}
+
+describe('useSavedTokenExpiryLabel', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ now: NOW })
+ container = document.createElement('div')
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ vi.useRealTimers()
+ })
+
+ it('reads expired once the token expires while the page stays open', () => {
+ mount(new Date(NOW + 60_000).toISOString())
+ expect(label).toBe('expires in 1 day')
+ act(() => {
+ vi.advanceTimersByTime(60_000)
+ })
+ expect(label).toBe('expired')
+ })
+
+ it('counts the days down while the page stays open', () => {
+ mount(new Date(NOW + 2 * DAY_MS).toISOString())
+ expect(label).toBe('expires in 2 days')
+ act(() => {
+ vi.advanceTimersByTime(DAY_MS + 1)
+ })
+ expect(label).toBe('expires in 1 day')
+ })
+
+ it('times a reconnected token from the moment it arrives, not from when the page opened', () => {
+ mount(new Date(NOW + 3 * DAY_MS).toISOString())
+ act(() => {
+ vi.advanceTimersByTime(6 * 60 * 60 * 1000)
+ })
+ mount(new Date(Date.now() + 60 * 60 * 1000).toISOString())
+ expect(label).toBe('expires in 1 day')
+ act(() => {
+ vi.advanceTimersByTime(60 * 60 * 1000)
+ })
+ expect(label).toBe('expired')
+ })
+
+ it('counts a token that gains an expiry after the page has been open for days from now', () => {
+ mount(undefined)
+ act(() => {
+ vi.advanceTimersByTime(3 * DAY_MS)
+ })
+ mount(new Date(Date.now() + 60 * 60 * 1000).toISOString())
+ expect(label).toBe('expires in 1 day')
+ })
+
+ it('has no caption for a token that states no expiry', () => {
+ mount(undefined)
+ expect(label).toBeUndefined()
+ expect(vi.getTimerCount()).toBe(0)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label.ts b/apps/sim/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label.ts
new file mode 100644
index 00000000000..cc4e40d9f23
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label.ts
@@ -0,0 +1,33 @@
+import { useEffect, useState } from 'react'
+import {
+ msUntilSavedTokenExpiryLabelChanges,
+ savedTokenExpiryLabel,
+} from '@/lib/credentials/expiry-label'
+
+/**
+ * The caption for a saved token's expiry, kept current while the page stays open: it re-reads the
+ * clock when the expiry changes (a reconnect), when the day count drops, and when the token
+ * expires. `undefined` when the token states no readable expiry.
+ */
+export function useSavedTokenExpiryLabel(expiresAt: string | undefined): string | undefined {
+ const expiresAtMs = expiresAt ? new Date(expiresAt).getTime() : Number.NaN
+ const [clock, setClock] = useState(() => ({ expiresAtMs, now: Date.now() }))
+ if (!Object.is(clock.expiresAtMs, expiresAtMs)) {
+ setClock({ expiresAtMs, now: Date.now() })
+ }
+ const { now } = clock
+
+ useEffect(() => {
+ const untilChange = msUntilSavedTokenExpiryLabelChanges(new Date(expiresAtMs), now)
+ if (untilChange === null) return
+ const timer = setTimeout(
+ () => setClock((current) => ({ ...current, now: Date.now() })),
+ untilChange
+ )
+ return () => clearTimeout(timer)
+ }, [expiresAtMs, now])
+
+ return Number.isFinite(expiresAtMs)
+ ? savedTokenExpiryLabel(new Date(expiresAtMs), now)
+ : undefined
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx
index 7817b234ae4..e979b82658c 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx
@@ -26,6 +26,7 @@ import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components/
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
+import { useSavedTokenExpiryLabel } from '@/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
import {
ALL_CATEGORY,
@@ -142,15 +143,32 @@ interface ConnectedItemProps {
name: string
description: string
icon: ComponentType<{ className?: string }>
+ /** ISO time the saved token stops working, when its vendor stated one. */
+ expiresAt?: string
}
-function ConnectedItem({ href, blockType, name, description, icon: Icon }: ConnectedItemProps) {
+function ConnectedItem({
+ href,
+ blockType,
+ name,
+ description,
+ icon: Icon,
+ expiresAt,
+}: ConnectedItemProps) {
+ const expiryLabel = useSavedTokenExpiryLabel(expiresAt)
return (
}
title={name}
description={description}
+ badge={
+ expiryLabel ? (
+
+ {expiryLabel}
+
+ ) : undefined
+ }
href={href}
clickLabel={`Open ${name}`}
navigable
@@ -439,6 +457,7 @@ function IntegrationsContent() {
name={item.name}
description={item.description}
icon={item.icon}
+ expiresAt={item.credential.expiresAt}
/>
))}
diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts
index ca17bf95a48..cfa77a6e223 100644
--- a/apps/sim/lib/api/contracts/credentials.ts
+++ b/apps/sim/lib/api/contracts/credentials.ts
@@ -61,6 +61,8 @@ export const workspaceCredentialSchema = z.object({
envKey: z.string().nullable(),
envOwnerUserId: z.string().nullable(),
instanceUrl: z.string().url().optional(),
+ /** ISO time the saved token stops working, when its vendor stated one; absent when it never expires or is unknown. */
+ expiresAt: z.string().optional(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
diff --git a/apps/sim/lib/credentials/__integration__/saved-token-expiry.integration.ts b/apps/sim/lib/credentials/__integration__/saved-token-expiry.integration.ts
new file mode 100644
index 00000000000..1310caa2248
--- /dev/null
+++ b/apps/sim/lib/credentials/__integration__/saved-token-expiry.integration.ts
@@ -0,0 +1,283 @@
+/**
+ * A saved token whose vendor states an expiry at verification records it on the credential row,
+ * the workspace credential list reports it, and replacing the token replaces (or clears) it, so a
+ * reconnected credential never keeps the previous token's expiry. A provider refusing a token past
+ * that expiry is explained as the expiry, with the provider's own failure kept underneath.
+ */
+
+import { db } from '@sim/db'
+import { credential, member, organization, permissions, user, workspace } from '@sim/db/schema'
+import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
+import { jsonResponse } from '@sim/testing/helpers/http'
+import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock'
+import { generateId } from '@sim/utils/id'
+import { eq } from 'drizzle-orm'
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/posthog/server', () => posthogServerMock)
+
+import {
+ toOrganizationCredential,
+ toWorkspaceCredential,
+} from '@/lib/credentials/application/presentation'
+import { updateCredentialRecord } from '@/lib/credentials/orchestration'
+import { createCredentialRecord } from '@/lib/credentials/orchestration/credential-create'
+import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries'
+import { explainExpiredSavedToken } from '@/lib/credentials/saved-token-expiry'
+import { GITHUB_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors'
+
+const userId = generateId()
+const workspaceId = generateId()
+const organizationId = generateId()
+const originalFetch = globalThis.fetch
+const DAY_MS = 24 * 60 * 60 * 1000
+
+/** Expiry GitHub reports for each fixture token; a token without one never expires. */
+const tokenExpirations = new Map()
+
+/** GitHub's header form, whole seconds with a numeric offset, here UTC's. */
+function githubExpirationHeader(expiresAt: Date): string {
+ return `${expiresAt.toISOString().slice(0, 19).replace('T', ' ')} +0000`
+}
+
+function expiringToken(daysAhead: number): { token: string; expiresAt: Date } {
+ const token = `github_pat_${generateId()}`
+ const expiresAt = new Date(Math.floor((Date.now() + daysAhead * DAY_MS) / 1000) * 1000)
+ tokenExpirations.set(token, expiresAt)
+ return { token, expiresAt }
+}
+
+async function storedExpiry(credentialId: string): Promise {
+ const [row] = await db
+ .select({ expiresAt: credential.accessTokenExpiresAt })
+ .from(credential)
+ .where(eq(credential.id, credentialId))
+ return row?.expiresAt ?? null
+}
+
+async function listedExpiry(credentialId: string): Promise {
+ const page = await listVisibleWorkspaceCredentials({
+ workspaceId,
+ userId,
+ workspaceAccess: { canAdmin: true },
+ })
+ const row = page.data.find((entry) => entry.id === credentialId)
+ if (!row) throw new Error('Expected the credential in the workspace list')
+ return toWorkspaceCredential(row).expiresAt
+}
+
+function create(displayName: string, apiToken: string) {
+ return createCredentialRecord(
+ {
+ workspaceId,
+ type: 'service_account',
+ userId,
+ providerId: GITHUB_SERVICE_ACCOUNT_PROVIDER_ID,
+ displayName,
+ apiToken,
+ },
+ { authorizeWorkspace: true }
+ )
+}
+
+async function reconnect(credentialId: string, apiToken: string) {
+ const [row] = await db.select().from(credential).where(eq(credential.id, credentialId))
+ return updateCredentialRecord({ credentialId, credential: row, actorUserId: userId, apiToken })
+}
+
+describe('saved token expiry', () => {
+ beforeAll(async () => {
+ const now = new Date()
+ await db.insert(user).values({
+ id: userId,
+ name: 'Token expiry fixture',
+ email: `${userId}@token-expiry.test`,
+ emailVerified: true,
+ createdAt: now,
+ updatedAt: now,
+ })
+ await insertWorkspaceFixture(db, {
+ id: workspaceId,
+ name: 'Token expiry fixture',
+ ownerId: userId,
+ billedAccountUserId: userId,
+ })
+ await db.insert(organization).values({
+ id: organizationId,
+ name: 'Token expiry fixture',
+ slug: organizationId,
+ createdAt: now,
+ updatedAt: now,
+ })
+ await db.insert(member).values({
+ id: generateId(),
+ userId,
+ organizationId,
+ role: 'owner',
+ createdAt: now,
+ })
+ await db.insert(permissions).values({
+ id: generateId(),
+ userId,
+ entityType: 'workspace',
+ entityId: workspaceId,
+ permissionType: 'admin',
+ })
+
+ vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
+ const target = typeof input === 'string' || input instanceof URL ? String(input) : input.url
+ if (target === 'https://api.github.com/user') {
+ const authorization = new Headers(init?.headers).get('Authorization') ?? ''
+ const expiresAt = tokenExpirations.get(authorization.replace(/^Bearer /, ''))
+ return jsonResponse(
+ { login: 'octo-bot', id: 1 },
+ expiresAt
+ ? {
+ headers: {
+ 'GitHub-Authentication-Token-Expiration': githubExpirationHeader(expiresAt),
+ },
+ }
+ : 200
+ )
+ }
+ return originalFetch(input, init)
+ })
+ }, 60_000)
+
+ afterAll(async () => {
+ vi.unstubAllGlobals()
+ await db.delete(credential).where(eq(credential.workspaceId, workspaceId))
+ await db.delete(credential).where(eq(credential.organizationId, organizationId))
+ await db.delete(organization).where(eq(organization.id, organizationId))
+ await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId))
+ await db.delete(user).where(eq(user.id, userId))
+ })
+
+ it('records the expiry GitHub states for a new token and lists it', async () => {
+ const { token, expiresAt } = expiringToken(30)
+
+ const created = await create('Expiring token', token)
+
+ expect(created.success).toBe(true)
+ const credentialId = created.credential?.id ?? ''
+ expect(await storedExpiry(credentialId)).toEqual(expiresAt)
+ expect(await listedExpiry(credentialId)).toBe(expiresAt.toISOString())
+ })
+
+ it('records no expiry for a token that never expires', async () => {
+ const created = await create('Lasting token', `ghp_${generateId()}`)
+
+ const credentialId = created.credential?.id ?? ''
+ expect(await storedExpiry(credentialId)).toBeNull()
+ expect(await listedExpiry(credentialId)).toBeUndefined()
+ })
+
+ it('reports the expiry of an organization-owned token', async () => {
+ const { token, expiresAt } = expiringToken(30)
+
+ const created = await createCredentialRecord(
+ {
+ organizationId,
+ type: 'service_account',
+ userId,
+ providerId: GITHUB_SERVICE_ACCOUNT_PROVIDER_ID,
+ displayName: 'Organization token',
+ apiToken: token,
+ },
+ { authorizeWorkspace: false }
+ )
+
+ expect(created.success).toBe(true)
+ const [row] = await db
+ .select()
+ .from(credential)
+ .where(eq(credential.id, created.credential?.id ?? ''))
+ expect(toOrganizationCredential(row).expiresAt).toBe(expiresAt.toISOString())
+ })
+
+ it('replaces the expiry when the token is replaced with another expiring token', async () => {
+ const first = expiringToken(5)
+ const created = await create('Rotated token', first.token)
+ const credentialId = created.credential?.id ?? ''
+ const second = expiringToken(90)
+
+ expect((await reconnect(credentialId, second.token)).success).toBe(true)
+
+ expect(await storedExpiry(credentialId)).toEqual(second.expiresAt)
+ })
+
+ it('clears the expiry when the replacement token never expires', async () => {
+ const created = await create('Made permanent', expiringToken(5).token)
+ const credentialId = created.credential?.id ?? ''
+
+ expect((await reconnect(credentialId, `ghp_${generateId()}`)).success).toBe(true)
+
+ expect(await storedExpiry(credentialId)).toBeNull()
+ expect(await listedExpiry(credentialId)).toBeUndefined()
+ })
+
+ describe('explaining a refused token', () => {
+ const refusal = {
+ status: 401,
+ statusText: 'Unauthorized',
+ data: { message: 'Bad credentials' },
+ }
+
+ async function savedCredential(expiresAt: Date | null) {
+ const created = await create(`Explained ${generateId()}`, `ghp_${generateId()}`)
+ const credentialId = created.credential?.id ?? ''
+ await db
+ .update(credential)
+ .set({ accessTokenExpiresAt: expiresAt })
+ .where(eq(credential.id, credentialId))
+ return credentialId
+ }
+
+ it('names the expiry when the refused token is past it, keeping the provider failure', async () => {
+ const credentialId = await savedCredential(new Date('2026-01-15T00:00:00.000Z'))
+
+ const explained = await explainExpiredSavedToken(credentialId, refusal)
+
+ expect(explained?.message).toBe(
+ 'The saved GitHub personal access token expired on 2026-01-15 UTC. Create a new personal access token and reconnect the credential.'
+ )
+ expect(explained).toMatchObject({
+ status: 401,
+ statusText: 'Unauthorized',
+ data: { message: 'Bad credentials' },
+ })
+ })
+
+ it.each([
+ ['an expiry still ahead', new Date(Date.now() + 30 * DAY_MS)],
+ ['no recorded expiry', null],
+ ])('leaves the refusal to stand for %s', async (_, expiresAt) => {
+ const credentialId = await savedCredential(expiresAt)
+
+ expect(await explainExpiredSavedToken(credentialId, refusal)).toBeNull()
+ })
+
+ it('ignores the expiry of a credential that is not a saved service account', async () => {
+ const credentialId = generateId()
+ await db.insert(credential).values({
+ id: credentialId,
+ workspaceId,
+ type: 'personal_token',
+ providerId: 'gitlab',
+ displayName: 'Personal GitLab token',
+ providerSubjectId: '1',
+ providerTenantId: 'https://gitlab.example.com',
+ encryptedPersonalToken: 'sealed',
+ grantedScopes: ['api'],
+ accessTokenExpiresAt: new Date('2026-01-15T00:00:00.000Z'),
+ createdBy: userId,
+ })
+
+ expect(await explainExpiredSavedToken(credentialId, refusal)).toBeNull()
+ })
+
+ it('leaves the refusal to stand for a credential that no longer exists', async () => {
+ expect(await explainExpiredSavedToken(generateId(), refusal)).toBeNull()
+ })
+ })
+})
diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts
index 50a45db01dd..c652fa13bf3 100644
--- a/apps/sim/lib/credentials/application/presentation.ts
+++ b/apps/sim/lib/credentials/application/presentation.ts
@@ -31,6 +31,15 @@ export function toV2Credential(row: PublicCredentialSource): V2Credential {
}
}
+/**
+ * When a credential's saved token stops working. Only a service account's pasted token and a
+ * personal token keep their expiry on the row; an OAuth grant's tokens live on its account.
+ */
+function savedTokenExpiresAt(row: CredentialRow | VisibleWorkspaceCredential): Date | null {
+ if (row.type !== 'service_account' && row.type !== 'personal_token') return null
+ return row.accessTokenExpiresAt ?? null
+}
+
/** Serializes credential metadata for the internal workspace surface. */
export function toWorkspaceCredential(
row: CredentialRow | VisibleWorkspaceCredential,
@@ -44,6 +53,7 @@ export function toWorkspaceCredential(
? 'admin'
: (access?.member?.role ?? ('role' in row ? row.role : undefined))
const status = access?.member?.status ?? (access?.isAdmin ? 'active' : undefined)
+ const expiresAt = savedTokenExpiresAt(row)
return {
id: row.id,
workspaceId: row.workspaceId,
@@ -59,6 +69,7 @@ export function toWorkspaceCredential(
...(row.type === 'personal_token' && row.providerTenantId
? { instanceUrl: row.providerTenantId }
: {}),
+ ...(expiresAt ? { expiresAt: expiresAt.toISOString() } : {}),
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
@@ -77,6 +88,7 @@ export function toOrganizationCredential(row: CredentialRow): OrganizationCreden
) {
throw new Error('Organization credential presentation requires an organization connection')
}
+ const expiresAt = savedTokenExpiresAt(row)
return {
id: row.id,
organizationId: row.organizationId,
@@ -89,6 +101,7 @@ export function toOrganizationCredential(row: CredentialRow): OrganizationCreden
accountId: row.accountId,
envKey: null,
envOwnerUserId: null,
+ ...(expiresAt ? { expiresAt: expiresAt.toISOString() } : {}),
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
diff --git a/apps/sim/lib/credentials/expiry-label.test.ts b/apps/sim/lib/credentials/expiry-label.test.ts
new file mode 100644
index 00000000000..e46a409b495
--- /dev/null
+++ b/apps/sim/lib/credentials/expiry-label.test.ts
@@ -0,0 +1,21 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { savedTokenExpiryLabel } from '@/lib/credentials/expiry-label'
+
+const NOW = Date.parse('2026-11-11T12:00:00.000Z')
+const DAY_MS = 24 * 60 * 60 * 1000
+
+describe('saved token expiry caption', () => {
+ it.each([
+ ['at the moment it expires', 0, 'expired'],
+ ['after it expired', -DAY_MS, 'expired'],
+ ['with a minute left', 60_000, 'expires in 1 day'],
+ ['with exactly a day left', DAY_MS, 'expires in 1 day'],
+ ['with just over a day left', DAY_MS + 1, 'expires in 2 days'],
+ ['with a month left', 30 * DAY_MS, 'expires in 30 days'],
+ ])('reads %s', (_, remainingMs, label) => {
+ expect(savedTokenExpiryLabel(new Date(NOW + remainingMs), NOW)).toBe(label)
+ })
+})
diff --git a/apps/sim/lib/credentials/expiry-label.ts b/apps/sim/lib/credentials/expiry-label.ts
new file mode 100644
index 00000000000..4d4b31ace7a
--- /dev/null
+++ b/apps/sim/lib/credentials/expiry-label.ts
@@ -0,0 +1,23 @@
+const DAY_MS = 24 * 60 * 60 * 1000
+
+/**
+ * How long a saved token has left, as a lowercase caption: `expired` once the moment has come,
+ * otherwise the whole days left rounded up, so a token with an hour left still reads
+ * `expires in 1 day` rather than as already gone. Client-safe.
+ */
+export function savedTokenExpiryLabel(expiresAt: Date, now: number): string {
+ const remainingMs = expiresAt.getTime() - now
+ if (remainingMs <= 0) return 'expired'
+ const days = Math.ceil(remainingMs / DAY_MS)
+ return `expires in ${days} ${days === 1 ? 'day' : 'days'}`
+}
+
+/**
+ * How long until {@link savedTokenExpiryLabel} reads differently: until the day count drops, or
+ * until the token expires on its last day. `null` once it reads `expired`, which never changes.
+ */
+export function msUntilSavedTokenExpiryLabelChanges(expiresAt: Date, now: number): number | null {
+ const remainingMs = expiresAt.getTime() - now
+ if (!(remainingMs > 0)) return null
+ return remainingMs - (Math.ceil(remainingMs / DAY_MS) - 1) * DAY_MS
+}
diff --git a/apps/sim/lib/credentials/gitlab-personal-token.test.ts b/apps/sim/lib/credentials/gitlab-personal-token.test.ts
index 825d9851022..cc7d96f7882 100644
--- a/apps/sim/lib/credentials/gitlab-personal-token.test.ts
+++ b/apps/sim/lib/credentials/gitlab-personal-token.test.ts
@@ -92,6 +92,17 @@ describe('GitLab personal token verification', () => {
.mockResolvedValueOnce(respond(details))
await expect(verifyGitLabPersonalToken('secret')).rejects.toThrow('api scope')
})
+ it.each([
+ ['midnight UTC on its expiry date', '2027-03-01', new Date('2027-03-01T00:00:00.000Z')],
+ ['no expiry for a date that does not exist', '2027-02-30', null],
+ ['no expiry for a date already past', '2020-01-01', null],
+ ])('records %s', async (_, expiresAt, expected) => {
+ mocks.fetch
+ .mockReset()
+ .mockResolvedValueOnce(respond(user))
+ .mockResolvedValueOnce(respond({ ...token, expires_at: expiresAt }))
+ expect((await verifyGitLabPersonalToken('secret')).expiresAt).toEqual(expected)
+ })
it('does not reflect provider error bodies or retry failed verification', async () => {
mocks.fetch.mockReset().mockResolvedValue(respond({ secret: 'sensitive' }, 401))
await expect(verifyGitLabPersonalToken('secret')).rejects.toThrow('GitLab rejected this token')
diff --git a/apps/sim/lib/credentials/gitlab-personal-token.ts b/apps/sim/lib/credentials/gitlab-personal-token.ts
index a0ee529c7b0..209ddab7496 100644
--- a/apps/sim/lib/credentials/gitlab-personal-token.ts
+++ b/apps/sim/lib/credentials/gitlab-personal-token.ts
@@ -5,6 +5,7 @@ import { resourceScopeFromOwner, sameResourceScope } from '@/lib/core/resource-s
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { isHostNotFound } from '@/lib/credentials/token-service-accounts/errors'
+import { credibleTokenExpiry, gitlabTokenExpiration } from '@/lib/credentials/vendor-token-expiry'
import { normalizeGitLabHost } from '@/tools/gitlab/utils'
const gitLabUserSchema = z.object({
@@ -99,7 +100,7 @@ export async function verifyGitLabPersonalToken(accessToken: string, rawHost?: s
instanceUrl,
displayName: `${user.data.name || user.data.username} (${new URL(instanceUrl).host})`,
grantedScopes: token.data.scopes,
- expiresAt: token.data.expires_at ? new Date(`${token.data.expires_at}T00:00:00Z`) : null,
+ expiresAt: credibleTokenExpiry(gitlabTokenExpiration(token.data.expires_at)) ?? null,
}
}
diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts
index 3efb824c52d..8ea4fc7ac18 100644
--- a/apps/sim/lib/credentials/orchestration/credential-create.ts
+++ b/apps/sim/lib/credentials/orchestration/credential-create.ts
@@ -286,6 +286,7 @@ export async function createCredentialRecord(
let resolvedEnvOwnerUserId: string | null = null
let resolvedEncryptedServiceAccountKey: string | null = null
let resolvedWorkloadIdentitySubject: string | undefined
+ let resolvedTokenExpiresAt: Date | null = null
const extraAuditMetadata: Record = {}
if (type === 'oauth') {
@@ -353,6 +354,7 @@ export async function createCredentialRecord(
if (!resolvedDisplayName) resolvedDisplayName = secret.displayName
resolvedEncryptedServiceAccountKey = secret.encryptedServiceAccountKey
resolvedWorkloadIdentitySubject = secret.newWorkloadIdentitySubject
+ resolvedTokenExpiresAt = secret.tokenExpiresAt ?? null
Object.assign(extraAuditMetadata, secret.auditMetadata)
} catch (error) {
if (error instanceof KeylessCredentialRefusedError) return keylessFailure(error)
@@ -559,6 +561,7 @@ export async function createCredentialRecord(
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey,
+ accessTokenExpiresAt: resolvedTokenExpiresAt,
createdBy: userId,
createdAt: now,
updatedAt: now,
diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts
index 5674a09afd1..b90b7fd64c7 100644
--- a/apps/sim/lib/credentials/orchestration/index.ts
+++ b/apps/sim/lib/credentials/orchestration/index.ts
@@ -480,6 +480,8 @@ export async function updateCredentialRecord(
},
})
updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey
+ // The replaced token's expiry never outlives it: a token that states none clears it.
+ updates.accessTokenExpiresAt = secret.tokenExpiresAt ?? null
rotatedSlackBotUserId = secret.botUserId
rotatedAuditMetadata = secret.auditMetadata
if (needsStoredBoundSubject) rotatedKeylessSecret = secret.encryptedServiceAccountKey
diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts
index 819fb20a27d..38566040553 100644
--- a/apps/sim/lib/credentials/queries.ts
+++ b/apps/sim/lib/credentials/queries.ts
@@ -44,6 +44,8 @@ export interface VisibleWorkspaceCredential {
envKey: string | null
envOwnerUserId: string | null
providerTenantId?: string | null
+ /** When the saved token stops working, for a service account or personal token that states it. */
+ accessTokenExpiresAt?: Date | null
createdBy: string
createdAt: Date
updatedAt: Date
@@ -197,6 +199,7 @@ export async function listVisibleWorkspaceCredentials(params: {
envKey: credential.envKey,
envOwnerUserId: credential.envOwnerUserId,
providerTenantId: credential.providerTenantId,
+ accessTokenExpiresAt: credential.accessTokenExpiresAt,
createdBy: credential.createdBy,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
diff --git a/apps/sim/lib/credentials/saved-token-expiry.ts b/apps/sim/lib/credentials/saved-token-expiry.ts
new file mode 100644
index 00000000000..c36a7b9aa16
--- /dev/null
+++ b/apps/sim/lib/credentials/saved-token-expiry.ts
@@ -0,0 +1,65 @@
+import { db } from '@sim/db'
+import { credential } from '@sim/db/schema'
+import { and, eq, lte } from 'drizzle-orm'
+import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors'
+
+/** The provider's own failure that a saved token's expiry explains. */
+interface ProviderRefusal {
+ status: number
+ statusText?: unknown
+ data?: unknown
+}
+
+/**
+ * A provider refused a saved token that is past the expiry its vendor stated when it was saved.
+ * Its message tells the user to replace the token, while the failure stays the provider's refusal:
+ * it keeps the provider's `status`, `statusText`, and `data`, so callers that branch on the HTTP
+ * failure still see it, and the boundary that replaces the refusal with it carries the refusal's
+ * log attribution over.
+ */
+class SavedTokenExpiredError extends Error {
+ readonly status: number
+ readonly statusText?: string
+ readonly data?: unknown
+
+ constructor(message: string, refusal: ProviderRefusal) {
+ super(message)
+ this.name = 'SavedTokenExpiredError'
+ this.status = refusal.status
+ if (typeof refusal.statusText === 'string') this.statusText = refusal.statusText
+ if (refusal.data !== undefined) this.data = refusal.data
+ }
+}
+
+/**
+ * Explains a provider's refusal of a saved service-account token when the credential records a
+ * vendor-stated expiry that has passed: the run then says the token expired and must be replaced,
+ * instead of reporting a bare authorization failure. Returns `null`, leaving the provider's own
+ * failure to stand, when the credential records no expiry, the expiry is still ahead, or the
+ * credential is not a saved service account. Never refuses a token on its own: only a refusal the
+ * provider already made is explained.
+ */
+export async function explainExpiredSavedToken(
+ credentialId: string,
+ refusal: ProviderRefusal
+): Promise {
+ const [row] = await db
+ .select({ providerId: credential.providerId, expiresAt: credential.accessTokenExpiresAt })
+ .from(credential)
+ .where(
+ and(
+ eq(credential.id, credentialId),
+ eq(credential.type, 'service_account'),
+ lte(credential.accessTokenExpiresAt, new Date())
+ )
+ )
+ .limit(1)
+ if (!row?.expiresAt) return null
+ const descriptor = getTokenServiceAccountDescriptor(row.providerId)
+ const token = descriptor ? `${descriptor.serviceLabel} ${descriptor.tokenNoun}` : 'token'
+ const replacement = descriptor?.tokenNoun ?? 'token'
+ return new SavedTokenExpiredError(
+ `The saved ${token} expired on ${row.expiresAt.toISOString().slice(0, 10)} UTC. Create a new ${replacement} and reconnect the credential.`,
+ refusal
+ )
+}
diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts
index 0bb97d88280..e188431130b 100644
--- a/apps/sim/lib/credentials/service-account-secret.ts
+++ b/apps/sim/lib/credentials/service-account-secret.ts
@@ -131,6 +131,11 @@ export interface ServiceAccountSecretResult {
principal: ServiceAccountPrincipal | null
/** Slack custom bot: the derived bot user id (for reaction self-drop). */
botUserId?: string
+ /**
+ * When the vendor stops accepting the saved token, for a pasted token whose vendor states it at
+ * verification. Absent when the token never expires or the vendor does not say.
+ */
+ tokenExpiresAt?: Date
/**
* Keyless AWS, Databricks, Snowflake, Microsoft app, or Google: a subject this save binds that
* the credential did not already hold, which must not belong to another credential of the owner.
@@ -612,6 +617,7 @@ async function buildTokenServiceAccountSecret(
displayName: validation.displayName,
auditMetadata: { ...validation.auditMetadata, ...principalMetadata },
principal: validation.principal,
+ ...(validation.expiresAt ? { tokenExpiresAt: validation.expiresAt } : {}),
}
}
diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts
index af49d437e10..aa4735ffdad 100644
--- a/apps/sim/lib/credentials/token-service-accounts/server.ts
+++ b/apps/sim/lib/credentials/token-service-accounts/server.ts
@@ -291,6 +291,11 @@ export interface TokenServiceAccountValidationResult {
normalizedDomain?: string
/** Normalized account id to persist instead of the raw user input (when collected). */
normalizedWorkspaceId?: string
+ /**
+ * When the vendor stops accepting the token, for a vendor that states it at verification.
+ * Absent when the token never expires or the vendor does not say.
+ */
+ expiresAt?: Date
}
export type TokenServiceAccountValidator = (
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/github.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/github.test.ts
index 088cddc504a..4c1e7b0765b 100644
--- a/apps/sim/lib/credentials/token-service-accounts/validators/github.test.ts
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/github.test.ts
@@ -2,7 +2,7 @@
* @vitest-environment node
*/
import { jsonResponse } from '@sim/testing/helpers/http'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { validateGitHubServiceAccount } from '@/lib/credentials/token-service-accounts/validators/token-checks'
const TOKEN = 'test-github-token'
@@ -61,4 +61,64 @@ describe('GitHub saved personal access token verification', () => {
code: 'provider_unavailable',
})
})
+
+ describe('token expiry', () => {
+ const NOW = new Date('2026-11-11T12:00:00.000Z')
+
+ beforeEach(() => {
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(NOW)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ function userReply(expiration?: string) {
+ return jsonResponse(
+ { login: 'octo-bot', id: 1 },
+ expiration ? { headers: { 'GitHub-Authentication-Token-Expiration': expiration } } : 200
+ )
+ }
+
+ it.each([
+ ['a timestamp with a UTC offset', '2026-12-01 10:30:00 +0000', '2026-12-01T10:30:00.000Z'],
+ [
+ 'a timestamp with a positive offset',
+ '2026-12-01 12:30:00 +0200',
+ '2026-12-01T10:30:00.000Z',
+ ],
+ [
+ 'a timestamp with a negative offset',
+ '2026-12-01 03:30:00 -0700',
+ '2026-12-01T10:30:00.000Z',
+ ],
+ ['a timestamp minutes away', '2026-11-11 12:30:00 +0000', '2026-11-11T12:30:00.000Z'],
+ ['a timestamp marked UTC', '2026-12-01 10:30:00 UTC', '2026-12-01T10:30:00.000Z'],
+ ])('records the expiry GitHub reports as %s', async (_, header, expected) => {
+ fetch.mockResolvedValue(userReply(header))
+
+ const result = await validateGitHubServiceAccount({ apiToken: TOKEN })
+
+ expect(result.expiresAt?.toISOString()).toBe(expected)
+ })
+
+ it.each([
+ ['no expiry header (a token that never expires)', undefined],
+ ['an unreadable expiry', 'next Tuesday'],
+ ['a zone name other than UTC', '2026-12-01 10:30:00 PST'],
+ ['a lowercase utc marker', '2026-12-01 10:30:00 utc'],
+ ['an impossible date', '2026-13-45 10:00:00 +0000'],
+ ['an offset hour past 23', '2026-12-01 10:30:00 +2400'],
+ ['an offset minute past 59', '2026-12-01 10:30:00 -0060'],
+ ['an expiry in the past, contradicting the accepted request', '2026-11-10 12:00:00 +0000'],
+ ])('records no expiry for %s and still accepts the token', async (_, header) => {
+ fetch.mockResolvedValue(userReply(header))
+
+ const result = await validateGitHubServiceAccount({ apiToken: TOKEN })
+
+ expect(result.displayName).toBe('octo-bot (GitHub)')
+ expect(result.expiresAt).toBeUndefined()
+ })
+ })
})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/gitlab.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/gitlab.test.ts
index 9edffc5060b..7346f3ccbf2 100644
--- a/apps/sim/lib/credentials/token-service-accounts/validators/gitlab.test.ts
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/gitlab.test.ts
@@ -6,7 +6,7 @@ import {
inputValidationMock,
inputValidationMockFns,
} from '@sim/testing/mocks/input-validation.mock'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
@@ -99,4 +99,55 @@ describe('GitLab saved token verification', () => {
validateGitLabServiceAccount({ apiToken: 'test-gitlab-token', domain: 'gitlab.com' })
).rejects.toMatchObject({ code: 'provider_unavailable' })
})
+
+ describe('token expiry', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(new Date('2026-11-11T12:00:00.000Z'))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('records the expiry date as midnight UTC, when GitLab stops accepting the token', async () => {
+ fetch.mockResolvedValue(jsonResponse({ ...activeToken, expires_at: '2026-12-01' }))
+
+ const result = await validateGitLabServiceAccount({
+ apiToken: 'test-gitlab-token',
+ domain: 'gitlab.com',
+ })
+
+ expect(result.expiresAt?.toISOString()).toBe('2026-12-01T00:00:00.000Z')
+ })
+
+ it('records an expiry that is less than an hour away', async () => {
+ vi.setSystemTime(new Date('2026-11-11T23:30:00.000Z'))
+ fetch.mockResolvedValue(jsonResponse({ ...activeToken, expires_at: '2026-11-12' }))
+
+ const result = await validateGitLabServiceAccount({
+ apiToken: 'test-gitlab-token',
+ domain: 'gitlab.com',
+ })
+
+ expect(result.expiresAt?.toISOString()).toBe('2026-11-12T00:00:00.000Z')
+ })
+
+ it.each([
+ ['a token without an expiry', null],
+ ['an unreadable expiry', 'soon'],
+ ['an impossible date', '2027-02-30'],
+ ['an expiry that already passed, contradicting the accepted token', '2026-11-11'],
+ ])('records no expiry for %s and still accepts the token', async (_, expiresAt) => {
+ fetch.mockResolvedValue(jsonResponse({ ...activeToken, expires_at: expiresAt }))
+
+ const result = await validateGitLabServiceAccount({
+ apiToken: 'test-gitlab-token',
+ domain: 'gitlab.com',
+ })
+
+ expect(result.displayName).toBe('sim-workflows (gitlab.com)')
+ expect(result.expiresAt).toBeUndefined()
+ })
+ })
})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts
index e9aabc970b7..9b68a505134 100644
--- a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts
@@ -20,6 +20,7 @@ import type {
TokenServiceAccountValidationResult,
TokenServiceAccountValidator,
} from '@/lib/credentials/token-service-accounts/server'
+import { credibleTokenExpiry } from '@/lib/credentials/vendor-token-expiry'
interface TokenCheckRequest {
url: string
@@ -86,6 +87,32 @@ interface TokenCheck {
* {@link rejectsKey}.
*/
provesKey?: (errorBody: unknown) => boolean
+ /**
+ * For a vendor that states when the verified token stops working: reads that moment from the
+ * accepted response, whose `body` is the parsed JSON when {@link acceptsBody} read it. Returns
+ * `undefined` when the vendor states no expiry or one that cannot be read; the token is still
+ * accepted either way.
+ */
+ expiresAt?: (accepted: {
+ headers: ProviderResponse['headers']
+ body: unknown
+ }) => Date | undefined
+}
+
+/** The expiry a check states for an accepted token, when it is credible. */
+function credibleExpiry(
+ check: TokenCheck,
+ accepted: { headers: ProviderResponse['headers']; body: unknown }
+): Date | undefined {
+ return credibleTokenExpiry(check.expiresAt?.(accepted))
+}
+
+/** `accepted` with the expiry the check states, when there is a credible one. */
+function withExpiry(
+ accepted: TokenServiceAccountValidationResult,
+ expiresAt: Date | undefined
+): TokenServiceAccountValidationResult {
+ return expiresAt ? { ...accepted, expiresAt } : accepted
}
const MAX_CHECK_RESPONSE_BYTES = 64 * 1024
@@ -275,13 +302,22 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid
if (!verdict) {
throw new TokenServiceAccountValidationError('invalid_credentials', 400, { step: 'verify' })
}
+ const expiresAt = credibleExpiry(check, { headers: response.headers, body })
if (typeof verdict === 'object') {
const name = verdict.displayName
- return { ...accepted, displayName: where ? `${name} (${where})` : name }
+ return withExpiry(
+ { ...accepted, displayName: where ? `${name} (${where})` : name },
+ expiresAt
+ )
}
- return accepted
+ return withExpiry(accepted, expiresAt)
}
await response.body?.cancel().catch(() => undefined)
- return accepted
+ return withExpiry(
+ accepted,
+ response.ok
+ ? credibleExpiry(check, { headers: response.headers, body: undefined })
+ : undefined
+ )
}
}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts
index 12555723594..2efeec0abe4 100644
--- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts
@@ -19,6 +19,7 @@ import {
readJsonBody,
tokenCheckValidator,
} from '@/lib/credentials/token-service-accounts/validators/http'
+import { githubTokenExpiration, gitlabTokenExpiration } from '@/lib/credentials/vendor-token-expiry'
import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/lib/internal/jupyter/protocol'
import { ashbyAuthHeaders } from '@/tools/ashby/utils'
import { BITBUCKET_API_BASE, bitbucketHeaders } from '@/tools/bitbucket/utils'
@@ -635,6 +636,8 @@ export const validateGitHubServiceAccount = tokenCheckValidator({
}
return { displayName: `${login.trim()} (GitHub)` }
},
+ expiresAt: ({ headers }) =>
+ githubTokenExpiration(headers.get('github-authentication-token-expiration')),
})
/**
@@ -708,6 +711,7 @@ export const validateGitLabServiceAccount = tokenCheckValidator({
const name = typeof token.name === 'string' ? token.name.trim() : ''
return usable && name ? { displayName: name } : usable
},
+ expiresAt: ({ body }) => gitlabTokenExpiration(toRecord(body).expires_at),
})
/**
diff --git a/apps/sim/lib/credentials/vendor-token-expiry.ts b/apps/sim/lib/credentials/vendor-token-expiry.ts
new file mode 100644
index 00000000000..6b72a51567a
--- /dev/null
+++ b/apps/sim/lib/credentials/vendor-token-expiry.ts
@@ -0,0 +1,72 @@
+/**
+ * Readers for the expiry a vendor states for a token it just accepted. Each returns `undefined`
+ * for a missing value, an unreadable one, or a calendar date that does not exist, so a caller never
+ * records a wrong moment.
+ */
+
+/**
+ * The UTC moment for a calendar date and time, or `undefined` for one that does not exist (a
+ * 30 February, hour 24): `Date` would roll it over into a different, wrong moment.
+ */
+function calendarMomentUtc(
+ [year, month, day, hour = 0, minute = 0, second = 0]: readonly number[],
+ offsetMinutes = 0
+): Date | undefined {
+ const local = new Date(Date.UTC(year, month - 1, day, hour, minute, second))
+ if (
+ local.getUTCFullYear() !== year ||
+ local.getUTCMonth() !== month - 1 ||
+ local.getUTCDate() !== day ||
+ local.getUTCHours() !== hour ||
+ local.getUTCMinutes() !== minute ||
+ local.getUTCSeconds() !== second
+ ) {
+ return undefined
+ }
+ return new Date(local.getTime() - offsetMinutes * 60_000)
+}
+
+/**
+ * `2026-12-01 12:30:00 +0200` or `2026-12-01 10:30:00 UTC`. GitHub documents the header but not
+ * its value format, so this is parsed strictly by design: the two forms accepted are the ones in
+ * the API responses GitHub's own client library records (octokit/octokit.rb spec cassettes), and
+ * any other form (including an offset past 23 hours or 59 minutes) is left unread.
+ */
+const GITHUB_TOKEN_EXPIRATION =
+ /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-](?:[01]\d|2[0-3])[0-5]\d|UTC)$/
+
+/**
+ * Reads GitHub's `GitHub-Authentication-Token-Expiration` response header, which GitHub sends on
+ * API calls made with a personal access token that has an expiration date, and omits for one that
+ * never expires. https://github.blog/changelog/2021-07-26-expiration-options-for-personal-access-tokens/
+ */
+export function githubTokenExpiration(header: string | null): Date | undefined {
+ const match = header ? GITHUB_TOKEN_EXPIRATION.exec(header.trim()) : null
+ if (!match) return undefined
+ const offset = match[7]
+ const offsetMinutes =
+ offset === 'UTC'
+ ? 0
+ : (offset.startsWith('-') ? -1 : 1) *
+ (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(3, 5)))
+ return calendarMomentUtc(match.slice(1, 7).map(Number), offsetMinutes)
+}
+
+/**
+ * GitLab's `expires_at` date for a token: it stops working at midnight UTC on that date.
+ * https://docs.gitlab.com/user/profile/personal_access_tokens/
+ */
+export function gitlabTokenExpiration(expiresAt: unknown): Date | undefined {
+ const match = typeof expiresAt === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(expiresAt) : null
+ return match ? calendarMomentUtc(match.slice(1, 4).map(Number)) : undefined
+}
+
+/**
+ * A stated expiry for a token the vendor just accepted, when it is still ahead. The acceptance
+ * proves the token works now, so a moment already past contradicts the request (a misread value or
+ * a skewed clock) and is dropped rather than recorded as fact; an imminent one is kept.
+ */
+export function credibleTokenExpiry(expiresAt: Date | undefined): Date | undefined {
+ if (!expiresAt || Number.isNaN(expiresAt.getTime())) return undefined
+ return expiresAt.getTime() > Date.now() ? expiresAt : undefined
+}
diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts
index ca73c9649ab..81b168d403d 100644
--- a/apps/sim/tools/index.test.ts
+++ b/apps/sim/tools/index.test.ts
@@ -234,6 +234,14 @@ vi.mock('@/lib/credentials/coordinated-client-token', () => ({
mockInvalidateCoordinatedClientToken(...args),
}))
+const { mockExplainExpiredSavedToken } = vi.hoisted(() => ({
+ mockExplainExpiredSavedToken: vi.fn(),
+}))
+
+vi.mock('@/lib/credentials/saved-token-expiry', () => ({
+ explainExpiredSavedToken: (...args: unknown[]) => mockExplainExpiredSavedToken(...args),
+}))
+
vi.mock('@/executor/handlers/workflow/tool-runner', () => ({
runWorkflowTool: (...args: unknown[]) => mockRunWorkflowTool(...args),
}))
@@ -3781,6 +3789,106 @@ describe('Internal Route Trust', () => {
})
})
+ describe('a saved token the provider refuses after it expired', () => {
+ const toolId = 'test_expired_saved_token'
+ const EXPIRED = 'The saved GitHub personal access token expired on 2026-01-15.'
+
+ beforeEach(() => {
+ mockExplainExpiredSavedToken.mockReset()
+ mockExplainExpiredSavedToken.mockResolvedValue(null)
+ mockInvalidateCoordinatedClientToken.mockReset()
+ mockInvalidateCoordinatedClientToken.mockResolvedValue(undefined)
+ ;(tools as Record)[toolId] = {
+ id: toolId,
+ name: 'Expired Saved Token Test',
+ description: 'Calls a provider with a saved token',
+ version: '1.0.0',
+ oauth: { required: true, provider: 'github' },
+ params: { accessToken: { type: 'string', required: true, visibility: 'hidden' } },
+ request: {
+ url: 'https://api.github.com/user',
+ method: 'GET' as const,
+ headers: (params: Record) => ({
+ Authorization: `Bearer ${String(params.accessToken)}`,
+ }),
+ },
+ }
+ })
+ afterEach(() => {
+ Reflect.deleteProperty(tools, toolId)
+ })
+
+ async function callWith(credentialType: string, status: number) {
+ mockResolveExecutorCredentialToken.mockResolvedValue({
+ accessToken: 'saved-token',
+ credentialType,
+ })
+ mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
+ toSecureFetchResponse(
+ new Response(JSON.stringify({ message: 'Bad credentials' }), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ })
+ )
+ )
+ return executeTool(toolId, { credential: 'saved-credential' })
+ }
+
+ it('reports the expiry instead of the bare refusal', async () => {
+ mockExplainExpiredSavedToken.mockImplementation(async (_id, refusal) =>
+ Object.assign(new Error(EXPIRED), refusal)
+ )
+
+ const result = await callWith('service_account', 401)
+
+ expect(result).toMatchObject({ success: false, error: EXPIRED })
+ expect(mockExplainExpiredSavedToken).toHaveBeenCalledWith(
+ 'saved-credential',
+ expect.objectContaining({ status: 401, data: { message: 'Bad credentials' } })
+ )
+ })
+
+ it("logs the explained refusal as the provider's refusal, with its response body", async () => {
+ mockExplainExpiredSavedToken.mockImplementation(async (_id, refusal) =>
+ Object.assign(new Error(EXPIRED), refusal)
+ )
+ mockToolsLogger.warn.mockClear()
+
+ const result = await callWith('service_account', 401)
+
+ expect(classifyFailure(adoptToolFailure(new Error(result.error), result))).toBe(
+ 'third_party_client'
+ )
+ const [, fields] =
+ mockToolsLogger.warn.mock.calls.find(([message]) => message === 'Error executing tool') ??
+ []
+ expect(fields).toMatchObject({ error: EXPIRED, errorData: { message: 'Bad credentials' } })
+ })
+
+ it("keeps the provider's message when the token has not expired", async () => {
+ const result = await callWith('service_account', 401)
+
+ expect(result).toMatchObject({ success: false, error: 'Bad credentials' })
+ })
+
+ it("keeps the provider's message when the expiry cannot be read", async () => {
+ mockExplainExpiredSavedToken.mockRejectedValue(new Error('database unavailable'))
+
+ const result = await callWith('service_account', 401)
+
+ expect(result).toMatchObject({ success: false, error: 'Bad credentials' })
+ })
+
+ it.each([
+ ['a refusal that is not a 401', 'service_account', 403],
+ ["a signed-in account's refusal", 'oauth', 401],
+ ])('never consults the saved expiry for %s', async (_label, credentialType, status) => {
+ await callWith(credentialType, status)
+
+ expect(mockExplainExpiredSavedToken).not.toHaveBeenCalled()
+ })
+ })
+
describe('a service-account token an internal operation refuses', () => {
const toolId = 'test_internal_minted_token_refusal'
beforeEach(() => {
@@ -3851,6 +3959,21 @@ describe('Internal Route Trust', () => {
expect(mockForgetRejectedClientCredentialToken).not.toHaveBeenCalled()
})
+
+ it('reports an expired saved token instead of the bare refusal', async () => {
+ mockExplainExpiredSavedToken.mockReset()
+ mockExplainExpiredSavedToken.mockImplementation(async (_id, refusal) =>
+ Object.assign(new Error('The saved token expired.'), refusal)
+ )
+
+ const result = await runWith('service_account', 401)
+
+ expect(result).toMatchObject({ success: false, error: 'The saved token expired.' })
+ expect(mockExplainExpiredSavedToken).toHaveBeenCalledWith(
+ 'saved-credential',
+ expect.objectContaining({ status: 401 })
+ )
+ })
})
describe('Dataverse environment authority', () => {
diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts
index ee722855039..dfb2e72afef 100644
--- a/apps/sim/tools/index.ts
+++ b/apps/sim/tools/index.ts
@@ -23,6 +23,7 @@ import {
} from '@/lib/core/errors/database-query-error'
import {
classifyFailure,
+ inheritFailureMarks,
logFailureOnce,
markFailureKind,
markFailureLogged,
@@ -2326,6 +2327,14 @@ async function executeToolImplementation(
internalSandboxProfile,
refreshCredential,
authorizedServiceAccountCredential,
+ }).catch(async (error: unknown) => {
+ throw authorizedServiceAccountCredential
+ ? await explainExpiredServiceAccountToken(
+ error,
+ authorizedServiceAccountCredential.credentialId,
+ requestId
+ )
+ : error
})
let finalResult = result
@@ -2424,6 +2433,11 @@ async function executeToolImplementation(
).catch(async (error: unknown) => {
if (authorizedServiceAccountCredential) {
await expireRejectedServiceAccountToken(error, contextParams.accessToken, requestId)
+ throw await explainExpiredServiceAccountToken(
+ error,
+ authorizedServiceAccountCredential.credentialId,
+ requestId
+ )
}
throw error
})
@@ -3094,6 +3108,37 @@ async function expireRejectedServiceAccountToken(
}
}
+/**
+ * A 401 for a saved service-account token past the expiry its vendor stated when it was saved
+ * becomes an error that says so and asks for a new token, keeping the provider's status and body
+ * and the refusal's log attribution and logged response body. Any other failure, or one whose
+ * expiry cannot be read, is returned unchanged.
+ */
+async function explainExpiredServiceAccountToken(
+ error: unknown,
+ credentialId: string,
+ requestId: string
+): Promise {
+ if (!(error instanceof Error) || !('status' in error) || error.status !== 401) return error
+ try {
+ const { explainExpiredSavedToken } = await import('@/lib/credentials/saved-token-expiry')
+ const explained = await explainExpiredSavedToken(credentialId, {
+ status: error.status,
+ statusText: 'statusText' in error ? error.statusText : undefined,
+ data: 'data' in error ? error.data : undefined,
+ })
+ if (!explained) return error
+ if (externalHttpFailures.has(error)) externalHttpFailures.add(explained)
+ return inheritFailureMarks(explained, error)
+ } catch (lookupError) {
+ logger.warn('Could not check whether a refused saved token had expired', {
+ requestId,
+ error: toError(lookupError).message,
+ })
+ return error
+ }
+}
+
/**
* Drops a refused service-account token from this instance's client-credential cache: an internal
* operation answers 401 or 403, and an external request 401, when the provider refused it. The