Skip to content

Commit 2ac05ba

Browse files
DevMellol1shen
andauthored
fix(oauth): keep a token expiry when refresh omits expires_in (oomol-lab#266)
`expires_in` is optional on a refresh response and plenty of providers only send it on the initial grant. When it is missing the stored credential ends up with no `expiresAt`, and a credential with no expiry is never considered expired, so it is never refreshed again. There is no retry on a 401 either, so once the access token actually lapses every call through that connection fails until someone reconnects by hand. One refresh is enough to get there. The refreshed credential now falls back to the lifetime the provider last reported, which is already kept in the credential metadata. If no lifetime was ever reported the expiry stays unset, same as today. Worth noting why the fallback is not the previous `expiresAt`: a refresh only runs once that timestamp is in the past, so reusing it would mark the new token expired immediately and trigger a refresh on every request. --------- Co-authored-by: l1shen <648952316@qq.com>
1 parent 5cd85fe commit 2ac05ba

3 files changed

Lines changed: 142 additions & 3 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { ResolvedCredential } from "../core/types.ts";
2+
import type { OAuthClientConfigService } from "./oauth-client-config-service.ts";
3+
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { OAuthCredentialRefreshService } from "./oauth-credential-refresh-service.ts";
6+
7+
type OAuthCredential = Extract<ResolvedCredential, { authType: "oauth2" }>;
8+
9+
const clientConfigs = {
10+
getOAuthDefinition: () => ({ type: "oauth2", tokenUrl: "https://provider.example.com/oauth/token" }),
11+
getConfig: async () => ({ clientId: "client-id", clientSecret: "client-secret", extra: {} }),
12+
resolveEndpointUrl: (_service: string, endpointUrl: string) => endpointUrl,
13+
} as unknown as OAuthClientConfigService;
14+
15+
/** A stored credential whose access token has already lapsed, which is when a refresh runs. */
16+
function expiredCredential(metadata: Record<string, unknown>): OAuthCredential {
17+
return {
18+
authType: "oauth2",
19+
accessToken: "old-access-token",
20+
tokenType: "Bearer",
21+
expiresAt: new Date(Date.now() - 60_000).toISOString(),
22+
refreshToken: "refresh-token",
23+
profile: { accountId: "oauth2", displayName: "OAuth Credential", grantedScopes: [] },
24+
metadata,
25+
};
26+
}
27+
28+
function stubRefreshResponse(payload: Record<string, unknown>): void {
29+
vi.stubGlobal(
30+
"fetch",
31+
vi.fn(async () => Response.json({ access_token: "new-access-token", ...payload })),
32+
);
33+
}
34+
35+
describe("OAuthCredentialRefreshService", () => {
36+
afterEach(() => {
37+
vi.unstubAllGlobals();
38+
vi.restoreAllMocks();
39+
});
40+
41+
it("keeps an expiry when the refresh response omits expires_in", async () => {
42+
const now = Date.now();
43+
vi.spyOn(Date, "now").mockReturnValue(now);
44+
stubRefreshResponse({});
45+
46+
const refreshed = await new OAuthCredentialRefreshService(clientConfigs).refresh(
47+
"example",
48+
expiredCredential({ expires_in: 3600 }),
49+
);
50+
51+
expect(refreshed.expiresAt).toBe(new Date(now + 3600_000).toISOString());
52+
});
53+
54+
it("never carries the lapsed expiry forward, which would refresh on every call", async () => {
55+
const credential = expiredCredential({ expires_in: 3600 });
56+
stubRefreshResponse({});
57+
58+
const refreshed = await new OAuthCredentialRefreshService(clientConfigs).refresh("example", credential);
59+
60+
expect(refreshed.expiresAt).not.toBe(credential.expiresAt);
61+
expect(Date.parse(refreshed.expiresAt!)).toBeGreaterThan(Date.now());
62+
});
63+
64+
it("prefers the expiry the refresh response reports", async () => {
65+
const now = Date.now();
66+
vi.spyOn(Date, "now").mockReturnValue(now);
67+
stubRefreshResponse({ expires_in: 120 });
68+
69+
const refreshed = await new OAuthCredentialRefreshService(clientConfigs).refresh(
70+
"example",
71+
expiredCredential({ expires_in: 3600 }),
72+
);
73+
74+
expect(refreshed.expiresAt).toBe(new Date(now + 120_000).toISOString());
75+
});
76+
77+
it("leaves the expiry unset when no lifetime was ever reported", async () => {
78+
stubRefreshResponse({});
79+
80+
const refreshed = await new OAuthCredentialRefreshService(clientConfigs).refresh("example", expiredCredential({}));
81+
82+
expect(refreshed.expiresAt).toBeUndefined();
83+
});
84+
85+
it("carries a reported lifetime through a later refresh that omits it", async () => {
86+
const service = new OAuthCredentialRefreshService(clientConfigs);
87+
stubRefreshResponse({ expires_in: 3600 });
88+
const first = await service.refresh("example", expiredCredential({}));
89+
90+
const now = Date.now();
91+
vi.spyOn(Date, "now").mockReturnValue(now);
92+
stubRefreshResponse({});
93+
const second = await service.refresh("example", { ...first, expiresAt: new Date(now - 60_000).toISOString() });
94+
95+
expect(second.expiresAt).toBe(new Date(now + 3600_000).toISOString());
96+
});
97+
98+
it("keeps the last usable lifetime when a refresh reports an unusable value", async () => {
99+
const now = Date.now();
100+
vi.spyOn(Date, "now").mockReturnValue(now);
101+
const service = new OAuthCredentialRefreshService(clientConfigs);
102+
stubRefreshResponse({ expires_in: 0 });
103+
const first = await service.refresh("example", expiredCredential({ expires_in: 3600 }));
104+
105+
stubRefreshResponse({});
106+
const second = await service.refresh("example", { ...first, expiresAt: new Date(now - 60_000).toISOString() });
107+
108+
expect(second.expiresAt).toBe(new Date(now + 3600_000).toISOString());
109+
});
110+
111+
it("keeps the stored refresh token when the response omits a new one", async () => {
112+
stubRefreshResponse({});
113+
114+
const refreshed = await new OAuthCredentialRefreshService(clientConfigs).refresh(
115+
"example",
116+
expiredCredential({ expires_in: 3600 }),
117+
);
118+
119+
expect(refreshed.refreshToken).toBe("refresh-token");
120+
});
121+
});

src/oauth/oauth-credential-refresh-service.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ResolvedCredential } from "../core/types.ts";
22
import type { OAuthClientConfigService } from "./oauth-client-config-service.ts";
33

