feat: add fluxer upstream source and self-hosting documentation

- Clone of github.com/fluxerapp/fluxer (official upstream)
- SELF_HOSTING.md: full VM rebuild procedure, architecture overview,
  service reference, step-by-step setup, troubleshooting, seattle reference
- dev/.env.example: all env vars with secrets redacted and generation instructions
- dev/livekit.yaml: LiveKit config template with placeholder keys
- fluxer-seattle/: existing seattle deployment setup scripts
This commit is contained in:
Vish
2026-03-13 00:55:14 -07:00
parent 5ceda343b8
commit 3b9d759b4b
5859 changed files with 1923440 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
/*
* 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 {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import {MessageAnonymizationService} from '@fluxer/api/src/channel/services/message/MessageAnonymizationService';
import {EMPTY_USER_ROW} from '@fluxer/api/src/database/types/UserTypes';
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
import {Logger} from '@fluxer/api/src/Logger';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {
DELETED_USER_DISCRIMINATOR,
DELETED_USER_GLOBAL_NAME,
DELETED_USER_USERNAME,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
interface RemapAuthorMessagesToDeletedUserParams {
originalAuthorId: UserID;
channelRepository: IChannelRepository;
userRepository: IUserRepository;
snowflakeService: SnowflakeService;
}
async function createDeletedMessageAuthorUser(params: {
userRepository: IUserRepository;
snowflakeService: SnowflakeService;
}): Promise<UserID> {
const deletedUserId = createUserID(await params.snowflakeService.generate());
await params.userRepository.create({
...EMPTY_USER_ROW,
user_id: deletedUserId,
username: DELETED_USER_USERNAME,
discriminator: DELETED_USER_DISCRIMINATOR,
global_name: DELETED_USER_GLOBAL_NAME,
bot: false,
system: true,
flags: UserFlags.DELETED,
});
await params.userRepository.deleteUserSecondaryIndices(deletedUserId);
return deletedUserId;
}
export async function remapAuthorMessagesToDeletedUser(
params: RemapAuthorMessagesToDeletedUserParams,
): Promise<UserID | null> {
const {originalAuthorId, channelRepository, userRepository, snowflakeService} = params;
const hasMessages = await channelRepository.listMessagesByAuthor(originalAuthorId, 1);
if (hasMessages.length === 0) {
return null;
}
const replacementAuthorId = await createDeletedMessageAuthorUser({
userRepository,
snowflakeService,
});
const anonymizationService = new MessageAnonymizationService(channelRepository);
await anonymizationService.anonymizeMessagesByAuthor(originalAuthorId, replacementAuthorId);
Logger.info(
{originalAuthorId: originalAuthorId.toString(), replacementAuthorId: replacementAuthorId.toString()},
'Remapped authored messages to deleted user id',
);
return replacementAuthorId;
}

View File

@@ -0,0 +1,649 @@
/*
* 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 type {ApplicationID, UserID} from '@fluxer/api/src/BrandedTypes';
import {applicationIdToUserId} from '@fluxer/api/src/BrandedTypes';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import type {ApplicationRow} from '@fluxer/api/src/database/types/OAuth2Types';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import type {DiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import type {EntityAssetService, PreparedAssetUpload} from '@fluxer/api/src/infrastructure/EntityAssetService';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import {Logger} from '@fluxer/api/src/Logger';
import type {Application} from '@fluxer/api/src/models/Application';
import type {User} from '@fluxer/api/src/models/User';
import {remapAuthorMessagesToDeletedUser} from '@fluxer/api/src/oauth/ApplicationMessageAuthorAnonymization';
import type {BotAuthService} from '@fluxer/api/src/oauth/BotAuthService';
import type {IApplicationRepository} from '@fluxer/api/src/oauth/repositories/IApplicationRepository';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {hasPartialUserFieldsChanged} from '@fluxer/api/src/user/UserMappers';
import {hashPassword} from '@fluxer/api/src/utils/PasswordUtils';
import {generateRandomUsername} from '@fluxer/api/src/utils/UsernameGenerator';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {DELETED_USER_GLOBAL_NAME, DELETED_USER_USERNAME, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {InternalServerError} from '@fluxer/errors/src/domains/core/InternalServerError';
import {BotUserNotFoundError} from '@fluxer/errors/src/domains/oauth/BotUserNotFoundError';
import {UnclaimedAccountCannotCreateApplicationsError} from '@fluxer/errors/src/domains/oauth/UnclaimedAccountCannotCreateApplicationsError';
import {UnknownApplicationError} from '@fluxer/errors/src/domains/oauth/UnknownApplicationError';
export interface ApplicationServiceDeps {
discriminatorService: DiscriminatorService;
channelRepository: IChannelRepository;
userRepository: IUserRepository;
snowflakeService: SnowflakeService;
applicationRepository: IApplicationRepository;
botAuthService: BotAuthService;
entityAssetService: EntityAssetService;
userCacheService: UserCacheService;
gatewayService: IGatewayService;
}
export class ApplicationNotOwnedError extends ForbiddenError {
constructor() {
super({code: APIErrorCodes.APPLICATION_NOT_OWNED});
this.name = 'ApplicationNotOwnedError';
}
}
class BotUserGenerationError extends InternalServerError {
constructor() {
super({code: APIErrorCodes.BOT_USER_GENERATION_FAILED});
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,
});
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,
});
if (randomDiscResult.available && randomDiscResult.discriminator !== -1) {
return {username: randomUsername, discriminator: randomDiscResult.discriminator};
}
}
throw new BotUserGenerationError();
}
async createApplication(args: {
ownerUserId: UserID;
name: string;
redirectUris?: Array<string>;
botPublic?: boolean;
botRequireCodeGrant?: 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 botRequireCodeGrant = args.botRequireCodeGrant ?? false;
if (owner.isUnclaimedAccount()) {
throw new UnclaimedAccountCannotCreateApplicationsError();
}
const applicationId: ApplicationID = (await 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,
traits: null,
first_refund_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 hashPassword(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,
bot_require_code_grant: botRequireCodeGrant,
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;
botRequireCodeGrant?: 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,
bot_require_code_grant: args.botRequireCodeGrant ?? application.botRequireCodeGrant,
};
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()!;
const replacementAuthorId = await remapAuthorMessagesToDeletedUser({
originalAuthorId: botUserId,
channelRepository: this.deps.channelRepository,
userRepository: this.deps.userRepository,
snowflakeService: this.deps.snowflakeService,
});
const guildIds = await this.deps.userRepository.getUserGuildIds(botUserId);
await this.deps.userRepository.deleteUserSecondaryIndices(botUserId);
await this.deps.userRepository.removeFromAllGuilds(botUserId);
for (const guildId of guildIds) {
try {
await this.deps.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_MEMBER_REMOVE',
data: {user: {id: botUserId.toString()}},
});
await this.deps.gatewayService.leaveGuild({userId: botUserId, guildId});
} catch (error) {
Logger.error(
{
error,
applicationId: applicationId.toString(),
botUserId: botUserId.toString(),
guildId: guildId.toString(),
},
'Failed to dispatch guild removal for deleted bot',
);
}
}
const botUser = await this.deps.userRepository.findUniqueAssert(botUserId);
await this.deps.userRepository.patchUpsert(
botUserId,
{
username: DELETED_USER_USERNAME,
global_name: DELETED_USER_GLOBAL_NAME,
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,
},
botUser.toRow(),
);
await this.deps.userCacheService.invalidateUserCache(botUserId);
if (replacementAuthorId) {
await this.deps.userCacheService.invalidateUserCache(replacementAuthorId);
}
Logger.info(
{
applicationId: applicationId.toString(),
botUserId: botUserId.toString(),
replacementAuthorId: replacementAuthorId?.toString() ?? null,
},
'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 hashPassword(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;
botFlags?: number;
},
): 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();
}
if (args.discriminator !== undefined && args.discriminator !== botUser.discriminator) {
throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.BOT_DISCRIMINATOR_CANNOT_BE_CHANGED);
}
const updates: Partial<UserRow> = {};
const newUsername = args.username ?? botUser.username;
const usernameChanged = args.username !== undefined && args.username !== botUser.username;
if (usernameChanged) {
const result = await this.deps.discriminatorService.resolveUsernameChange({
currentUsername: botUser.username,
currentDiscriminator: botUser.discriminator,
newUsername,
});
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;
}
if (args.botFlags !== undefined) {
const friendlyFlag = UserFlags.FRIENDLY_BOT;
const manualApprovalFlag = UserFlags.FRIENDLY_BOT_MANUAL_APPROVAL;
const desiredFriendly = (BigInt(args.botFlags) & friendlyFlag) === friendlyFlag;
const desiredManualApproval = (BigInt(args.botFlags) & manualApprovalFlag) === manualApprovalFlag;
const currentlyFriendly = (botUser.flags & friendlyFlag) === friendlyFlag;
const currentlyManualApproval = (botUser.flags & manualApprovalFlag) === manualApprovalFlag;
let updatedFlags = botUser.flags;
if (desiredFriendly && !currentlyFriendly) {
updatedFlags |= friendlyFlag;
} else if (!desiredFriendly && currentlyFriendly) {
updatedFlags &= ~friendlyFlag;
}
if (desiredManualApproval && !currentlyManualApproval) {
updatedFlags |= manualApprovalFlag;
} else if (!desiredManualApproval && currentlyManualApproval) {
updatedFlags &= ~manualApprovalFlag;
}
if (updatedFlags !== botUser.flags) {
updates.flags = updatedFlags;
}
}
let updatedUser: User | null;
try {
updatedUser = await this.deps.userRepository.patchUpsert(botUserId, updates, botUser.toRow());
} 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;
}
if (hasPartialUserFieldsChanged(botUser, updatedUser)) {
await this.deps.userCacheService.setUserPartialResponseFromUser(updatedUser);
}
Logger.info(
{applicationId: applicationId.toString(), botUserId: botUserId.toString()},
'Successfully updated bot profile',
);
return {
user: updatedUser,
application,
};
}
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);
}
}
}

View 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 type {ApplicationID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {IApplicationRepository} from '@fluxer/api/src/oauth/repositories/IApplicationRepository';
import {hashPassword, verifyPassword} from '@fluxer/api/src/utils/PasswordUtils';
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 verifyPassword({password: secret, passwordHash: application.botTokenHash});
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 hashPassword(secret);
const preview = secret.slice(0, 8);
const token = `${applicationId.toString()}.${secret}`;
return {token, hash, preview};
}
}

View File

@@ -0,0 +1,92 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {Application} from '@fluxer/api/src/models/Application';
import type {User} from '@fluxer/api/src/models/User';
import type {IApplicationRepository} from '@fluxer/api/src/oauth/repositories/IApplicationRepository';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {mapUserToPrivateResponse} from '@fluxer/api/src/user/UserMappers';
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,
},
botUser.toRow(),
);
if (updatedBotUser) {
await this.gatewayService.dispatchPresence({
userId: botUserId,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedBotUser),
});
}
}),
);
}
}

View File

@@ -0,0 +1,280 @@
/*
* 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 {DefaultUserOnly, LoginRequiredAllowSuspicious} from '@fluxer/api/src/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
import {SudoModeMiddleware} from '@fluxer/api/src/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@fluxer/api/src/types/HonoEnv';
import {Validator} from '@fluxer/api/src/Validator';
import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {ApplicationIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
ApplicationCreateRequest,
ApplicationListResponse,
ApplicationResponse,
ApplicationUpdateRequest,
BotProfileResponse,
BotProfileUpdateRequest,
BotTokenResetResponse,
} from '@fluxer/schema/src/domains/oauth/OAuthSchemas';
import type {Context} from 'hono';
export function OAuth2ApplicationsController(app: HonoApp) {
const listApplicationsHandler = async (ctx: Context<HonoEnv>) => {
const userId = ctx.get('user').id;
const response = await ctx.get('oauth2ApplicationsRequestService').listApplications(userId);
return ctx.json(response);
};
app.get(
'/users/@me/applications',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
OpenAPI({
operationId: 'list_user_applications',
summary: 'List user applications',
responseSchema: ApplicationListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Lists all OAuth2 applications owned by the authenticated user. Includes application credentials, metadata, and configuration.',
}),
listApplicationsHandler,
);
app.get(
'/oauth2/applications/@me',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
OpenAPI({
operationId: 'list_user_applications',
summary: 'List user applications',
responseSchema: ApplicationListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Lists all OAuth2 applications owned by the authenticated user. Includes application credentials, metadata, and configuration.',
}),
listApplicationsHandler,
);
app.post(
'/oauth2/applications',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_CREATE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator('json', ApplicationCreateRequest),
OpenAPI({
operationId: 'create_oauth_application',
summary: 'Create OAuth2 application',
responseSchema: ApplicationResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Creates a new OAuth2 application (client). Returns client credentials including ID and secret. Application can be used for authorization flows and API access.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const body = ctx.req.valid('json');
const response = await ctx.get('oauth2ApplicationsRequestService').createApplication(userId, body);
return ctx.json(response);
},
);
app.get(
'/oauth2/applications/:id',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator('param', ApplicationIdParam),
OpenAPI({
operationId: 'get_oauth_application',
summary: 'Get application',
responseSchema: ApplicationResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Retrieves details of a specific OAuth2 application owned by the user. Returns full application configuration and credentials.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const response = await ctx
.get('oauth2ApplicationsRequestService')
.getApplication(userId, ctx.req.valid('param').id);
return ctx.json(response);
},
);
app.patch(
'/oauth2/applications/:id',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_UPDATE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator('param', ApplicationIdParam),
Validator('json', ApplicationUpdateRequest),
OpenAPI({
operationId: 'update_oauth_application',
summary: 'Update application',
responseSchema: ApplicationResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Modifies OAuth2 application configuration such as name, description, and redirect URIs. Does not rotate credentials.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const body = ctx.req.valid('json');
const response = await ctx
.get('oauth2ApplicationsRequestService')
.updateApplication(userId, ctx.req.valid('param').id, body);
return ctx.json(response);
},
);
app.delete(
'/oauth2/applications/:id',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_DELETE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('param', ApplicationIdParam),
Validator('json', SudoVerificationSchema),
OpenAPI({
operationId: 'delete_oauth_application',
summary: 'Delete application',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Permanently deletes an OAuth2 application. Requires sudo mode authentication. Invalidates all issued tokens and revokes all user authorizations.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await ctx.get('oauth2ApplicationsRequestService').deleteApplication({
ctx,
userId: user.id,
body,
applicationId: ctx.req.valid('param').id,
});
return ctx.body(null, 204);
},
);
app.post(
'/oauth2/applications/:id/bot/reset-token',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('param', ApplicationIdParam),
Validator('json', SudoVerificationSchema),
OpenAPI({
operationId: 'reset_bot_token',
summary: 'Reset bot token',
responseSchema: BotTokenResetResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Rotates the bot token for an OAuth2 application. Requires sudo mode authentication. Invalidates all previously issued bot tokens. Used for security rotation and compromise mitigation.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
const applicationId = ctx.req.valid('param').id;
const response = await ctx.get('oauth2RequestService').resetBotToken({
ctx,
userId: user.id,
body,
applicationId,
});
return ctx.json(response);
},
);
app.post(
'/oauth2/applications/:id/client-secret/reset',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('param', ApplicationIdParam),
Validator('json', SudoVerificationSchema),
OpenAPI({
operationId: 'reset_client_secret',
summary: 'Reset client secret',
responseSchema: ApplicationResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Rotates the client secret for an OAuth2 application. Requires sudo mode authentication. Essential security operation for protecting client credentials. Existing access tokens remain valid.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
const applicationId = ctx.req.valid('param').id;
const response = await ctx.get('oauth2RequestService').resetClientSecret({
ctx,
userId: user.id,
body,
applicationId,
});
return ctx.json(response);
},
);
app.patch(
'/oauth2/applications/:id/bot',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_UPDATE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator('param', ApplicationIdParam),
Validator('json', BotProfileUpdateRequest),
OpenAPI({
operationId: 'update_bot_profile',
summary: 'Update bot profile',
responseSchema: BotProfileResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Modifies bot profile information such as name, avatar, and status. Changes apply to the bot account associated with this OAuth2 application.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const body = ctx.req.valid('json');
const response = await ctx
.get('oauth2ApplicationsRequestService')
.updateBotProfile(userId, ctx.req.valid('param').id, body);
return ctx.json(response);
},
);
}

View File

@@ -0,0 +1,209 @@
/*
* 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 {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {AuthMfaService} from '@fluxer/api/src/auth/services/AuthMfaService';
import type {SudoVerificationBody} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {requireSudoMode} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {createApplicationID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {UsernameNotAvailableError} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import type {Application} from '@fluxer/api/src/models/Application';
import type {User} from '@fluxer/api/src/models/User';
import type {ApplicationService} from '@fluxer/api/src/oauth/ApplicationService';
import {ApplicationNotOwnedError} from '@fluxer/api/src/oauth/ApplicationService';
import {mapApplicationToResponse, mapBotProfileToResponse} from '@fluxer/api/src/oauth/OAuth2Mappers';
import type {IApplicationRepository} from '@fluxer/api/src/oauth/repositories/IApplicationRepository';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {BotUserNotFoundError} from '@fluxer/errors/src/domains/oauth/BotUserNotFoundError';
import {InvalidClientError} from '@fluxer/errors/src/domains/oauth/InvalidClientError';
import {UnknownApplicationError} from '@fluxer/errors/src/domains/oauth/UnknownApplicationError';
import type {
ApplicationCreateRequest,
ApplicationUpdateRequest,
BotProfileResponse,
BotProfileUpdateRequest,
} from '@fluxer/schema/src/domains/oauth/OAuthSchemas';
import type {Context} from 'hono';
export class OAuth2ApplicationsRequestService {
constructor(
private readonly applicationService: ApplicationService,
private readonly applicationRepository: IApplicationRepository,
private readonly userRepository: IUserRepository,
private readonly authService: AuthService,
private readonly authMfaService: AuthMfaService,
) {}
async listApplications(userId: UserID) {
const applications: Array<Application> = await this.applicationService.listApplicationsByOwner(userId);
const botUserMap = new Map<string, User>();
const botUserFetches: Array<{id: string; promise: Promise<User | null>}> = [];
for (const app of applications) {
if (app.hasBotUser()) {
const botUserId = app.getBotUserId();
if (botUserId) {
botUserFetches.push({
id: botUserId.toString(),
promise: this.userRepository.findUnique(botUserId),
});
}
}
}
const botUsers = await Promise.all(botUserFetches.map((f) => f.promise));
for (let i = 0; i < botUsers.length; i++) {
const user = botUsers[i];
if (user !== null) {
botUserMap.set(botUserFetches[i].id, user);
}
}
return applications.map((app: Application) => {
const botUserId = app.hasBotUser() ? app.getBotUserId() : null;
const botUser = botUserId ? botUserMap.get(botUserId.toString()) : null;
return mapApplicationToResponse(app, {botUser: botUser ?? undefined});
});
}
async createApplication(userId: UserID, body: ApplicationCreateRequest) {
const result = await this.applicationService.createApplication({
ownerUserId: userId,
name: body.name,
redirectUris: body.redirect_uris,
botPublic: body.bot_public,
botRequireCodeGrant: body.bot_require_code_grant,
});
return mapApplicationToResponse(result.application, {
botUser: result.botUser,
botToken: result.botToken,
clientSecret: result.clientSecret,
});
}
async getApplication(userId: UserID, applicationId: bigint) {
const appId = createApplicationID(applicationId);
const application = await this.applicationRepository.getApplication(appId);
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 this.userRepository.findUnique(botUserId);
}
}
return mapApplicationToResponse(application, {botUser});
}
async updateApplication(userId: UserID, applicationId: bigint, body: ApplicationUpdateRequest) {
try {
const updated = await this.applicationService.updateApplication({
userId,
applicationId: createApplicationID(applicationId),
name: body.name,
redirectUris: body.redirect_uris,
botPublic: body.bot_public,
botRequireCodeGrant: body.bot_require_code_grant,
});
let botUser = null;
if (updated.hasBotUser()) {
const botUserId = updated.getBotUserId();
if (botUserId) {
botUser = await this.userRepository.findUnique(botUserId);
}
}
return mapApplicationToResponse(updated, {botUser: botUser ?? undefined});
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof UnknownApplicationError) {
throw err;
}
throw err;
}
}
async deleteApplication(params: {
ctx: Context;
userId: UserID;
body: SudoVerificationBody;
applicationId: bigint;
}): Promise<void> {
await requireSudoMode(params.ctx, params.ctx.get('user'), params.body, this.authService, this.authMfaService);
try {
await this.applicationService.deleteApplication(params.userId, createApplicationID(params.applicationId));
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof UnknownApplicationError) {
throw err;
}
throw err;
}
}
async updateBotProfile(
userId: UserID,
applicationId: bigint,
body: BotProfileUpdateRequest,
): Promise<BotProfileResponse> {
try {
const result = await this.applicationService.updateBotProfile(userId, createApplicationID(applicationId), {
username: body.username,
discriminator: body.discriminator,
avatar: body.avatar,
banner: body.banner,
bio: body.bio,
botFlags: body.bot_flags,
});
return 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;
}
}
}

View File

@@ -0,0 +1,361 @@
/*
* 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 {Logger} from '@fluxer/api/src/Logger';
import {DefaultUserOnly, LoginRequiredAllowSuspicious} from '@fluxer/api/src/middleware/AuthMiddleware';
import {requireOAuth2BearerToken, requireOAuth2Scope} from '@fluxer/api/src/middleware/OAuth2ScopeMiddleware';
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
import {SudoModeMiddleware} from '@fluxer/api/src/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
import {Validator} from '@fluxer/api/src/Validator';
import {HttpGetAuthorizeNotSupportedError} from '@fluxer/errors/src/domains/auth/HttpGetAuthorizeNotSupportedError';
import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {
ApplicationAuthorizationIdParam,
ApplicationIdParam,
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
ApplicationPublicResponse,
ApplicationResponse,
ApplicationsMeResponse,
AuthorizeConsentRequest,
AuthorizeRequest,
BotTokenResetResponse,
IntrospectRequestForm,
OAuth2AuthorizationsListResponse,
OAuth2ConsentResponse,
OAuth2IntrospectResponse,
OAuth2MeResponse,
OAuth2TokenResponse,
OAuth2UserInfoResponse,
RevokeRequestForm,
TokenRequest,
} from '@fluxer/schema/src/domains/oauth/OAuthSchemas';
import type {z} from 'zod';
export function 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 HttpGetAuthorizeNotSupportedError();
},
);
app.post(
'/oauth2/authorize/consent',
RateLimitMiddleware(RateLimitConfigs.OAUTH_AUTHORIZE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator('json', AuthorizeConsentRequest),
OpenAPI({
operationId: 'provide_oauth2_consent',
summary: 'Grant OAuth2 consent',
responseSchema: OAuth2ConsentResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'User grants permission for an OAuth2 application to access authorized scopes. Used in authorization code flow to complete the authorization process after user review.',
}),
async (ctx) => {
const body: z.infer<typeof AuthorizeConsentRequest> = ctx.req.valid('json');
const user = ctx.get('user');
return ctx.json(
await ctx.get('oauth2RequestService').authorizeConsent({
body,
userId: user.id,
requestCache: ctx.get('requestCache'),
}),
);
},
);
app.post(
'/oauth2/token',
RateLimitMiddleware(RateLimitConfigs.OAUTH_TOKEN),
Validator('form', TokenRequest),
OpenAPI({
operationId: 'exchange_oauth2_token',
summary: 'Exchange OAuth2 token',
responseSchema: OAuth2TokenResponse,
statusCode: 200,
security: [],
tags: ['OAuth2'],
description:
'Exchanges authorization code or other grant type for access tokens. Supports authorization code, refresh token, and client credentials flows. Client authentication via authorization header or client credentials.',
}),
async (ctx) => {
const form = ctx.req.valid('form');
const result = await ctx.get('oauth2RequestService').tokenExchange({
form,
authorizationHeader: ctx.req.header('authorization') ?? undefined,
logPrefix: 'OAuth2',
});
return ctx.json(result);
},
);
app.get(
'/oauth2/userinfo',
RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT),
requireOAuth2Scope('identify'),
OpenAPI({
operationId: 'get_oauth2_userinfo',
summary: 'Get OAuth2 user information',
responseSchema: OAuth2UserInfoResponse,
statusCode: 200,
security: ['bearerToken'],
tags: ['OAuth2'],
description:
'Retrieves authenticated user information using a valid access token. Requires identify scope and supports email scope for email fields.',
}),
async (ctx) => {
return ctx.json(await ctx.get('oauth2RequestService').userInfo(ctx.req.header('authorization') ?? undefined));
},
);
app.post(
'/oauth2/token/revoke',
RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT),
Validator('form', RevokeRequestForm),
OpenAPI({
operationId: 'revoke_oauth2_token',
summary: 'Revoke OAuth2 token',
responseSchema: null,
statusCode: 200,
security: [],
tags: ['OAuth2'],
description:
'Revokes an access or refresh token, immediately invalidating it. Client authentication required via authorization header or client credentials. Returns 200 on success.',
}),
async (ctx) => {
await ctx.get('oauth2RequestService').revoke({
form: ctx.req.valid('form'),
authorizationHeader: ctx.req.header('authorization') ?? undefined,
});
return ctx.body(null, 200);
},
);
app.post(
'/oauth2/introspect',
RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT),
Validator('form', IntrospectRequestForm),
OpenAPI({
operationId: 'introspect_oauth2_token',
summary: 'Introspect OAuth2 token',
responseSchema: OAuth2IntrospectResponse,
statusCode: 200,
security: [],
tags: ['OAuth2'],
description:
'Verifies token validity and retrieves metadata. Returns active status, scope, expiration, and user information. Client authentication via authorization header or client credentials.',
}),
async (ctx) => {
const result = await ctx.get('oauth2RequestService').introspect({
form: ctx.req.valid('form'),
authorizationHeader: ctx.req.header('authorization') ?? undefined,
});
return ctx.json(result);
},
);
app.get(
'/oauth2/@me',
requireOAuth2BearerToken(),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
OpenAPI({
operationId: 'get_current_user_oauth2',
summary: 'Get current OAuth2 user',
responseSchema: OAuth2MeResponse,
statusCode: 200,
security: ['bearerToken'],
tags: ['OAuth2'],
description:
'Retrieves current authorization details for a valid OAuth2 bearer token. Includes OAuth2 metadata and user details when identify is present.',
}),
async (ctx) => {
const response = await ctx.get('oauth2RequestService').getMe(ctx.req.header('authorization') ?? undefined);
return ctx.json(response);
},
);
app.get(
'/oauth2/applications/:id/public',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
Validator('param', ApplicationIdParam),
OpenAPI({
operationId: 'get_public_application',
summary: 'Get public application',
responseSchema: ApplicationPublicResponse,
statusCode: 200,
security: [],
tags: ['OAuth2'],
description:
'Retrieves public information about an OAuth2 application without authentication. Allows clients to discover application metadata before initiating authorization.',
}),
async (ctx) => {
const appId = ctx.req.valid('param').id;
const response = await ctx.get('oauth2RequestService').getApplicationPublic(appId);
return ctx.json(response);
},
);
app.get(
'/applications/@me',
OpenAPI({
operationId: 'get_current_user_applications',
summary: 'List current user applications',
responseSchema: ApplicationsMeResponse,
statusCode: 200,
security: [],
tags: ['OAuth2'],
description:
'Lists all OAuth2 applications registered by the authenticated user. Includes application credentials and metadata. Requires valid OAuth2 access token.',
}),
async (ctx) => {
const response = await ctx
.get('oauth2RequestService')
.getApplicationsMe(ctx.req.header('authorization') ?? undefined);
return ctx.json(response);
},
);
app.post(
'/oauth2/applications/:id/bot/reset-token',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('param', ApplicationIdParam),
Validator('json', SudoVerificationSchema),
OpenAPI({
operationId: 'reset_bot_token',
summary: 'Reset bot token',
responseSchema: BotTokenResetResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Rotates the bot token for an OAuth2 application. Requires sudo mode authentication. Invalidates all previously issued bot tokens. Used for security rotation and compromise mitigation.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
const applicationId = ctx.req.valid('param').id;
const response = await ctx.get('oauth2RequestService').resetBotToken({
ctx,
userId: user.id,
body,
applicationId,
});
return ctx.json(response);
},
);
app.post(
'/oauth2/applications/:id/client-secret/reset',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('param', ApplicationIdParam),
Validator('json', SudoVerificationSchema),
OpenAPI({
operationId: 'reset_client_secret',
summary: 'Reset client secret',
responseSchema: ApplicationResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Rotates the client secret for an OAuth2 application. Requires sudo mode authentication. Essential security operation for protecting client credentials. Existing access tokens remain valid.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
const applicationId = ctx.req.valid('param').id;
const response = await ctx.get('oauth2RequestService').resetClientSecret({
ctx,
userId: user.id,
body,
applicationId,
});
return ctx.json(response);
},
);
app.get(
'/oauth2/@me/authorizations',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
OpenAPI({
operationId: 'list_user_oauth2_authorizations',
summary: 'List user OAuth2 authorizations',
responseSchema: OAuth2AuthorizationsListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Lists all third-party applications the user has authorized. Shows granted scopes and authorization metadata. Allows user to review and manage delegated access.',
}),
async (ctx) => {
const user = ctx.get('user');
const authorizations = await ctx.get('oauth2RequestService').listAuthorizations(user.id);
return ctx.json(authorizations);
},
);
app.delete(
'/oauth2/@me/authorizations/:applicationId',
RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator('param', ApplicationAuthorizationIdParam),
OpenAPI({
operationId: 'delete_user_oauth2_authorization',
summary: 'Revoke OAuth2 authorization',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Revokes user authorization for a third-party application. Immediately invalidates all tokens issued to that application. User regains control of delegated access.',
}),
async (ctx) => {
const user = ctx.get('user');
const applicationId = ctx.req.valid('param').applicationId;
await ctx.get('oauth2RequestService').deleteAuthorization(user.id, applicationId);
return ctx.body(null, 204);
},
);
}

View File

@@ -0,0 +1,94 @@
/*
* 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 {getGlobalLimitConfigSnapshot} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {Application} from '@fluxer/api/src/models/Application';
import type {User} from '@fluxer/api/src/models/User';
import type {ApplicationBotResponse, ApplicationResponse} from '@fluxer/api/src/oauth/OAuth2Types';
import {mapUserToPartialResponse} from '@fluxer/api/src/user/UserMappers';
export function mapBotUserToResponse(user: User, opts?: {token?: string}): ApplicationBotResponse {
const partial = mapUserToPartialResponse(user);
const snapshot = getGlobalLimitConfigSnapshot();
const ctx = createLimitMatchContext({user});
const hasAnimatedBanner = resolveLimitSafe(snapshot, ctx, 'feature_animated_banner', 0);
const bannerHash = !user.isBot && hasAnimatedBanner === 0 ? 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) : [],
flags: partial.flags,
};
}
export function 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: application.botRequireCodeGrant,
};
if (options?.botUser) {
baseResponse.bot = mapBotUserToResponse(options.botUser, {token: options.botToken});
}
if (options?.clientSecret) {
return {
...baseResponse,
client_secret: options.clientSecret,
};
}
return baseResponse;
}
export function mapBotTokenResetResponse(user: User, token: string) {
return {
token,
bot: mapBotUserToResponse(user),
};
}
export function 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,
};
}

View 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 '@fluxer/schema/src/primitives/SchemaPrimitives';
function isLoopbackHost(hostname: string) {
const lowercaseHost = hostname.toLowerCase();
return (
lowercaseHost === 'localhost' ||
lowercaseHost === '127.0.0.1' ||
lowercaseHost === '[::1]' ||
lowercaseHost.endsWith('.localhost')
);
}
function 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');

View File

@@ -0,0 +1,625 @@
/*
* 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 {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {AuthMfaService} from '@fluxer/api/src/auth/services/AuthMfaService';
import type {SudoVerificationBody} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {requireSudoMode} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {createApplicationID, createGuildID, createRoleID, type UserID} from '@fluxer/api/src/BrandedTypes';
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import {Logger} from '@fluxer/api/src/Logger';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {ApplicationService} from '@fluxer/api/src/oauth/ApplicationService';
import {ApplicationNotOwnedError} from '@fluxer/api/src/oauth/ApplicationService';
import type {BotAuthService} from '@fluxer/api/src/oauth/BotAuthService';
import {
mapApplicationToResponse,
mapBotTokenResetResponse,
mapBotUserToResponse,
} from '@fluxer/api/src/oauth/OAuth2Mappers';
import {ACCESS_TOKEN_TTL_SECONDS, type OAuth2Service} from '@fluxer/api/src/oauth/OAuth2Service';
import type {IApplicationRepository} from '@fluxer/api/src/oauth/repositories/IApplicationRepository';
import type {IOAuth2TokenRepository} from '@fluxer/api/src/oauth/repositories/IOAuth2TokenRepository';
import {parseClientCredentials} from '@fluxer/api/src/oauth/utils/ParseClientCredentials';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {mapUserToOAuthResponse} from '@fluxer/api/src/user/UserMappers';
import {verifyPassword} from '@fluxer/api/src/utils/PasswordUtils';
import {ALL_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {InvalidPermissionsIntegerError} from '@fluxer/errors/src/domains/core/InvalidPermissionsIntegerError';
import {InvalidPermissionsNegativeError} from '@fluxer/errors/src/domains/core/InvalidPermissionsNegativeError';
import {InvalidTokenError} from '@fluxer/errors/src/domains/core/InvalidTokenError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
import {BotAlreadyInGuildError} from '@fluxer/errors/src/domains/oauth/BotAlreadyInGuildError';
import {BotUserNotFoundError} from '@fluxer/errors/src/domains/oauth/BotUserNotFoundError';
import {InvalidClientError} from '@fluxer/errors/src/domains/oauth/InvalidClientError';
import {InvalidGrantError} from '@fluxer/errors/src/domains/oauth/InvalidGrantError';
import {InvalidResponseTypeForNonBotError} from '@fluxer/errors/src/domains/oauth/InvalidResponseTypeForNonBotError';
import {MissingClientSecretError} from '@fluxer/errors/src/domains/oauth/MissingClientSecretError';
import {NotABotApplicationError} from '@fluxer/errors/src/domains/oauth/NotABotApplicationError';
import {RedirectUriRequiredForNonBotError} from '@fluxer/errors/src/domains/oauth/RedirectUriRequiredForNonBotError';
import {UnknownApplicationError} from '@fluxer/errors/src/domains/oauth/UnknownApplicationError';
import type {
ApplicationBotResponse,
ApplicationResponse,
AuthorizeConsentRequest,
BotTokenResetResponse,
IntrospectRequestForm,
OAuth2ConsentResponse,
OAuth2IntrospectResponse,
OAuth2MeResponse,
OAuth2TokenResponse,
OAuth2UserInfoResponse,
RevokeRequestForm,
TokenRequest,
} from '@fluxer/schema/src/domains/oauth/OAuthSchemas';
import type {Context} from 'hono';
import type {z} from 'zod';
export class OAuth2RequestService {
constructor(
private readonly oauth2Service: OAuth2Service,
private readonly applicationRepository: IApplicationRepository,
private readonly oauth2TokenRepository: IOAuth2TokenRepository,
private readonly userRepository: IUserRepository,
private readonly botAuthService: BotAuthService,
private readonly applicationService: ApplicationService,
private readonly gatewayService: IGatewayService,
private readonly guildService: GuildService,
private readonly authService: AuthService,
private readonly authMfaService: AuthMfaService,
) {}
async tokenExchange(params: {
form: z.infer<typeof TokenRequest>;
authorizationHeader?: string;
logPrefix: string;
}): Promise<OAuth2TokenResponse> {
try {
const {form, authorizationHeader, logPrefix} = params;
const hasAuthHeader = !!authorizationHeader;
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(authorizationHeader ?? ''),
},
`${logPrefix} token request received`,
);
if (form.grant_type === 'authorization_code') {
const response = await this.oauth2Service.tokenExchange({
headersAuthorization: authorizationHeader,
grantType: 'authorization_code',
code: form.code,
redirectUri: form.redirect_uri,
clientId: form.client_id ? form.client_id.toString() : undefined,
clientSecret: form.client_secret,
});
return this.requireTokenResponseFields(response);
}
const response = await this.oauth2Service.tokenExchange({
headersAuthorization: authorizationHeader,
grantType: 'refresh_token',
refreshToken: form.refresh_token,
clientId: form.client_id ? form.client_id.toString() : undefined,
clientSecret: form.client_secret,
});
return this.requireTokenResponseFields(response);
} catch (err: unknown) {
if (err instanceof InvalidGrantError) {
Logger.warn({error: (err as Error).message}, `${params.logPrefix} token request failed`);
}
throw err;
}
}
async userInfo(authorizationHeader: string | undefined): Promise<OAuth2UserInfoResponse> {
const token = this.extractBearerToken(authorizationHeader ?? '');
if (!token) {
throw new InvalidTokenError();
}
return this.oauth2Service.userInfo(token);
}
private requireTokenResponseFields(response: {
access_token: string;
token_type: string;
expires_in: number;
scope?: string;
refresh_token?: string;
}): OAuth2TokenResponse {
if (!response.refresh_token || !response.scope) {
throw new InvalidGrantError();
}
return {
access_token: response.access_token,
token_type: response.token_type,
expires_in: response.expires_in,
refresh_token: response.refresh_token,
scope: response.scope,
};
}
async revoke(params: {form: z.infer<typeof RevokeRequestForm>; authorizationHeader?: string}): Promise<void> {
const {clientId: clientIdStr, clientSecret: secret} = parseClientCredentials(
params.authorizationHeader,
params.form.client_id,
params.form.client_secret,
);
if (!secret) {
throw new MissingClientSecretError();
}
await this.oauth2Service.revoke(params.form.token, params.form.token_type_hint ?? undefined, {
clientId: createApplicationID(BigInt(clientIdStr)),
clientSecret: secret,
});
}
async introspect(params: {
form: z.infer<typeof IntrospectRequestForm>;
authorizationHeader?: string;
}): Promise<OAuth2IntrospectResponse> {
const {clientId: clientIdStr, clientSecret: secret} = parseClientCredentials(
params.authorizationHeader,
params.form.client_id,
params.form.client_secret,
);
if (!secret) {
throw new MissingClientSecretError();
}
const applicationId = createApplicationID(BigInt(clientIdStr));
const application = await this.applicationRepository.getApplication(applicationId);
if (!application) {
throw new InvalidClientError();
}
if (application.clientSecretHash) {
const valid = await verifyPassword({password: secret, passwordHash: application.clientSecretHash});
if (!valid) {
throw new InvalidClientError();
}
}
return this.oauth2Service.introspect(params.form.token, {
clientId: applicationId,
clientSecret: secret,
});
}
async authorizeConsent(params: {
body: z.infer<typeof AuthorizeConsentRequest>;
userId: UserID;
requestCache: RequestCache;
}): Promise<OAuth2ConsentResponse> {
const scopeStr = params.body.scope;
const scopeSet = new Set(scopeStr.split(/\s+/).filter(Boolean));
const isBotOnly = scopeSet.size === 1 && scopeSet.has('bot');
const responseType = params.body.response_type ?? (isBotOnly ? undefined : 'code');
const guildId = params.body.guild_id ? createGuildID(params.body.guild_id) : null;
let requestedPermissions: bigint | null = null;
if (params.body.permissions !== undefined) {
try {
requestedPermissions = BigInt(params.body.permissions);
} catch {
throw new InvalidPermissionsIntegerError();
}
if (requestedPermissions < 0) {
throw new InvalidPermissionsNegativeError();
}
requestedPermissions = requestedPermissions & ALL_PERMISSIONS;
}
if (!isBotOnly && responseType !== 'code') {
throw new InvalidResponseTypeForNonBotError();
}
if (!isBotOnly && !params.body.redirect_uri) {
throw new RedirectUriRequiredForNonBotError();
}
const {redirectTo} = await this.oauth2Service.authorizeAndConsent({
clientId: params.body.client_id.toString(),
redirectUri: params.body.redirect_uri,
scope: params.body.scope,
state: params.body.state ?? undefined,
responseType: responseType as 'code' | undefined,
userId: params.userId,
});
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(params.body.client_id));
const application = await this.applicationRepository.getApplication(applicationId);
if (!application || !application.botUserId) {
throw new NotABotApplicationError();
}
const botUserId = application.botUserId;
const hasManageGuild = await this.gatewayService.checkPermission({
guildId,
userId: params.userId,
permission: Permissions.MANAGE_GUILD,
});
if (!hasManageGuild) {
throw new MissingPermissionsError();
}
try {
await this.guildService.members.getMember({
userId: params.userId,
targetId: botUserId,
guildId,
requestCache: params.requestCache,
});
throw new BotAlreadyInGuildError();
} catch (err) {
if (!(err instanceof UnknownGuildMemberError)) {
throw err;
}
}
await this.guildService.members.addUserToGuild({
userId: botUserId,
guildId,
skipGuildLimitCheck: true,
skipBanCheck: true,
joinSourceType: JoinSourceTypes.BOT_INVITE,
inviterId: params.userId,
requestCache: params.requestCache,
initiatorId: params.userId,
});
if (requestedPermissions && requestedPermissions > 0n) {
const role = await this.guildService.systemCreateRole({
initiatorId: params.userId,
guildId,
data: {
name: `${application.name}`,
color: 0,
permissions: requestedPermissions,
},
});
await this.guildService.members.systemAddMemberRole({
targetId: botUserId,
guildId,
roleId: createRoleID(BigInt(role.id)),
initiatorId: params.userId,
requestCache: params.requestCache,
});
}
} catch (err) {
if (authCode) {
await this.oauth2TokenRepository.deleteAuthorizationCode(authCode);
}
throw err;
}
}
Logger.info({redirectTo}, 'OAuth2 consent: returning redirect URL');
return {redirect_to: redirectTo};
}
async getMe(authorizationHeader: string | undefined): Promise<OAuth2MeResponse> {
const token = this.extractBearerToken(authorizationHeader ?? '');
if (!token) {
throw new InvalidTokenError();
}
try {
const tokenData = await this.oauth2TokenRepository.getAccessToken(token);
if (!tokenData) {
throw new InvalidTokenError();
}
const application = await this.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: OAuth2MeResponse = {
application: {
id: application.applicationId.toString(),
name: application.name,
icon: null,
description: null,
bot_public: application.botIsPublic,
bot_require_code_grant: application.botRequireCodeGrant,
flags: 0,
},
scopes,
expires: expiresAt.toISOString(),
};
if (tokenData.userId && tokenData.scope.has('identify')) {
const user = await this.userRepository.findUnique(tokenData.userId);
if (user) {
response.user = mapUserToOAuthResponse(user, {includeEmail: tokenData.scope.has('email')});
}
}
return response;
} catch (err) {
if (err instanceof InvalidTokenError) {
throw err;
}
throw new InvalidTokenError();
}
}
async getApplicationPublic(applicationId: bigint) {
const application = await this.applicationRepository.getApplication(createApplicationID(applicationId));
if (!application) {
throw new UnknownApplicationError();
}
let botUser = null;
if (application.hasBotUser() && application.getBotUserId()) {
botUser = await this.userRepository.findUnique(application.getBotUserId()!);
}
const scopes: Array<string> = [];
if (application.hasBotUser()) {
scopes.push('bot');
}
return {
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 ? mapBotUserToResponse(botUser) : null,
};
}
async getApplicationsMe(authorizationHeader: string | undefined): Promise<{
id: string;
name: string;
icon: null;
description: null;
bot_public: boolean;
bot_require_code_grant: boolean;
flags: number;
bot?: ApplicationBotResponse;
}> {
const botToken = this.extractBotToken(authorizationHeader ?? '');
if (!botToken) {
throw new InvalidTokenError();
}
const botUserId = await this.botAuthService.validateBotToken(botToken);
if (!botUserId) {
throw new InvalidTokenError();
}
const [appIdStr] = botToken.split('.');
if (!appIdStr) {
throw new InvalidTokenError();
}
const application = await this.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;
flags: number;
bot?: ApplicationBotResponse;
} = {
id: application.applicationId.toString(),
name: application.name,
icon: null,
description: null,
bot_public: application.botIsPublic,
bot_require_code_grant: application.botRequireCodeGrant,
flags: 0,
};
if (application.hasBotUser() && application.getBotUserId()) {
const botUser = await this.userRepository.findUnique(application.getBotUserId()!);
if (botUser) {
response.bot = mapBotUserToResponse(botUser);
}
}
return response;
}
async resetBotToken(params: {
ctx: Context;
userId: UserID;
body: SudoVerificationBody;
applicationId: bigint;
}): Promise<BotTokenResetResponse> {
await requireSudoMode(params.ctx, params.ctx.get('user'), params.body, this.authService, this.authMfaService);
try {
const {token} = await this.applicationService.rotateBotToken(
params.userId,
createApplicationID(params.applicationId),
);
const application = await this.applicationRepository.getApplication(createApplicationID(params.applicationId));
if (!application || !application.botUserId) {
throw new BotUserNotFoundError();
}
const botUser = await this.userRepository.findUnique(application.botUserId);
if (!botUser) {
throw new BotUserNotFoundError();
}
return mapBotTokenResetResponse(botUser, token);
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
throw new UnknownApplicationError();
}
throw err;
}
}
async resetClientSecret(params: {
ctx: Context;
userId: UserID;
body: SudoVerificationBody;
applicationId: bigint;
}): Promise<ApplicationResponse> {
await requireSudoMode(params.ctx, params.ctx.get('user'), params.body, this.authService, this.authMfaService);
try {
const {clientSecret} = await this.applicationService.rotateClientSecret(
params.userId,
createApplicationID(params.applicationId),
);
const application = await this.applicationRepository.getApplication(createApplicationID(params.applicationId));
if (!application) {
throw new UnknownApplicationError();
}
return mapApplicationToResponse(application, {clientSecret});
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
throw new UnknownApplicationError();
}
throw err;
}
}
async listAuthorizations(userId: UserID) {
const refreshTokens = await this.oauth2TokenRepository.listRefreshTokensForUser(userId);
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 this.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 this.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,
},
});
}
}
}
}
return Array.from(appMap.values()).map((entry) => ({
application: entry.application,
scopes: Array.from(entry.scopes),
authorized_at: entry.createdAt.toISOString(),
}));
}
async deleteAuthorization(userId: UserID, applicationId: bigint): Promise<void> {
const application = await this.applicationRepository.getApplication(createApplicationID(applicationId));
if (!application) {
throw new UnknownApplicationError();
}
await this.oauth2TokenRepository.deleteAllTokensForUserAndApplication(userId, createApplicationID(applicationId));
}
private extractBearerToken(authHeader: string): string | null {
const match = /^Bearer\s+(.+)$/.exec(authHeader);
return match ? match[1] : null;
}
private extractBotToken(authHeader: string): string | null {
const match = /^Bot\s+(.+)$/i.exec(authHeader);
return match ? match[1] : null;
}
}

View File

@@ -0,0 +1,464 @@
/*
* 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 type {ApplicationID, UserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {
OAuth2AccessTokenRow,
OAuth2AuthorizationCodeRow,
OAuth2RefreshTokenRow,
} from '@fluxer/api/src/database/types/OAuth2Types';
import {Logger} from '@fluxer/api/src/Logger';
import type {Application} from '@fluxer/api/src/models/Application';
import {ApplicationRepository} from '@fluxer/api/src/oauth/repositories/ApplicationRepository';
import type {IApplicationRepository} from '@fluxer/api/src/oauth/repositories/IApplicationRepository';
import type {IOAuth2TokenRepository} from '@fluxer/api/src/oauth/repositories/IOAuth2TokenRepository';
import {OAuth2TokenRepository} from '@fluxer/api/src/oauth/repositories/OAuth2TokenRepository';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {mapUserToOAuthResponse} from '@fluxer/api/src/user/UserMappers';
import {verifyPassword} from '@fluxer/api/src/utils/PasswordUtils';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {ADMIN_OAUTH2_APPLICATION_ID} from '@fluxer/constants/src/Core';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {InvalidRequestError} from '@fluxer/errors/src/domains/core/InvalidRequestError';
import {InvalidTokenError} from '@fluxer/errors/src/domains/core/InvalidTokenError';
import {InvalidClientError} from '@fluxer/errors/src/domains/oauth/InvalidClientError';
import {InvalidClientSecretError} from '@fluxer/errors/src/domains/oauth/InvalidClientSecretError';
import {InvalidGrantError} from '@fluxer/errors/src/domains/oauth/InvalidGrantError';
import {InvalidRedirectUriError} from '@fluxer/errors/src/domains/oauth/InvalidRedirectUriError';
import {InvalidScopeError} from '@fluxer/errors/src/domains/oauth/InvalidScopeError';
import {MissingClientSecretError} from '@fluxer/errors/src/domains/oauth/MissingClientSecretError';
import {MissingRedirectUriError} from '@fluxer/errors/src/domains/oauth/MissingRedirectUriError';
import type {OAuthScope} from '@fluxer/schema/src/domains/oauth/OAuthSchemas';
import {seconds} from 'itty-time';
interface OAuth2ServiceDeps {
userRepository: IUserRepository;
applicationRepository?: IApplicationRepository;
oauth2TokenRepository?: IOAuth2TokenRepository;
cacheService?: ICacheService;
}
const PREFERRED_SCOPE_ORDER = ['identify', 'email', 'guilds', 'connections', 'bot', 'admin'];
function 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 = seconds('7 days');
export class OAuth2Service {
private applications: IApplicationRepository;
private tokens: IOAuth2TokenRepository;
private static readonly ALLOWED_SCOPES = ['identify', 'email', 'guilds', 'connections', 'bot', 'admin'];
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('admin') && parsedClientId !== ADMIN_OAUTH2_APPLICATION_ID) {
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 || application.botRequireCodeGrant;
if (!redirectUri && requireRedirect) {
throw new MissingRedirectUriError();
}
if (redirectUri && !this.validateRedirectUri(application, redirectUri)) {
throw new InvalidRedirectUriError();
}
const resolvedRedirectUri = redirectUri ?? Config.endpoints.webApp;
let loc: URL;
try {
loc = new URL(resolvedRedirectUri);
} catch {
throw new InvalidRequestError();
}
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 MissingClientSecretError();
}
if (application.clientSecretHash) {
const ok = await verifyPassword({password: clientSecret, passwordHash: application.clientSecretHash});
if (!ok) {
Logger.debug(
{application_id: application.applicationId.toString()},
'OAuth2 tokenExchange: client_secret verification failed',
);
throw new InvalidClientSecretError();
}
}
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();
}
const expectedRedirectUri = authCode.redirectUri ?? '';
const providedRedirectUri = params.redirectUri ?? '';
if (expectedRedirectUri !== providedRedirectUri) {
Logger.debug(
{expected: expectedRedirectUri, got: providedRedirectUri},
'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 verifyPassword({password: auth.clientSecret, passwordHash: application.clientSecretHash});
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 verifyPassword({password: auth.clientSecret, passwordHash: application.clientSecretHash})
: false;
if (!valid) {
throw new InvalidClientSecretError();
}
}
if (tokenTypeHint === 'refresh_token') {
const refresh = await this.tokens.getRefreshToken(tokenStr);
if (refresh && refresh.applicationId === application.applicationId) {
await this.tokens.deleteAllTokensForUserAndApplication(refresh.userId, application.applicationId);
return;
}
}
const access = await this.tokens.getAccessToken(tokenStr);
if (access && access.applicationId === application.applicationId) {
if (access.userId) {
await this.tokens.deleteAllTokensForUserAndApplication(access.userId, application.applicationId);
return;
}
await this.tokens.deleteAccessToken(tokenStr, application.applicationId, access.userId);
return;
}
}
}

View File

@@ -0,0 +1,41 @@
/*
* 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>;
flags: 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;
}

View File

@@ -0,0 +1,166 @@
/*
* 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, createApplicationID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import {SYSTEM_USER_ID} from '@fluxer/api/src/constants/Core';
import {
BatchBuilder,
buildPatchFromData,
executeVersionedUpdate,
fetchMany,
fetchOne,
} from '@fluxer/api/src/database/Cassandra';
import type {ApplicationByOwnerRow, ApplicationRow} from '@fluxer/api/src/database/types/OAuth2Types';
import {APPLICATION_COLUMNS} from '@fluxer/api/src/database/types/OAuth2Types';
import {Application} from '@fluxer/api/src/models/Application';
import type {IApplicationRepository} from '@fluxer/api/src/oauth/repositories/IApplicationRepository';
import {Applications, ApplicationsByOwner} from '@fluxer/api/src/Tables';
import {hashPassword} from '@fluxer/api/src/utils/PasswordUtils';
import {ADMIN_OAUTH2_APPLICATION_ID} from '@fluxer/constants/src/Core';
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'),
});
let cachedAdminSecretHash: string | null = null;
async function getAdminSecretHash(): Promise<string | null> {
const secret = Config.admin.oauthClientSecret;
if (!secret) {
return null;
}
if (cachedAdminSecretHash === null) {
cachedAdminSecretHash = await hashPassword(secret);
}
return cachedAdminSecretHash;
}
function getAdminRedirectUri(): string {
return `${Config.endpoints.admin}/oauth2_callback`;
}
function buildAdminApplication(secretHash: string | null): Application {
const row: ApplicationRow = {
application_id: createApplicationID(ADMIN_OAUTH2_APPLICATION_ID),
owner_user_id: SYSTEM_USER_ID,
name: 'Fluxer Admin',
bot_user_id: null,
bot_is_public: false,
bot_require_code_grant: false,
oauth2_redirect_uris: new Set<string>([getAdminRedirectUri()]),
client_secret_hash: secretHash,
bot_token_hash: null,
bot_token_preview: null,
bot_token_created_at: null,
client_secret_created_at: null,
version: 1,
};
return new Application(row);
}
export class ApplicationRepository implements IApplicationRepository {
async getApplication(applicationId: ApplicationID): Promise<Application | null> {
if (applicationId === createApplicationID(ADMIN_OAUTH2_APPLICATION_ID)) {
const secretHash = await getAdminSecretHash();
if (secretHash === null) {
return null;
}
return buildAdminApplication(secretHash);
}
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;
if (applicationId === createApplicationID(ADMIN_OAUTH2_APPLICATION_ID)) {
throw new Error('Cannot modify the built-in admin OAuth2 application');
}
const result = await executeVersionedUpdate<ApplicationRow, 'application_id'>(
async () => fetchOne<ApplicationRow>(SELECT_APPLICATION_CQL, {application_id: applicationId}),
(current) => ({
pk: {application_id: applicationId},
patch: buildPatchFromData(data, current, APPLICATION_COLUMNS, ['application_id']),
}),
Applications,
{initialData: oldData},
);
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> {
if (applicationId === createApplicationID(ADMIN_OAUTH2_APPLICATION_ID)) {
throw new Error('Cannot delete the built-in admin OAuth2 application');
}
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();
}
}

View 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 '@fluxer/api/src/BrandedTypes';
import type {ApplicationRow} from '@fluxer/api/src/database/types/OAuth2Types';
import type {Application} from '@fluxer/api/src/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>;
}

View 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 '@fluxer/api/src/BrandedTypes';
import type {
OAuth2AccessTokenRow,
OAuth2AuthorizationCodeRow,
OAuth2RefreshTokenRow,
} from '@fluxer/api/src/database/types/OAuth2Types';
import type {OAuth2AccessToken} from '@fluxer/api/src/models/OAuth2AccessToken';
import type {OAuth2AuthorizationCode} from '@fluxer/api/src/models/OAuth2AuthorizationCode';
import type {OAuth2RefreshToken} from '@fluxer/api/src/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>;
}

View 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 '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {
OAuth2AccessTokenByUserRow,
OAuth2AccessTokenRow,
OAuth2AuthorizationCodeRow,
OAuth2RefreshTokenByUserRow,
OAuth2RefreshTokenRow,
} from '@fluxer/api/src/database/types/OAuth2Types';
import {OAuth2AccessToken} from '@fluxer/api/src/models/OAuth2AccessToken';
import {OAuth2AuthorizationCode} from '@fluxer/api/src/models/OAuth2AuthorizationCode';
import {OAuth2RefreshToken} from '@fluxer/api/src/models/OAuth2RefreshToken';
import type {IOAuth2TokenRepository} from '@fluxer/api/src/oauth/repositories/IOAuth2TokenRepository';
import {
OAuth2AccessTokens,
OAuth2AccessTokensByUser,
OAuth2AuthorizationCodes,
OAuth2RefreshTokens,
OAuth2RefreshTokensByUser,
} from '@fluxer/api/src/Tables';
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();
}
}

View File

@@ -0,0 +1,131 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {createOAuth2Application, createUniqueApplicationName} from '@fluxer/api/src/oauth/tests/OAuth2TestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Application Create', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('creates OAuth2 application with bot user', async () => {
const account = await createTestAccount(harness);
const appName = createUniqueApplicationName();
const redirectURIs = ['https://example.com/callback'];
const result = await createOAuth2Application(harness, account.token, {
name: appName,
redirect_uris: redirectURIs,
bot_public: true,
});
expect(result.application.id).toBeTruthy();
expect(result.application.name).toBe(appName);
expect(result.application.redirect_uris).toEqual(redirectURIs);
expect(result.application.bot).toBeDefined();
expect(result.application.bot?.id).toBeTruthy();
expect(result.application.bot?.username).toBeTruthy();
expect(result.application.bot?.discriminator).toBeTruthy();
expect(result.application.bot?.token).toBeTruthy();
expect(result.clientSecret).toBeTruthy();
expect(result.botUserId).toBe(result.application.bot?.id);
expect(result.botToken).toBe(result.application.bot?.token);
const botUser = await createBuilder<{id: string; bot: boolean}>(harness, `Bot ${result.botToken}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(botUser.id).toBe(result.botUserId);
expect(botUser.bot).toBe(true);
});
test('creates application without optional fields', async () => {
const account = await createTestAccount(harness);
const appName = createUniqueApplicationName();
const result = await createOAuth2Application(harness, account.token, {
name: appName,
});
expect(result.application.id).toBeTruthy();
expect(result.application.name).toBe(appName);
expect(result.application.redirect_uris).toEqual([]);
expect(result.application.bot).toBeDefined();
expect(result.application.bot?.id).toBeTruthy();
});
test('rejects missing name', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/oauth2/applications')
.body({})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('rejects non-localhost http redirect URIs', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/oauth2/applications')
.body({
name: createUniqueApplicationName(),
redirect_uris: ['http://example.com/callback'],
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('accepts https redirect URIs', async () => {
const account = await createTestAccount(harness);
const appName = createUniqueApplicationName();
const result = await createOAuth2Application(harness, account.token, {
name: appName,
redirect_uris: ['https://example.com/callback'],
});
expect(result.application.redirect_uris).toEqual(['https://example.com/callback']);
});
test('accepts localhost redirect URIs with http', async () => {
const account = await createTestAccount(harness);
const appName = createUniqueApplicationName();
const result = await createOAuth2Application(harness, account.token, {
name: appName,
redirect_uris: ['http://localhost:3000/callback'],
});
expect(result.application.redirect_uris).toEqual(['http://localhost:3000/callback']);
});
});

View File

@@ -0,0 +1,225 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createOAuth2Application,
createUniqueApplicationName,
deleteOAuth2Application,
getOAuth2Application,
listOAuth2Applications,
} from '@fluxer/api/src/oauth/tests/OAuth2TestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Application Delete', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('deletes application and invalidates bot token', async () => {
const account = await createTestAccount(harness);
const appName = createUniqueApplicationName();
const createResult = await createOAuth2Application(harness, account.token, {
name: appName,
redirect_uris: ['https://example.com/callback'],
});
await createBuilder(harness, `Bot ${createResult.botToken}`).get('/users/@me').expect(HTTP_STATUS.OK).execute();
await deleteOAuth2Application(harness, account.token, createResult.application.id, account.password);
await createBuilder(harness, account.token)
.get(`/oauth2/applications/${createResult.application.id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
const applications = await listOAuth2Applications(harness, account.token);
const found = applications.find((app) => app.id === createResult.application.id);
expect(found).toBeUndefined();
await createBuilder(harness, `Bot ${createResult.botToken}`)
.get('/users/@me')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
const botUser = await createBuilder<{
id: string;
username: string;
discriminator: string;
avatar: string | null;
}>(harness, account.token)
.get(`/users/${createResult.botUserId}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(botUser.username).toBe('DeletedUser');
expect(botUser.discriminator).toBe('0000');
expect(botUser.avatar).toBeNull();
});
test('keeps bot-authored messages readable after deleting the application', async () => {
const account = await createTestAccount(harness);
const createResult = await createOAuth2Application(harness, account.token, {
name: createUniqueApplicationName(),
});
const guild = await createBuilder<{id: string; system_channel_id?: string}>(harness, account.token)
.post('/guilds')
.body({name: `Bot Message Retention ${Date.now()}`})
.expect(HTTP_STATUS.OK)
.execute();
const channelId = guild.system_channel_id;
if (!channelId) {
throw new Error('Guild response missing system channel');
}
const seeded = await createBuilder<{messages: Array<{message_id: string}>}>(harness, '')
.post('/test/messages/seed')
.body({
channel_id: channelId,
author_id: createResult.botUserId,
clear_existing: true,
messages: [{content: 'bot message before deletion'}],
})
.expect(HTTP_STATUS.OK)
.execute();
const botMessageId = seeded.messages[0]?.message_id;
if (!botMessageId) {
throw new Error('Seeded message response missing message id');
}
await deleteOAuth2Application(harness, account.token, createResult.application.id, account.password);
const messages = await createBuilder<Array<MessageResponse>>(harness, account.token)
.get(`/channels/${channelId}/messages`)
.expect(HTTP_STATUS.OK)
.execute();
const foundMessage = messages.find((message) => message.id === botMessageId);
expect(foundMessage).toBeDefined();
expect(foundMessage?.author.id).not.toBe(createResult.botUserId);
expect(foundMessage?.author.username).toBe('DeletedUser');
expect(foundMessage?.author.discriminator).toBe('0000');
const previousAuthorCount = await createBuilderWithoutAuth<{count: number}>(harness)
.get(`/test/users/${createResult.botUserId}/messages/count`)
.expect(HTTP_STATUS.OK)
.execute();
expect(previousAuthorCount.count).toBe(0);
const replacementAuthorId = foundMessage?.author.id;
expect(replacementAuthorId).toBeTruthy();
const replacementAuthorCount = await createBuilderWithoutAuth<{count: number}>(harness)
.get(`/test/users/${replacementAuthorId}/messages/count`)
.expect(HTTP_STATUS.OK)
.execute();
expect(replacementAuthorCount.count).toBe(1);
});
test('returns 404 for non-existent application', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.delete('/oauth2/applications/999999999999999999')
.body({password: account.password})
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
test('enforces access control', async () => {
const owner = await createTestAccount(harness);
const otherUser = await createTestAccount(harness);
const createResult = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
});
await createBuilder(harness, otherUser.token)
.delete(`/oauth2/applications/${createResult.application.id}`)
.body({password: otherUser.password})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
const application = await getOAuth2Application(harness, owner.token, createResult.application.id);
expect(application.id).toBe(createResult.application.id);
});
test('requires sudo verification', async () => {
const account = await createTestAccount(harness);
const createResult = await createOAuth2Application(harness, account.token, {
name: createUniqueApplicationName(),
});
await createBuilder(harness, account.token)
.delete(`/oauth2/applications/${createResult.application.id}`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
.execute();
const application = await getOAuth2Application(harness, account.token, createResult.application.id);
expect(application.id).toBe(createResult.application.id);
});
test('rejects wrong password', async () => {
const account = await createTestAccount(harness);
const createResult = await createOAuth2Application(harness, account.token, {
name: createUniqueApplicationName(),
});
await createBuilder(harness, account.token)
.delete(`/oauth2/applications/${createResult.application.id}`)
.body({password: 'wrong-password'})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
});
test('is idempotent', async () => {
const account = await createTestAccount(harness);
const createResult = await createOAuth2Application(harness, account.token, {
name: createUniqueApplicationName(),
});
await deleteOAuth2Application(harness, account.token, createResult.application.id, account.password);
await createBuilder(harness, account.token)
.delete(`/oauth2/applications/${createResult.application.id}`)
.body({password: account.password})
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
test('requires authentication', async () => {
await createBuilderWithoutAuth(harness)
.delete('/oauth2/applications/123')
.body({password: 'test'})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});

View 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createOAuth2Application,
createUniqueApplicationName,
getOAuth2Application,
} from '@fluxer/api/src/oauth/tests/OAuth2TestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Application Get', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('returns application response shape', async () => {
const account = await createTestAccount(harness);
const appName = createUniqueApplicationName();
const redirectURIs = ['https://example.com/callback', 'https://example.com/callback2'];
const createResult = await createOAuth2Application(harness, account.token, {
name: appName,
redirect_uris: redirectURIs,
});
const application = await getOAuth2Application(harness, account.token, createResult.application.id);
expect(application.id).toBeTruthy();
expect(application.name).toBe(appName);
expect(application.redirect_uris).toEqual(redirectURIs);
expect(application.bot).toBeDefined();
expect(application.bot?.id).toBeTruthy();
expect(application.bot?.username).toBeTruthy();
expect(application.bot?.discriminator).toBeTruthy();
expect(application.bot?.token).toBeUndefined();
expect(application.client_secret).toBeUndefined();
});
test('returns 404 for non-existent application', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.get('/oauth2/applications/999999999999999999')
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
test('enforces access control - user cannot access another users application', async () => {
const owner = await createTestAccount(harness);
const otherUser = await createTestAccount(harness);
const createResult = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
});
await createBuilder(harness, otherUser.token)
.get(`/oauth2/applications/${createResult.application.id}`)
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('requires authentication', async () => {
await createBuilderWithoutAuth(harness).get('/oauth2/applications/123').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
});

View File

@@ -0,0 +1,110 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createOAuth2Application,
createUniqueApplicationName,
listOAuth2Applications,
} from '@fluxer/api/src/oauth/tests/OAuth2TestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Application List', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('returns empty list when no applications exist', async () => {
const account = await createTestAccount(harness);
const applications = await listOAuth2Applications(harness, account.token);
expect(applications).toEqual([]);
});
test('returns list response shape', async () => {
const account = await createTestAccount(harness);
const appName = createUniqueApplicationName();
await createOAuth2Application(harness, account.token, {
name: appName,
redirect_uris: ['https://example.com/callback'],
});
const applications = await listOAuth2Applications(harness, account.token);
expect(applications.length).toBeGreaterThan(0);
const app = applications[0]!;
expect(app.id).toBeTruthy();
expect(app.name).toBeTruthy();
expect(app.redirect_uris).toBeDefined();
expect(app.bot?.id).toBeTruthy();
expect(app.bot?.username).toBeTruthy();
expect(app.bot?.discriminator).toBeTruthy();
expect(app.bot?.token).toBeUndefined();
expect(app.client_secret).toBeUndefined();
});
test('returns only applications owned by user', async () => {
const owner1 = await createTestAccount(harness);
const owner2 = await createTestAccount(harness);
const app1 = await createOAuth2Application(harness, owner1.token, {
name: createUniqueApplicationName(),
});
const app2 = await createOAuth2Application(harness, owner2.token, {
name: createUniqueApplicationName(),
});
const owner1Apps = await listOAuth2Applications(harness, owner1.token);
const owner2Apps = await listOAuth2Applications(harness, owner2.token);
expect(owner1Apps.length).toBe(1);
expect(owner1Apps[0]?.id).toBe(app1.application.id);
expect(owner2Apps.length).toBe(1);
expect(owner2Apps[0]?.id).toBe(app2.application.id);
});
test('supports alternative endpoint /users/@me/applications', async () => {
const account = await createTestAccount(harness);
await createOAuth2Application(harness, account.token, {
name: createUniqueApplicationName(),
});
const applications = await createBuilder<Array<{id: string}>>(harness, account.token)
.get('/users/@me/applications')
.expect(HTTP_STATUS.OK)
.execute();
expect(applications.length).toBeGreaterThan(0);
});
test('requires authentication', async () => {
await createBuilderWithoutAuth(harness).get('/oauth2/applications/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
});

View File

@@ -0,0 +1,173 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createOAuth2Application,
createUniqueApplicationName,
updateOAuth2Application,
} from '@fluxer/api/src/oauth/tests/OAuth2TestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Application Update', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('updates application name', async () => {
const account = await createTestAccount(harness);
const originalName = createUniqueApplicationName();
const redirectURIs = ['https://example.com/callback'];
const createResult = await createOAuth2Application(harness, account.token, {
name: originalName,
redirect_uris: redirectURIs,
});
const newName = createUniqueApplicationName();
const updated = await updateOAuth2Application(harness, account.token, createResult.application.id, {
name: newName,
});
expect(updated.name).toBe(newName);
expect(updated.id).toBe(createResult.application.id);
expect(updated.redirect_uris).toEqual(redirectURIs);
});
test('supports partial updates', async () => {
const account = await createTestAccount(harness);
const originalName = createUniqueApplicationName();
const originalURIs = ['https://example.com/old'];
const createResult = await createOAuth2Application(harness, account.token, {
name: originalName,
redirect_uris: originalURIs,
});
const newName = createUniqueApplicationName();
const updated = await updateOAuth2Application(harness, account.token, createResult.application.id, {
name: newName,
});
expect(updated.name).toBe(newName);
expect(updated.redirect_uris).toEqual(originalURIs);
});
test('updates redirect URIs', async () => {
const account = await createTestAccount(harness);
const createResult = await createOAuth2Application(harness, account.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
});
const newURIs = ['https://example.com/new1', 'https://example.com/new2'];
const updated = await updateOAuth2Application(harness, account.token, createResult.application.id, {
redirect_uris: newURIs,
});
expect(updated.redirect_uris).toEqual(newURIs);
});
test('updates bot_public flag', async () => {
const account = await createTestAccount(harness);
const createResult = await createOAuth2Application(harness, account.token, {
name: createUniqueApplicationName(),
bot_public: false,
});
const updated = await updateOAuth2Application(harness, account.token, createResult.application.id, {
bot_public: true,
});
expect(updated.bot_public).toBe(true);
});
test('updates multiple fields', async () => {
const account = await createTestAccount(harness);
const createResult = await createOAuth2Application(harness, account.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
bot_public: false,
});
const newName = createUniqueApplicationName();
const newURIs = ['https://example.com/updated'];
const updated = await updateOAuth2Application(harness, account.token, createResult.application.id, {
name: newName,
redirect_uris: newURIs,
bot_public: true,
});
expect(updated.name).toBe(newName);
expect(updated.redirect_uris).toEqual(newURIs);
expect(updated.bot_public).toBe(true);
});
test('keeps bot user after update', async () => {
const account = await createTestAccount(harness);
const createResult = await createOAuth2Application(harness, account.token, {
name: createUniqueApplicationName(),
});
const updated = await updateOAuth2Application(harness, account.token, createResult.application.id, {
name: createUniqueApplicationName(),
});
expect(updated.bot).toBeDefined();
expect(updated.bot?.id).toBe(createResult.application.bot?.id);
});
test('returns 404 for non-existent application', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.patch('/oauth2/applications/999999999999999999')
.body({name: createUniqueApplicationName()})
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
test('enforces access control', async () => {
const owner = await createTestAccount(harness);
const otherUser = await createTestAccount(harness);
const createResult = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
});
await createBuilder(harness, otherUser.token)
.patch(`/oauth2/applications/${createResult.application.id}`)
.body({name: createUniqueApplicationName()})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('requires authentication', async () => {
await createBuilderWithoutAuth(harness)
.patch('/oauth2/applications/123')
.body({name: createUniqueApplicationName()})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});

View File

@@ -0,0 +1,154 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
authorizeOAuth2,
createOAuth2Application,
deauthorizeOAuth2Application,
exchangeOAuth2AuthorizationCode,
getOAuth2UserInfo,
listOAuth2Authorizations,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('OAuth2 authorizations deauthorize', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('verifies that a user can deauthorize an application, which revokes all associated tokens', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const redirectURI = 'https://example.com/authz/deauth';
const app = await createOAuth2Application(harness, appOwner, {
name: 'Deauth Test',
redirect_uris: [redirectURI],
});
const {code: authCode} = await authorizeOAuth2(harness, endUser.token, {
client_id: app.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: app.id,
client_secret: app.client_secret,
code: authCode,
redirect_uri: redirectURI,
});
const userInfo = await getOAuth2UserInfo(harness, tokens.access_token);
expect(userInfo.sub).toBe(endUser.userId);
const beforeAuthz = await listOAuth2Authorizations(harness, endUser.token);
expect(beforeAuthz).toHaveLength(1);
await deauthorizeOAuth2Application(harness, endUser.token, app.id);
await createBuilder(harness, `Bearer ${tokens.access_token}`)
.get('/oauth2/userinfo')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
const afterAuthz = await listOAuth2Authorizations(harness, endUser.token);
expect(afterAuthz).toHaveLength(0);
});
it('verifies that deauthorizing one application does not affect other authorized applications', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const redirectURI = 'https://example.com/authz/partial';
const app1 = await createOAuth2Application(harness, appOwner, {
name: 'Partial Deauth 1',
redirect_uris: [redirectURI],
});
const {code: code1} = await authorizeOAuth2(harness, endUser.token, {
client_id: app1.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens1 = await exchangeOAuth2AuthorizationCode(harness, {
client_id: app1.id,
client_secret: app1.client_secret,
code: code1,
redirect_uri: redirectURI,
});
const app2 = await createOAuth2Application(harness, appOwner, {
name: 'Partial Deauth 2',
redirect_uris: [redirectURI],
});
const {code: code2} = await authorizeOAuth2(harness, endUser.token, {
client_id: app2.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens2 = await exchangeOAuth2AuthorizationCode(harness, {
client_id: app2.id,
client_secret: app2.client_secret,
code: code2,
redirect_uri: redirectURI,
});
await deauthorizeOAuth2Application(harness, endUser.token, app1.id);
await createBuilder(harness, `Bearer ${tokens1.access_token}`)
.get('/oauth2/userinfo')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
const userInfo2 = await getOAuth2UserInfo(harness, tokens2.access_token);
expect(userInfo2.sub).toBe(endUser.userId);
const authorizations = await listOAuth2Authorizations(harness, endUser.token);
expect(authorizations).toHaveLength(1);
expect(authorizations[0].application.id).toBe(app2.id);
});
it('verifies that deauthorizing a non-existent application returns an appropriate error', async () => {
const user = await createTestAccount(harness);
await createBuilder(harness, user.token)
.delete('/oauth2/@me/authorizations/123456789012345678')
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_APPLICATION')
.execute();
});
});

View File

@@ -0,0 +1,168 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
authorizeOAuth2,
createOAuth2Application,
exchangeOAuth2AuthorizationCode,
listOAuth2Authorizations,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('OAuth2 authorizations list', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('verifies that a user with no authorized apps receives an empty list', async () => {
const user = await createTestAccount(harness);
const authorizations = await listOAuth2Authorizations(harness, user.token);
expect(authorizations).toHaveLength(0);
});
it('verifies that after a user authorizes an OAuth2 application, it appears in their authorizations list', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const redirectURI = 'https://example.com/authz/callback';
const app = await createOAuth2Application(harness, appOwner, {
name: 'Auth List Test',
redirect_uris: [redirectURI],
});
const {code: authCode} = await authorizeOAuth2(harness, endUser.token, {
client_id: app.id,
redirect_uri: redirectURI,
scope: 'identify email',
});
await exchangeOAuth2AuthorizationCode(harness, {
client_id: app.id,
client_secret: app.client_secret,
code: authCode,
redirect_uri: redirectURI,
});
const authorizations = await listOAuth2Authorizations(harness, endUser.token);
expect(authorizations).toHaveLength(1);
expect(authorizations[0].application.id).toBe(app.id);
const scopes = authorizations[0].scopes;
expect(scopes).toContain('identify');
expect(scopes).toContain('email');
expect(scopes).not.toContain('bot');
expect(authorizations[0].authorized_at).toBeTruthy();
});
it('verifies that multiple authorized applications are correctly listed', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const redirectURI = 'https://example.com/authz/multi';
const app1 = await createOAuth2Application(harness, appOwner, {
name: 'Multi Test App 1',
redirect_uris: [redirectURI],
});
const {code: code1} = await authorizeOAuth2(harness, endUser.token, {
client_id: app1.id,
redirect_uri: redirectURI,
scope: 'identify',
});
await exchangeOAuth2AuthorizationCode(harness, {
client_id: app1.id,
client_secret: app1.client_secret,
code: code1,
redirect_uri: redirectURI,
});
const app2 = await createOAuth2Application(harness, appOwner, {
name: 'Multi Test App 2',
redirect_uris: [redirectURI],
});
const {code: code2} = await authorizeOAuth2(harness, endUser.token, {
client_id: app2.id,
redirect_uri: redirectURI,
scope: 'identify email',
});
await exchangeOAuth2AuthorizationCode(harness, {
client_id: app2.id,
client_secret: app2.client_secret,
code: code2,
redirect_uri: redirectURI,
});
const authorizations = await listOAuth2Authorizations(harness, endUser.token);
expect(authorizations).toHaveLength(2);
const appIds = authorizations.map((a) => a.application.id);
expect(appIds).toContain(app1.id);
expect(appIds).toContain(app2.id);
});
it('verifies that bot-only authorizations (scope = "bot" only) do not appear in the authorizations list', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const redirectURI = 'https://example.com/authz/bot';
const app = await createOAuth2Application(harness, appOwner, {
name: 'Bot Only Test',
redirect_uris: [redirectURI],
});
const {code: authCode} = await authorizeOAuth2(harness, endUser.token, {
client_id: app.id,
redirect_uri: redirectURI,
scope: 'identify',
});
await exchangeOAuth2AuthorizationCode(harness, {
client_id: app.id,
client_secret: app.client_secret,
code: authCode,
redirect_uri: redirectURI,
});
const authorizations = await listOAuth2Authorizations(harness, endUser.token);
expect(authorizations).toHaveLength(1);
expect(authorizations[0].scopes).not.toContain('bot');
});
});

View File

@@ -0,0 +1,46 @@
/*
* 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 ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
describe('OAuth2 authorizations requires auth', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('verifies that the authorizations endpoint requires authentication', async () => {
await createBuilderWithoutAuth(harness)
.get('/oauth2/@me/authorizations')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});

View File

@@ -0,0 +1,91 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
authorizeOAuth2,
createOAuth2Application,
exchangeOAuth2AuthorizationCode,
getOAuth2UserInfo,
introspectOAuth2Token,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('OAuth2 authorization code flow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('verifies the basic authorization code flow works end-to-end', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const redirectURI = 'https://example.com/oauth/callback';
const app = await createOAuth2Application(harness, appOwner, {
name: 'OAuth2 Basic Flow',
redirect_uris: [redirectURI],
});
const {code: authCode, state: returnedState} = await authorizeOAuth2(harness, endUser.token, {
client_id: app.id,
redirect_uri: redirectURI,
scope: 'identify email',
});
expect(authCode).toBeTruthy();
expect(returnedState).toBeTruthy();
const tokenResp = await exchangeOAuth2AuthorizationCode(harness, {
client_id: app.id,
client_secret: app.client_secret,
code: authCode,
redirect_uri: redirectURI,
});
expect(tokenResp.access_token).toBeTruthy();
expect(tokenResp.refresh_token).toBeTruthy();
expect(tokenResp.token_type).toBe('Bearer');
expect(tokenResp.expires_in).toBeGreaterThan(0);
expect(tokenResp.scope).toBe('identify email');
const userInfo = await getOAuth2UserInfo(harness, tokenResp.access_token);
expect(userInfo.sub).toBe(endUser.userId);
const introspection = await introspectOAuth2Token(harness, {
client_id: app.id,
client_secret: app.client_secret,
token: tokenResp.access_token,
});
expect(introspection.active).toBe(true);
expect(introspection.client_id).toBe(app.id);
expect(introspection.scope).toBe('identify email');
});
});

View File

@@ -0,0 +1,141 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
authorizeOAuth2,
createOAuth2Application,
exchangeOAuth2AuthorizationCode,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('OAuth2 authorize redirect URI validation', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('verifies that localhost URIs work correctly for development purposes', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const localhostURI = 'http://localhost:8080/oauth/callback';
const app = await createOAuth2Application(harness, appOwner, {
name: 'Localhost URI',
redirect_uris: [localhostURI],
});
const {code: authCode} = await authorizeOAuth2(harness, endUser.token, {
client_id: app.id,
redirect_uri: localhostURI,
scope: 'identify',
state: 'localhost-state',
});
const tokenResp = await exchangeOAuth2AuthorizationCode(harness, {
client_id: app.id,
client_secret: app.client_secret,
code: authCode,
redirect_uri: localhostURI,
});
expect(tokenResp.access_token).toBeTruthy();
});
it('verifies that an application can have multiple registered redirect URIs and use any of them', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const uri1 = 'https://app.example.com/callback';
const uri2 = 'https://staging.example.com/callback';
const uri3 = 'https://localhost:3000/callback';
const app = await createOAuth2Application(harness, appOwner, {
name: 'Multiple URIs',
redirect_uris: [uri1, uri2, uri3],
});
const uris = [uri1, uri2, uri3];
for (const uri of uris) {
const {code: authCode} = await authorizeOAuth2(harness, endUser.token, {
client_id: app.id,
redirect_uri: uri,
scope: 'identify',
});
const tokenResp = await exchangeOAuth2AuthorizationCode(harness, {
client_id: app.id,
client_secret: app.client_secret,
code: authCode,
redirect_uri: uri,
});
expect(tokenResp.access_token).toBeTruthy();
}
});
it('verifies that redirect URIs must match exactly (no partial matches)', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const registeredURI = 'https://example.com/callback';
const app = await createOAuth2Application(harness, appOwner, {
name: 'Exact Match',
redirect_uris: [registeredURI],
});
const testCases = [
'https://example.com/callback/extra',
'https://example.com/callback?foo=bar',
'https://example.com/callback#fragment',
'http://example.com/callback',
'https://other.com/callback',
'https://example.com:8080/callback',
'https://example.com/callback/',
];
for (const invalidURI of testCases) {
await createBuilder(harness, endUser.token)
.post('/oauth2/authorize/consent')
.body({
response_type: 'code',
client_id: app.id,
redirect_uri: invalidURI,
scope: 'identify',
state: 'test-state',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
}
});
});

View File

@@ -0,0 +1,135 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
authorizeOAuth2,
createOAuth2Application,
exchangeOAuth2AuthorizationCode,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('OAuth2 authorize state parameter', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('verifies that the state parameter is correctly echoed back in the authorization redirect', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const redirectURI = 'https://example.com/state/callback';
const app = await createOAuth2Application(harness, appOwner, {
name: 'State Test App',
redirect_uris: [redirectURI],
});
const customState = 'my-custom-state-12345';
const {code: authCode, state: returnedState} = await authorizeOAuth2(harness, endUser.token, {
client_id: app.id,
redirect_uri: redirectURI,
scope: 'identify',
state: customState,
});
expect(authCode).toBeTruthy();
expect(returnedState).toBe(customState);
const tokenResp = await exchangeOAuth2AuthorizationCode(harness, {
client_id: app.id,
client_secret: app.client_secret,
code: authCode,
redirect_uri: redirectURI,
});
expect(tokenResp.access_token).toBeTruthy();
});
it('verifies behavior when state is omitted', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const redirectURI = 'https://example.com/state/empty';
const app = await createOAuth2Application(harness, appOwner, {
name: 'Empty State',
redirect_uris: [redirectURI],
});
const {code: authCode, state: returnedState} = await authorizeOAuth2(harness, endUser.token, {
client_id: app.id,
redirect_uri: redirectURI,
scope: 'identify',
});
expect(authCode).toBeTruthy();
expect(returnedState).toBeTruthy();
const tokenResp = await exchangeOAuth2AuthorizationCode(harness, {
client_id: app.id,
client_secret: app.client_secret,
code: authCode,
redirect_uri: redirectURI,
});
expect(tokenResp.access_token).toBeTruthy();
});
it('verifies that state parameters with special characters are preserved correctly', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const redirectURI = 'https://example.com/state/special';
const app = await createOAuth2Application(harness, appOwner, {
name: 'Special State',
redirect_uris: [redirectURI],
});
const testCases = [
{state: 'state-with-dashes-123', name: 'with dashes'},
{state: 'state_with_underscores_456', name: 'with underscores'},
{state: 'state.with.periods.789', name: 'with periods'},
{state: 'state=with=equals', name: 'with equals'},
{state: 'state%20with%20spaces', name: 'with encoded chars'},
{state: 'c3RhdGUtYmFzZTY0LWxpa2U=', name: 'base64-like'},
];
for (const tc of testCases) {
const {state: returnedState} = await authorizeOAuth2(harness, endUser.token, {
client_id: app.id,
redirect_uri: redirectURI,
scope: 'identify',
state: tc.state,
});
expect(returnedState).toBe(tc.state);
}
});
});

View File

@@ -0,0 +1,355 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
acceptInvite,
addMemberRole,
createChannelInvite,
createGuild,
createRole,
getChannel,
getMember,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {createOAuth2Application, createUniqueApplicationName} from '@fluxer/api/src/oauth/tests/OAuth2TestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Bot Guild Add', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('should add bot to guild with proper role creation', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Bot Test Guild');
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
bot_public: true,
});
await createBuilder(harness, owner.token)
.post('/oauth2/authorize/consent')
.body({
client_id: app.application.id,
scope: 'bot',
guild_id: guild.id,
permissions: Permissions.SEND_MESSAGES.toString(),
})
.expect(HTTP_STATUS.OK)
.execute();
const botMember = await getMember(harness, owner.token, guild.id, app.botUserId);
expect(botMember.user?.id).toBe(app.botUserId);
});
test('should require MANAGE_GUILD permission to add bot', async () => {
const owner = await createTestAccount(harness);
const regularUser = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Bot Test Guild');
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, regularUser.token, invite.code);
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
bot_public: true,
});
await createBuilder(harness, regularUser.token)
.post('/oauth2/authorize/consent')
.body({
client_id: app.application.id,
scope: 'bot',
guild_id: guild.id,
permissions: '0',
})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should reject adding bot that is already in guild', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Bot Test Guild');
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
bot_public: true,
});
await createBuilder(harness, owner.token)
.post('/oauth2/authorize/consent')
.body({
client_id: app.application.id,
scope: 'bot',
guild_id: guild.id,
permissions: '0',
})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.post('/oauth2/authorize/consent')
.body({
client_id: app.application.id,
scope: 'bot',
guild_id: guild.id,
permissions: '0',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should gracefully handle unknown permission bits', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Bot Test Guild');
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
bot_public: true,
});
const unknownPermissionBits = (1n << 60n).toString();
await createBuilder(harness, owner.token)
.post('/oauth2/authorize/consent')
.body({
client_id: app.application.id,
scope: 'bot',
guild_id: guild.id,
permissions: unknownPermissionBits,
})
.expect(HTTP_STATUS.OK)
.execute();
});
test('should add bot without permissions when permissions is 0', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Bot Test Guild');
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
bot_public: true,
});
await createBuilder(harness, owner.token)
.post('/oauth2/authorize/consent')
.body({
client_id: app.application.id,
scope: 'bot',
guild_id: guild.id,
permissions: '0',
})
.expect(HTTP_STATUS.OK)
.execute();
const botMember = await getMember(harness, owner.token, guild.id, app.botUserId);
expect(botMember.user?.id).toBe(app.botUserId);
});
test('should reject bot scope for non-public bot without owner consent', async () => {
const owner = await createTestAccount(harness);
const otherUser = await createTestAccount(harness);
const guild = await createGuild(harness, otherUser.token, 'Other User Guild');
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
bot_public: false,
});
await createBuilder(harness, otherUser.token)
.post('/oauth2/authorize/consent')
.body({
client_id: app.application.id,
scope: 'bot',
guild_id: guild.id,
permissions: '0',
})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should allow public bot to be added by any user with MANAGE_GUILD', async () => {
const owner = await createTestAccount(harness);
const otherUser = await createTestAccount(harness);
const guild = await createGuild(harness, otherUser.token, 'Other User Guild');
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
bot_public: true,
});
await createBuilder(harness, otherUser.token)
.post('/oauth2/authorize/consent')
.body({
client_id: app.application.id,
scope: 'bot',
guild_id: guild.id,
permissions: '0',
})
.expect(HTTP_STATUS.OK)
.execute();
});
test('should reject negative permissions', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Bot Test Guild');
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
bot_public: true,
});
await createBuilder(harness, owner.token)
.post('/oauth2/authorize/consent')
.body({
client_id: app.application.id,
scope: 'bot',
guild_id: guild.id,
permissions: '-1',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject invalid permissions string', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Bot Test Guild');
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
bot_public: true,
});
await createBuilder(harness, owner.token)
.post('/oauth2/authorize/consent')
.body({
client_id: app.application.id,
scope: 'bot',
guild_id: guild.id,
permissions: 'not_a_number',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should create role with correct permissions and assign to bot', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Bot Test Guild');
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
bot_public: true,
});
const requestedPermissions = Permissions.SEND_MESSAGES | Permissions.MANAGE_MESSAGES;
await createBuilder(harness, owner.token)
.post('/oauth2/authorize/consent')
.body({
client_id: app.application.id,
scope: 'bot',
guild_id: guild.id,
permissions: requestedPermissions.toString(),
})
.expect(HTTP_STATUS.OK)
.execute();
const roles = await createBuilder<Array<GuildRoleResponse>>(harness, owner.token)
.get(`/guilds/${guild.id}/roles`)
.execute();
const botRole = roles.find((r) => r.name === app.application.name);
expect(botRole).toBeDefined();
expect(BigInt(botRole!.permissions)).toBe(requestedPermissions);
const botMember = await getMember(harness, owner.token, guild.id, app.botUserId);
expect(botMember.roles).toContain(botRole!.id);
});
test('should succeed when admin with MANAGE_GUILD adds bot with permissions they lack', async () => {
const owner = await createTestAccount(harness);
const admin = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Bot Test Guild');
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, admin.token, invite.code);
const adminRole = await createRole(harness, owner.token, guild.id, {
name: 'Admin',
permissions: (Permissions.MANAGE_GUILD | Permissions.SEND_MESSAGES).toString(),
});
await addMemberRole(harness, owner.token, guild.id, admin.userId, adminRole.id);
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
bot_public: true,
});
const botPermissions = Permissions.MANAGE_MESSAGES | Permissions.MANAGE_CHANNELS;
await createBuilder(harness, admin.token)
.post('/oauth2/authorize/consent')
.body({
client_id: app.application.id,
scope: 'bot',
guild_id: guild.id,
permissions: botPermissions.toString(),
})
.expect(HTTP_STATUS.OK)
.execute();
const roles = await createBuilder<Array<GuildRoleResponse>>(harness, owner.token)
.get(`/guilds/${guild.id}/roles`)
.execute();
const botRole = roles.find((r) => r.name === app.application.name);
expect(botRole).toBeDefined();
expect(BigInt(botRole!.permissions)).toBe(botPermissions);
const botMember = await getMember(harness, owner.token, guild.id, app.botUserId);
expect(botMember.roles).toContain(botRole!.id);
});
});

View File

@@ -0,0 +1,202 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {createOAuth2Application, createUniqueApplicationName} from '@fluxer/api/src/oauth/tests/OAuth2TestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS, TEST_CREDENTIALS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface ApplicationResponse {
id: string;
name: string;
bot_public: boolean;
bot?: {
id: string;
username: string;
};
}
describe('OAuth2 Bot Token', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('should authenticate with Bot prefix in authorization header', async () => {
const owner = await createTestAccount(harness);
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
});
const json = await createBuilder<ApplicationResponse>(harness, `Bot ${app.botToken}`)
.get('/applications/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.id).toBe(app.application.id);
});
test('should reject Bearer prefix for bot authentication', async () => {
const owner = await createTestAccount(harness);
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
});
await createBuilder(harness, `Bearer ${app.botToken}`)
.get('/applications/@me')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
test('should reject invalid bot token', async () => {
await createBuilder(harness, 'Bot invalid_bot_token_12345')
.get('/applications/@me')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
test('should reject bot token without prefix', async () => {
const owner = await createTestAccount(harness);
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
});
await createBuilder(harness, app.botToken).get('/applications/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
test('should reset bot token and invalidate old token', async () => {
const owner = await createTestAccount(harness);
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
});
await createBuilder(harness, `Bot ${app.botToken}`).get('/applications/@me').expect(HTTP_STATUS.OK).execute();
const resetJson = await createBuilder<{token: string}>(harness, owner.token)
.post(`/oauth2/applications/${app.application.id}/bot/reset-token`)
.body({password: TEST_CREDENTIALS.STRONG_PASSWORD})
.expect(HTTP_STATUS.OK)
.execute();
expect(resetJson.token).toBeTruthy();
expect(resetJson.token).not.toBe(app.botToken);
await createBuilder(harness, `Bot ${app.botToken}`)
.get('/applications/@me')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
await createBuilder(harness, `Bot ${resetJson.token}`).get('/applications/@me').expect(HTTP_STATUS.OK).execute();
});
test('should reject bot token reset without password', async () => {
const owner = await createTestAccount(harness);
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
});
await createBuilder(harness, owner.token)
.post(`/oauth2/applications/${app.application.id}/bot/reset-token`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should reject bot token reset with wrong password', async () => {
const owner = await createTestAccount(harness);
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
});
await createBuilder(harness, owner.token)
.post(`/oauth2/applications/${app.application.id}/bot/reset-token`)
.body({password: 'wrong-password'})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject bot token reset by non-owner', async () => {
const owner = await createTestAccount(harness);
const otherUser = await createTestAccount(harness);
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
});
await createBuilder(harness, otherUser.token)
.post(`/oauth2/applications/${app.application.id}/bot/reset-token`)
.body({password: TEST_CREDENTIALS.STRONG_PASSWORD})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should return bot information in application response', async () => {
const owner = await createTestAccount(harness);
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
});
const json = await createBuilder<ApplicationResponse>(harness, `Bot ${app.botToken}`)
.get('/applications/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.id).toBe(app.application.id);
expect(json.bot).toBeTruthy();
expect(json.bot?.id).toBe(app.botUserId);
});
test('should create application with bot user', async () => {
const owner = await createTestAccount(harness);
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName(),
redirect_uris: ['https://example.com/callback'],
});
expect(app.application.id).toBeTruthy();
expect(app.botUserId).toBeTruthy();
expect(app.botToken).toBeTruthy();
expect(app.clientSecret).toBeTruthy();
expect(app.botToken).toMatch(/^\d+\./);
});
});

View File

@@ -0,0 +1,133 @@
/*
* 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 {
authorizeOAuth2,
createOAuth2Application,
createOAuth2TestSetup,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Client Secret', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('should require client secret for confidential applications', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: authCodeResponse.code,
redirect_uri: redirectURI,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should validate client secret', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: authCodeResponse.code,
redirect_uri: redirectURI,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header('Authorization', `Basic ${Buffer.from(`${application.id}:wrong_secret`).toString('base64')}`)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should accept correct client secret via basic auth', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: authCodeResponse.code,
redirect_uri: redirectURI,
client_id: application.id,
});
const json = await createBuilder<{access_token: string}>(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.OK)
.execute();
expect(json.access_token).toBeTruthy();
expect(json.access_token).toHaveLength(43);
});
test('should not leak client secret in application response', async () => {
const {appOwner} = await createOAuth2TestSetup(harness);
const application = await createOAuth2Application(harness, appOwner, {
name: 'Secret Test App',
redirect_uris: ['https://example.com/callback'],
});
const json = await createBuilderWithoutAuth<Record<string, unknown>>(harness)
.get(`/oauth2/applications/${application.id}/public`)
.expect(HTTP_STATUS.OK)
.execute();
expect(json.client_secret).toBeUndefined();
expect(json.id).toBe(application.id);
});
});

View File

@@ -0,0 +1,143 @@
/*
* 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 {
authorizeOAuth2,
createOAuth2TestSetup,
exchangeOAuth2AuthorizationCode,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Authorization Code Replay Prevention', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('should prevent authorization code reuse', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const firstTokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
expect(firstTokenResponse.access_token).toBeTruthy();
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: authCodeResponse.code,
redirect_uri: redirectURI,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should issue different access tokens for same authorization', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse1 = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokenResponse1 = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse1.code,
redirect_uri: redirectURI,
});
const authCodeResponse2 = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokenResponse2 = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse2.code,
redirect_uri: redirectURI,
});
expect(tokenResponse1.access_token).not.toBe(tokenResponse2.access_token);
expect(tokenResponse1.refresh_token).not.toBe(tokenResponse2.refresh_token);
});
test('should consume authorization code after first use', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const code = authCodeResponse.code;
await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code,
redirect_uri: redirectURI,
});
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectURI,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});

View File

@@ -0,0 +1,709 @@
/*
* 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 {createAdminApiKey} from '@fluxer/api/src/admin/tests/AdminTestUtils';
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {createGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
import {
authorizeOAuth2,
createOAuth2Application,
createOAuth2TestSetup,
exchangeOAuth2AuthorizationCode,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {beforeEach, describe, expect, test} from 'vitest';
interface OAuth2TokenResponse {
token: string;
user_id: string;
scopes: Array<string>;
application_id: string;
}
interface UserMeResponse {
id: string;
username: string;
email?: string | null;
}
interface UserGuildResponse {
id: string;
name: string;
}
async function createOAuth2Token(
harness: ApiTestHarness,
userId: string,
scopes: Array<string>,
): Promise<OAuth2TokenResponse> {
return createBuilder<OAuth2TokenResponse>(harness, '')
.post('/test/oauth2/access-token')
.body({
user_id: userId,
scopes,
})
.execute();
}
describe('OAuth2 Scope Enforcement', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
describe('Scope enforcement for user endpoints (/users/@me)', () => {
test('GET /users/@me with bearer token succeeds when user is authenticated', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, ['identify']);
const json = await createBuilder<UserMeResponse>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.id).toBe(account.userId);
expect(json.username).toBe(account.username);
});
test('GET /users/@me with bearer token returns user without email when only identify scope', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, ['identify']);
const json = await createBuilder<UserMeResponse>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.id).toBe(account.userId);
expect(json.email).toBeNull();
});
test('GET /users/@me with bearer token returns email when email scope is present', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, ['identify', 'email']);
const json = await createBuilder<UserMeResponse>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.id).toBe(account.userId);
expect(json.email).toBe(account.email);
});
test('GET /users/@me with session token (no scope check) succeeds', async () => {
const account = await createTestAccount(harness);
const json = await createBuilder<UserMeResponse>(harness, account.token)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.id).toBe(account.userId);
expect(json.email).toBe(account.email);
});
test('GET /users/@me with bot token (no scope check) succeeds', async () => {
const appOwner = await createTestAccount(harness);
const application = await createOAuth2Application(harness, appOwner, {
name: 'Bot Token Test',
redirect_uris: [],
});
const json = await createBuilder<UserMeResponse>(harness, `Bot ${application.bot.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.id).toBe(application.bot.id);
});
test('GET /users/@me with bearer token fails when identify scope is missing', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, ['guilds']);
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_SCOPE')
.execute();
});
test('GET /users/@me/settings rejects OAuth2 bearer tokens on unsupported endpoints', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, ['identify', 'email']);
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me/settings')
.expect(HTTP_STATUS.FORBIDDEN, 'ACCESS_DENIED')
.execute();
});
});
describe('Scope enforcement for guild endpoints (/users/@me/guilds)', () => {
test('GET /users/@me/guilds with guilds scope succeeds', async () => {
const account = await createTestAccount(harness);
await createGuild(harness, account.token, 'Test Guild');
const oauth2Token = await createOAuth2Token(harness, account.userId, ['identify', 'guilds']);
const json = await createBuilder<Array<UserGuildResponse>>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.OK)
.execute();
expect(Array.isArray(json)).toBe(true);
expect(json.length).toBeGreaterThan(0);
expect(json[0]?.name).toBe('Test Guild');
});
test('GET /users/@me/guilds without guilds scope fails with MISSING_OAUTH_SCOPE', async () => {
const account = await createTestAccount(harness);
await createGuild(harness, account.token, 'Test Guild');
const oauth2Token = await createOAuth2Token(harness, account.userId, ['identify']);
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_SCOPE')
.execute();
});
test('GET /users/@me/guilds with session token (no scope check) succeeds', async () => {
const account = await createTestAccount(harness);
await createGuild(harness, account.token, 'Test Guild');
const json = await createBuilder<Array<UserGuildResponse>>(harness, account.token)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.OK)
.execute();
expect(Array.isArray(json)).toBe(true);
expect(json.length).toBeGreaterThan(0);
});
test('GET /users/@me/guilds with bot token (no scope check) succeeds', async () => {
const appOwner = await createTestAccount(harness);
const application = await createOAuth2Application(harness, appOwner, {
name: 'Bot Guilds Test',
redirect_uris: [],
});
const json = await createBuilder<Array<UserGuildResponse>>(harness, `Bot ${application.bot.token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.OK)
.execute();
expect(Array.isArray(json)).toBe(true);
});
});
describe('Scope enforcement for connections endpoint (/users/@me/connections)', () => {
test('GET /users/@me/connections with connections scope succeeds', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, ['identify', 'connections']);
const json = await createBuilder<Array<unknown>>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me/connections')
.expect(HTTP_STATUS.OK)
.execute();
expect(Array.isArray(json)).toBe(true);
});
test('GET /users/@me/connections without connections scope fails with MISSING_OAUTH_SCOPE', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, ['identify']);
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me/connections')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_SCOPE')
.execute();
});
test('GET /users/@me/connections with session token succeeds', async () => {
const account = await createTestAccount(harness);
const json = await createBuilder<Array<unknown>>(harness, account.token)
.get('/users/@me/connections')
.expect(HTTP_STATUS.OK)
.execute();
expect(Array.isArray(json)).toBe(true);
});
});
describe('Scope enforcement for userinfo endpoint (/oauth2/userinfo)', () => {
test('GET /oauth2/userinfo with identify scope succeeds', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
await createBuilder(harness, `Bearer ${tokenResponse.access_token}`)
.get('/oauth2/userinfo')
.expect(HTTP_STATUS.OK)
.execute();
});
test('GET /oauth2/userinfo without identify scope fails with MISSING_OAUTH_SCOPE', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, ['guilds']);
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
.get('/oauth2/userinfo')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_SCOPE')
.execute();
});
test('GET /oauth2/userinfo with session token is unauthorized', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.get('/oauth2/userinfo')
.expect(HTTP_STATUS.UNAUTHORIZED, 'UNAUTHORIZED')
.execute();
});
});
describe('Multiple scope scenarios', () => {
test('Token with identify but not email cannot access email in response', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, ['identify']);
const json = await createBuilder<UserMeResponse>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.email).toBeNull();
});
test('Token with identify and email can access both user info and email', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, ['identify', 'email']);
const json = await createBuilder<UserMeResponse>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.id).toBe(account.userId);
expect(json.email).toBe(account.email);
});
test('Token with guilds but not identify cannot access /users/@me/guilds without auth context', async () => {
const account = await createTestAccount(harness);
await createGuild(harness, account.token, 'Test Guild');
const oauth2Token = await createOAuth2Token(harness, account.userId, ['guilds']);
const json = await createBuilder<Array<UserGuildResponse>>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.OK)
.execute();
expect(Array.isArray(json)).toBe(true);
expect(json.length).toBeGreaterThan(0);
});
test('Token with all common scopes can access all corresponding endpoints', async () => {
const account = await createTestAccount(harness);
await createGuild(harness, account.token, 'Test Guild');
const oauth2Token = await createOAuth2Token(harness, account.userId, ['identify', 'email', 'guilds']);
const userJson = await createBuilder<UserMeResponse>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(userJson.id).toBe(account.userId);
expect(userJson.email).toBe(account.email);
const guildsJson = await createBuilder<Array<UserGuildResponse>>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.OK)
.execute();
expect(Array.isArray(guildsJson)).toBe(true);
expect(guildsJson.length).toBeGreaterThan(0);
});
});
describe('Edge cases', () => {
test('Empty scopes set cannot access /users/@me', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, []);
await createBuilder<UserMeResponse>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_SCOPE')
.execute();
});
test('Token with empty scopes cannot access guilds endpoint', async () => {
const account = await createTestAccount(harness);
await createGuild(harness, account.token, 'Test Guild');
const oauth2Token = await createOAuth2Token(harness, account.userId, []);
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_SCOPE')
.execute();
});
test('Scope checking does not affect session tokens', async () => {
const account = await createTestAccount(harness);
await createGuild(harness, account.token, 'Test Guild');
const userJson = await createBuilder<UserMeResponse>(harness, account.token)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(userJson.id).toBe(account.userId);
expect(userJson.email).toBe(account.email);
const guildsJson = await createBuilder<Array<UserGuildResponse>>(harness, account.token)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.OK)
.execute();
expect(Array.isArray(guildsJson)).toBe(true);
});
test('Scope checking does not affect bot tokens', async () => {
const appOwner = await createTestAccount(harness);
const application = await createOAuth2Application(harness, appOwner, {
name: 'Bot Scope Check Test',
redirect_uris: [],
});
const userJson = await createBuilder<UserMeResponse>(harness, `Bot ${application.bot.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(userJson.id).toBe(application.bot.id);
const guildsJson = await createBuilder<Array<UserGuildResponse>>(harness, `Bot ${application.bot.token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.OK)
.execute();
expect(Array.isArray(guildsJson)).toBe(true);
});
});
describe('Admin scope integration', () => {
test('OAuth2 token with admin scope can access admin endpoints when user has proper ACL', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email', 'admin']);
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
.post('/admin/users/lookup')
.body({
user_ids: [admin.userId],
})
.expect(HTTP_STATUS.OK)
.execute();
});
test('OAuth2 token without admin scope cannot access admin endpoints even with proper user ACL', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email']);
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
.post('/admin/users/lookup')
.body({
user_ids: [admin.userId],
})
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_ADMIN_SCOPE')
.execute();
});
test('Session token can access admin endpoints with proper ACLs (no admin scope needed)', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
await createBuilder(harness, `Bearer ${admin.token}`)
.post('/admin/users/lookup')
.body({
user_ids: [admin.userId],
})
.expect(HTTP_STATUS.OK)
.execute();
});
test('Admin API key can access admin endpoints with granted ACLs (no scope check)', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
const apiKey = await createAdminApiKey(harness, admin, 'Test Key', ['user:lookup'], null);
await createBuilder(harness, apiKey.token)
.post('/admin/users/lookup')
.body({
user_ids: [admin.userId],
})
.expect(HTTP_STATUS.OK)
.execute();
});
test('OAuth2 token with admin scope still requires proper user ACLs', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate']);
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email', 'admin']);
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
.post('/admin/users/lookup')
.body({
user_ids: [admin.userId],
})
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
.execute();
});
test('OAuth2 token with admin scope without admin:authenticate ACL cannot access admin endpoints', async () => {
const user = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, user.userId, ['identify', 'email', 'admin']);
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
.post('/admin/users/lookup')
.body({
user_ids: [user.userId],
})
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
.execute();
});
});
describe('Negative tests - Error response verification', () => {
test('Endpoint without proper guilds scope returns 403 with MISSING_OAUTH_SCOPE code', async () => {
const account = await createTestAccount(harness);
await createGuild(harness, account.token, 'Test Guild');
const oauth2Token = await createOAuth2Token(harness, account.userId, ['identify']);
const json = await createBuilder<{code: string; message: string}>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_SCOPE')
.execute();
expect(json.code).toBe('MISSING_OAUTH_SCOPE');
});
test('Admin endpoint without admin scope returns 403 with MISSING_OAUTH_ADMIN_SCOPE code', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['identify', 'email']);
const json = await createBuilder<{code: string; message: string}>(harness, `Bearer ${oauth2Token.token}`)
.post('/admin/users/lookup')
.body({user_ids: [admin.userId]})
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_ADMIN_SCOPE')
.execute();
expect(json.code).toBe('MISSING_OAUTH_ADMIN_SCOPE');
});
test('Admin endpoint with admin scope but missing ACL returns MISSING_ACL code', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate']);
const oauth2Token = await createOAuth2Token(harness, admin.userId, ['admin']);
const json = await createBuilder<{code: string; message: string}>(harness, `Bearer ${oauth2Token.token}`)
.post('/admin/users/lookup')
.body({user_ids: [admin.userId]})
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
.execute();
expect(json.code).toBe('MISSING_ACL');
});
test('Unauthenticated request returns 401 UNAUTHORIZED', async () => {
await createBuilder(harness, '').get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED, 'UNAUTHORIZED').execute();
});
test('Invalid bearer token returns 401', async () => {
await createBuilder(harness, 'Bearer invalid_token_12345')
.get('/users/@me')
.expect(HTTP_STATUS.UNAUTHORIZED, 'UNAUTHORIZED')
.execute();
});
});
describe('Scope combinations and boundaries', () => {
test('Token with only email scope (no identify) cannot access user info', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, ['email']);
await createBuilder<UserMeResponse>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_SCOPE')
.execute();
});
test('Multiple OAuth2 tokens with different scopes work independently', async () => {
const account = await createTestAccount(harness);
await createGuild(harness, account.token, 'Test Guild');
const identifyToken = await createOAuth2Token(harness, account.userId, ['identify']);
const guildsToken = await createOAuth2Token(harness, account.userId, ['identify', 'guilds']);
const fullToken = await createOAuth2Token(harness, account.userId, ['identify', 'email', 'guilds']);
const userJson1 = await createBuilder<UserMeResponse>(harness, `Bearer ${identifyToken.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(userJson1.email).toBeNull();
await createBuilder(harness, `Bearer ${identifyToken.token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_SCOPE')
.execute();
const guildsJson = await createBuilder<Array<UserGuildResponse>>(harness, `Bearer ${guildsToken.token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.OK)
.execute();
expect(guildsJson.length).toBeGreaterThan(0);
const userJson2 = await createBuilder<UserMeResponse>(harness, `Bearer ${guildsToken.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(userJson2.email).toBeNull();
const userJson3 = await createBuilder<UserMeResponse>(harness, `Bearer ${fullToken.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(userJson3.email).toBe(account.email);
const guildsJson2 = await createBuilder<Array<UserGuildResponse>>(harness, `Bearer ${fullToken.token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.OK)
.execute();
expect(guildsJson2.length).toBeGreaterThan(0);
});
test('Token created for one user cannot access another users data', async () => {
const account1 = await createTestAccount(harness);
const account2 = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account1.userId, ['identify', 'email']);
const json = await createBuilder<UserMeResponse>(harness, `Bearer ${oauth2Token.token}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.id).toBe(account1.userId);
expect(json.id).not.toBe(account2.userId);
});
});
describe('Real OAuth2 flow scope enforcement', () => {
test('OAuth2 token from real authorization flow respects scope restrictions', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
await createGuild(harness, endUser.token, 'Test Guild');
const {authorizeOAuth2, exchangeOAuth2AuthorizationCode} = await import(
'@fluxer/api/src/oauth/tests/OAuthTestUtils'
);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const userJson = await createBuilder<UserMeResponse>(harness, `Bearer ${tokenResponse.access_token}`)
.get('/users/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(userJson.id).toBe(endUser.userId);
expect(userJson.email).toBeNull();
await createBuilder(harness, `Bearer ${tokenResponse.access_token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_SCOPE')
.execute();
});
test('OAuth2 token with guilds scope from real flow can access guilds', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
await createGuild(harness, endUser.token, 'Real Flow Guild');
const {authorizeOAuth2, exchangeOAuth2AuthorizationCode} = await import(
'@fluxer/api/src/oauth/tests/OAuthTestUtils'
);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify guilds',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const guildsJson = await createBuilder<Array<UserGuildResponse>>(harness, `Bearer ${tokenResponse.access_token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.OK)
.execute();
expect(Array.isArray(guildsJson)).toBe(true);
expect(guildsJson.length).toBeGreaterThan(0);
expect(guildsJson.some((g) => g.name === 'Real Flow Guild')).toBe(true);
});
});
});

View File

@@ -0,0 +1,93 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterEach, beforeEach, describe, test} from 'vitest';
interface OAuth2TokenResponse {
token: string;
user_id: string;
scopes: Array<string>;
application_id: string;
}
async function createOAuth2Token(
harness: ApiTestHarness,
userId: string,
scopes: Array<string>,
): Promise<OAuth2TokenResponse> {
return createBuilder<OAuth2TokenResponse>(harness, '')
.post('/test/oauth2/access-token')
.body({
user_id: userId,
scopes,
})
.execute();
}
describe('OAuth2 scope middleware', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('rejects session tokens for OAuth2-only routes', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.get('/test/oauth2/require-identify')
.expect(HTTP_STATUS.UNAUTHORIZED, 'UNAUTHORIZED')
.execute();
});
test('rejects unauthenticated requests for OAuth2-only routes', async () => {
await createBuilder(harness, '')
.get('/test/oauth2/require-identify')
.expect(HTTP_STATUS.UNAUTHORIZED, 'UNAUTHORIZED')
.execute();
});
test('rejects bearer tokens missing required scope', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, ['email']);
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
.get('/test/oauth2/require-identify')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_OAUTH_SCOPE')
.execute();
});
test('allows bearer tokens with required scope', async () => {
const account = await createTestAccount(harness);
const oauth2Token = await createOAuth2Token(harness, account.userId, ['identify']);
await createBuilder(harness, `Bearer ${oauth2Token.token}`)
.get('/test/oauth2/require-identify')
.expect(HTTP_STATUS.OK)
.execute();
});
});

View File

@@ -0,0 +1,227 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {Config} from '@fluxer/api/src/Config';
import {
authorizeOAuth2,
createOAuth2Application,
createOAuth2TestSetup,
exchangeOAuth2AuthorizationCode,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {ADMIN_OAUTH2_APPLICATION_ID} from '@fluxer/constants/src/Core';
import {beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Scope Validation', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('should accept valid platform scopes', async () => {
const {appOwner, endUser} = await createOAuth2TestSetup(harness);
const validScopes = ['identify', 'email', 'guilds', 'connections'];
for (const scope of validScopes) {
const application = await createOAuth2Application(harness, appOwner, {
name: `Scope Test ${scope}`,
redirect_uris: ['https://example.com/callback'],
});
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: 'https://example.com/callback',
scope,
});
expect(authCodeResponse.code).toBeTruthy();
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: 'https://example.com/callback',
});
expect(tokenResponse.scope).toContain(scope);
}
}, 10000);
test('should accept multiple valid scopes', async () => {
const {appOwner, endUser} = await createOAuth2TestSetup(harness);
const application = await createOAuth2Application(harness, appOwner, {
name: 'Multi Scope Test',
redirect_uris: ['https://example.com/callback'],
});
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: 'https://example.com/callback',
scope: 'identify email guilds',
});
expect(authCodeResponse.code).toBeTruthy();
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: 'https://example.com/callback',
});
expect(tokenResponse.scope).toContain('identify');
expect(tokenResponse.scope).toContain('email');
expect(tokenResponse.scope).toContain('guilds');
}, 10000);
test('should reject unknown scope', async () => {
const {appOwner, endUser} = await createOAuth2TestSetup(harness);
const application = await createOAuth2Application(harness, appOwner, {
name: 'Unknown Scope Test',
redirect_uris: ['https://example.com/callback'],
});
await createBuilder(harness, endUser.token)
.post('/oauth2/authorize/consent')
.body({
response_type: 'code',
client_id: application.id,
redirect_uri: 'https://example.com/callback',
scope: 'unknown_scope_invalid',
state: 'test-state',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject mix of valid and invalid scopes', async () => {
const {appOwner, endUser} = await createOAuth2TestSetup(harness);
const application = await createOAuth2Application(harness, appOwner, {
name: 'Mixed Scope Test',
redirect_uris: ['https://example.com/callback'],
});
await createBuilder(harness, endUser.token)
.post('/oauth2/authorize/consent')
.body({
response_type: 'code',
client_id: application.id,
redirect_uri: 'https://example.com/callback',
scope: 'identify invalid_scope_xyz',
state: 'test-state',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should allow bot scope on applications with bots', async () => {
const {appOwner, endUser} = await createOAuth2TestSetup(harness);
const application = await createOAuth2Application(harness, appOwner, {
name: 'Bot Scope Test',
redirect_uris: [],
});
const json = await createBuilder<{redirect_to: string}>(harness, endUser.token)
.post('/oauth2/authorize/consent')
.body({
response_type: 'code',
client_id: application.id,
scope: 'bot',
state: 'test-state',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(json.redirect_to).toBeTruthy();
});
test('should reject bot scope for non-bot applications', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const application = await createOAuth2Application(harness, appOwner, {
name: 'Bot Scope Test App',
redirect_uris: ['https://example.com/callback'],
});
await createBuilder(harness, endUser.token)
.post('/oauth2/authorize/consent')
.body({
response_type: 'code',
client_id: application.id,
redirect_uri: 'https://example.com/callback',
scope: 'bot',
state: 'test-state',
})
.expect(HTTP_STATUS.OK)
.execute();
});
test('should reject admin scope for non-admin applications', async () => {
const {appOwner, endUser} = await createOAuth2TestSetup(harness);
const application = await createOAuth2Application(harness, appOwner, {
name: 'Admin Scope Test',
redirect_uris: ['https://example.com/callback'],
});
const json = await createBuilder<{error: string}>(harness, endUser.token)
.post('/oauth2/authorize/consent')
.body({
response_type: 'code',
client_id: application.id,
redirect_uri: 'https://example.com/callback',
scope: 'identify admin',
state: 'test-state',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(json.error).toBe('invalid_scope');
});
test('should allow admin scope only for admin OAuth2 application', async () => {
const endUser = await createTestAccount(harness);
const adminRedirectUri = `${Config.endpoints.admin}/oauth2_callback`;
const json = await createBuilder<{redirect_to: string}>(harness, endUser.token)
.post('/oauth2/authorize/consent')
.body({
response_type: 'code',
client_id: ADMIN_OAUTH2_APPLICATION_ID.toString(),
redirect_uri: adminRedirectUri,
scope: 'identify email admin',
state: 'test-state',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(json.redirect_to).toBeTruthy();
expect(json.redirect_to).toContain('code=');
});
});

View File

@@ -0,0 +1,329 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {createGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
import {
authorizeOAuth2,
createOAuth2Application,
createOAuth2TestSetup,
exchangeOAuth2AuthorizationCode,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
interface OAuth2MeResponse {
application: {
id: string;
name: string;
};
scopes: Array<string>;
expires: string;
user?: {
id: string;
username: string;
email?: string;
};
}
interface UserGuildsResponse {
id: string;
name: string;
}
describe('OAuth2 Scopes', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
test('token with "identify" scope can access /oauth2/@me user info', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const json = await createBuilder<OAuth2MeResponse>(harness, `Bearer ${tokenResponse.access_token}`)
.get('/oauth2/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.application).toBeDefined();
expect(json.application.id).toBe(application.id);
expect(json.scopes).toContain('identify');
expect(json.user).toBeDefined();
expect(json.user?.id).toBe(endUser.userId);
expect(json.user?.username).toBeDefined();
});
test('token with "email" scope returns email in user info', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const json = await createBuilder<OAuth2MeResponse>(harness, `Bearer ${tokenResponse.access_token}`)
.get('/oauth2/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.user).toBeDefined();
expect(json.user?.email).toBe(endUser.email);
expect(json.scopes).toContain('email');
});
test('token without "email" scope omits email from user info', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const json = await createBuilder<OAuth2MeResponse>(harness, `Bearer ${tokenResponse.access_token}`)
.get('/oauth2/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.user).toBeDefined();
expect(json.user?.email).toBeNull();
expect(json.scopes).not.toContain('email');
});
test('token with "guilds" scope can access /users/@me/guilds', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
await createGuild(harness, endUser.token, 'Test Guild');
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify guilds',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const json = await createBuilder<Array<UserGuildsResponse>>(harness, `Bearer ${tokenResponse.access_token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.OK)
.execute();
expect(Array.isArray(json)).toBe(true);
expect(json.length).toBeGreaterThan(0);
expect(json[0]?.name).toBe('Test Guild');
});
test('token without "guilds" scope cannot access /users/@me/guilds', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
await createGuild(harness, endUser.token, 'Test Guild');
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
await createBuilder(harness, `Bearer ${tokenResponse.access_token}`)
.get('/users/@me/guilds')
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('invalid scope in authorization request returns error', async () => {
const {appOwner, endUser} = await createOAuth2TestSetup(harness);
const application = await createOAuth2Application(harness, appOwner, {
name: 'Invalid Scope Test',
redirect_uris: ['https://example.com/callback'],
});
await createBuilder(harness, endUser.token)
.post('/oauth2/authorize/consent')
.body({
response_type: 'code',
client_id: application.id,
redirect_uri: 'https://example.com/callback',
scope: 'invalid_scope_xyz',
state: 'test-state',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('token without "identify" scope cannot access /oauth2/@me user info', async () => {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const application = await createOAuth2Application(harness, appOwner, {
name: 'No Identify Scope Test',
redirect_uris: [],
});
const json = await createBuilder<{redirect_to: string}>(harness, endUser.token)
.post('/oauth2/authorize/consent')
.body({
response_type: 'code',
client_id: application.id,
scope: 'bot',
state: 'test-state',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(json.redirect_to).toBeTruthy();
const redirectUrl = new URL(json.redirect_to);
const code = redirectUrl.searchParams.get('code');
if (code) {
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code,
redirect_uri: json.redirect_to.split('?')[0] ?? '',
});
const meJson = await createBuilder<OAuth2MeResponse>(harness, `Bearer ${tokenResponse.access_token}`)
.get('/oauth2/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(meJson.user).toBeUndefined();
}
});
test('scopes are correctly returned in token response', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email guilds',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
expect(tokenResponse.scope).toContain('identify');
expect(tokenResponse.scope).toContain('email');
expect(tokenResponse.scope).toContain('guilds');
});
test('scope is preserved through token refresh', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
expect(tokenResponse.refresh_token).toBeTruthy();
const formData = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokenResponse.refresh_token!,
client_id: application.id,
});
const refreshJson = await createBuilder<{scope: string; access_token: string}>(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.OK)
.execute();
expect(refreshJson.scope).toContain('identify');
expect(refreshJson.scope).toContain('email');
const meJson = await createBuilder<OAuth2MeResponse>(harness, `Bearer ${refreshJson.access_token}`)
.get('/oauth2/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(meJson.user?.email).toBe(endUser.email);
});
});

View File

@@ -0,0 +1,329 @@
/*
* 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 {randomUUID} from 'node:crypto';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import type {ApplicationResponse} from '@fluxer/schema/src/domains/oauth/OAuthSchemas';
export interface OAuth2CreateResult {
application: ApplicationResponse;
clientSecret: string;
botUserId: string;
botToken: string;
}
export async function createOAuth2Application(
harness: ApiTestHarness,
token: string,
params: {
name: string;
redirect_uris?: Array<string> | null;
bot_public?: boolean;
bot_require_code_grant?: boolean;
},
): Promise<OAuth2CreateResult> {
const body = {
name: params.name,
redirect_uris: params.redirect_uris ?? [],
...(params.bot_public !== undefined && {bot_public: params.bot_public}),
...(params.bot_require_code_grant !== undefined && {bot_require_code_grant: params.bot_require_code_grant}),
};
const {response, text, json} = await createBuilder<ApplicationResponse>(harness, token)
.post('/oauth2/applications')
.body(body)
.executeRaw();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
if (!json.id) {
throw new Error('Application response missing id');
}
if (!json.bot?.id || !json.bot?.token) {
throw new Error('Application response missing bot id or token');
}
if (!json.client_secret) {
throw new Error('Application response missing client_secret');
}
return {
application: json,
clientSecret: json.client_secret,
botUserId: json.bot.id,
botToken: json.bot.token,
};
}
export async function getOAuth2Application(
harness: ApiTestHarness,
token: string,
applicationId: string,
): Promise<ApplicationResponse> {
const {response, text, json} = await createBuilder<ApplicationResponse>(harness, token)
.get(`/oauth2/applications/${applicationId}`)
.executeRaw();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
return json;
}
export async function listOAuth2Applications(
harness: ApiTestHarness,
token: string,
): Promise<Array<ApplicationResponse>> {
const {response, text, json} = await createBuilder<Array<ApplicationResponse>>(harness, token)
.get('/oauth2/applications/@me')
.executeRaw();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
return json;
}
export async function updateOAuth2Application(
harness: ApiTestHarness,
token: string,
applicationId: string,
params: {
name?: string;
redirect_uris?: Array<string> | null;
bot_public?: boolean;
bot_require_code_grant?: boolean;
},
): Promise<ApplicationResponse> {
const {response, text, json} = await createBuilder<ApplicationResponse>(harness, token)
.patch(`/oauth2/applications/${applicationId}`)
.body(params)
.executeRaw();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
return json;
}
export async function deleteOAuth2Application(
harness: ApiTestHarness,
token: string,
applicationId: string,
password: string,
): Promise<void> {
const {response, text} = await createBuilder(harness, token)
.delete(`/oauth2/applications/${applicationId}`)
.body({password})
.executeRaw();
if (response.status !== 204) {
throw new Error(`Expected 204, got ${response.status}: ${text}`);
}
}
export function createUniqueApplicationName(prefix = 'Test App'): string {
return `${prefix} ${randomUUID()}`;
}
export function generateBotToken(): string {
const randomPart = randomUUID().replace(/-/g, '');
return `MT${randomPart}`;
}
export interface OAuth2TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope?: string;
}
export interface OAuth2IntrospectionResponse {
active: boolean;
client_id: string;
scope?: string;
}
export interface OAuth2UserInfoResponse {
sub: string;
[key: string]: unknown;
}
export interface OAuth2AuthorizationResponse {
application: ApplicationResponse;
scopes: Array<string>;
authorized_at: string;
}
export interface OAuth2ConsentResponse {
redirect_to: string;
}
export async function authorizeOAuth2(
harness: ApiTestHarness,
userToken: string,
params: {
client_id: string;
redirect_uri: string;
scope: Array<string>;
state?: string;
},
): Promise<{code: string; state: string}> {
const state = params.state ?? `state-${randomUUID()}`;
const scope = params.scope.join(' ');
const {response, text, json} = await createBuilder<OAuth2ConsentResponse>(harness, userToken)
.post('/oauth2/authorize/consent')
.body({
response_type: 'code',
client_id: params.client_id,
redirect_uri: params.redirect_uri,
scope: scope,
state: state,
})
.executeRaw();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
if (!json.redirect_to) {
throw new Error('Authorization response missing redirect_to');
}
const redirectUrl = new URL(json.redirect_to);
const code = redirectUrl.searchParams.get('code');
const returnedState = redirectUrl.searchParams.get('state');
if (!code) {
throw new Error('Redirect missing authorization code');
}
return {code, state: returnedState ?? ''};
}
export async function exchangeOAuth2AuthorizationCode(
harness: ApiTestHarness,
params: {
client_id: string;
client_secret: string;
code: string;
redirect_uri: string;
},
): Promise<OAuth2TokenResponse> {
const formParams = new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
redirect_uri: params.redirect_uri,
client_id: params.client_id,
});
const {response, text} = await createBuilder<OAuth2TokenResponse>(harness, '')
.post('/oauth2/token')
.header('content-type', 'application/x-www-form-urlencoded')
.header('Authorization', `Basic ${btoa(`${params.client_id}:${params.client_secret}`)}`)
.body(formParams.toString())
.executeRaw();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
const token = JSON.parse(text) as OAuth2TokenResponse;
if (!token.access_token) {
throw new Error('OAuth2 token response missing access_token');
}
return token;
}
export async function getOAuth2UserInfo(harness: ApiTestHarness, accessToken: string): Promise<OAuth2UserInfoResponse> {
const {response, text, json} = await createBuilder<OAuth2UserInfoResponse>(harness, `Bearer ${accessToken}`)
.get('/oauth2/userinfo')
.executeRaw();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
return json;
}
export async function introspectOAuth2Token(
harness: ApiTestHarness,
params: {
client_id: string;
client_secret: string;
token: string;
},
): Promise<OAuth2IntrospectionResponse> {
const formParams = new URLSearchParams({
token: params.token,
});
const {response, text} = await createBuilder<OAuth2IntrospectionResponse>(harness, '')
.post('/oauth2/introspect')
.header('content-type', 'application/x-www-form-urlencoded')
.header('Authorization', `Basic ${btoa(`${params.client_id}:${params.client_secret}`)}`)
.body(formParams.toString())
.executeRaw();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
return JSON.parse(text) as OAuth2IntrospectionResponse;
}
export async function listOAuth2Authorizations(
harness: ApiTestHarness,
userToken: string,
): Promise<Array<OAuth2AuthorizationResponse>> {
const {response, text, json} = await createBuilder<Array<OAuth2AuthorizationResponse>>(harness, userToken)
.get('/oauth2/@me/authorizations')
.executeRaw();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
return json;
}
export async function deauthorizeOAuth2Application(
harness: ApiTestHarness,
userToken: string,
applicationId: string,
): Promise<void> {
const {response, text} = await createBuilder(harness, userToken)
.delete(`/oauth2/@me/authorizations/${applicationId}`)
.executeRaw();
if (response.status !== 204) {
throw new Error(`Expected 204, got ${response.status}: ${text}`);
}
}

View File

@@ -0,0 +1,394 @@
/*
* 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 {
authorizeOAuth2,
createOAuth2TestSetup,
exchangeOAuth2AuthorizationCode,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Token Exchange', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('should exchange authorization code for tokens', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
expect(tokenResponse.access_token).toBeTruthy();
expect(tokenResponse.access_token).toHaveLength(43);
expect(tokenResponse.token_type).toBe('Bearer');
expect(tokenResponse.expires_in).toBeGreaterThan(0);
expect(tokenResponse.refresh_token).toBeTruthy();
expect(tokenResponse.scope).toBe('identify email');
});
test('should fail without authorization code', async () => {
const {application, redirectURI} = await createOAuth2TestSetup(harness);
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: '',
redirect_uri: redirectURI,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should fail with invalid authorization code', async () => {
const {application, redirectURI} = await createOAuth2TestSetup(harness);
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: 'invalid_code_12345',
redirect_uri: redirectURI,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should fail without redirect_uri in token exchange', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: authCodeResponse.code,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should fail with mismatched redirect_uri in token exchange', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: authCodeResponse.code,
redirect_uri: 'https://evil.com/callback',
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should fail without client authentication', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: authCodeResponse.code,
redirect_uri: redirectURI,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should fail with wrong client_id', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: authCodeResponse.code,
redirect_uri: redirectURI,
client_id: '999999999999999999',
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`999999999999999999:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should support scope subset during exchange', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email guilds',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
expect(tokenResponse.scope).toContain('identify');
expect(tokenResponse.scope).toContain('email');
expect(tokenResponse.scope).toContain('guilds');
expect(tokenResponse.access_token).toBeTruthy();
});
test('should fail without authentication', async () => {
const {redirectURI, application} = await createOAuth2TestSetup(harness);
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: 'some_code',
redirect_uri: redirectURI,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should support basic auth client authentication', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: authCodeResponse.code,
redirect_uri: redirectURI,
client_id: application.id,
});
const json = await createBuilder<{access_token: string}>(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.OK)
.execute();
expect(json.access_token).toBeTruthy();
expect(json.access_token).toHaveLength(43);
});
test('should fail authorization code reuse', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
expect(tokenResponse.access_token).toBeTruthy();
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: authCodeResponse.code,
redirect_uri: redirectURI,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should fail with wrong client_secret', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: authCodeResponse.code,
redirect_uri: redirectURI,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header('Authorization', `Basic ${Buffer.from(`${application.id}:wrong_secret_value`).toString('base64')}`)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should fail with missing grant_type', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const formData = new URLSearchParams({
code: authCodeResponse.code,
redirect_uri: redirectURI,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should fail with missing client_id', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});

View File

@@ -0,0 +1,265 @@
/*
* 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 {
authorizeOAuth2,
createOAuth2TestSetup,
exchangeOAuth2AuthorizationCode,
getOAuth2UserInfo,
introspectOAuth2Token,
refreshOAuth2Token,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Token Expiration', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('should include expires_in in token response', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email',
});
const tokenResponse = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
expect(tokenResponse.expires_in).toBeGreaterThan(0);
expect(tokenResponse.expires_in).toBe(604800);
});
test('should have consistent expiration across multiple token exchanges', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCode1 = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const token1 = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCode1.code,
redirect_uri: redirectURI,
});
const authCode2 = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email',
});
const token2 = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCode2.code,
redirect_uri: redirectURI,
});
expect(token1.expires_in).toBe(token2.expires_in);
});
test('should return expiration metadata in introspection response', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const introspection = await introspectOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.access_token,
});
expect(introspection.active).toBe(true);
expect(introspection.exp).toBeGreaterThan(0);
expect(introspection.iat).toBeGreaterThan(0);
expect(introspection.exp).toBeGreaterThan(introspection.iat!);
});
test('should maintain consistent expiration with multiple scopes', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email guilds',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
expect(tokens.expires_in).toBe(604800);
const introspection = await introspectOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.access_token,
});
expect(introspection.active).toBe(true);
expect(introspection.scope).toContain('identify');
expect(introspection.scope).toContain('email');
expect(introspection.scope).toContain('guilds');
});
test('should maintain expiration after token refresh', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email',
});
const initialTokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const refreshedTokens = await refreshOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
refresh_token: initialTokens.refresh_token!,
});
expect(refreshedTokens.expires_in).toBe(initialTokens.expires_in);
});
test('should allow userinfo access with valid token', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const userInfo = await getOAuth2UserInfo(harness, tokens.access_token);
expect(userInfo.sub).toBe(endUser.userId);
});
test('should reject userinfo access with invalid token', async () => {
await createBuilder(harness, 'Bearer invalid_token_12345')
.get('/oauth2/userinfo')
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
test('should track token active state in introspection', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const introspection = await introspectOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.access_token,
});
expect(introspection.active).toBe(true);
expect(introspection.token_type).toBe('Bearer');
expect(introspection.client_id).toBe(application.id);
});
test('should provide expires timestamp through oauth2/@me endpoint', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const json = await createBuilder<{
application: {id: string};
scopes: Array<string>;
expires: string;
}>(harness, `Bearer ${tokens.access_token}`)
.get('/oauth2/@me')
.expect(HTTP_STATUS.OK)
.execute();
expect(json.application.id).toBe(application.id);
expect(json.scopes).toContain('identify');
expect(json.expires).toBeTruthy();
const expiresDate = new Date(json.expires);
expect(expiresDate.getTime()).toBeGreaterThan(Date.now());
});
});

View File

@@ -0,0 +1,147 @@
/*
* 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 {
authorizeOAuth2,
createOAuth2TestSetup,
exchangeOAuth2AuthorizationCode,
introspectOAuth2Token,
revokeOAuth2Token,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Token Introspection', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('should introspect active access token', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const introspection = await introspectOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.access_token,
});
expect(introspection.active).toBe(true);
expect(introspection.client_id).toBe(application.id);
expect(introspection.sub).toBe(endUser.userId);
expect(introspection.scope).toContain('identify');
expect(introspection.scope).toContain('email');
expect(introspection.token_type).toBe('Bearer');
expect(introspection.exp).toBeGreaterThan(0);
expect(introspection.iat).toBeGreaterThan(0);
});
test('should show revoked token as inactive', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
await revokeOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.access_token,
token_type_hint: 'access_token',
});
const introspection = await introspectOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.access_token,
});
expect(introspection.active).toBe(false);
});
test('should show invalid token as inactive', async () => {
const {application} = await createOAuth2TestSetup(harness);
const introspection = await introspectOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: 'invalid_token_12345',
});
expect(introspection.active).toBe(false);
});
test('should fail introspection with wrong client credentials', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
await createBuilder(harness, '')
.post('/oauth2/introspect')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header('Authorization', `Basic ${Buffer.from(`999999999999999999:wrong_secret`).toString('base64')}`)
.body(
new URLSearchParams({
token: tokens.access_token,
}).toString(),
)
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});

View File

@@ -0,0 +1,403 @@
/*
* 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 {
authorizeOAuth2,
createOAuth2TestSetup,
exchangeOAuth2AuthorizationCode,
getOAuth2UserInfo,
refreshOAuth2Token,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Token Refresh', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('should refresh access token using refresh token', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email',
});
const initialTokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
expect(initialTokens.refresh_token).toBeTruthy();
const refreshedTokens = await refreshOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
refresh_token: initialTokens.refresh_token!,
});
expect(refreshedTokens.access_token).toBeTruthy();
expect(refreshedTokens.access_token).toHaveLength(43);
expect(refreshedTokens.token_type).toBe('Bearer');
expect(refreshedTokens.expires_in).toBeGreaterThan(0);
expect(refreshedTokens.refresh_token).toBeTruthy();
expect(refreshedTokens.scope).toContain('identify');
expect(refreshedTokens.scope).toContain('email');
const userInfo = await getOAuth2UserInfo(harness, refreshedTokens.access_token);
expect(userInfo.sub).toBe(endUser.userId);
});
test('should return new access_token and refresh_token on valid refresh', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const initialTokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const refreshedTokens = await refreshOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
refresh_token: initialTokens.refresh_token!,
});
expect(refreshedTokens.access_token).toBeTruthy();
expect(refreshedTokens.access_token).not.toBe(initialTokens.access_token);
expect(refreshedTokens.refresh_token).toBeTruthy();
expect(refreshedTokens.refresh_token).not.toBe(initialTokens.refresh_token);
expect(refreshedTokens.token_type).toBe('Bearer');
expect(refreshedTokens.expires_in).toBeGreaterThan(0);
});
test('should allow multiple sequential refreshes', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email',
});
const initialTokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const firstRefresh = await refreshOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
refresh_token: initialTokens.refresh_token!,
});
expect(firstRefresh.access_token).toBeTruthy();
const secondRefresh = await refreshOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
refresh_token: firstRefresh.refresh_token!,
});
expect(secondRefresh.access_token).toBeTruthy();
});
test('should preserve scopes during refresh', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify email guilds',
});
const initialTokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const refreshedTokens = await refreshOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
refresh_token: initialTokens.refresh_token!,
});
expect(refreshedTokens.scope).toContain('identify');
expect(refreshedTokens.scope).toContain('email');
expect(refreshedTokens.scope).toContain('guilds');
});
test('should fail with invalid refresh token', async () => {
const {application} = await createOAuth2TestSetup(harness);
const formData = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: 'invalid_refresh_token_12345',
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should fail with revoked refresh token', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const initialTokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const revokeFormData = new URLSearchParams({
token: initialTokens.refresh_token!,
token_type_hint: 'refresh_token',
});
await createBuilder(harness, '')
.post('/oauth2/token/revoke')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(revokeFormData.toString())
.expect(HTTP_STATUS.OK)
.execute();
const refreshFormData = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: initialTokens.refresh_token!,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(refreshFormData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should fail with wrong client_id', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const initialTokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const formData = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: initialTokens.refresh_token!,
client_id: '999999999999999999',
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`999999999999999999:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should fail with wrong client_secret', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const initialTokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const formData = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: initialTokens.refresh_token!,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header('Authorization', `Basic ${Buffer.from(`${application.id}:wrong_client_secret_12345`).toString('base64')}`)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should fail with missing client_secret', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const initialTokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const formData = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: initialTokens.refresh_token!,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should fail when using refresh token from different application', async () => {
const setup1 = await createOAuth2TestSetup(harness);
const setup2 = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, setup1.endUser.token, {
client_id: setup1.application.id,
redirect_uri: setup1.redirectURI,
scope: 'identify',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: setup1.application.id,
client_secret: setup1.application.client_secret,
code: authCodeResponse.code,
redirect_uri: setup1.redirectURI,
});
const formData = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token!,
client_id: setup2.application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${setup2.application.id}:${setup2.application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should issue tokens that rotate on each refresh', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const initialTokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const firstRefresh = await refreshOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
refresh_token: initialTokens.refresh_token!,
});
expect(firstRefresh.refresh_token).not.toBe(initialTokens.refresh_token);
const secondRefresh = await refreshOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
refresh_token: firstRefresh.refresh_token!,
});
expect(secondRefresh.refresh_token).not.toBe(firstRefresh.refresh_token);
expect(secondRefresh.refresh_token).not.toBe(initialTokens.refresh_token);
});
});

View File

@@ -0,0 +1,328 @@
/*
* 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 {createApplicationID} from '@fluxer/api/src/BrandedTypes';
import {OAuth2TokenRepository} from '@fluxer/api/src/oauth/repositories/OAuth2TokenRepository';
import {
authorizeOAuth2,
createOAuth2TestSetup,
exchangeOAuth2AuthorizationCode,
getOAuth2UserInfo,
introspectOAuth2Token,
revokeOAuth2Token,
} from '@fluxer/api/src/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {beforeEach, describe, expect, test} from 'vitest';
describe('OAuth2 Token Revocation', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('should revoke access token', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const userInfo = await getOAuth2UserInfo(harness, tokens.access_token);
expect(userInfo.sub).toBeTruthy();
await revokeOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.access_token,
token_type_hint: 'access_token',
});
await createBuilder(harness, `Bearer ${tokens.access_token}`).get('/oauth2/userinfo').expect(401).execute();
const refreshGrant = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token!,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(refreshGrant.toString())
.expect(400)
.execute();
const introspection = await introspectOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.access_token,
});
expect(introspection.active).toBe(false);
});
test('should revoke refresh token', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
await revokeOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.refresh_token!,
token_type_hint: 'refresh_token',
});
const formData = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token!,
client_id: application.id,
});
await createBuilder(harness, '')
.post('/oauth2/token')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(formData.toString())
.expect(400)
.execute();
});
test('should cascade revocation from refresh token to access token', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const userInfoBefore = await getOAuth2UserInfo(harness, tokens.access_token);
expect(userInfoBefore.sub).toBeTruthy();
await revokeOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.refresh_token!,
token_type_hint: 'refresh_token',
});
await createBuilder(harness, `Bearer ${tokens.access_token}`).get('/oauth2/userinfo').expect(401).execute();
});
test('should handle revocation of already revoked token', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
await revokeOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.refresh_token!,
token_type_hint: 'refresh_token',
});
await revokeOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.refresh_token!,
token_type_hint: 'refresh_token',
});
});
test('should handle revocation with invalid token gracefully', async () => {
const {application} = await createOAuth2TestSetup(harness);
await createBuilder(harness, '')
.post('/oauth2/token/revoke')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
)
.body(
new URLSearchParams({
token: 'invalid_token_12345',
}).toString(),
)
.expect(200)
.execute();
});
test('should not cascade app-only access token revocation to user authorization tokens', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const userTokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
const tokenRepository = new OAuth2TokenRepository();
const appOnlyToken = randomBytes(32).toString('base64url');
await tokenRepository.createAccessToken({
token_: appOnlyToken,
application_id: createApplicationID(BigInt(application.id)),
user_id: null,
scope: new Set(['identify']),
created_at: new Date(),
});
const appOnlyBefore = await introspectOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: appOnlyToken,
});
expect(appOnlyBefore.active).toBe(true);
await revokeOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: appOnlyToken,
token_type_hint: 'access_token',
});
const appOnlyAfter = await introspectOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: appOnlyToken,
});
expect(appOnlyAfter.active).toBe(false);
const userTokenStillActive = await introspectOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: userTokens.access_token,
});
expect(userTokenStillActive.active).toBe(true);
});
test('should fail revocation with wrong client_id', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
await createBuilder(harness, '')
.post('/oauth2/token/revoke')
.header('Content-Type', 'application/x-www-form-urlencoded')
.header(
'Authorization',
`Basic ${Buffer.from(`999999999999999999:${application.client_secret}`).toString('base64')}`,
)
.body(
new URLSearchParams({
token: tokens.access_token,
token_type_hint: 'access_token',
}).toString(),
)
.expect(400)
.execute();
});
test('should revoke token without type hint', async () => {
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
const authCodeResponse = await authorizeOAuth2(harness, endUser.token, {
client_id: application.id,
redirect_uri: redirectURI,
scope: 'identify',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: application.id,
client_secret: application.client_secret,
code: authCodeResponse.code,
redirect_uri: redirectURI,
});
await revokeOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.access_token,
});
await createBuilder(harness, `Bearer ${tokens.access_token}`).get('/oauth2/userinfo').expect(401).execute();
});
});

View File

@@ -0,0 +1,316 @@
/*
* 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 {randomUUID} from 'node:crypto';
import {createTestAccount, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import type {
ApplicationResponse,
OAuth2ConsentResponse,
OAuth2IntrospectResponse,
OAuth2TokenResponse,
} from '@fluxer/schema/src/domains/oauth/OAuthSchemas';
export interface OAuth2Application extends Omit<ApplicationResponse, 'bot'> {
client_secret: string;
bot: {
id: string;
token: string;
};
}
export async function createOAuth2Application(
harness: ApiTestHarness,
owner: TestAccount,
params: {
name?: string;
redirect_uris: Array<string>;
},
): Promise<OAuth2Application> {
const {response, text, json} = await createBuilder<OAuth2Application>(harness, owner.token)
.post('/oauth2/applications')
.body({
name: params.name ?? `Test App ${randomUUID()}`,
redirect_uris: params.redirect_uris,
})
.executeRaw();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
const payload = json;
if (!payload.id || !payload.client_secret || !payload.bot?.id || !payload.bot?.token) {
throw new Error('Invalid OAuth2 application response');
}
return payload;
}
export async function authorizeOAuth2(
harness: ApiTestHarness,
userToken: string,
params: {
client_id: string;
redirect_uri?: string;
scope: string;
state?: string;
},
): Promise<{code: string; state: string}> {
const {response, text, json} = await createBuilder<OAuth2ConsentResponse>(harness, userToken)
.post('/oauth2/authorize/consent')
.body({
response_type: 'code',
client_id: params.client_id,
redirect_uri: params.redirect_uri,
scope: params.scope,
state: params.state ?? `state-${randomUUID()}`,
})
.executeRaw();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
const payload = json;
if (!payload.redirect_to) {
throw new Error('Missing redirect_to in response');
}
const url = new URL(payload.redirect_to);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state') ?? '';
if (!code) {
throw new Error('Missing code in redirect');
}
return {code, state};
}
export async function exchangeOAuth2AuthorizationCode(
harness: ApiTestHarness,
params: {
client_id: string;
client_secret: string;
code: string;
redirect_uri: string;
},
): Promise<OAuth2TokenResponse> {
const formData = new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
redirect_uri: params.redirect_uri,
client_id: params.client_id,
});
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${params.client_id}:${params.client_secret}`).toString('base64')}`,
'x-forwarded-for': '127.0.0.1',
};
const response = await harness.app.request('/oauth2/token', {
method: 'POST',
headers,
body: formData.toString(),
});
if (response.status !== 200) {
const text = await response.text();
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
const json = (await response.json()) as OAuth2TokenResponse;
if (!json.access_token) {
throw new Error('Missing access_token in response');
}
return json;
}
export async function refreshOAuth2Token(
harness: ApiTestHarness,
params: {
client_id: string;
client_secret: string;
refresh_token: string;
},
): Promise<OAuth2TokenResponse> {
const formData = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: params.refresh_token,
client_id: params.client_id,
});
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${params.client_id}:${params.client_secret}`).toString('base64')}`,
'x-forwarded-for': '127.0.0.1',
};
const response = await harness.app.request('/oauth2/token', {
method: 'POST',
headers,
body: formData.toString(),
});
if (response.status !== 200) {
const text = await response.text();
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
const json = (await response.json()) as OAuth2TokenResponse;
if (!json.access_token) {
throw new Error('Missing access_token in response');
}
return json;
}
export async function revokeOAuth2Token(
harness: ApiTestHarness,
params: {
client_id: string;
client_secret: string;
token: string;
token_type_hint?: 'access_token' | 'refresh_token';
},
): Promise<void> {
const formData = new URLSearchParams({
token: params.token,
});
if (params.token_type_hint) {
formData.set('token_type_hint', params.token_type_hint);
}
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${params.client_id}:${params.client_secret}`).toString('base64')}`,
'x-forwarded-for': '127.0.0.1',
};
const response = await harness.app.request('/oauth2/token/revoke', {
method: 'POST',
headers,
body: formData.toString(),
});
if (response.status !== 200) {
const text = await response.text();
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
}
export async function introspectOAuth2Token(
harness: ApiTestHarness,
params: {
client_id: string;
client_secret: string;
token: string;
},
): Promise<OAuth2IntrospectResponse> {
const formData = new URLSearchParams({
token: params.token,
});
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${params.client_id}:${params.client_secret}`).toString('base64')}`,
'x-forwarded-for': '127.0.0.1',
};
const response = await harness.app.request('/oauth2/introspect', {
method: 'POST',
headers,
body: formData.toString(),
});
if (response.status !== 200) {
const text = await response.text();
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
return (await response.json()) as OAuth2IntrospectResponse;
}
export async function getOAuth2UserInfo(
harness: ApiTestHarness,
accessToken: string,
): Promise<Record<string, unknown>> {
const {response, json} = await createBuilder<Record<string, unknown>>(harness, `Bearer ${accessToken}`)
.get('/oauth2/userinfo')
.executeRaw();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}`);
}
return json;
}
export async function listOAuth2Authorizations(
harness: ApiTestHarness,
userToken: string,
): Promise<Array<{application: {id: string}; scopes: Array<string>; authorized_at: string}>> {
const {response, text, json} = await createBuilder<
Array<{application: {id: string}; scopes: Array<string>; authorized_at: string}>
>(harness, userToken)
.get('/oauth2/@me/authorizations')
.executeRaw();
if (response.status !== 200) {
throw new Error(`Expected 200, got ${response.status}: ${text}`);
}
return json;
}
export async function deauthorizeOAuth2Application(
harness: ApiTestHarness,
userToken: string,
applicationId: string,
): Promise<void> {
const {response, text} = await createBuilder(harness, userToken)
.delete(`/oauth2/@me/authorizations/${applicationId}`)
.executeRaw();
if (response.status !== 204) {
throw new Error(`Expected 204, got ${response.status}: ${text}`);
}
}
export async function createOAuth2TestSetup(harness: ApiTestHarness) {
const appOwner = await createTestAccount(harness);
const endUser = await createTestAccount(harness);
const redirectURI = `https://example.com/callback/${randomUUID()}`;
const application = await createOAuth2Application(harness, appOwner, {
name: `Test App ${randomUUID()}`,
redirect_uris: [redirectURI],
});
return {
appOwner,
endUser,
redirectURI,
application,
};
}

View File

@@ -0,0 +1,66 @@
/*
* 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 {Logger} from '@fluxer/api/src/Logger';
import {z} from 'zod';
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 (error) {
Logger.debug({error}, 'Failed to decode basic auth credentials, falling back to body credentials');
}
}
}
return {
clientId: bodyClientIdStr,
clientSecret: bodyClientSecret,
};
}