Files
outline/server/models/oauth/OAuthAuthentication.ts
T
Tom Moor adbffc0734 chore: clear mechanical lint warnings (Phase 1) (#12198)
* chore: clear mechanical lint warnings

Drops 44 oxlint warnings (559 → 515) by fixing easy mechanical rules
across the codebase: no-useless-escape, no-duplicate-type-constituents,
no-redundant-type-constituents, no-unused-expressions,
no-meaningless-void-operator, require-array-sort-compare, await-thenable.

* chore: drop callback parameter from useCallback deps

The `open` argument is a parameter of the callback, not a closed-over
variable, so it doesn't belong in the deps array.

* chore: promote cleared lint rules to errors

Promotes the rules cleared in this PR from warn to error so future
violations fail the lint:

- no-unused-expressions
- typescript/await-thenable
- typescript/no-duplicate-type-constituents
- typescript/no-meaningless-void-operator
- typescript/require-array-sort-compare

Removes the override that suppressed no-useless-escape on source
files (the global rule is already error) and fixes the 21 escape
violations that this exposed in regex character classes and template
literals.

* chore: address PR review feedback

- usePinnedDocuments: simplify UrlId to plain string instead of the
  intersection trick.
- PlantUML embed: move - to end of character class so it's a literal
  hyphen rather than a range operator.
- checkboxes: type token params as Token | undefined to match the
  actual call sites that pass tokens[index - 2] etc.
2026-04-28 20:00:03 -04:00

248 lines
6.2 KiB
TypeScript

import { Matches } from "class-validator";
import { subMinutes } from "date-fns";
import type {
FindOptions,
InferAttributes,
InferCreationAttributes,
NonNullFindOptions,
} from "sequelize";
import {
Column,
DataType,
BelongsTo,
ForeignKey,
Table,
IsDate,
Unique,
} from "sequelize-typescript";
import env from "@server/env";
import User from "@server/models/User";
import ParanoidModel from "@server/models/base/ParanoidModel";
import { SkipChangeset } from "@server/models/decorators/Changeset";
import Fix from "@server/models/decorators/Fix";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
import { hash } from "@server/utils/crypto";
import OAuthClient from "./OAuthClient";
@Table({
tableName: "oauth_authentications",
modelName: "oauth_authentication",
})
@Fix
class OAuthAuthentication extends ParanoidModel<
InferAttributes<OAuthAuthentication>,
Partial<InferCreationAttributes<OAuthAuthentication>>
> {
static eventNamespace = "oauthAuthentications";
/** The lifetime of an access token in seconds. */
public static accessTokenLifetime = env.OAUTH_PROVIDER_ACCESS_TOKEN_LIFETIME;
/** The lifetime of a refresh token in seconds. */
public static refreshTokenLifetime =
env.OAUTH_PROVIDER_REFRESH_TOKEN_LIFETIME;
/** A recognizable prefix for access tokens. */
public static accessTokenPrefix = "ol_at_";
/** A recognizable prefix for refresh tokens. */
public static refreshTokenPrefix = "ol_rt_";
@Unique
@Column
@SkipChangeset
accessTokenHash: string;
/** The cached plain text access token. Only available during creation. */
@Column(DataType.VIRTUAL)
accessToken: string | null;
@IsDate
@Column
accessTokenExpiresAt: Date;
@Unique
@Column
@SkipChangeset
refreshTokenHash: string;
/** The cached plain text refresh token. Only available during creation. */
@Column(DataType.VIRTUAL)
refreshToken: string | null;
@IsDate
@Column
refreshTokenExpiresAt: Date;
/**
* The ID of the grant that this authentication belongs to. Used for
* refresh token rotation and revocation of all tokens in a grant.
*/
@Column(DataType.UUID)
@SkipChangeset
grantId: string | null;
/** A list of scopes that this authentication has access to */
@Matches(/[/.\w\s]*/, {
each: true,
})
@Column(DataType.ARRAY(DataType.STRING))
scope: string[];
@IsDate
@Column
@SkipChangeset
lastActiveAt: Date;
// associations
@BelongsTo(() => OAuthClient, "oauthClientId")
oauthClient: OAuthClient;
@ForeignKey(() => OAuthClient)
@Column(DataType.UUID)
oauthClientId: string;
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
// methods
updateActiveAt = async () => {
const fiveMinutesAgo = subMinutes(new Date(), 5);
const now = new Date();
// ensure this is updated only every few minutes otherwise
// we'll be constantly writing to the DB as API requests happen
if (!this.lastActiveAt || this.lastActiveAt < fiveMinutesAgo) {
this.lastActiveAt = now;
}
const promises: Promise<unknown>[] = [this.save({ silent: true })];
// Propagate activity timestamp to the parent OAuth client
if (
this.oauthClient &&
(!this.oauthClient.lastActiveAt ||
this.oauthClient.lastActiveAt < fiveMinutesAgo)
) {
this.oauthClient.lastActiveAt = now;
promises.push(this.oauthClient.save({ silent: true }));
}
await Promise.all(promises);
};
// instance methods
/** Checks if the authentication has access to the given path */
canAccess = (path: string) => {
// Special case for the revoke endpoint, which is always allowed
if (path === "/oauth/revoke") {
return true;
}
// MCP endpoint access is allowed if the token has any valid scope.
// Fine-grained scope enforcement happens at the tool level.
if (path.startsWith("/mcp")) {
return this.scope.length > 0;
}
return AuthenticationHelper.canAccess(path, this.scope);
};
// static methods
/**
* Validates that the input text _could_ be an OAuth token, this does not check
* that the key actually exists in the database.
*
* @param text The text to validate
* @returns True if likely an OAuth token
*/
public static match(text: string) {
return !!text.startsWith(this.accessTokenPrefix);
}
/**
* Validates that the input text _could_ be an OAuth refresh token, this does
* not check that the key actually exists in the database.
*
* @param text The text to validate
* @returns True if likely an OAuth refresh token
*/
public static matchRefreshToken(text: string) {
return !!text.startsWith(this.refreshTokenPrefix);
}
/**
* Finds an OAuthAuthentication by the given access token, including the
* associated user.
*
* @param input The access token to search for
* @param options The options to pass to the find method
* @returns The OAuthAuthentication if found
*/
static findByAccessToken(
input: string,
options?:
| FindOptions<OAuthAuthentication>
| NonNullFindOptions<OAuthAuthentication>
): Promise<OAuthAuthentication | null> {
return this.findOne({
where: {
accessTokenHash: hash(input),
},
include: [
{
association: "user",
required: true,
},
{
association: "oauthClient",
required: true,
},
],
...options,
});
}
/**
* Finds an OAuthAuthentication by the given refresh token, including the
* associated user.
*
* @param input The refresh token to search for
* @param options The options to pass to the find method
* @returns The OAuthAuthentication if found
*/
public static findByRefreshToken(
input: string,
options?:
| FindOptions<OAuthAuthentication>
| NonNullFindOptions<OAuthAuthentication>
) {
return this.findOne({
where: {
refreshTokenHash: hash(input),
},
include: [
{
association: "user",
required: true,
},
{
association: "oauthClient",
required: true,
},
],
...options,
});
}
}
export default OAuthAuthentication;