mirror of
https://github.com/outline/outline.git
synced 2026-01-02 09:09:50 -06:00
184 lines
4.7 KiB
TypeScript
184 lines
4.7 KiB
TypeScript
import { addMinutes, subMinutes } from "date-fns";
|
|
import invariant from "invariant";
|
|
import { SaveOptions } from "sequelize";
|
|
import {
|
|
BeforeCreate,
|
|
BelongsTo,
|
|
Column,
|
|
DataType,
|
|
ForeignKey,
|
|
Table,
|
|
Unique,
|
|
} from "sequelize-typescript";
|
|
import Logger from "@server/logging/Logger";
|
|
import { AuthenticationError } from "../errors";
|
|
import AuthenticationProvider from "./AuthenticationProvider";
|
|
import User from "./User";
|
|
import IdModel from "./base/IdModel";
|
|
import Encrypted, {
|
|
getEncryptedColumn,
|
|
setEncryptedColumn,
|
|
} from "./decorators/Encrypted";
|
|
import Fix from "./decorators/Fix";
|
|
|
|
@Table({ tableName: "user_authentications", modelName: "user_authentication" })
|
|
@Fix
|
|
class UserAuthentication extends IdModel {
|
|
@Column(DataType.ARRAY(DataType.STRING))
|
|
scopes: string[];
|
|
|
|
@Column(DataType.BLOB)
|
|
@Encrypted
|
|
get accessToken() {
|
|
return getEncryptedColumn(this, "accessToken");
|
|
}
|
|
|
|
set accessToken(value: string) {
|
|
setEncryptedColumn(this, "accessToken", value);
|
|
}
|
|
|
|
@Column(DataType.BLOB)
|
|
@Encrypted
|
|
get refreshToken() {
|
|
return getEncryptedColumn(this, "refreshToken");
|
|
}
|
|
|
|
set refreshToken(value: string) {
|
|
setEncryptedColumn(this, "refreshToken", value);
|
|
}
|
|
|
|
@Column
|
|
providerId: string;
|
|
|
|
@Column(DataType.DATE)
|
|
expiresAt: Date;
|
|
|
|
@Column(DataType.DATE)
|
|
lastValidatedAt: Date;
|
|
|
|
// associations
|
|
|
|
@BelongsTo(() => User, "userId")
|
|
user: User;
|
|
|
|
@ForeignKey(() => User)
|
|
@Column(DataType.UUID)
|
|
userId: string;
|
|
|
|
@BelongsTo(() => AuthenticationProvider, "authenticationProviderId")
|
|
authenticationProvider: AuthenticationProvider;
|
|
|
|
@ForeignKey(() => AuthenticationProvider)
|
|
@Unique
|
|
@Column(DataType.UUID)
|
|
authenticationProviderId: string;
|
|
|
|
@BeforeCreate
|
|
static setValidated(model: UserAuthentication) {
|
|
model.lastValidatedAt = new Date();
|
|
}
|
|
|
|
// instance methods
|
|
|
|
/**
|
|
* Validates that the tokens within this authentication record are still
|
|
* valid. Will update the record with a new access token if it is expired.
|
|
*
|
|
* @param options SaveOptions
|
|
* @param force Force validation to occur with third party provider
|
|
* @returns true if the accessToken or refreshToken is still valid
|
|
*/
|
|
public async validateAccess(
|
|
options: SaveOptions,
|
|
force = false
|
|
): Promise<boolean> {
|
|
// Check a maximum of once every 5 minutes
|
|
if (this.lastValidatedAt > subMinutes(Date.now(), 5) && !force) {
|
|
return true;
|
|
}
|
|
|
|
const authenticationProvider = await this.$get("authenticationProvider", {
|
|
transaction: options.transaction,
|
|
});
|
|
invariant(
|
|
authenticationProvider,
|
|
"authenticationProvider must exist for user authentication"
|
|
);
|
|
|
|
try {
|
|
await this.refreshAccessTokenIfNeeded(authenticationProvider, options);
|
|
|
|
const client = authenticationProvider.oauthClient;
|
|
if (client) {
|
|
await client.userInfo(this.accessToken);
|
|
}
|
|
|
|
// write to db when we last checked
|
|
this.lastValidatedAt = new Date();
|
|
await this.save({
|
|
transaction: options.transaction,
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof AuthenticationError) {
|
|
return false;
|
|
}
|
|
|
|
// Throw unknown errors to trigger a retry of the task.
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the accessToken and refreshToken in the database if expiring. If the
|
|
* accessToken is still valid or the AuthenticationProvider does not support
|
|
* refreshTokens then no work is done.
|
|
*
|
|
* @param options SaveOptions
|
|
* @returns true if the tokens were updated
|
|
*/
|
|
private async refreshAccessTokenIfNeeded(
|
|
authenticationProvider: AuthenticationProvider,
|
|
options: SaveOptions
|
|
): Promise<boolean> {
|
|
if (
|
|
this.expiresAt > addMinutes(Date.now(), 5) ||
|
|
!this.refreshToken ||
|
|
// Some providers send no expiry depending on setup, in this case we can't
|
|
// refresh and assume the session is valid until logged out.
|
|
!this.expiresAt
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
Logger.info("utils", "Refreshing expiring access token", {
|
|
id: this.id,
|
|
userId: this.userId,
|
|
});
|
|
|
|
const client = authenticationProvider.oauthClient;
|
|
if (client) {
|
|
const response = await client.rotateToken(this.refreshToken);
|
|
|
|
// Not all OAuth providers return a new refreshToken so we need to guard
|
|
// against setting to an empty value.
|
|
if (response.refreshToken) {
|
|
this.refreshToken = response.refreshToken;
|
|
}
|
|
this.accessToken = response.accessToken;
|
|
this.expiresAt = response.expiresAt;
|
|
await this.save(options);
|
|
}
|
|
|
|
Logger.info("utils", "Successfully refreshed expired access token", {
|
|
id: this.id,
|
|
userId: this.userId,
|
|
});
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export default UserAuthentication;
|