initial commit
This commit is contained in:
581
fluxer_api/src/oauth/ApplicationService.ts
Normal file
581
fluxer_api/src/oauth/ApplicationService.ts
Normal file
@@ -0,0 +1,581 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import argon2 from 'argon2';
|
||||
import type {ApplicationID, UserID} from '~/BrandedTypes';
|
||||
import {applicationIdToUserId} from '~/BrandedTypes';
|
||||
import {UserFlags, UserPremiumTypes} from '~/Constants';
|
||||
import type {UserRow} from '~/database/CassandraTypes';
|
||||
import type {ApplicationRow} from '~/database/types/OAuth2Types';
|
||||
import {BotUserNotFoundError, InputValidationError, UnknownApplicationError} from '~/Errors';
|
||||
import type {DiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {Application} from '~/models/Application';
|
||||
import type {User} from '~/models/User';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {UserService} from '~/user/UserService';
|
||||
import {generateRandomUsername} from '~/utils/UsernameGenerator';
|
||||
import type {BotAuthService} from './BotAuthService';
|
||||
import type {IApplicationRepository} from './repositories/IApplicationRepository';
|
||||
|
||||
interface ApplicationServiceDeps {
|
||||
applicationRepository: IApplicationRepository;
|
||||
userRepository: IUserRepository;
|
||||
userService: UserService;
|
||||
userCacheService: UserCacheService;
|
||||
entityAssetService: EntityAssetService;
|
||||
discriminatorService: DiscriminatorService;
|
||||
snowflakeService: SnowflakeService;
|
||||
botAuthService: BotAuthService;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
const DELETED_USERNAME = '__deleted__';
|
||||
|
||||
export class ApplicationNotOwnedError extends Error {
|
||||
constructor() {
|
||||
super('You do not own this application');
|
||||
this.name = 'ApplicationNotOwnedError';
|
||||
}
|
||||
}
|
||||
|
||||
class BotUserGenerationError extends Error {
|
||||
constructor(message = 'Failed to generate unique username for bot') {
|
||||
super(message);
|
||||
this.name = 'BotUserGenerationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApplicationService {
|
||||
constructor(public readonly deps: ApplicationServiceDeps) {}
|
||||
|
||||
private sanitizeUsername(name: string): string {
|
||||
let sanitized = name
|
||||
.replace(/[\s\-.]+/g, '_')
|
||||
.replace(/[^a-zA-Z0-9_]/g, '')
|
||||
.substring(0, 32);
|
||||
|
||||
if (sanitized.length < 2) {
|
||||
sanitized = `bot${sanitized}`;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private async generateBotUsername(applicationName: string): Promise<{username: string; discriminator: number}> {
|
||||
const sanitized = this.sanitizeUsername(applicationName);
|
||||
|
||||
const discResult = await this.deps.discriminatorService.generateDiscriminator({
|
||||
username: sanitized,
|
||||
isPremium: false,
|
||||
});
|
||||
|
||||
if (discResult.available && discResult.discriminator !== -1) {
|
||||
return {username: sanitized, discriminator: discResult.discriminator};
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
{applicationName, sanitizedName: sanitized},
|
||||
'Application name discriminators exhausted, falling back to random username',
|
||||
);
|
||||
|
||||
for (let attempts = 0; attempts < 100; attempts++) {
|
||||
const randomUsername = generateRandomUsername();
|
||||
const randomDiscResult = await this.deps.discriminatorService.generateDiscriminator({
|
||||
username: randomUsername,
|
||||
isPremium: false,
|
||||
});
|
||||
|
||||
if (randomDiscResult.available && randomDiscResult.discriminator !== -1) {
|
||||
return {username: randomUsername, discriminator: randomDiscResult.discriminator};
|
||||
}
|
||||
}
|
||||
|
||||
throw new BotUserGenerationError('Failed to generate unique username for bot after 100 attempts');
|
||||
}
|
||||
|
||||
async createApplication(args: {
|
||||
ownerUserId: UserID;
|
||||
name: string;
|
||||
redirectUris?: Array<string>;
|
||||
botPublic?: boolean;
|
||||
}): Promise<{
|
||||
application: Application;
|
||||
botUser: User;
|
||||
botToken: string;
|
||||
clientSecret: string;
|
||||
}> {
|
||||
const initialRedirectUris = args.redirectUris ?? [];
|
||||
const owner = await this.deps.userRepository.findUniqueAssert(args.ownerUserId);
|
||||
const botIsPublic = args.botPublic ?? true;
|
||||
|
||||
const applicationId: ApplicationID = this.deps.snowflakeService.generate() as ApplicationID;
|
||||
const botUserId = applicationIdToUserId(applicationId);
|
||||
|
||||
const {username, discriminator} = await this.generateBotUsername(args.name);
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
applicationId: applicationId.toString(),
|
||||
botUserId: botUserId.toString(),
|
||||
username,
|
||||
discriminator,
|
||||
applicationName: args.name,
|
||||
},
|
||||
'Creating application with bot user',
|
||||
);
|
||||
|
||||
const botUserRow: UserRow = {
|
||||
user_id: botUserId,
|
||||
username,
|
||||
discriminator,
|
||||
global_name: null,
|
||||
bot: true,
|
||||
system: false,
|
||||
email: null,
|
||||
email_verified: null,
|
||||
email_bounced: null,
|
||||
phone: null,
|
||||
password_hash: null,
|
||||
password_last_changed_at: null,
|
||||
totp_secret: null,
|
||||
authenticator_types: owner.authenticatorTypes ? new Set(owner.authenticatorTypes) : null,
|
||||
avatar_hash: null,
|
||||
avatar_color: null,
|
||||
banner_hash: null,
|
||||
banner_color: null,
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
accent_color: null,
|
||||
date_of_birth: null,
|
||||
locale: null,
|
||||
flags: 0n,
|
||||
premium_type: null,
|
||||
premium_since: null,
|
||||
premium_until: null,
|
||||
premium_will_cancel: null,
|
||||
premium_billing_cycle: null,
|
||||
premium_lifetime_sequence: null,
|
||||
stripe_subscription_id: null,
|
||||
stripe_customer_id: null,
|
||||
has_ever_purchased: null,
|
||||
suspicious_activity_flags: null,
|
||||
terms_agreed_at: null,
|
||||
privacy_agreed_at: null,
|
||||
last_active_at: null,
|
||||
last_active_ip: null,
|
||||
temp_banned_until: null,
|
||||
pending_deletion_at: null,
|
||||
pending_bulk_message_deletion_at: null,
|
||||
pending_bulk_message_deletion_channel_count: null,
|
||||
pending_bulk_message_deletion_message_count: null,
|
||||
deletion_reason_code: null,
|
||||
deletion_public_reason: null,
|
||||
deletion_audit_log_reason: null,
|
||||
acls: null,
|
||||
first_refund_at: null,
|
||||
beta_code_allowance: null,
|
||||
beta_code_last_reset_at: null,
|
||||
gift_inventory_server_seq: null,
|
||||
gift_inventory_client_seq: null,
|
||||
premium_onboarding_dismissed_at: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const botUser = await this.deps.userRepository.create(botUserRow);
|
||||
|
||||
const {
|
||||
token: botToken,
|
||||
hash: botTokenHash,
|
||||
preview: botTokenPreview,
|
||||
} = await this.deps.botAuthService.generateBotToken(applicationId);
|
||||
const botTokenCreatedAt = new Date();
|
||||
const clientSecret = randomBytes(32).toString('base64url');
|
||||
const clientSecretHash = await argon2.hash(clientSecret);
|
||||
const clientSecretCreatedAt = new Date();
|
||||
|
||||
const applicationRow: ApplicationRow = {
|
||||
application_id: applicationId,
|
||||
owner_user_id: args.ownerUserId,
|
||||
name: args.name,
|
||||
bot_user_id: botUserId,
|
||||
bot_is_public: botIsPublic,
|
||||
oauth2_redirect_uris: new Set<string>(initialRedirectUris),
|
||||
client_secret_hash: clientSecretHash,
|
||||
bot_token_hash: botTokenHash,
|
||||
bot_token_preview: botTokenPreview,
|
||||
bot_token_created_at: botTokenCreatedAt,
|
||||
client_secret_created_at: clientSecretCreatedAt,
|
||||
};
|
||||
|
||||
const application = await this.deps.applicationRepository.upsertApplication(applicationRow);
|
||||
|
||||
Logger.info(
|
||||
{applicationId: applicationId.toString(), botUserId: botUserId.toString()},
|
||||
'Successfully created application with bot user',
|
||||
);
|
||||
|
||||
return {application, botUser, botToken, clientSecret};
|
||||
}
|
||||
|
||||
async getApplication(applicationId: ApplicationID): Promise<Application | null> {
|
||||
return this.deps.applicationRepository.getApplication(applicationId);
|
||||
}
|
||||
|
||||
async listApplicationsByOwner(ownerUserId: UserID): Promise<Array<Application>> {
|
||||
return this.deps.applicationRepository.listApplicationsByOwner(ownerUserId);
|
||||
}
|
||||
|
||||
private async verifyOwnership(userId: UserID, applicationId: ApplicationID): Promise<Application> {
|
||||
const application = await this.deps.applicationRepository.getApplication(applicationId);
|
||||
if (!application) {
|
||||
throw new UnknownApplicationError();
|
||||
}
|
||||
|
||||
if (application.ownerUserId !== userId) {
|
||||
throw new ApplicationNotOwnedError();
|
||||
}
|
||||
|
||||
return application;
|
||||
}
|
||||
|
||||
async updateApplication(args: {
|
||||
userId: UserID;
|
||||
applicationId: ApplicationID;
|
||||
name?: string;
|
||||
redirectUris?: Array<string>;
|
||||
botPublic?: boolean;
|
||||
}): Promise<Application> {
|
||||
const application = await this.verifyOwnership(args.userId, args.applicationId);
|
||||
|
||||
const updatedRow: ApplicationRow = {
|
||||
...application.toRow(),
|
||||
name: args.name ?? application.name,
|
||||
oauth2_redirect_uris: args.redirectUris ? new Set(args.redirectUris) : application.oauth2RedirectUris,
|
||||
bot_is_public: args.botPublic ?? application.botIsPublic,
|
||||
};
|
||||
|
||||
return this.deps.applicationRepository.upsertApplication(updatedRow);
|
||||
}
|
||||
|
||||
async deleteApplication(userId: UserID, applicationId: ApplicationID): Promise<void> {
|
||||
const application = await this.verifyOwnership(userId, applicationId);
|
||||
|
||||
if (application.hasBotUser()) {
|
||||
const botUserId = application.getBotUserId()!;
|
||||
await this.deps.userRepository.deleteUserSecondaryIndices(botUserId);
|
||||
await this.deps.userRepository.removeFromAllGuilds(botUserId);
|
||||
|
||||
await this.deps.userRepository.patchUpsert(botUserId, {
|
||||
username: DELETED_USERNAME,
|
||||
discriminator: 0,
|
||||
email: null,
|
||||
email_verified: false,
|
||||
phone: null,
|
||||
password_hash: null,
|
||||
password_last_changed_at: null,
|
||||
totp_secret: null,
|
||||
authenticator_types: new Set(),
|
||||
avatar_hash: null,
|
||||
banner_hash: null,
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
accent_color: null,
|
||||
date_of_birth: null,
|
||||
flags: UserFlags.DELETED,
|
||||
premium_type: null,
|
||||
premium_since: null,
|
||||
premium_until: null,
|
||||
stripe_customer_id: null,
|
||||
stripe_subscription_id: null,
|
||||
pending_deletion_at: null,
|
||||
deletion_reason_code: null,
|
||||
deletion_public_reason: null,
|
||||
deletion_audit_log_reason: null,
|
||||
});
|
||||
|
||||
await this.deps.userCacheService.invalidateUserCache(botUserId);
|
||||
|
||||
Logger.info(
|
||||
{applicationId: applicationId.toString(), botUserId: botUserId.toString()},
|
||||
'Anonymized bot user associated with application',
|
||||
);
|
||||
}
|
||||
|
||||
await this.deps.applicationRepository.deleteApplication(applicationId);
|
||||
Logger.info({applicationId: applicationId.toString()}, 'Successfully deleted application');
|
||||
}
|
||||
|
||||
async rotateBotToken(
|
||||
userId: UserID,
|
||||
applicationId: ApplicationID,
|
||||
): Promise<{
|
||||
token: string;
|
||||
preview: string;
|
||||
}> {
|
||||
const application = await this.verifyOwnership(userId, applicationId);
|
||||
|
||||
if (!application.hasBotUser()) {
|
||||
throw new BotUserNotFoundError();
|
||||
}
|
||||
|
||||
const {token, hash, preview} = await this.deps.botAuthService.generateBotToken(applicationId);
|
||||
const botTokenCreatedAt = new Date();
|
||||
|
||||
const updatedRow: ApplicationRow = {
|
||||
...application.toRow(),
|
||||
bot_token_hash: hash,
|
||||
bot_token_preview: preview,
|
||||
bot_token_created_at: botTokenCreatedAt,
|
||||
};
|
||||
|
||||
await this.deps.applicationRepository.upsertApplication(updatedRow);
|
||||
Logger.info({applicationId: applicationId.toString()}, 'Successfully rotated bot token');
|
||||
|
||||
const botUserId = application.getBotUserId();
|
||||
if (botUserId !== null) {
|
||||
await this.deps.gatewayService.terminateAllSessionsForUser({
|
||||
userId: botUserId,
|
||||
});
|
||||
}
|
||||
|
||||
return {token, preview};
|
||||
}
|
||||
|
||||
async rotateClientSecret(
|
||||
userId: UserID,
|
||||
applicationId: ApplicationID,
|
||||
): Promise<{
|
||||
clientSecret: string;
|
||||
}> {
|
||||
const application = await this.verifyOwnership(userId, applicationId);
|
||||
|
||||
const clientSecret = randomBytes(32).toString('base64url');
|
||||
const clientSecretHash = await argon2.hash(clientSecret);
|
||||
const clientSecretCreatedAt = new Date();
|
||||
|
||||
const updatedRow: ApplicationRow = {
|
||||
...application.toRow(),
|
||||
client_secret_hash: clientSecretHash,
|
||||
client_secret_created_at: clientSecretCreatedAt,
|
||||
};
|
||||
|
||||
await this.deps.applicationRepository.upsertApplication(updatedRow);
|
||||
Logger.info({applicationId: applicationId.toString()}, 'Successfully rotated client secret');
|
||||
|
||||
return {clientSecret};
|
||||
}
|
||||
|
||||
async updateBotProfile(
|
||||
userId: UserID,
|
||||
applicationId: ApplicationID,
|
||||
args: {
|
||||
username?: string;
|
||||
discriminator?: number;
|
||||
avatar?: string | null;
|
||||
banner?: string | null;
|
||||
bio?: string | null;
|
||||
},
|
||||
): Promise<{
|
||||
user: User;
|
||||
application: Application;
|
||||
}> {
|
||||
const application = await this.verifyOwnership(userId, applicationId);
|
||||
|
||||
if (!application.hasBotUser()) {
|
||||
throw new BotUserNotFoundError();
|
||||
}
|
||||
|
||||
const botUserId = application.getBotUserId()!;
|
||||
const botUser = await this.deps.userRepository.findUnique(botUserId);
|
||||
if (!botUser) {
|
||||
throw new BotUserNotFoundError();
|
||||
}
|
||||
|
||||
const ownerUser = await this.deps.userRepository.findUnique(userId);
|
||||
const isOwnerLifetimePremium = ownerUser?.premiumType === UserPremiumTypes.LIFETIME;
|
||||
|
||||
if (args.discriminator !== undefined && args.discriminator !== botUser.discriminator) {
|
||||
if (!isOwnerLifetimePremium) {
|
||||
throw InputValidationError.create(
|
||||
'discriminator',
|
||||
'You must be on the Visionary lifetime plan to customize bot discriminators.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Partial<UserRow> = {};
|
||||
|
||||
const newUsername = args.username ?? botUser.username;
|
||||
const requestedDiscriminator = args.discriminator;
|
||||
const usernameChanged = args.username !== undefined && args.username !== botUser.username;
|
||||
const discriminatorChanged =
|
||||
requestedDiscriminator !== undefined && requestedDiscriminator !== botUser.discriminator;
|
||||
|
||||
if (usernameChanged || discriminatorChanged) {
|
||||
const result = await this.deps.discriminatorService.resolveUsernameChange({
|
||||
currentUsername: botUser.username,
|
||||
currentDiscriminator: botUser.discriminator,
|
||||
newUsername,
|
||||
isPremium: isOwnerLifetimePremium,
|
||||
requestedDiscriminator,
|
||||
});
|
||||
|
||||
if (result.username !== botUser.username) {
|
||||
updates.username = result.username;
|
||||
}
|
||||
if (result.discriminator !== botUser.discriminator) {
|
||||
updates.discriminator = result.discriminator;
|
||||
}
|
||||
}
|
||||
|
||||
updates.global_name = null;
|
||||
|
||||
const assetPrep = await this.prepareBotAssets({
|
||||
botUser,
|
||||
botUserId,
|
||||
avatar: args.avatar,
|
||||
banner: args.banner,
|
||||
});
|
||||
if (assetPrep.avatarHash !== undefined) {
|
||||
updates.avatar_hash = assetPrep.avatarHash;
|
||||
}
|
||||
if (assetPrep.bannerHash !== undefined) {
|
||||
updates.banner_hash = assetPrep.bannerHash;
|
||||
}
|
||||
|
||||
if (args.bio !== undefined) {
|
||||
updates.bio = args.bio;
|
||||
}
|
||||
|
||||
let updatedUser: User | null = null;
|
||||
try {
|
||||
updatedUser = await this.deps.userRepository.patchUpsert(botUserId, updates);
|
||||
} catch (err) {
|
||||
await this.rollbackBotAssets(assetPrep);
|
||||
throw err;
|
||||
}
|
||||
if (!updatedUser) {
|
||||
await this.rollbackBotAssets(assetPrep);
|
||||
throw new BotUserNotFoundError();
|
||||
}
|
||||
|
||||
try {
|
||||
await this.commitBotAssets(assetPrep);
|
||||
} catch (err) {
|
||||
await this.rollbackBotAssets(assetPrep);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const updatedApplication = await this.deps.applicationRepository.getApplication(applicationId);
|
||||
if (!updatedApplication) {
|
||||
throw new UnknownApplicationError();
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
{applicationId: applicationId.toString(), botUserId: botUserId.toString()},
|
||||
'Successfully updated bot profile',
|
||||
);
|
||||
|
||||
return {
|
||||
user: updatedUser,
|
||||
application: updatedApplication,
|
||||
};
|
||||
}
|
||||
|
||||
private async prepareBotAssets(params: {
|
||||
botUser: User;
|
||||
botUserId: UserID;
|
||||
avatar?: string | null;
|
||||
banner?: string | null;
|
||||
}): Promise<{
|
||||
avatarUpload: PreparedAssetUpload | null;
|
||||
bannerUpload: PreparedAssetUpload | null;
|
||||
avatarHash: string | null | undefined;
|
||||
bannerHash: string | null | undefined;
|
||||
}> {
|
||||
const {botUser, botUserId, avatar, banner} = params;
|
||||
|
||||
let avatarUpload: PreparedAssetUpload | null = null;
|
||||
let bannerUpload: PreparedAssetUpload | null = null;
|
||||
let avatarHash: string | null | undefined;
|
||||
let bannerHash: string | null | undefined;
|
||||
|
||||
if (avatar !== undefined) {
|
||||
avatarUpload = await this.deps.entityAssetService.prepareAssetUpload({
|
||||
assetType: 'avatar',
|
||||
entityType: 'user',
|
||||
entityId: botUserId,
|
||||
previousHash: botUser.avatarHash,
|
||||
base64Image: avatar,
|
||||
errorPath: 'avatar',
|
||||
});
|
||||
avatarHash = avatarUpload.newHash;
|
||||
if (avatarUpload.newHash === botUser.avatarHash) {
|
||||
avatarUpload = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (banner !== undefined) {
|
||||
bannerUpload = await this.deps.entityAssetService.prepareAssetUpload({
|
||||
assetType: 'banner',
|
||||
entityType: 'user',
|
||||
entityId: botUserId,
|
||||
previousHash: botUser.bannerHash,
|
||||
base64Image: banner,
|
||||
errorPath: 'banner',
|
||||
});
|
||||
bannerHash = bannerUpload.newHash;
|
||||
if (bannerUpload.newHash === botUser.bannerHash) {
|
||||
bannerUpload = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {avatarUpload, bannerUpload, avatarHash, bannerHash};
|
||||
}
|
||||
|
||||
private async commitBotAssets(assetPrep: {
|
||||
avatarUpload: PreparedAssetUpload | null;
|
||||
bannerUpload: PreparedAssetUpload | null;
|
||||
}) {
|
||||
if (assetPrep.avatarUpload) {
|
||||
await this.deps.entityAssetService.commitAssetChange({prepared: assetPrep.avatarUpload, deferDeletion: true});
|
||||
}
|
||||
if (assetPrep.bannerUpload) {
|
||||
await this.deps.entityAssetService.commitAssetChange({prepared: assetPrep.bannerUpload, deferDeletion: true});
|
||||
}
|
||||
}
|
||||
|
||||
private async rollbackBotAssets(assetPrep: {
|
||||
avatarUpload: PreparedAssetUpload | null;
|
||||
bannerUpload: PreparedAssetUpload | null;
|
||||
}) {
|
||||
if (assetPrep.avatarUpload) {
|
||||
await this.deps.entityAssetService.rollbackAssetUpload(assetPrep.avatarUpload);
|
||||
}
|
||||
if (assetPrep.bannerUpload) {
|
||||
await this.deps.entityAssetService.rollbackAssetUpload(assetPrep.bannerUpload);
|
||||
}
|
||||
}
|
||||
}
|
||||
80
fluxer_api/src/oauth/BotAuthService.ts
Normal file
80
fluxer_api/src/oauth/BotAuthService.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import argon2 from 'argon2';
|
||||
import type {ApplicationID, UserID} from '~/BrandedTypes';
|
||||
import type {IApplicationRepository} from './repositories/IApplicationRepository';
|
||||
|
||||
export class BotAuthService {
|
||||
constructor(private readonly applicationRepository: IApplicationRepository) {}
|
||||
|
||||
private parseBotToken(token: string): {applicationId: ApplicationID; secret: string} | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [applicationIdStr, secret] = parts;
|
||||
if (!applicationIdStr || !secret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const applicationId = BigInt(applicationIdStr) as ApplicationID;
|
||||
return {applicationId, secret};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async validateBotToken(token: string): Promise<UserID | null> {
|
||||
const parsed = this.parseBotToken(token);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {applicationId, secret} = parsed;
|
||||
const application = await this.applicationRepository.getApplication(applicationId);
|
||||
|
||||
if (!application || !application.hasBotUser() || !application.botTokenHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = await argon2.verify(application.botTokenHash, secret);
|
||||
return isValid ? application.getBotUserId() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async generateBotToken(applicationId: ApplicationID): Promise<{
|
||||
token: string;
|
||||
hash: string;
|
||||
preview: string;
|
||||
}> {
|
||||
const secret = randomBytes(32).toString('base64url');
|
||||
const hash = await argon2.hash(secret);
|
||||
const preview = secret.slice(0, 8);
|
||||
const token = `${applicationId.toString()}.${secret}`;
|
||||
|
||||
return {token, hash, preview};
|
||||
}
|
||||
}
|
||||
88
fluxer_api/src/oauth/BotMfaMirrorService.ts
Normal file
88
fluxer_api/src/oauth/BotMfaMirrorService.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {User} from '~/Models';
|
||||
import type {Application} from '~/models/Application';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {mapUserToPrivateResponse} from '~/user/UserMappers';
|
||||
import type {IApplicationRepository} from './repositories/IApplicationRepository';
|
||||
|
||||
export class BotMfaMirrorService {
|
||||
constructor(
|
||||
private readonly applicationRepository: IApplicationRepository,
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
) {}
|
||||
|
||||
private cloneAuthenticatorTypes(source: User): Set<number> {
|
||||
return source.authenticatorTypes ? new Set(source.authenticatorTypes) : new Set();
|
||||
}
|
||||
|
||||
private hasSameAuthenticatorTypes(target: User, desired: Set<number>): boolean {
|
||||
const current = target.authenticatorTypes ?? new Set<number>();
|
||||
if (current.size !== desired.size) return false;
|
||||
for (const value of current) {
|
||||
if (!desired.has(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async listApplications(ownerUserId: UserID): Promise<Array<Application>> {
|
||||
return this.applicationRepository.listApplicationsByOwner(ownerUserId);
|
||||
}
|
||||
|
||||
async syncAuthenticatorTypesForOwner(owner: User): Promise<void> {
|
||||
if (owner.isBot) return;
|
||||
|
||||
const desiredTypes = this.cloneAuthenticatorTypes(owner);
|
||||
const applications = await this.listApplications(owner.id);
|
||||
|
||||
await Promise.all(
|
||||
applications.map(async (application) => {
|
||||
if (!application.hasBotUser()) return;
|
||||
|
||||
const botUserId = application.getBotUserId();
|
||||
if (!botUserId) return;
|
||||
|
||||
const botUser = await this.userRepository.findUnique(botUserId);
|
||||
if (!botUser) return;
|
||||
|
||||
if (this.hasSameAuthenticatorTypes(botUser, desiredTypes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedBotUser = await this.userRepository.patchUpsert(botUserId, {
|
||||
authenticator_types: desiredTypes,
|
||||
});
|
||||
|
||||
if (updatedBotUser) {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: botUserId,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedBotUser),
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
351
fluxer_api/src/oauth/OAuth2ApplicationsController.ts
Normal file
351
fluxer_api/src/oauth/OAuth2ApplicationsController.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Context} from 'hono';
|
||||
import type {HonoApp, HonoEnv} from '~/App';
|
||||
import {requireSudoMode} from '~/auth/services/SudoVerificationService';
|
||||
import {createApplicationID} from '~/BrandedTypes';
|
||||
import {AVATAR_MAX_SIZE} from '~/Constants';
|
||||
import {AccessDeniedError, BotUserNotFoundError, InvalidClientError, UnknownApplicationError} from '~/Errors';
|
||||
import {UsernameNotAvailableError} from '~/infrastructure/DiscriminatorService';
|
||||
import {DefaultUserOnly, LoginRequiredAllowSuspicious} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
|
||||
import type {Application} from '~/models/Application';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {
|
||||
createBase64StringType,
|
||||
createStringType,
|
||||
DiscriminatorType,
|
||||
SudoVerificationSchema,
|
||||
UsernameType,
|
||||
z,
|
||||
} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
import {ApplicationNotOwnedError} from './ApplicationService';
|
||||
import {mapApplicationToResponse, mapBotProfileToResponse, mapBotTokenResetResponse} from './OAuth2Mappers';
|
||||
import {OAuth2RedirectURICreateType, OAuth2RedirectURIUpdateType} from './OAuth2RedirectURI';
|
||||
|
||||
export const OAuth2ApplicationsController = (app: HonoApp) => {
|
||||
const listApplicationsHandler = async (ctx: Context<HonoEnv>) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const applications: Array<Application> = await ctx.get('applicationService').listApplicationsByOwner(userId);
|
||||
|
||||
const botUserIds = applications
|
||||
.filter((app: Application) => app.hasBotUser())
|
||||
.map((app: Application) => app.getBotUserId()!);
|
||||
const botUsers = await Promise.all(botUserIds.map((botUserId) => ctx.get('userRepository').findUnique(botUserId)));
|
||||
const botUserMap = new Map(botUsers.filter((u) => u !== null).map((u) => [u!.id, u!]));
|
||||
|
||||
return ctx.json(
|
||||
applications.map((app: Application) => {
|
||||
const botUser = app.hasBotUser() && app.getBotUserId() ? botUserMap.get(app.getBotUserId()!) : null;
|
||||
return mapApplicationToResponse(app, {botUser: botUser ?? undefined});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
app.get(
|
||||
'/users/@me/applications',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
listApplicationsHandler,
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/oauth2/applications/@me',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
listApplicationsHandler,
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/oauth2/applications',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_CREATE),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
name: createStringType(1, 100),
|
||||
redirect_uris: z
|
||||
.array(OAuth2RedirectURICreateType)
|
||||
.max(10, 'Maximum of 10 redirect URIs allowed')
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((value) => value ?? []),
|
||||
bot_public: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const body = ctx.req.valid('json');
|
||||
|
||||
const result = await ctx.get('applicationService').createApplication({
|
||||
ownerUserId: userId,
|
||||
name: body.name,
|
||||
redirectUris: body.redirect_uris,
|
||||
botPublic: body.bot_public,
|
||||
});
|
||||
|
||||
return ctx.json(
|
||||
mapApplicationToResponse(result.application, {
|
||||
botUser: result.botUser,
|
||||
botToken: result.botToken,
|
||||
clientSecret: result.clientSecret,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/oauth2/applications/:id',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
|
||||
|
||||
const application = await ctx.get('applicationRepository').getApplication(applicationId);
|
||||
if (!application) {
|
||||
throw new UnknownApplicationError();
|
||||
}
|
||||
|
||||
if (application.ownerUserId !== userId) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
|
||||
let botUser = null;
|
||||
if (application.hasBotUser()) {
|
||||
const botUserId = application.getBotUserId();
|
||||
if (botUserId) {
|
||||
botUser = await ctx.get('userRepository').findUnique(botUserId);
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json(mapApplicationToResponse(application, {botUser}));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/oauth2/applications/:id',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_UPDATE),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
name: createStringType(1, 100).optional(),
|
||||
redirect_uris: z
|
||||
.array(OAuth2RedirectURIUpdateType)
|
||||
.max(10, 'Maximum of 10 redirect URIs allowed')
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((value) => (value === undefined ? undefined : (value ?? []))),
|
||||
bot_public: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
|
||||
const body = ctx.req.valid('json');
|
||||
|
||||
try {
|
||||
const updated = await ctx.get('applicationService').updateApplication({
|
||||
userId,
|
||||
applicationId,
|
||||
name: body.name,
|
||||
redirectUris: body.redirect_uris,
|
||||
botPublic: body.bot_public,
|
||||
});
|
||||
|
||||
let botUser = null;
|
||||
if (updated.hasBotUser()) {
|
||||
const botUserId = updated.getBotUserId();
|
||||
if (botUserId) {
|
||||
botUser = await ctx.get('userRepository').findUnique(botUserId);
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json(mapApplicationToResponse(updated, {botUser: botUser ?? undefined}));
|
||||
} catch (err) {
|
||||
if (err instanceof ApplicationNotOwnedError) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
if (err instanceof UnknownApplicationError) {
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/oauth2/applications/:id',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_DELETE),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
|
||||
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
|
||||
try {
|
||||
await ctx.get('applicationService').deleteApplication(user.id, applicationId);
|
||||
return ctx.body(null, 204);
|
||||
} catch (err) {
|
||||
if (err instanceof ApplicationNotOwnedError) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
if (err instanceof UnknownApplicationError) {
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/oauth2/applications/:id/bot/reset-token',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
|
||||
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
|
||||
|
||||
try {
|
||||
const {token} = await ctx.get('applicationService').rotateBotToken(user.id, applicationId);
|
||||
|
||||
const application = await ctx.get('applicationRepository').getApplication(applicationId);
|
||||
if (!application || !application.botUserId) {
|
||||
throw new BotUserNotFoundError();
|
||||
}
|
||||
|
||||
const botUser = await ctx.get('userRepository').findUnique(application.botUserId);
|
||||
if (!botUser) {
|
||||
throw new BotUserNotFoundError();
|
||||
}
|
||||
|
||||
return ctx.json(mapBotTokenResetResponse(botUser, token));
|
||||
} catch (err) {
|
||||
if (err instanceof ApplicationNotOwnedError) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
|
||||
throw new UnknownApplicationError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/oauth2/applications/:id/client-secret/reset',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
|
||||
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
|
||||
|
||||
try {
|
||||
const {clientSecret} = await ctx.get('applicationService').rotateClientSecret(user.id, applicationId);
|
||||
const application = await ctx.get('applicationRepository').getApplication(applicationId);
|
||||
if (!application) {
|
||||
throw new UnknownApplicationError();
|
||||
}
|
||||
|
||||
return ctx.json(mapApplicationToResponse(application, {clientSecret}));
|
||||
} catch (err) {
|
||||
if (err instanceof ApplicationNotOwnedError) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
|
||||
throw new UnknownApplicationError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/oauth2/applications/:id/bot',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_UPDATE),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
username: UsernameType.optional(),
|
||||
discriminator: DiscriminatorType.optional(),
|
||||
avatar: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
|
||||
banner: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
|
||||
bio: createStringType(0, 1024).nullish(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
|
||||
const body = ctx.req.valid('json');
|
||||
|
||||
try {
|
||||
const result = await ctx.get('applicationService').updateBotProfile(userId, applicationId, {
|
||||
username: body.username,
|
||||
discriminator: body.discriminator,
|
||||
avatar: body.avatar,
|
||||
banner: body.banner,
|
||||
bio: body.bio,
|
||||
});
|
||||
|
||||
return ctx.json(mapBotProfileToResponse(result.user));
|
||||
} catch (err) {
|
||||
if (err instanceof ApplicationNotOwnedError) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
if (err instanceof BotUserNotFoundError) {
|
||||
throw err;
|
||||
}
|
||||
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
|
||||
throw new UnknownApplicationError();
|
||||
}
|
||||
if (err instanceof UsernameNotAvailableError) {
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
651
fluxer_api/src/oauth/OAuth2Controller.ts
Normal file
651
fluxer_api/src/oauth/OAuth2Controller.ts
Normal file
@@ -0,0 +1,651 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Context} from 'hono';
|
||||
import type {HonoApp, HonoEnv} from '~/App';
|
||||
import {requireSudoMode} from '~/auth/services/SudoVerificationService';
|
||||
import {createApplicationID, createGuildID, createRoleID} from '~/BrandedTypes';
|
||||
import {ALL_PERMISSIONS, Permissions} from '~/Constants';
|
||||
import {JoinSourceTypes} from '~/constants/Guild';
|
||||
import {
|
||||
AccessDeniedError,
|
||||
BotAlreadyInGuildError,
|
||||
BotUserNotFoundError,
|
||||
InvalidClientError,
|
||||
InvalidGrantError,
|
||||
InvalidRequestError,
|
||||
InvalidTokenError,
|
||||
MissingPermissionsError,
|
||||
NotABotApplicationError,
|
||||
UnknownApplicationError,
|
||||
UnknownGuildMemberError,
|
||||
} from '~/Errors';
|
||||
import {Logger} from '~/Logger';
|
||||
import {DefaultUserOnly, LoginRequiredAllowSuspicious} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
|
||||
import {
|
||||
AuthorizeConsentRequest,
|
||||
AuthorizeRequest,
|
||||
IntrospectRequestForm,
|
||||
RevokeRequestForm,
|
||||
TokenRequest,
|
||||
} from '~/oauth/OAuthModels';
|
||||
import {parseClientCredentials} from '~/oauth/utils/parseClientCredentials';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {SudoVerificationSchema, type z} from '~/Schema';
|
||||
import {mapUserToOAuthResponse, mapUserToPartialResponse} from '~/user/UserMappers';
|
||||
import {Validator} from '~/Validator';
|
||||
import {ApplicationNotOwnedError} from './ApplicationService';
|
||||
import {mapApplicationToResponse, mapBotTokenResetResponse} from './OAuth2Mappers';
|
||||
import {ACCESS_TOKEN_TTL_SECONDS} from './OAuth2Service';
|
||||
|
||||
type FormContext<TForm> = Context<HonoEnv, string, {out: {form: TForm}}>;
|
||||
|
||||
const extractBearerToken = (authHeader: string): string | null => {
|
||||
const match = /^Bearer\s+(.+)$/.exec(authHeader);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const handleTokenExchange = async (ctx: FormContext<z.infer<typeof TokenRequest>>, logPrefix: string) => {
|
||||
try {
|
||||
const form = ctx.req.valid('form');
|
||||
const hasAuthHeader = !!ctx.req.header('authorization');
|
||||
const isAuthorizationCodeRequest = form.grant_type === 'authorization_code';
|
||||
const isRefreshRequest = form.grant_type === 'refresh_token';
|
||||
|
||||
Logger.debug(
|
||||
{
|
||||
grant_type: form.grant_type,
|
||||
client_id_present: form.client_id != null,
|
||||
redirect_uri_present: isAuthorizationCodeRequest ? form.redirect_uri != null : undefined,
|
||||
code_len: isAuthorizationCodeRequest ? form.code.length : undefined,
|
||||
refresh_token_len: isRefreshRequest ? form.refresh_token.length : undefined,
|
||||
auth_header_basic: hasAuthHeader && /^Basic\s+/i.test(ctx.req.header('authorization') ?? ''),
|
||||
},
|
||||
`${logPrefix} token request received`,
|
||||
);
|
||||
|
||||
if (form.grant_type === 'authorization_code') {
|
||||
const result = await ctx.get('oauth2Service').tokenExchange({
|
||||
headersAuthorization: ctx.req.header('authorization') ?? undefined,
|
||||
grantType: 'authorization_code',
|
||||
code: form.code,
|
||||
redirectUri: form.redirect_uri,
|
||||
clientId: form.client_id ? form.client_id.toString() : undefined,
|
||||
clientSecret: form.client_secret,
|
||||
});
|
||||
return ctx.json(result);
|
||||
} else {
|
||||
const result = await ctx.get('oauth2Service').tokenExchange({
|
||||
headersAuthorization: ctx.req.header('authorization') ?? undefined,
|
||||
grantType: 'refresh_token',
|
||||
refreshToken: form.refresh_token,
|
||||
clientId: form.client_id ? form.client_id.toString() : undefined,
|
||||
clientSecret: form.client_secret,
|
||||
});
|
||||
return ctx.json(result);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof InvalidGrantError) {
|
||||
Logger.warn({error: (err as Error).message}, `${logPrefix} token request failed`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserInfo = async (ctx: Context<HonoEnv>) => {
|
||||
const authHeader = ctx.req.header('authorization') ?? '';
|
||||
const token = extractBearerToken(authHeader);
|
||||
|
||||
if (!token) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
|
||||
const user = await ctx.get('oauth2Service').userInfo(token);
|
||||
return ctx.json(user);
|
||||
};
|
||||
|
||||
const handleRevoke = async (ctx: FormContext<z.infer<typeof RevokeRequestForm>>) => {
|
||||
const body = ctx.req.valid('form');
|
||||
const {clientId: clientIdStr, clientSecret: secret} = parseClientCredentials(
|
||||
ctx.req.header('authorization') ?? undefined,
|
||||
body.client_id,
|
||||
body.client_secret,
|
||||
);
|
||||
if (!secret) {
|
||||
throw new InvalidClientError('Missing client_secret');
|
||||
}
|
||||
await ctx.get('oauth2Service').revoke(body.token, body.token_type_hint ?? undefined, {
|
||||
clientId: createApplicationID(BigInt(clientIdStr)),
|
||||
clientSecret: secret,
|
||||
});
|
||||
return ctx.body(null, 200);
|
||||
};
|
||||
|
||||
const handleIntrospect = async (ctx: FormContext<z.infer<typeof IntrospectRequestForm>>) => {
|
||||
const body = ctx.req.valid('form');
|
||||
const {clientId: clientIdStr, clientSecret: secret} = parseClientCredentials(
|
||||
ctx.req.header('authorization') ?? undefined,
|
||||
body.client_id,
|
||||
body.client_secret,
|
||||
);
|
||||
if (!secret) {
|
||||
throw new InvalidClientError('Missing client_secret');
|
||||
}
|
||||
const result = await ctx.get('oauth2Service').introspect(body.token, {
|
||||
clientId: createApplicationID(BigInt(clientIdStr)),
|
||||
clientSecret: secret,
|
||||
});
|
||||
return ctx.json(result);
|
||||
};
|
||||
|
||||
export const OAuth2Controller = (app: HonoApp) => {
|
||||
app.get(
|
||||
'/oauth2/authorize',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_AUTHORIZE),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
Validator('query', AuthorizeRequest),
|
||||
async (ctx) => {
|
||||
const q = ctx.req.valid('query');
|
||||
Logger.info(
|
||||
{client_id: q.client_id?.toString?.(), scope: q.scope},
|
||||
'GET /oauth2/authorize called; not supported',
|
||||
);
|
||||
throw new InvalidRequestError('GET /oauth2/authorize is not supported. Use POST /oauth2/authorize/consent.');
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/oauth2/authorize/consent',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_AUTHORIZE),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
Validator('json', AuthorizeConsentRequest),
|
||||
async (ctx) => {
|
||||
const body: z.infer<typeof AuthorizeConsentRequest> = ctx.req.valid('json');
|
||||
const user = ctx.get('user');
|
||||
const scopeStr: string = body.scope;
|
||||
const scopeSet = new Set(scopeStr.split(/\s+/).filter(Boolean));
|
||||
const isBotOnly = scopeSet.size === 1 && scopeSet.has('bot');
|
||||
const responseType = body.response_type ?? (isBotOnly ? undefined : 'code');
|
||||
const guildId = body.guild_id ? createGuildID(body.guild_id) : null;
|
||||
let requestedPermissions: bigint | null = null;
|
||||
if (body.permissions !== undefined) {
|
||||
try {
|
||||
requestedPermissions = BigInt(body.permissions);
|
||||
} catch {
|
||||
throw new InvalidRequestError('permissions must be a valid integer');
|
||||
}
|
||||
if (requestedPermissions < 0) {
|
||||
throw new InvalidRequestError('permissions must be non-negative');
|
||||
}
|
||||
requestedPermissions = requestedPermissions & ALL_PERMISSIONS;
|
||||
}
|
||||
|
||||
if (!isBotOnly && responseType !== 'code') {
|
||||
throw new InvalidRequestError('response_type must be code for non-bot scopes');
|
||||
}
|
||||
if (!isBotOnly && !body.redirect_uri) {
|
||||
throw new InvalidRequestError('redirect_uri required for non-bot scopes');
|
||||
}
|
||||
|
||||
const {redirectTo} = await ctx.get('oauth2Service').authorizeAndConsent({
|
||||
clientId: body.client_id.toString(),
|
||||
redirectUri: body.redirect_uri,
|
||||
scope: body.scope,
|
||||
state: body.state ?? undefined,
|
||||
responseType: responseType as 'code' | undefined,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const authCode = (() => {
|
||||
try {
|
||||
const url = new URL(redirectTo);
|
||||
return url.searchParams.get('code');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (scopeSet.has('bot') && guildId) {
|
||||
try {
|
||||
const applicationId = createApplicationID(BigInt(body.client_id));
|
||||
const application = await ctx.get('applicationRepository').getApplication(applicationId);
|
||||
if (!application || !application.botUserId) {
|
||||
throw new NotABotApplicationError();
|
||||
}
|
||||
|
||||
const botUserId = application.botUserId;
|
||||
const hasManageGuild = await ctx.get('gatewayService').checkPermission({
|
||||
guildId,
|
||||
userId: user.id,
|
||||
permission: Permissions.MANAGE_GUILD,
|
||||
});
|
||||
|
||||
if (!hasManageGuild) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.get('guildService').members.getMember({
|
||||
userId: user.id,
|
||||
targetId: botUserId,
|
||||
guildId,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
throw new BotAlreadyInGuildError();
|
||||
} catch (err) {
|
||||
if (!(err instanceof UnknownGuildMemberError)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.get('guildService').members.addUserToGuild({
|
||||
userId: botUserId,
|
||||
guildId,
|
||||
skipGuildLimitCheck: true,
|
||||
skipBanCheck: true,
|
||||
joinSourceType: JoinSourceTypes.BOT_INVITE,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
initiatorId: user.id,
|
||||
});
|
||||
|
||||
if (requestedPermissions && requestedPermissions > 0n) {
|
||||
const role = await ctx.get('guildService').createRole({
|
||||
userId: user.id,
|
||||
guildId,
|
||||
data: {
|
||||
name: `${application.name}`,
|
||||
color: 0,
|
||||
permissions: requestedPermissions,
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.get('guildService').members.addMemberRole({
|
||||
userId: user.id,
|
||||
targetId: botUserId,
|
||||
guildId,
|
||||
roleId: createRoleID(BigInt(role.id)),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (authCode) {
|
||||
await ctx.get('oauth2TokenRepository').deleteAuthorizationCode(authCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info({redirectTo}, 'OAuth2 consent: returning redirect URL');
|
||||
return ctx.json({redirect_to: redirectTo});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/oauth2/token',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_TOKEN),
|
||||
Validator('form', TokenRequest),
|
||||
async (ctx) => handleTokenExchange(ctx, 'OAuth2'),
|
||||
);
|
||||
|
||||
app.get('/oauth2/userinfo', RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT), async (ctx) => {
|
||||
return handleUserInfo(ctx);
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/oauth2/token/revoke',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT),
|
||||
Validator('form', RevokeRequestForm),
|
||||
async (ctx) => handleRevoke(ctx),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/oauth2/introspect',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT),
|
||||
Validator('form', IntrospectRequestForm),
|
||||
async (ctx) => handleIntrospect(ctx),
|
||||
);
|
||||
|
||||
app.get('/oauth2/@me', LoginRequiredAllowSuspicious, DefaultUserOnly, async (ctx) => {
|
||||
const authHeader = ctx.req.header('authorization') ?? '';
|
||||
const token = extractBearerToken(authHeader);
|
||||
|
||||
if (!token) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenData = await ctx.get('oauth2TokenRepository').getAccessToken(token);
|
||||
if (!tokenData) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
|
||||
const application = await ctx.get('applicationRepository').getApplication(tokenData.applicationId);
|
||||
if (!application) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
|
||||
const scopes = Array.from(tokenData.scope);
|
||||
const expiresAt = new Date(tokenData.createdAt.getTime() + ACCESS_TOKEN_TTL_SECONDS * 1000);
|
||||
const response: {
|
||||
application: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: null;
|
||||
description: null;
|
||||
bot_public: boolean;
|
||||
bot_require_code_grant: boolean;
|
||||
verify_key: null;
|
||||
flags: number;
|
||||
};
|
||||
scopes: Array<string>;
|
||||
expires: string;
|
||||
user?: ReturnType<typeof mapUserToOAuthResponse>;
|
||||
} = {
|
||||
application: {
|
||||
id: application.applicationId.toString(),
|
||||
name: application.name,
|
||||
icon: null,
|
||||
description: null,
|
||||
bot_public: application.botIsPublic,
|
||||
bot_require_code_grant: false,
|
||||
verify_key: null,
|
||||
flags: 0,
|
||||
},
|
||||
scopes,
|
||||
expires: expiresAt.toISOString(),
|
||||
};
|
||||
|
||||
if (tokenData.userId && tokenData.scope.has('identify')) {
|
||||
const user = await ctx.get('userRepository').findUnique(tokenData.userId);
|
||||
if (user) {
|
||||
response.user = mapUserToOAuthResponse(user, {includeEmail: tokenData.scope.has('email')});
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json(response);
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidTokenError) {
|
||||
throw err;
|
||||
}
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/oauth2/applications/:id/public',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
|
||||
async (ctx) => {
|
||||
const appId = createApplicationID(BigInt(ctx.req.param('id')));
|
||||
const application = await ctx.get('applicationRepository').getApplication(appId);
|
||||
if (!application) {
|
||||
throw new UnknownApplicationError();
|
||||
}
|
||||
|
||||
let botUser = null;
|
||||
if (application.hasBotUser() && application.getBotUserId()) {
|
||||
botUser = await ctx.get('userRepository').findUnique(application.getBotUserId()!);
|
||||
}
|
||||
|
||||
const scopes: Array<string> = [];
|
||||
if (application.hasBotUser()) {
|
||||
scopes.push('bot');
|
||||
}
|
||||
|
||||
return ctx.json({
|
||||
id: application.applicationId.toString(),
|
||||
name: application.name,
|
||||
icon: botUser?.avatarHash ?? null,
|
||||
description: null,
|
||||
redirect_uris: Array.from(application.oauth2RedirectUris),
|
||||
scopes,
|
||||
bot_public: application.botIsPublic,
|
||||
bot: botUser ? mapUserToPartialResponse(botUser) : null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const extractBotToken = (authHeader: string): string | null => {
|
||||
const match = /^Bot\s+(.+)$/i.exec(authHeader);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
app.get('/applications/@me', async (ctx) => {
|
||||
const authHeader = ctx.req.header('authorization') ?? '';
|
||||
const botToken = extractBotToken(authHeader);
|
||||
if (!botToken) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
|
||||
const botUserId = await ctx.get('botAuthService').validateBotToken(botToken);
|
||||
if (!botUserId) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
|
||||
const [appIdStr] = botToken.split('.');
|
||||
if (!appIdStr) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
|
||||
const application = await ctx.get('applicationRepository').getApplication(createApplicationID(BigInt(appIdStr)));
|
||||
if (!application) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
|
||||
const response: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: null;
|
||||
description: null;
|
||||
bot_public: boolean;
|
||||
bot_require_code_grant: boolean;
|
||||
verify_key: null;
|
||||
flags: number;
|
||||
bot?: ReturnType<typeof mapUserToPartialResponse>;
|
||||
} = {
|
||||
id: application.applicationId.toString(),
|
||||
name: application.name,
|
||||
icon: null,
|
||||
description: null,
|
||||
bot_public: application.botIsPublic,
|
||||
bot_require_code_grant: false,
|
||||
verify_key: null,
|
||||
flags: 0,
|
||||
};
|
||||
|
||||
if (application.hasBotUser() && application.getBotUserId()) {
|
||||
const botUser = await ctx.get('userRepository').findUnique(application.getBotUserId()!);
|
||||
if (botUser) {
|
||||
response.bot = mapUserToPartialResponse(botUser);
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json(response);
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/oauth2/applications/:id/bot/reset-token',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
|
||||
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
|
||||
|
||||
try {
|
||||
const {token} = await ctx.get('applicationService').rotateBotToken(user.id, applicationId);
|
||||
|
||||
const application = await ctx.get('applicationRepository').getApplication(applicationId);
|
||||
if (!application || !application.botUserId) {
|
||||
throw new BotUserNotFoundError();
|
||||
}
|
||||
|
||||
const botUser = await ctx.get('userRepository').findUnique(application.botUserId);
|
||||
if (!botUser) {
|
||||
throw new BotUserNotFoundError();
|
||||
}
|
||||
|
||||
return ctx.json(mapBotTokenResetResponse(botUser, token));
|
||||
} catch (err) {
|
||||
if (err instanceof ApplicationNotOwnedError) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
|
||||
throw new UnknownApplicationError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/oauth2/applications/:id/client-secret/reset',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
|
||||
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
|
||||
|
||||
try {
|
||||
const {clientSecret} = await ctx.get('applicationService').rotateClientSecret(user.id, applicationId);
|
||||
const application = await ctx.get('applicationRepository').getApplication(applicationId);
|
||||
if (!application) {
|
||||
throw new UnknownApplicationError();
|
||||
}
|
||||
|
||||
return ctx.json(mapApplicationToResponse(application, {clientSecret}));
|
||||
} catch (err) {
|
||||
if (err instanceof ApplicationNotOwnedError) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
|
||||
throw new UnknownApplicationError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/oauth2/@me/authorizations',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const refreshTokens = await ctx.get('oauth2TokenRepository').listRefreshTokensForUser(user.id);
|
||||
|
||||
const appMap = new Map<
|
||||
string,
|
||||
{
|
||||
applicationId: string;
|
||||
scopes: Set<string>;
|
||||
createdAt: Date;
|
||||
application: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
description: null;
|
||||
bot_public: boolean;
|
||||
};
|
||||
}
|
||||
>();
|
||||
|
||||
for (const token of refreshTokens) {
|
||||
const appIdStr = token.applicationId.toString();
|
||||
const existing = appMap.get(appIdStr);
|
||||
|
||||
if (existing) {
|
||||
for (const scope of token.scope) {
|
||||
existing.scopes.add(scope);
|
||||
}
|
||||
if (token.createdAt < existing.createdAt) {
|
||||
existing.createdAt = token.createdAt;
|
||||
}
|
||||
} else {
|
||||
const application = await ctx.get('applicationRepository').getApplication(token.applicationId);
|
||||
if (application) {
|
||||
const nonBotScopes = new Set([...token.scope].filter((s) => s !== 'bot'));
|
||||
if (nonBotScopes.size > 0) {
|
||||
let botUser = null;
|
||||
if (application.hasBotUser() && application.getBotUserId()) {
|
||||
botUser = await ctx.get('userRepository').findUnique(application.getBotUserId()!);
|
||||
}
|
||||
|
||||
appMap.set(appIdStr, {
|
||||
applicationId: appIdStr,
|
||||
scopes: nonBotScopes,
|
||||
createdAt: token.createdAt,
|
||||
application: {
|
||||
id: application.applicationId.toString(),
|
||||
name: application.name,
|
||||
icon: botUser?.avatarHash ?? null,
|
||||
description: null,
|
||||
bot_public: application.botIsPublic,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const authorizations = Array.from(appMap.values()).map((entry) => ({
|
||||
application: entry.application,
|
||||
scopes: Array.from(entry.scopes),
|
||||
authorized_at: entry.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
return ctx.json(authorizations);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/oauth2/@me/authorizations/:applicationId',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const applicationId = createApplicationID(BigInt(ctx.req.param('applicationId')));
|
||||
|
||||
const application = await ctx.get('applicationRepository').getApplication(applicationId);
|
||||
if (!application) {
|
||||
throw new UnknownApplicationError();
|
||||
}
|
||||
|
||||
await ctx.get('oauth2TokenRepository').deleteAllTokensForUserAndApplication(user.id, applicationId);
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
};
|
||||
87
fluxer_api/src/oauth/OAuth2Mappers.ts
Normal file
87
fluxer_api/src/oauth/OAuth2Mappers.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {User} from '~/Models';
|
||||
import type {Application} from '~/models/Application';
|
||||
import {mapUserToPartialResponse} from '~/user/UserMappers';
|
||||
import type {ApplicationBotResponse, ApplicationResponse} from './OAuth2Types';
|
||||
|
||||
export const mapBotUserToResponse = (user: User, opts?: {token?: string}): ApplicationBotResponse => {
|
||||
const partial = mapUserToPartialResponse(user);
|
||||
const bannerHash = !user.isBot && !user.isPremium() ? null : user.bannerHash;
|
||||
return {
|
||||
id: partial.id,
|
||||
username: partial.username,
|
||||
discriminator: partial.discriminator,
|
||||
avatar: partial.avatar,
|
||||
banner: bannerHash,
|
||||
bio: user.bio ?? null,
|
||||
token: opts?.token,
|
||||
mfa_enabled: (user.authenticatorTypes?.size ?? 0) > 0,
|
||||
authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : [],
|
||||
};
|
||||
};
|
||||
|
||||
export const mapApplicationToResponse = (
|
||||
application: Application,
|
||||
options?: {
|
||||
botUser?: User | null;
|
||||
botToken?: string;
|
||||
clientSecret?: string | null;
|
||||
},
|
||||
): ApplicationResponse => {
|
||||
const baseResponse: ApplicationResponse = {
|
||||
id: application.applicationId.toString(),
|
||||
name: application.name,
|
||||
redirect_uris: Array.from(application.oauth2RedirectUris),
|
||||
bot_public: application.botIsPublic,
|
||||
bot_require_code_grant: false,
|
||||
};
|
||||
|
||||
if (options?.botUser) {
|
||||
baseResponse.bot = mapBotUserToResponse(options.botUser, {token: options.botToken});
|
||||
}
|
||||
|
||||
if (options?.clientSecret) {
|
||||
return {
|
||||
...baseResponse,
|
||||
client_secret: options.clientSecret,
|
||||
};
|
||||
}
|
||||
|
||||
return baseResponse;
|
||||
};
|
||||
|
||||
export const mapBotTokenResetResponse = (user: User, token: string) => {
|
||||
return {
|
||||
token,
|
||||
bot: mapBotUserToResponse(user),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapBotProfileToResponse = (user: User) => {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
discriminator: user.discriminator.toString().padStart(4, '0'),
|
||||
avatar: user.avatarHash,
|
||||
banner: user.bannerHash,
|
||||
bio: user.bio,
|
||||
};
|
||||
};
|
||||
85
fluxer_api/src/oauth/OAuth2RedirectURI.test.ts
Normal file
85
fluxer_api/src/oauth/OAuth2RedirectURI.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {OAuth2RedirectURICreateType, OAuth2RedirectURIUpdateType} from './OAuth2RedirectURI';
|
||||
|
||||
const loopbackRedirects = [
|
||||
'https://example.com/callback',
|
||||
'https://example.com/callback?foo=bar',
|
||||
'http://localhost:3000/callback',
|
||||
'http://127.0.0.1/callback',
|
||||
'http://[::1]/callback',
|
||||
'http://foo.localhost/callback',
|
||||
];
|
||||
|
||||
const deniedProtocols = [
|
||||
'javascript://example.com/%0Aalert(1)',
|
||||
'data://example.com/text',
|
||||
'file://example.com/etc/passwd',
|
||||
'vbscript://example.com/code',
|
||||
'ftp://example.com/file',
|
||||
'ws://example.com/socket',
|
||||
'wss://example.com/socket',
|
||||
'custom://example.com/path',
|
||||
];
|
||||
|
||||
describe('OAuth2 redirect URI validation', () => {
|
||||
describe('create redirect URI type', () => {
|
||||
it('allows secure redirect URIs', () => {
|
||||
for (const redirect of loopbackRedirects) {
|
||||
const result = OAuth2RedirectURICreateType.safeParse(redirect);
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-localhost http hosts', () => {
|
||||
const result = OAuth2RedirectURICreateType.safeParse('http://example.com/callback');
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
for (const entry of deniedProtocols) {
|
||||
it(`rejects ${entry.split('://')[0]} protocols`, () => {
|
||||
const result = OAuth2RedirectURICreateType.safeParse(entry);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('update redirect URI type', () => {
|
||||
it('allows http redirects for all hosts', () => {
|
||||
const result = OAuth2RedirectURIUpdateType.safeParse('http://example.com/callback');
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
for (const redirect of loopbackRedirects) {
|
||||
it(`still allows ${redirect} redirects`, () => {
|
||||
const result = OAuth2RedirectURIUpdateType.safeParse(redirect);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of deniedProtocols) {
|
||||
it(`rejects ${entry.split('://')[0]} protocols`, () => {
|
||||
const result = OAuth2RedirectURIUpdateType.safeParse(entry);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
56
fluxer_api/src/oauth/OAuth2RedirectURI.ts
Normal file
56
fluxer_api/src/oauth/OAuth2RedirectURI.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createStringType} from '~/Schema';
|
||||
|
||||
const isLoopbackHost = (hostname: string) => {
|
||||
const lowercaseHost = hostname.toLowerCase();
|
||||
return (
|
||||
lowercaseHost === 'localhost' ||
|
||||
lowercaseHost === '127.0.0.1' ||
|
||||
lowercaseHost === '[::1]' ||
|
||||
lowercaseHost.endsWith('.localhost')
|
||||
);
|
||||
};
|
||||
|
||||
const isValidRedirectURI = (value: string, allowAnyHttp: boolean) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!allowAnyHttp && url.protocol === 'http:' && !isLoopbackHost(url.hostname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!url.host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const createRedirectURIType = (allowAnyHttp: boolean, message: string) =>
|
||||
createStringType(1).refine((value) => isValidRedirectURI(value, allowAnyHttp), message);
|
||||
|
||||
export const OAuth2RedirectURICreateType = createRedirectURIType(
|
||||
false,
|
||||
'Redirect URIs must use HTTPS, or HTTP for localhost only',
|
||||
);
|
||||
export const OAuth2RedirectURIUpdateType = createRedirectURIType(true, 'Redirect URIs must use HTTP or HTTPS');
|
||||
449
fluxer_api/src/oauth/OAuth2Service.ts
Normal file
449
fluxer_api/src/oauth/OAuth2Service.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import argon2 from 'argon2';
|
||||
import type {ApplicationID, UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import type {OAuth2AccessTokenRow, OAuth2AuthorizationCodeRow, OAuth2RefreshTokenRow} from '~/database/CassandraTypes';
|
||||
import {
|
||||
AccessDeniedError,
|
||||
InvalidClientError,
|
||||
InvalidGrantError,
|
||||
InvalidRequestError,
|
||||
InvalidScopeError,
|
||||
InvalidTokenError,
|
||||
} from '~/Errors';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {Application} from '~/models/Application';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {mapUserToOAuthResponse} from '~/user/UserMappers';
|
||||
import type {OAuthScope} from './OAuthModels';
|
||||
import {ApplicationRepository} from './repositories/ApplicationRepository';
|
||||
import type {IApplicationRepository} from './repositories/IApplicationRepository';
|
||||
import type {IOAuth2TokenRepository} from './repositories/IOAuth2TokenRepository';
|
||||
import {OAuth2TokenRepository} from './repositories/OAuth2TokenRepository';
|
||||
|
||||
interface OAuth2ServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
applicationRepository?: IApplicationRepository;
|
||||
oauth2TokenRepository?: IOAuth2TokenRepository;
|
||||
cacheService?: ICacheService;
|
||||
}
|
||||
|
||||
const PREFERRED_SCOPE_ORDER = ['identify', 'email', 'guilds', 'connections', 'bot', 'applications.commands'];
|
||||
|
||||
const sortScopes = (scope: Set<string>): Array<string> => {
|
||||
return Array.from(scope).sort((a, b) => {
|
||||
const ai = PREFERRED_SCOPE_ORDER.indexOf(a);
|
||||
const bi = PREFERRED_SCOPE_ORDER.indexOf(b);
|
||||
if (ai === -1 && bi === -1) return a.localeCompare(b);
|
||||
if (ai === -1) return 1;
|
||||
if (bi === -1) return -1;
|
||||
return ai - bi;
|
||||
});
|
||||
};
|
||||
|
||||
export const ACCESS_TOKEN_TTL_SECONDS = 7 * 24 * 3600;
|
||||
|
||||
export class OAuth2Service {
|
||||
private applications: IApplicationRepository;
|
||||
private tokens: IOAuth2TokenRepository;
|
||||
private static readonly ALLOWED_SCOPES = [
|
||||
'identify',
|
||||
'email',
|
||||
'guilds',
|
||||
'connections',
|
||||
'bot',
|
||||
'applications.commands',
|
||||
];
|
||||
|
||||
constructor(private readonly deps: OAuth2ServiceDeps) {
|
||||
this.applications = deps.applicationRepository ?? new ApplicationRepository();
|
||||
this.tokens = deps.oauth2TokenRepository ?? new OAuth2TokenRepository();
|
||||
}
|
||||
|
||||
private parseScope(scope: string): Array<OAuthScope> {
|
||||
const parts = scope.split(/\s+/).filter(Boolean);
|
||||
return parts as Array<OAuthScope>;
|
||||
}
|
||||
|
||||
private validateRedirectUri(application: Application, redirectUri: string): boolean {
|
||||
if (application.oauth2RedirectUris.size === 0) {
|
||||
return false;
|
||||
}
|
||||
return application.oauth2RedirectUris.has(redirectUri);
|
||||
}
|
||||
|
||||
async authorizeAndConsent(params: {
|
||||
clientId: string;
|
||||
redirectUri?: string;
|
||||
scope: string;
|
||||
state?: string;
|
||||
codeChallenge?: string;
|
||||
codeChallengeMethod?: 'S256' | 'plain';
|
||||
responseType?: 'code';
|
||||
userId: UserID;
|
||||
}): Promise<{redirectTo: string}> {
|
||||
const parsedClientId = BigInt(params.clientId) as ApplicationID;
|
||||
const application = await this.applications.getApplication(parsedClientId);
|
||||
|
||||
if (!application) {
|
||||
throw new InvalidClientError();
|
||||
}
|
||||
|
||||
const scopeSet = new Set<string>(this.parseScope(params.scope));
|
||||
for (const s of scopeSet) {
|
||||
if (!OAuth2Service.ALLOWED_SCOPES.includes(s)) {
|
||||
throw new InvalidScopeError();
|
||||
}
|
||||
}
|
||||
|
||||
if (scopeSet.has('bot') && !application.botIsPublic && params.userId !== application.ownerUserId) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
|
||||
const isBotOnly = scopeSet.size === 1 && scopeSet.has('bot');
|
||||
|
||||
const redirectUri = params.redirectUri;
|
||||
const requireRedirect = !isBotOnly;
|
||||
|
||||
if (!redirectUri && requireRedirect) {
|
||||
throw new InvalidRequestError('Missing redirect_uri');
|
||||
}
|
||||
|
||||
if (redirectUri && !this.validateRedirectUri(application, redirectUri)) {
|
||||
throw new InvalidRequestError('Invalid redirect_uri');
|
||||
}
|
||||
|
||||
const resolvedRedirectUri = redirectUri ?? Config.endpoints.webApp;
|
||||
|
||||
let loc: URL;
|
||||
try {
|
||||
loc = new URL(resolvedRedirectUri);
|
||||
} catch {
|
||||
throw new InvalidRequestError('Invalid redirect_uri');
|
||||
}
|
||||
|
||||
const codeRow: OAuth2AuthorizationCodeRow = {
|
||||
code: randomBytes(32).toString('base64url'),
|
||||
application_id: application.applicationId,
|
||||
user_id: params.userId,
|
||||
redirect_uri: loc.toString(),
|
||||
scope: scopeSet,
|
||||
nonce: null,
|
||||
created_at: new Date(),
|
||||
};
|
||||
|
||||
await this.tokens.createAuthorizationCode(codeRow);
|
||||
|
||||
loc.searchParams.set('code', codeRow.code);
|
||||
if (params.state) {
|
||||
loc.searchParams.set('state', params.state);
|
||||
}
|
||||
|
||||
return {redirectTo: loc.toString()};
|
||||
}
|
||||
|
||||
private basicAuth(credentialsHeader?: string): {clientId: string; clientSecret: string} | null {
|
||||
if (!credentialsHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const m = /^Basic\s+(.+)$/.exec(credentialsHeader);
|
||||
if (!m) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decoded = Buffer.from(m[1], 'base64').toString('utf8');
|
||||
const idx = decoded.indexOf(':');
|
||||
|
||||
if (idx < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
clientId: decoded.slice(0, idx),
|
||||
clientSecret: decoded.slice(idx + 1),
|
||||
};
|
||||
}
|
||||
|
||||
private async issueTokens(args: {application: Application; userId: UserID | null; scope: Set<string>}): Promise<{
|
||||
accessToken: OAuth2AccessTokenRow;
|
||||
refreshToken?: OAuth2RefreshTokenRow;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
scope?: string;
|
||||
}> {
|
||||
const accessToken: OAuth2AccessTokenRow = {
|
||||
token_: randomBytes(32).toString('base64url'),
|
||||
application_id: args.application.applicationId,
|
||||
user_id: args.userId,
|
||||
scope: args.scope,
|
||||
created_at: new Date(),
|
||||
};
|
||||
|
||||
const createdAccess = await this.tokens.createAccessToken(accessToken);
|
||||
|
||||
let refreshToken: OAuth2RefreshTokenRow | undefined;
|
||||
if (args.userId) {
|
||||
const row: OAuth2RefreshTokenRow = {
|
||||
token_: randomBytes(32).toString('base64url'),
|
||||
application_id: args.application.applicationId,
|
||||
user_id: args.userId,
|
||||
scope: args.scope,
|
||||
created_at: new Date(),
|
||||
};
|
||||
const created = await this.tokens.createRefreshToken(row);
|
||||
refreshToken = created.toRow();
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: createdAccess.toRow(),
|
||||
refreshToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
||||
scope: sortScopes(args.scope).join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
async tokenExchange(params: {
|
||||
headersAuthorization?: string;
|
||||
grantType: 'authorization_code' | 'refresh_token';
|
||||
code?: string;
|
||||
refreshToken?: string;
|
||||
redirectUri?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
}): Promise<{
|
||||
access_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
scope?: string;
|
||||
refresh_token?: string;
|
||||
}> {
|
||||
Logger.debug(
|
||||
{
|
||||
grant_type: params.grantType,
|
||||
client_id_present: !!params.clientId || /^Basic\s+/.test(params.headersAuthorization ?? ''),
|
||||
has_basic_auth: /^Basic\s+/.test(params.headersAuthorization ?? ''),
|
||||
code_present: !!params.code,
|
||||
refresh_token_present: !!params.refreshToken,
|
||||
redirect_uri_present: !!params.redirectUri,
|
||||
},
|
||||
'OAuth2 tokenExchange start',
|
||||
);
|
||||
|
||||
const basic = this.basicAuth(params.headersAuthorization);
|
||||
const clientId = params.clientId ?? basic?.clientId ?? '';
|
||||
const clientSecret = params.clientSecret ?? basic?.clientSecret;
|
||||
const parsedClientId = BigInt(clientId) as ApplicationID;
|
||||
const application = await this.applications.getApplication(parsedClientId);
|
||||
|
||||
if (!application) {
|
||||
Logger.debug({client_id_len: clientId.length}, 'OAuth2 tokenExchange: unknown application');
|
||||
throw new InvalidClientError();
|
||||
}
|
||||
|
||||
if (!clientSecret) {
|
||||
Logger.debug(
|
||||
{application_id: application.applicationId.toString()},
|
||||
'OAuth2 tokenExchange: missing client_secret',
|
||||
);
|
||||
throw new InvalidClientError('Missing client_secret');
|
||||
}
|
||||
|
||||
if (application.clientSecretHash) {
|
||||
const ok = await argon2.verify(application.clientSecretHash, clientSecret);
|
||||
if (!ok) {
|
||||
Logger.debug(
|
||||
{application_id: application.applicationId.toString()},
|
||||
'OAuth2 tokenExchange: client_secret verification failed',
|
||||
);
|
||||
throw new InvalidClientError('Invalid client_secret');
|
||||
}
|
||||
}
|
||||
|
||||
if (params.grantType === 'authorization_code') {
|
||||
const code = params.code!;
|
||||
const authCode = await this.tokens.getAuthorizationCode(code);
|
||||
|
||||
if (!authCode) {
|
||||
Logger.debug({code_len: code.length}, 'OAuth2 tokenExchange: authorization code not found');
|
||||
throw new InvalidGrantError();
|
||||
}
|
||||
|
||||
if (authCode.applicationId !== application.applicationId) {
|
||||
Logger.debug(
|
||||
{application_id: application.applicationId.toString()},
|
||||
'OAuth2 tokenExchange: code application mismatch',
|
||||
);
|
||||
throw new InvalidGrantError();
|
||||
}
|
||||
|
||||
if (params.redirectUri && authCode.redirectUri !== params.redirectUri) {
|
||||
Logger.debug(
|
||||
{expected: authCode.redirectUri, got: params.redirectUri},
|
||||
'OAuth2 tokenExchange: redirect_uri mismatch',
|
||||
);
|
||||
throw new InvalidGrantError();
|
||||
}
|
||||
|
||||
await this.tokens.deleteAuthorizationCode(code);
|
||||
|
||||
const res = await this.issueTokens({
|
||||
application,
|
||||
userId: authCode.userId,
|
||||
scope: authCode.scope,
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: res.accessToken.token_,
|
||||
token_type: 'Bearer',
|
||||
expires_in: res.expires_in,
|
||||
scope: res.scope,
|
||||
refresh_token: res.refreshToken?.token_,
|
||||
};
|
||||
}
|
||||
|
||||
const refresh = await this.tokens.getRefreshToken(params.refreshToken!);
|
||||
if (!refresh) {
|
||||
throw new InvalidGrantError();
|
||||
}
|
||||
|
||||
if (refresh.applicationId !== application.applicationId) {
|
||||
throw new InvalidGrantError();
|
||||
}
|
||||
|
||||
const res = await this.issueTokens({
|
||||
application,
|
||||
userId: refresh.userId,
|
||||
scope: refresh.scope,
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: res.accessToken.token_,
|
||||
token_type: 'Bearer',
|
||||
expires_in: res.expires_in,
|
||||
scope: res.scope,
|
||||
refresh_token: res.refreshToken?.token_,
|
||||
};
|
||||
}
|
||||
|
||||
async userInfo(accessToken: string) {
|
||||
const token = await this.tokens.getAccessToken(accessToken);
|
||||
if (!token || !token.userId) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
|
||||
const application = await this.applications.getApplication(token.applicationId);
|
||||
if (!application) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
|
||||
const user = await this.deps.userRepository.findUnique(token.userId);
|
||||
if (!user) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
|
||||
const includeEmail = token.scope.has('email');
|
||||
return mapUserToOAuthResponse(user, {includeEmail});
|
||||
}
|
||||
|
||||
async introspect(
|
||||
tokenStr: string,
|
||||
auth: {clientId: ApplicationID; clientSecret?: string | null},
|
||||
): Promise<{
|
||||
active: boolean;
|
||||
client_id?: string;
|
||||
sub?: string;
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
exp?: number;
|
||||
iat?: number;
|
||||
}> {
|
||||
const application = await this.applications.getApplication(auth.clientId);
|
||||
if (!application) {
|
||||
return {active: false};
|
||||
}
|
||||
|
||||
if (!auth.clientSecret) {
|
||||
return {active: false};
|
||||
}
|
||||
|
||||
if (application.clientSecretHash) {
|
||||
const valid = await argon2.verify(application.clientSecretHash, auth.clientSecret);
|
||||
if (!valid) {
|
||||
return {active: false};
|
||||
}
|
||||
}
|
||||
|
||||
const token = await this.tokens.getAccessToken(tokenStr);
|
||||
if (!token) {
|
||||
return {active: false};
|
||||
}
|
||||
|
||||
if (token.applicationId !== application.applicationId) {
|
||||
return {active: false};
|
||||
}
|
||||
|
||||
return {
|
||||
active: true,
|
||||
client_id: token.applicationId.toString(),
|
||||
sub: token.userId ? token.userId.toString() : undefined,
|
||||
scope: sortScopes(token.scope).join(' '),
|
||||
token_type: 'Bearer',
|
||||
exp: Math.floor((token.createdAt.getTime() + ACCESS_TOKEN_TTL_SECONDS * 1000) / 1000),
|
||||
iat: Math.floor(token.createdAt.getTime() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
async revoke(
|
||||
tokenStr: string,
|
||||
tokenTypeHint: 'access_token' | 'refresh_token' | undefined,
|
||||
auth: {clientId: ApplicationID; clientSecret?: string | null},
|
||||
): Promise<void> {
|
||||
const application = await this.applications.getApplication(auth.clientId);
|
||||
if (!application) {
|
||||
throw new InvalidClientError();
|
||||
}
|
||||
|
||||
if (application.clientSecretHash) {
|
||||
const valid = auth.clientSecret ? await argon2.verify(application.clientSecretHash, auth.clientSecret) : false;
|
||||
if (!valid) {
|
||||
throw new InvalidClientError('Invalid client_secret');
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenTypeHint === 'refresh_token') {
|
||||
const refresh = await this.tokens.getRefreshToken(tokenStr);
|
||||
if (refresh && refresh.applicationId === application.applicationId) {
|
||||
await this.tokens.deleteRefreshToken(tokenStr, application.applicationId, refresh.userId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const access = await this.tokens.getAccessToken(tokenStr);
|
||||
if (access && access.applicationId === application.applicationId) {
|
||||
await this.tokens.deleteAccessToken(tokenStr, application.applicationId, access.userId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
fluxer_api/src/oauth/OAuth2Types.ts
Normal file
40
fluxer_api/src/oauth/OAuth2Types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface ApplicationBotResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
avatar?: string | null;
|
||||
banner?: string | null;
|
||||
bio: string | null;
|
||||
token?: string;
|
||||
mfa_enabled?: boolean;
|
||||
authenticator_types?: Array<number>;
|
||||
}
|
||||
|
||||
export interface ApplicationResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
redirect_uris: Array<string>;
|
||||
bot_public: boolean;
|
||||
bot_require_code_grant: boolean;
|
||||
client_secret?: string;
|
||||
bot?: ApplicationBotResponse;
|
||||
}
|
||||
87
fluxer_api/src/oauth/OAuthModels.ts
Normal file
87
fluxer_api/src/oauth/OAuthModels.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createStringType, Int64Type, z} from '~/Schema';
|
||||
|
||||
const RedirectURIString = createStringType(1).refine((value) => {
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return !!u.protocol && !!u.host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 'Invalid URL format');
|
||||
|
||||
export const OAuthScopes = ['identify', 'email', 'guilds', 'bot', 'applications.commands'] as const;
|
||||
|
||||
export type OAuthScope = (typeof OAuthScopes)[number];
|
||||
|
||||
export const AuthorizeRequest = z.object({
|
||||
response_type: z.literal('code').optional(),
|
||||
client_id: Int64Type,
|
||||
redirect_uri: RedirectURIString.optional(),
|
||||
scope: createStringType(1),
|
||||
state: createStringType(1).optional(),
|
||||
prompt: z.enum(['consent', 'none']).optional(),
|
||||
guild_id: Int64Type.optional(),
|
||||
permissions: z.string().optional(),
|
||||
disable_guild_select: z.enum(['true', 'false']).optional(),
|
||||
});
|
||||
|
||||
export const AuthorizeConsentRequest = z.object({
|
||||
response_type: z.string().optional(),
|
||||
client_id: Int64Type,
|
||||
redirect_uri: RedirectURIString.optional(),
|
||||
scope: createStringType(1),
|
||||
state: createStringType(1).optional(),
|
||||
permissions: z.string().optional(),
|
||||
guild_id: Int64Type.optional(),
|
||||
});
|
||||
|
||||
export const TokenRequest = z.discriminatedUnion('grant_type', [
|
||||
z.object({
|
||||
grant_type: z.literal('authorization_code'),
|
||||
code: createStringType(1),
|
||||
redirect_uri: RedirectURIString,
|
||||
client_id: Int64Type.optional(),
|
||||
client_secret: createStringType(1).optional(),
|
||||
}),
|
||||
z.object({
|
||||
grant_type: z.literal('refresh_token'),
|
||||
refresh_token: createStringType(1),
|
||||
client_id: Int64Type.optional(),
|
||||
client_secret: createStringType(1).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const IntrospectRequestForm = z.object({
|
||||
token: createStringType(1),
|
||||
client_id: Int64Type.optional(),
|
||||
client_secret: createStringType(1).optional(),
|
||||
});
|
||||
|
||||
export const RevokeRequestForm = z.object({
|
||||
token: createStringType(1),
|
||||
token_type_hint: z.enum(['access_token', 'refresh_token']).optional(),
|
||||
client_id: Int64Type.optional(),
|
||||
client_secret: createStringType(1).optional(),
|
||||
});
|
||||
|
||||
export type AuthorizeRequest = z.infer<typeof AuthorizeRequest>;
|
||||
export type TokenRequest = z.infer<typeof TokenRequest>;
|
||||
134
fluxer_api/src/oauth/init.ts
Normal file
134
fluxer_api/src/oauth/init.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import argon2 from 'argon2';
|
||||
import {createApplicationID, createUserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import type {ApplicationRow} from '~/database/types/OAuth2Types';
|
||||
import {Logger} from '~/Logger';
|
||||
import {ApplicationRepository} from '~/oauth/repositories/ApplicationRepository';
|
||||
import {z} from '~/Schema';
|
||||
|
||||
const OAuth2ConfigSchema = z.object({
|
||||
clientIdStr: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
redirectUri: z.string().url(),
|
||||
});
|
||||
|
||||
type OAuth2Config = z.infer<typeof OAuth2ConfigSchema>;
|
||||
|
||||
function getAdminOAuth2Config(): OAuth2Config | null {
|
||||
Logger.info(
|
||||
{
|
||||
ADMIN_OAUTH2_AUTO_CREATE: Config.adminOauth2.autoCreate,
|
||||
CLIENT_ID_SET: !!Config.adminOauth2.clientId,
|
||||
CLIENT_SECRET_SET: !!Config.adminOauth2.clientSecret,
|
||||
},
|
||||
'getAdminOAuth2Config check',
|
||||
);
|
||||
|
||||
if (!Config.adminOauth2.autoCreate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clientIdStr = Config.adminOauth2.clientId;
|
||||
const clientSecret = Config.adminOauth2.clientSecret;
|
||||
|
||||
if (!clientIdStr || !clientSecret) {
|
||||
Logger.info(
|
||||
'Skipping admin OAuth2 client auto-create; set ADMIN_OAUTH2_CLIENT_ID and ADMIN_OAUTH2_CLIENT_SECRET to enable.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const redirectUri = Config.adminOauth2.redirectUri ?? 'http://127.0.0.1:8001/oauth2_callback';
|
||||
|
||||
const parseResult = OAuth2ConfigSchema.safeParse({clientIdStr, clientSecret, redirectUri});
|
||||
if (!parseResult.success) {
|
||||
Logger.error({errors: parseResult.error.issues}, 'Invalid admin OAuth2 configuration');
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseResult.data;
|
||||
}
|
||||
|
||||
async function upsertAdminOAuth2Client(repo: ApplicationRepository, config: OAuth2Config): Promise<void> {
|
||||
Logger.info({clientId: config.clientIdStr, redirectUri: config.redirectUri}, 'Upserting admin OAuth2 client...');
|
||||
|
||||
const applicationId = createApplicationID(BigInt(config.clientIdStr));
|
||||
const existing = await repo.getApplication(applicationId);
|
||||
Logger.info({existing: !!existing}, 'Checked for existing admin application');
|
||||
|
||||
const ownerUserId = createUserID(-1n);
|
||||
const now = new Date();
|
||||
const secretHash = await argon2.hash(config.clientSecret);
|
||||
|
||||
if (existing) {
|
||||
const base = existing.toRow();
|
||||
|
||||
const row: ApplicationRow = {
|
||||
...base,
|
||||
client_secret_hash: secretHash,
|
||||
client_secret_created_at: now,
|
||||
oauth2_redirect_uris: new Set<string>([config.redirectUri]),
|
||||
bot_is_public: base.bot_is_public ?? false,
|
||||
};
|
||||
|
||||
await repo.upsertApplication(row);
|
||||
Logger.info(
|
||||
{application_id: applicationId.toString(), redirect_uris: [config.redirectUri]},
|
||||
'Updated admin OAuth2 application',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const row: ApplicationRow = {
|
||||
application_id: applicationId,
|
||||
owner_user_id: ownerUserId,
|
||||
name: 'Fluxer Admin',
|
||||
bot_user_id: null,
|
||||
bot_is_public: false,
|
||||
oauth2_redirect_uris: new Set<string>([config.redirectUri]),
|
||||
client_secret_hash: secretHash,
|
||||
client_secret_created_at: now,
|
||||
bot_token_hash: null,
|
||||
bot_token_preview: null,
|
||||
bot_token_created_at: null,
|
||||
};
|
||||
|
||||
await repo.upsertApplication(row);
|
||||
Logger.info({application_id: applicationId.toString()}, 'Created admin OAuth2 application');
|
||||
}
|
||||
|
||||
export async function initializeOAuth(): Promise<void> {
|
||||
try {
|
||||
Logger.info('Initializing OAuth applications...');
|
||||
const repo = new ApplicationRepository();
|
||||
|
||||
const adminCfg = getAdminOAuth2Config();
|
||||
Logger.info({adminCfg: !!adminCfg}, 'Admin OAuth2 config loaded');
|
||||
if (adminCfg) {
|
||||
await upsertAdminOAuth2Client(repo, adminCfg);
|
||||
}
|
||||
|
||||
Logger.info('OAuth application initialization complete');
|
||||
} catch (err) {
|
||||
Logger.error(err, 'Failed to auto-create OAuth2 application(s)');
|
||||
}
|
||||
}
|
||||
107
fluxer_api/src/oauth/repositories/ApplicationRepository.ts
Normal file
107
fluxer_api/src/oauth/repositories/ApplicationRepository.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ApplicationID, UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, buildPatchFromData, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import {APPLICATION_COLUMNS} from '~/database/CassandraTypes';
|
||||
import type {ApplicationByOwnerRow, ApplicationRow} from '~/database/types/OAuth2Types';
|
||||
import {Application} from '~/models/Application';
|
||||
import {Applications, ApplicationsByOwner} from '~/Tables';
|
||||
import type {IApplicationRepository} from './IApplicationRepository';
|
||||
|
||||
const SELECT_APPLICATION_CQL = Applications.selectCql({
|
||||
where: Applications.where.eq('application_id'),
|
||||
});
|
||||
|
||||
const SELECT_APPLICATION_IDS_BY_OWNER_CQL = ApplicationsByOwner.selectCql({
|
||||
columns: ['application_id'],
|
||||
where: ApplicationsByOwner.where.eq('owner_user_id'),
|
||||
});
|
||||
|
||||
const FETCH_APPLICATIONS_BY_IDS_CQL = Applications.selectCql({
|
||||
where: Applications.where.in('application_id', 'application_ids'),
|
||||
});
|
||||
|
||||
export class ApplicationRepository implements IApplicationRepository {
|
||||
async getApplication(applicationId: ApplicationID): Promise<Application | null> {
|
||||
const row = await fetchOne<ApplicationRow>(SELECT_APPLICATION_CQL, {application_id: applicationId});
|
||||
return row ? new Application(row) : null;
|
||||
}
|
||||
|
||||
async listApplicationsByOwner(ownerUserId: UserID): Promise<Array<Application>> {
|
||||
const ids = await fetchMany<ApplicationByOwnerRow>(SELECT_APPLICATION_IDS_BY_OWNER_CQL, {
|
||||
owner_user_id: ownerUserId,
|
||||
});
|
||||
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await fetchMany<ApplicationRow>(FETCH_APPLICATIONS_BY_IDS_CQL, {
|
||||
application_ids: ids.map((r) => r.application_id),
|
||||
});
|
||||
|
||||
return rows.map((r) => new Application(r));
|
||||
}
|
||||
|
||||
async upsertApplication(data: ApplicationRow, oldData?: ApplicationRow | null): Promise<Application> {
|
||||
const applicationId = data.application_id;
|
||||
|
||||
const result = await executeVersionedUpdate<ApplicationRow, 'application_id'>(
|
||||
async () => {
|
||||
if (oldData !== undefined) return oldData;
|
||||
return await fetchOne<ApplicationRow>(SELECT_APPLICATION_CQL, {application_id: applicationId});
|
||||
},
|
||||
(current) => ({
|
||||
pk: {application_id: applicationId},
|
||||
patch: buildPatchFromData(data, current, APPLICATION_COLUMNS, ['application_id']),
|
||||
}),
|
||||
Applications,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
ApplicationsByOwner.upsertAll({
|
||||
owner_user_id: data.owner_user_id,
|
||||
application_id: data.application_id,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
|
||||
return new Application({...data, version: result.finalVersion});
|
||||
}
|
||||
|
||||
async deleteApplication(applicationId: ApplicationID): Promise<void> {
|
||||
const application = await this.getApplication(applicationId);
|
||||
if (!application) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(Applications.deleteByPk({application_id: applicationId}));
|
||||
batch.addPrepared(
|
||||
ApplicationsByOwner.deleteByPk({
|
||||
owner_user_id: application.ownerUserId,
|
||||
application_id: applicationId,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
29
fluxer_api/src/oauth/repositories/IApplicationRepository.ts
Normal file
29
fluxer_api/src/oauth/repositories/IApplicationRepository.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ApplicationID, UserID} from '~/BrandedTypes';
|
||||
import type {ApplicationRow} from '~/database/types/OAuth2Types';
|
||||
import type {Application} from '~/models/Application';
|
||||
|
||||
export interface IApplicationRepository {
|
||||
getApplication(applicationId: ApplicationID): Promise<Application | null>;
|
||||
listApplicationsByOwner(ownerUserId: UserID): Promise<Array<Application>>;
|
||||
upsertApplication(data: ApplicationRow, oldData?: ApplicationRow | null): Promise<Application>;
|
||||
deleteApplication(applicationId: ApplicationID): Promise<void>;
|
||||
}
|
||||
47
fluxer_api/src/oauth/repositories/IOAuth2TokenRepository.ts
Normal file
47
fluxer_api/src/oauth/repositories/IOAuth2TokenRepository.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ApplicationID, UserID} from '~/BrandedTypes';
|
||||
import type {
|
||||
OAuth2AccessTokenRow,
|
||||
OAuth2AuthorizationCodeRow,
|
||||
OAuth2RefreshTokenRow,
|
||||
} from '~/database/types/OAuth2Types';
|
||||
import type {OAuth2AccessToken} from '~/models/OAuth2AccessToken';
|
||||
import type {OAuth2AuthorizationCode} from '~/models/OAuth2AuthorizationCode';
|
||||
import type {OAuth2RefreshToken} from '~/models/OAuth2RefreshToken';
|
||||
|
||||
export interface IOAuth2TokenRepository {
|
||||
createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise<OAuth2AuthorizationCode>;
|
||||
getAuthorizationCode(code: string): Promise<OAuth2AuthorizationCode | null>;
|
||||
deleteAuthorizationCode(code: string): Promise<void>;
|
||||
|
||||
createAccessToken(data: OAuth2AccessTokenRow): Promise<OAuth2AccessToken>;
|
||||
getAccessToken(token: string): Promise<OAuth2AccessToken | null>;
|
||||
deleteAccessToken(token: string, applicationId: ApplicationID, userId: UserID | null): Promise<void>;
|
||||
deleteAllAccessTokensForUser(userId: UserID): Promise<void>;
|
||||
|
||||
createRefreshToken(data: OAuth2RefreshTokenRow): Promise<OAuth2RefreshToken>;
|
||||
getRefreshToken(token: string): Promise<OAuth2RefreshToken | null>;
|
||||
deleteRefreshToken(token: string, applicationId: ApplicationID, userId: UserID): Promise<void>;
|
||||
deleteAllRefreshTokensForUser(userId: UserID): Promise<void>;
|
||||
|
||||
listRefreshTokensForUser(userId: UserID): Promise<Array<OAuth2RefreshToken>>;
|
||||
deleteAllTokensForUserAndApplication(userId: UserID, applicationId: ApplicationID): Promise<void>;
|
||||
}
|
||||
217
fluxer_api/src/oauth/repositories/OAuth2TokenRepository.ts
Normal file
217
fluxer_api/src/oauth/repositories/OAuth2TokenRepository.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ApplicationID, UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {
|
||||
OAuth2AccessTokenByUserRow,
|
||||
OAuth2AccessTokenRow,
|
||||
OAuth2AuthorizationCodeRow,
|
||||
OAuth2RefreshTokenByUserRow,
|
||||
OAuth2RefreshTokenRow,
|
||||
} from '~/database/types/OAuth2Types';
|
||||
import {OAuth2AccessToken} from '~/models/OAuth2AccessToken';
|
||||
import {OAuth2AuthorizationCode} from '~/models/OAuth2AuthorizationCode';
|
||||
import {OAuth2RefreshToken} from '~/models/OAuth2RefreshToken';
|
||||
import {
|
||||
OAuth2AccessTokens,
|
||||
OAuth2AccessTokensByUser,
|
||||
OAuth2AuthorizationCodes,
|
||||
OAuth2RefreshTokens,
|
||||
OAuth2RefreshTokensByUser,
|
||||
} from '~/Tables';
|
||||
import type {IOAuth2TokenRepository} from './IOAuth2TokenRepository';
|
||||
|
||||
const SELECT_AUTHORIZATION_CODE = OAuth2AuthorizationCodes.selectCql({
|
||||
where: OAuth2AuthorizationCodes.where.eq('code'),
|
||||
});
|
||||
|
||||
const SELECT_ACCESS_TOKEN = OAuth2AccessTokens.selectCql({
|
||||
where: OAuth2AccessTokens.where.eq('token_'),
|
||||
});
|
||||
|
||||
const SELECT_ACCESS_TOKENS_BY_USER = OAuth2AccessTokensByUser.selectCql({
|
||||
columns: ['token_'],
|
||||
where: OAuth2AccessTokensByUser.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const SELECT_REFRESH_TOKEN = OAuth2RefreshTokens.selectCql({
|
||||
where: OAuth2RefreshTokens.where.eq('token_'),
|
||||
});
|
||||
|
||||
const SELECT_REFRESH_TOKENS_BY_USER = OAuth2RefreshTokensByUser.selectCql({
|
||||
columns: ['token_'],
|
||||
where: OAuth2RefreshTokensByUser.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class OAuth2TokenRepository implements IOAuth2TokenRepository {
|
||||
async createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise<OAuth2AuthorizationCode> {
|
||||
await upsertOne(OAuth2AuthorizationCodes.insert(data));
|
||||
return new OAuth2AuthorizationCode(data);
|
||||
}
|
||||
|
||||
async getAuthorizationCode(code: string): Promise<OAuth2AuthorizationCode | null> {
|
||||
const row = await fetchOne<OAuth2AuthorizationCodeRow>(SELECT_AUTHORIZATION_CODE, {code});
|
||||
return row ? new OAuth2AuthorizationCode(row) : null;
|
||||
}
|
||||
|
||||
async deleteAuthorizationCode(code: string): Promise<void> {
|
||||
await deleteOneOrMany(OAuth2AuthorizationCodes.deleteByPk({code}));
|
||||
}
|
||||
|
||||
async createAccessToken(data: OAuth2AccessTokenRow): Promise<OAuth2AccessToken> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(OAuth2AccessTokens.insert(data));
|
||||
|
||||
if (data.user_id !== null) {
|
||||
batch.addPrepared(
|
||||
OAuth2AccessTokensByUser.insert({
|
||||
user_id: data.user_id,
|
||||
token_: data.token_,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
return new OAuth2AccessToken(data);
|
||||
}
|
||||
|
||||
async getAccessToken(token: string): Promise<OAuth2AccessToken | null> {
|
||||
const row = await fetchOne<OAuth2AccessTokenRow>(SELECT_ACCESS_TOKEN, {token_: token});
|
||||
return row ? new OAuth2AccessToken(row) : null;
|
||||
}
|
||||
|
||||
async deleteAccessToken(token: string, _applicationId: ApplicationID, userId: UserID | null): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(OAuth2AccessTokens.deleteByPk({token_: token}));
|
||||
|
||||
if (userId !== null) {
|
||||
batch.addPrepared(OAuth2AccessTokensByUser.deleteByPk({user_id: userId, token_: token}));
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteAllAccessTokensForUser(userId: UserID): Promise<void> {
|
||||
const tokens = await fetchMany<OAuth2AccessTokenByUserRow>(SELECT_ACCESS_TOKENS_BY_USER, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const tokenRow of tokens) {
|
||||
batch.addPrepared(OAuth2AccessTokens.deleteByPk({token_: tokenRow.token_}));
|
||||
batch.addPrepared(OAuth2AccessTokensByUser.deleteByPk({user_id: userId, token_: tokenRow.token_}));
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async createRefreshToken(data: OAuth2RefreshTokenRow): Promise<OAuth2RefreshToken> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(OAuth2RefreshTokens.insert(data));
|
||||
batch.addPrepared(
|
||||
OAuth2RefreshTokensByUser.insert({
|
||||
user_id: data.user_id,
|
||||
token_: data.token_,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
return new OAuth2RefreshToken(data);
|
||||
}
|
||||
|
||||
async getRefreshToken(token: string): Promise<OAuth2RefreshToken | null> {
|
||||
const row = await fetchOne<OAuth2RefreshTokenRow>(SELECT_REFRESH_TOKEN, {token_: token});
|
||||
return row ? new OAuth2RefreshToken(row) : null;
|
||||
}
|
||||
|
||||
async deleteRefreshToken(token: string, _applicationId: ApplicationID, userId: UserID): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(OAuth2RefreshTokens.deleteByPk({token_: token}));
|
||||
batch.addPrepared(OAuth2RefreshTokensByUser.deleteByPk({user_id: userId, token_: token}));
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteAllRefreshTokensForUser(userId: UserID): Promise<void> {
|
||||
const tokens = await fetchMany<OAuth2RefreshTokenByUserRow>(SELECT_REFRESH_TOKENS_BY_USER, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const tokenRow of tokens) {
|
||||
batch.addPrepared(OAuth2RefreshTokens.deleteByPk({token_: tokenRow.token_}));
|
||||
batch.addPrepared(OAuth2RefreshTokensByUser.deleteByPk({user_id: userId, token_: tokenRow.token_}));
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async listRefreshTokensForUser(userId: UserID): Promise<Array<OAuth2RefreshToken>> {
|
||||
const tokenRefs = await fetchMany<OAuth2RefreshTokenByUserRow>(SELECT_REFRESH_TOKENS_BY_USER, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
if (tokenRefs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tokens: Array<OAuth2RefreshToken> = [];
|
||||
for (const tokenRef of tokenRefs) {
|
||||
const row = await fetchOne<OAuth2RefreshTokenRow>(SELECT_REFRESH_TOKEN, {token_: tokenRef.token_});
|
||||
if (row) {
|
||||
tokens.push(new OAuth2RefreshToken(row));
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async deleteAllTokensForUserAndApplication(userId: UserID, applicationId: ApplicationID): Promise<void> {
|
||||
const accessTokenRefs = await fetchMany<OAuth2AccessTokenByUserRow>(SELECT_ACCESS_TOKENS_BY_USER, {
|
||||
user_id: userId,
|
||||
});
|
||||
const refreshTokenRefs = await fetchMany<OAuth2RefreshTokenByUserRow>(SELECT_REFRESH_TOKENS_BY_USER, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
for (const tokenRef of accessTokenRefs) {
|
||||
const row = await fetchOne<OAuth2AccessTokenRow>(SELECT_ACCESS_TOKEN, {token_: tokenRef.token_});
|
||||
if (row && row.application_id === applicationId) {
|
||||
batch.addPrepared(OAuth2AccessTokens.deleteByPk({token_: tokenRef.token_}));
|
||||
batch.addPrepared(OAuth2AccessTokensByUser.deleteByPk({user_id: userId, token_: tokenRef.token_}));
|
||||
}
|
||||
}
|
||||
|
||||
for (const tokenRef of refreshTokenRefs) {
|
||||
const row = await fetchOne<OAuth2RefreshTokenRow>(SELECT_REFRESH_TOKEN, {token_: tokenRef.token_});
|
||||
if (row && row.application_id === applicationId) {
|
||||
batch.addPrepared(OAuth2RefreshTokens.deleteByPk({token_: tokenRef.token_}));
|
||||
batch.addPrepared(OAuth2RefreshTokensByUser.deleteByPk({user_id: userId, token_: tokenRef.token_}));
|
||||
}
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
63
fluxer_api/src/oauth/utils/parseClientCredentials.ts
Normal file
63
fluxer_api/src/oauth/utils/parseClientCredentials.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {z} from '~/Schema';
|
||||
|
||||
const BasicAuthScheme = z
|
||||
.string()
|
||||
.regex(/^Basic\s+/i)
|
||||
.transform((val) => val.replace(/^Basic\s+/i, ''));
|
||||
|
||||
interface ParsedClientCredentials {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
}
|
||||
|
||||
export function parseClientCredentials(
|
||||
authorizationHeader: string | undefined,
|
||||
bodyClientId?: bigint,
|
||||
bodyClientSecret?: string,
|
||||
): ParsedClientCredentials {
|
||||
const bodyClientIdStr = bodyClientId?.toString() ?? '';
|
||||
|
||||
if (authorizationHeader) {
|
||||
const parseResult = BasicAuthScheme.safeParse(authorizationHeader);
|
||||
if (parseResult.success) {
|
||||
try {
|
||||
const decoded = Buffer.from(parseResult.data, 'base64').toString('utf8');
|
||||
const colonIndex = decoded.indexOf(':');
|
||||
|
||||
if (colonIndex >= 0) {
|
||||
const id = decoded.slice(0, colonIndex);
|
||||
const secret = decoded.slice(colonIndex + 1);
|
||||
|
||||
return {
|
||||
clientId: id || bodyClientIdStr,
|
||||
clientSecret: secret || bodyClientSecret,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clientId: bodyClientIdStr,
|
||||
clientSecret: bodyClientSecret,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user