mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
chore: Store refresh tokens for Linear integration (#10047)
* wip * Store expiry * refreshTokenIfNeeded * toDate * self review * refactor
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user