44
import { ConnectionError } from "../connection-service.ts";
5-
import { requestRefreshToken } from "./oauth-token.ts";
5+
import { expiresAtFromLifetime, requestRefreshToken } from "./oauth-token.ts";
66

77
type OAuthCredential = Extract<ResolvedCredential, { authType: "oauth2" }>;
88

@@ -41,14 +41,24 @@ export class OAuthCredentialRefreshService implements IOAuthCredentialRefresher
4141
tokenUrl: this.clientConfigs.resolveEndpointUrl(service, auth.refreshTokenUrl ?? auth.tokenUrl, config),
4242
createError: (message) => new ConnectionError("oauth_token_refresh_failed", message),
4343
});
44+
const expiresIn =
45+
refreshed.expiresAt === undefined ? credential.metadata.expires_in : refreshed.metadata.expires_in;
4446

4547
return {
4648
...refreshed,
4749
refreshToken: refreshed.refreshToken ?? credential.refreshToken,
50+
// `expires_in` is optional on a refresh response, and a credential without an
51+
// expiry is never treated as expired again, so the token silently stops being
52+
// refreshed and every later call fails once it lapses. Reuse the lifetime the
53+
// provider last reported instead. Carrying `credential.expiresAt` forward is
54+
// not an option: a refresh only runs once that timestamp is already past, so
55+
// the stored token would look expired immediately and refresh on every call.
56+
expiresAt: refreshed.expiresAt ?? expiresAtFromLifetime(expiresIn),
4857
profile: credential.profile,
4958
metadata: {
5059
...credential.metadata,
5160
...refreshed.metadata,
61+
expires_in: expiresIn,
5262
refreshedAt: new Date().toISOString(),
5363
},
5464
};

src/oauth/oauth-token.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,12 @@ async function requestToken(input: TokenRequest): Promise<Extract<ResolvedCreden
115115

116116
const accessToken = requiredString(payload.access_token ?? payload.token, "access_token", input.createError);
117117
const tokenType = optionalString(payload.token_type) ?? "Bearer";
118-
const expiresIn = readExpiresInSeconds(payload.expires_in);
119118
return {
120119
authType: "oauth2",
121120
accessToken,
122121
tokenType,
123122
refreshToken: optionalString(payload.refresh_token),
124-
expiresAt: expiresIn === undefined ? undefined : new Date(Date.now() + expiresIn * 1000).toISOString(),
123+
expiresAt: expiresAtFromLifetime(payload.expires_in),
125124
profile: {
126125
accountId: "oauth2",
127126
displayName: "OAuth Credential",
@@ -166,6 +165,15 @@ function createTokenMetadata(payload: Record<string, unknown>): Record<string, u
166165
return metadata;
167166
}
168167

168+
/**
169+
* Build the absolute expiry for an OAuth `expires_in` lifetime, or undefined when
170+
* the provider did not report a usable one.
171+
*/
172+
export function expiresAtFromLifetime(value: unknown): string | undefined {
173+
const seconds = readExpiresInSeconds(value);
174+
return seconds === undefined ? undefined : new Date(Date.now() + seconds * 1000).toISOString();
175+
}
176+
169177
/**
170178
* Parse OAuth `expires_in` lifetimes. Providers commonly return a JSON number,
171179
* but some return the same value as a string, which used to be dropped so the

0 commit comments

Comments
 (0)