Skip to content

Commit 0d77c23

Browse files
authored
feat(credentials): record, show, and explain saved token expiry (#9930)
* feat(credentials): record when a saved GitHub or GitLab token expires GitHub reports a personal access token's expiry in the GitHub-Authentication-Token-Expiration response header and GitLab in the expires_at of /personal_access_tokens/self. The token check factory gains an expiresAt reader, keeps only a credible moment (a real calendar time at least an hour ahead, since the vendor just accepted the token), and the create and reconnect paths store it on credential.access_token_expires_at, the column a personal token already uses for the same meaning. A replacement token that states no expiry clears the previous one. The workspace credential contract reports it as an optional expiresAt. * feat(credentials): say a saved token expired when its provider refuses it When a provider answers a saved service-account token with a 401 and the credential records a vendor-stated expiry that has passed, the run now reports that the token expired on that date and asks for a new one, instead of the bare authorization failure. The explanation keeps the provider's status, statusText, and body, and classifies as user-actionable like a revoked grant. A token is never refused on the recorded expiry alone: only a refusal the provider already made is explained, for both external requests and internal operations. * feat(integrations): show when a saved token expires A connected credential whose saved token has a recorded expiry shows "expires in N days" or "expired" in the integrations list, the same muted caption API keys use, and a Token expiry section with the date on its detail page. * refactor(credentials): keep the expired saved-token error private to its module * refactor(credentials): share the vendor token expiry readers with the GitLab personal token The GitHub and GitLab expiry readers move to one module so the GitLab personal token path reads expires_at the same strict way: a date that does not exist is recorded as no expiry instead of rolling over into another day. * feat(credentials): report a saved token's expiry on organization credentials The organization credential presentation reports the same optional expiresAt the workspace one does. * fix(credentials): log an expired saved token's refusal as the provider's refusal The expired-token explanation is a user failure rather than a revoked grant, so it never triggers the revoked-grant handling, and it inherits the refusal's failure classification, logged mark, and external response body so the failure is still attributed to the provider and logged once. * fix(integrations): show a saved token's expiry time in UTC GitHub states the expiry to the second, so the detail page shows the date and time in UTC rather than a date that can fall on the wrong day locally. * fix(credentials): keep an imminent stated token expiry and read only GitHub's sourced header form Only an expiry that has already passed contradicts the vendor's acceptance; one that is minutes away is real and is now recorded. GitHub does not document the expiration header's value format, so the reader accepts only the numeric-offset form GitHub's own client library records and leaves any other form unread. * fix(credentials): say an expired saved token's date is UTC and keep it the provider's refusal The expired-token error extended UserFailure, but the boundary always carries the provider refusal's attribution onto it, so the base did nothing and its docs contradicted the logged classification. It is now a plain Error, and the run message names the date as UTC. * fix(credentials): read GitHub's UTC-marked token expiration header form GitHub's octokit.rb spec cassettes record the GitHub-Authentication-Token-Expiration header both with a numeric offset and as 'YYYY-MM-DD HH:MM:SS UTC'. Accept the UTC-marked form as offset zero so those tokens get their expiry recorded; every other zone name stays unread. * fix(credentials): keep saved-token expiry captions current and drop a past GitLab personal-token expiry The integrations list and detail captions re-read the clock when the day count drops or the token expires, instead of only on render. A GitLab personal token's stated expiry that is already past is dropped, the same check the service-account validators apply, now shared as credibleTokenExpiry. * fix(credentials): time a reconnected token's expiry caption from now and reject out-of-range GitHub offsets
1 parent 7288b2d commit 0d77c23

24 files changed

Lines changed: 983 additions & 7 deletions

‎apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { ArrowLeft } from '@sim/emcn/icons'
1616
import { createLogger } from '@sim/logger'
1717
import { getErrorMessage } from '@sim/utils/errors'
18+
import { formatDateTime } from '@sim/utils/formatting'
1819
import { useRouter } from 'next/navigation'
1920
import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
2021
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
@@ -38,6 +39,7 @@ import {
3839
type ServiceAccountProviderId,
3940
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
4041
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
42+
import { useSavedTokenExpiryLabel } from '@/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label'
4143
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
4244
import {
4345
useCreateCredentialDraft,
@@ -98,6 +100,7 @@ export function ConnectedCredentialDetail({
98100
})
99101
const connectMicrosoftDataverseOAuthService = useConnectMicrosoftDataverseOAuthService()
100102
const isAdmin = credential?.role === 'admin'
103+
const expiryLabel = useSavedTokenExpiryLabel(credential?.expiresAt)
101104

102105
const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false)
103106
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
@@ -330,6 +333,13 @@ export function ConnectedCredentialDetail({
330333
<ChipCopyInput value={credential.instanceUrl} copyLabel='Copy GitLab instance' />
331334
</DetailSection>
332335
)}
336+
{credential.expiresAt && (
337+
<DetailSection title='Token expiry'>
338+
<span className='text-[var(--text-secondary)] text-small'>
339+
{formatDateTime(new Date(credential.expiresAt), 'UTC')} · {expiryLabel}
340+
</span>
341+
</DetailSection>
342+
)}
333343
</CredentialDetailLayout>
334344

335345
<ChipConfirmModal
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { act } from 'react'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { useSavedTokenExpiryLabel } from '@/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label'
9+
10+
const NOW = Date.parse('2026-11-11T12:00:00.000Z')
11+
const DAY_MS = 24 * 60 * 60 * 1000
12+
13+
let container: HTMLDivElement
14+
let root: Root
15+
let label: string | undefined
16+
17+
function Harness({ expiresAt }: { expiresAt?: string }) {
18+
label = useSavedTokenExpiryLabel(expiresAt)
19+
return null
20+
}
21+
22+
function mount(expiresAt?: string) {
23+
act(() => {
24+
root.render(<Harness expiresAt={expiresAt} />)
25+
})
26+
}
27+
28+
describe('useSavedTokenExpiryLabel', () => {
29+
beforeEach(() => {
30+
vi.useFakeTimers({ now: NOW })
31+
container = document.createElement('div')
32+
root = createRoot(container)
33+
})
34+
35+
afterEach(() => {
36+
act(() => root.unmount())
37+
vi.useRealTimers()
38+
})
39+
40+
it('reads expired once the token expires while the page stays open', () => {
41+
mount(new Date(NOW + 60_000).toISOString())
42+
expect(label).toBe('expires in 1 day')
43+
act(() => {
44+
vi.advanceTimersByTime(60_000)
45+
})
46+
expect(label).toBe('expired')
47+
})
48+
49+
it('counts the days down while the page stays open', () => {
50+
mount(new Date(NOW + 2 * DAY_MS).toISOString())
51+
expect(label).toBe('expires in 2 days')
52+
act(() => {
53+
vi.advanceTimersByTime(DAY_MS + 1)
54+
})
55+
expect(label).toBe('expires in 1 day')
56+
})
57+
58+
it('times a reconnected token from the moment it arrives, not from when the page opened', () => {
59+
mount(new Date(NOW + 3 * DAY_MS).toISOString())
60+
act(() => {
61+
vi.advanceTimersByTime(6 * 60 * 60 * 1000)
62+
})
63+
mount(new Date(Date.now() + 60 * 60 * 1000).toISOString())
64+
expect(label).toBe('expires in 1 day')
65+
act(() => {
66+
vi.advanceTimersByTime(60 * 60 * 1000)
67+
})
68+
expect(label).toBe('expired')
69+
})
70+
71+
it('counts a token that gains an expiry after the page has been open for days from now', () => {
72+
mount(undefined)
73+
act(() => {
74+
vi.advanceTimersByTime(3 * DAY_MS)
75+
})
76+
mount(new Date(Date.now() + 60 * 60 * 1000).toISOString())
77+
expect(label).toBe('expires in 1 day')
78+
})
79+
80+
it('has no caption for a token that states no expiry', () => {
81+
mount(undefined)
82+
expect(label).toBeUndefined()
83+
expect(vi.getTimerCount()).toBe(0)
84+
})
85+
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useEffect, useState } from 'react'
2+
import {
3+
msUntilSavedTokenExpiryLabelChanges,
4+
savedTokenExpiryLabel,
5+
} from '@/lib/credentials/expiry-label'
6+
7+
/**
8+
* The caption for a saved token's expiry, kept current while the page stays open: it re-reads the
9+
* clock when the expiry changes (a reconnect), when the day count drops, and when the token
10+
* expires. `undefined` when the token states no readable expiry.
11+
*/
12+
export function useSavedTokenExpiryLabel(expiresAt: string | undefined): string | undefined {
13+
const expiresAtMs = expiresAt ? new Date(expiresAt).getTime() : Number.NaN
14+
const [clock, setClock] = useState(() => ({ expiresAtMs, now: Date.now() }))
15+
if (!Object.is(clock.expiresAtMs, expiresAtMs)) {
16+
setClock({ expiresAtMs, now: Date.now() })
17+
}
18+
const { now } = clock
19+
20+
useEffect(() => {
21+
const untilChange = msUntilSavedTokenExpiryLabelChanges(new Date(expiresAtMs), now)
22+
if (untilChange === null) return
23+
const timer = setTimeout(
24+
() => setClock((current) => ({ ...current, now: Date.now() })),
25+
untilChange
26+
)
27+
return () => clearTimeout(timer)
28+
}, [expiresAtMs, now])
29+
30+
return Number.isFinite(expiresAtMs)
31+
? savedTokenExpiryLabel(new Date(expiresAtMs), now)
32+
: undefined
33+
}

‎apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components/
2626
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
2727
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
2828
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
29+
import { useSavedTokenExpiryLabel } from '@/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label'
2930
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
3031
import {
3132
ALL_CATEGORY,
@@ -142,15 +143,32 @@ interface ConnectedItemProps {
142143
name: string
143144
description: string
144145
icon: ComponentType<{ className?: string }>
146+
/** ISO time the saved token stops working, when its vendor stated one. */
147+
expiresAt?: string
145148
}
146149

147-
function ConnectedItem({ href, blockType, name, description, icon: Icon }: ConnectedItemProps) {
150+
function ConnectedItem({
151+
href,
152+
blockType,
153+
name,
154+
description,
155+
icon: Icon,
156+
expiresAt,
157+
}: ConnectedItemProps) {
158+
const expiryLabel = useSavedTokenExpiryLabel(expiresAt)
148159
return (
149160
<SettingsResourceRow
150161
iconVariant='custom'
151162
icon={<IntegrationTile blockType={blockType} icon={Icon} />}
152163
title={name}
153164
description={description}
165+
badge={
166+
expiryLabel ? (
167+
<span className='whitespace-nowrap text-[var(--text-muted)] text-caption'>
168+
{expiryLabel}
169+
</span>
170+
) : undefined
171+
}
154172
href={href}
155173
clickLabel={`Open ${name}`}
156174
navigable
@@ -439,6 +457,7 @@ function IntegrationsContent() {
439457
name={item.name}
440458
description={item.description}
441459
icon={item.icon}
460+
expiresAt={item.credential.expiresAt}
442461
/>
443462
))}
444463
</IntegrationSection>

‎apps/sim/lib/api/contracts/credentials.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export const workspaceCredentialSchema = z.object({
6161
envKey: z.string().nullable(),
6262
envOwnerUserId: z.string().nullable(),
6363
instanceUrl: z.string().url().optional(),
64+
/** ISO time the saved token stops working, when its vendor stated one; absent when it never expires or is unknown. */
65+
expiresAt: z.string().optional(),
6466
createdBy: z.string(),
6567
createdAt: z.string(),
6668
updatedAt: z.string(),

0 commit comments

Comments
 (0)