chore: Store refresh tokens for Linear integration (#10047)

* wip

* Store expiry

* refreshTokenIfNeeded

* toDate

* self review

* refactor
This commit is contained in:
Tom Moor
2025-09-14 04:55:38 +02:00
committed by GitHub
parent d07453d108
commit e1b29bd854
6 changed files with 159 additions and 11 deletions
+1 -1
View File
@@ -84,7 +84,7 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@linear/sdk": "^39.2.1",
"@linear/sdk": "^58.1.0",
"@node-oauth/oauth2-server": "^5.2.0",
"@notionhq/client": "^2.3.0",
"@octokit/auth-app": "^6.1.4",
+5
View File
@@ -11,6 +11,7 @@ import { Linear } from "../linear";
import UploadLinearWorkspaceLogoTask from "../tasks/UploadLinearWorkspaceLogoTask";
import * as T from "./schema";
import { LinearUtils } from "plugins/linear/shared/LinearUtils";
import { addSeconds } from "date-fns";
const router = new Router();
@@ -52,6 +53,10 @@ router.get(
userId: user.id,
teamId: user.teamId,
token: oauth.access_token,
refreshToken: oauth.refresh_token,
expiresAt: oauth.expires_in
? addSeconds(Date.now(), oauth.expires_in)
: undefined,
scopes: oauth.scope.split(" "),
},
{ transaction }
+38 -4
View File
@@ -12,9 +12,13 @@ import User from "@server/models/User";
import { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
import { LinearUtils } from "../shared/LinearUtils";
import env from "./env";
import { Minute } from "@shared/utils/time";
const AccessTokenResponseSchema = z.object({
access_token: z.string(),
// Linear is in the process of switching to short-lived refresh tokens. Some apps
// may not return a refresh token before April 2026, hence it's optional here.
refresh_token: z.string().optional(),
token_type: z.string(),
expires_in: z.number(),
scope: z.string(),
@@ -51,6 +55,33 @@ export class Linear {
return AccessTokenResponseSchema.parse(await res.json());
}
static async refreshToken(refreshToken: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const body = new URLSearchParams();
body.set("refresh_token", refreshToken);
body.set("client_id", env.LINEAR_CLIENT_ID!);
body.set("client_secret", env.LINEAR_CLIENT_SECRET!);
body.set("grant_type", "refresh_token");
const res = await fetch(LinearUtils.tokenUrl, {
method: "POST",
headers,
body,
});
if (res.status !== 200) {
throw new Error(
`Error while refreshing access token from Linear; status: ${res.status}`
);
}
return AccessTokenResponseSchema.parse(await res.json());
}
static async revokeAccess(accessToken: string) {
const headers = {
Authorization: `Bearer ${accessToken}`,
@@ -93,9 +124,12 @@ export class Linear {
}
try {
const client = new LinearClient({
accessToken: integration.authentication.token,
});
const accessToken = await integration.authentication.refreshTokenIfNeeded(
async (refreshToken: string) => Linear.refreshToken(refreshToken),
5 * Minute.ms
);
const client = new LinearClient({ accessToken });
const issue = await client.issue(resource.id);
if (!issue) {
@@ -193,7 +227,7 @@ export class Linear {
* Parses a given URL and returns resource identifiers for Linear specific URLs
*
* @param url URL to parse
* @returns {object} Containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
* @returns An object containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
*/
private static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
+2 -1
View File
@@ -21,6 +21,7 @@ import { sequelize } from "@server/storage/database";
import teamProvisioner from "./teamProvisioner";
import userProvisioner from "./userProvisioner";
import { APIContext } from "@server/types";
import { addSeconds } from "date-fns";
type Props = {
/** Details of the user logging in from SSO provider */
@@ -155,7 +156,7 @@ async function accountProvisioner(
authenticationProviderId: authenticationProvider.id,
...authenticationParams,
expiresAt: authenticationParams.expiresIn
? new Date(Date.now() + authenticationParams.expiresIn * 1000)
? addSeconds(Date.now(), authenticationParams.expiresIn)
: undefined,
},
});
+109 -1
View File
@@ -1,4 +1,8 @@
import { InferAttributes, InferCreationAttributes } from "sequelize";
import {
InferAttributes,
InferCreationAttributes,
Transaction,
} from "sequelize";
import {
DataType,
Table,
@@ -7,11 +11,24 @@ import {
Column,
} from "sequelize-typescript";
import { IntegrationService } from "@shared/types";
import Logger from "../logging/Logger";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
import Encrypted from "./decorators/Encrypted";
import Fix from "./decorators/Fix";
import { Minute } from "@shared/utils/time";
import { addSeconds } from "date-fns";
export interface TokenRefreshResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
}
export type TokenRefreshCallback = (
refreshToken: string
) => Promise<TokenRefreshResponse>;
@Table({ tableName: "authentications", modelName: "authentication" })
@Fix
@@ -51,6 +68,97 @@ class IntegrationAuthentication extends IdModel<
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
/**
* Check if the access token will expire soon (within the specified threshold)
*
* @param thresholdMs Number of milliseconds before expiration to consider "expiring soon" (default: 5 minutes)
* @returns true if the token will expire within the threshold, false otherwise
*/
isExpiringSoon(thresholdMs: number = 5 * Minute.ms): boolean {
if (!this.expiresAt) {
return false;
}
const now = new Date();
const thresholdTime = new Date(now.getTime() + thresholdMs);
return this.expiresAt <= thresholdTime;
}
/**
* Refresh the access token if it's expiring soon using provider-specific callback
*
* @param refreshCallback Provider-specific function to refresh the token
* @param thresholdMs Number of milliseconds before expiration to consider "expiring soon" (default: 5 minutes)
* @returns The current access token (refreshed if needed)
*/
async refreshTokenIfNeeded(
refreshCallback: TokenRefreshCallback,
thresholdMs: number = 5 * Minute.ms
): Promise<string> {
// Quick check without locking first
if (!this.isExpiringSoon(thresholdMs) || !this.refreshToken) {
return this.token;
}
try {
// Use transaction with row-level locking to prevent race conditions
let refreshedToken = this.token;
await this.sequelize.transaction(async (transaction: Transaction) => {
const lockedAuth = await (
this.constructor as typeof IntegrationAuthentication
).findByPk(this.id, {
transaction,
lock: transaction.LOCK.UPDATE,
rejectOnEmpty: true,
});
// Check again if token still needs refresh (another process might have refreshed it)
if (lockedAuth.isExpiringSoon(thresholdMs) && lockedAuth.refreshToken) {
Logger.info("plugins", `Refreshing ${this.service} access token`);
const tokenResponse = await refreshCallback(lockedAuth.refreshToken);
// Update the authentication record with new tokens
await lockedAuth.update(
{
token: tokenResponse.access_token,
refreshToken:
tokenResponse.refresh_token || lockedAuth.refreshToken,
expiresAt: addSeconds(Date.now(), tokenResponse.expires_in),
},
{ transaction }
);
refreshedToken = tokenResponse.access_token;
Logger.info(
"plugins",
`Successfully refreshed ${this.service} access token`
);
} else {
// Token was already refreshed by another process, use the current token
refreshedToken = lockedAuth.token;
}
// Update this instance with the latest values
this.token = refreshedToken;
if (lockedAuth.refreshToken) {
this.refreshToken = lockedAuth.refreshToken;
}
if (lockedAuth.expiresAt) {
this.expiresAt = lockedAuth.expiresAt;
}
});
return refreshedToken;
} catch (err) {
Logger.warn(`Failed to refresh ${this.service} access token`, err);
// Continue with existing token - it might still work
return this.token;
}
}
}
export default IntegrationAuthentication;
+4 -4
View File
@@ -2620,10 +2620,10 @@
resolved "https://registry.yarnpkg.com/@lifeomic/attempt/-/attempt-3.0.3.tgz#e742a5b85eb673e2f1746b0f39cb932cbc6145bb"
integrity "sha1-50KluF62c+LxdGsPOcuTLLxhRbs= sha512-GlM2AbzrErd/TmLL3E8hAHmb5Q7VhDJp35vIbyPVA5Rz55LZuRr8pwL3qrwwkVNo05gMX1J44gURKb4MHQZo7w=="
"@linear/sdk@^39.2.1":
version "39.2.1"
resolved "https://registry.yarnpkg.com/@linear/sdk/-/sdk-39.2.1.tgz#3b3f0353ebafff8d09119f08998f98ac08502898"
integrity sha512-tSqrS89HHrHdpShhqQK3eqVjtQkOWR585dKumBUZTt4Fl+B1NbpxofJcPX2G9YawvhHWpVKxcntwSrS0Mnm0QQ==
"@linear/sdk@^58.1.0":
version "58.1.0"
resolved "https://registry.yarnpkg.com/@linear/sdk/-/sdk-58.1.0.tgz#fd44c717a9b0a6665567909ccc43c2f4fdbe5e65"
integrity sha512-sqzo1j+uZsxeJlMTV2mrBH3yukB/liev7IySmkZil0ka7ic6b4RE9Jk3x+ohw8YgYB52IRR3SPWzhWu96E6W9g==
dependencies:
"@graphql-typed-document-node/core" "^3.1.0"
graphql "^15.4.0"