initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,581 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomBytes} from 'node:crypto';
import argon2 from 'argon2';
import type {ApplicationID, UserID} from '~/BrandedTypes';
import {applicationIdToUserId} from '~/BrandedTypes';
import {UserFlags, UserPremiumTypes} from '~/Constants';
import type {UserRow} from '~/database/CassandraTypes';
import type {ApplicationRow} from '~/database/types/OAuth2Types';
import {BotUserNotFoundError, InputValidationError, UnknownApplicationError} from '~/Errors';
import type {DiscriminatorService} from '~/infrastructure/DiscriminatorService';
import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {Logger} from '~/Logger';
import type {Application} from '~/models/Application';
import type {User} from '~/models/User';
import type {IUserRepository} from '~/user/IUserRepository';
import type {UserService} from '~/user/UserService';
import {generateRandomUsername} from '~/utils/UsernameGenerator';
import type {BotAuthService} from './BotAuthService';
import type {IApplicationRepository} from './repositories/IApplicationRepository';
interface ApplicationServiceDeps {
applicationRepository: IApplicationRepository;
userRepository: IUserRepository;
userService: UserService;
userCacheService: UserCacheService;
entityAssetService: EntityAssetService;
discriminatorService: DiscriminatorService;
snowflakeService: SnowflakeService;
botAuthService: BotAuthService;
gatewayService: IGatewayService;
}
const DELETED_USERNAME = '__deleted__';
export class ApplicationNotOwnedError extends Error {
constructor() {
super('You do not own this application');
this.name = 'ApplicationNotOwnedError';
}
}
class BotUserGenerationError extends Error {
constructor(message = 'Failed to generate unique username for bot') {
super(message);
this.name = 'BotUserGenerationError';
}
}
export class ApplicationService {
constructor(public readonly deps: ApplicationServiceDeps) {}
private sanitizeUsername(name: string): string {
let sanitized = name
.replace(/[\s\-.]+/g, '_')
.replace(/[^a-zA-Z0-9_]/g, '')
.substring(0, 32);
if (sanitized.length < 2) {
sanitized = `bot${sanitized}`;
}
return sanitized;
}
private async generateBotUsername(applicationName: string): Promise<{username: string; discriminator: number}> {
const sanitized = this.sanitizeUsername(applicationName);
const discResult = await this.deps.discriminatorService.generateDiscriminator({
username: sanitized,
isPremium: false,
});
if (discResult.available && discResult.discriminator !== -1) {
return {username: sanitized, discriminator: discResult.discriminator};
}
Logger.info(
{applicationName, sanitizedName: sanitized},
'Application name discriminators exhausted, falling back to random username',
);
for (let attempts = 0; attempts < 100; attempts++) {
const randomUsername = generateRandomUsername();
const randomDiscResult = await this.deps.discriminatorService.generateDiscriminator({
username: randomUsername,
isPremium: false,
});
if (randomDiscResult.available && randomDiscResult.discriminator !== -1) {
return {username: randomUsername, discriminator: randomDiscResult.discriminator};
}
}
throw new BotUserGenerationError('Failed to generate unique username for bot after 100 attempts');
}
async createApplication(args: {
ownerUserId: UserID;
name: string;
redirectUris?: Array<string>;
botPublic?: boolean;
}): Promise<{
application: Application;
botUser: User;
botToken: string;
clientSecret: string;
}> {
const initialRedirectUris = args.redirectUris ?? [];
const owner = await this.deps.userRepository.findUniqueAssert(args.ownerUserId);
const botIsPublic = args.botPublic ?? true;
const applicationId: ApplicationID = this.deps.snowflakeService.generate() as ApplicationID;
const botUserId = applicationIdToUserId(applicationId);
const {username, discriminator} = await this.generateBotUsername(args.name);
Logger.info(
{
applicationId: applicationId.toString(),
botUserId: botUserId.toString(),
username,
discriminator,
applicationName: args.name,
},
'Creating application with bot user',
);
const botUserRow: UserRow = {
user_id: botUserId,
username,
discriminator,
global_name: null,
bot: true,
system: false,
email: null,
email_verified: null,
email_bounced: null,
phone: null,
password_hash: null,
password_last_changed_at: null,
totp_secret: null,
authenticator_types: owner.authenticatorTypes ? new Set(owner.authenticatorTypes) : null,
avatar_hash: null,
avatar_color: null,
banner_hash: null,
banner_color: null,
bio: null,
pronouns: null,
accent_color: null,
date_of_birth: null,
locale: null,
flags: 0n,
premium_type: null,
premium_since: null,
premium_until: null,
premium_will_cancel: null,
premium_billing_cycle: null,
premium_lifetime_sequence: null,
stripe_subscription_id: null,
stripe_customer_id: null,
has_ever_purchased: null,
suspicious_activity_flags: null,
terms_agreed_at: null,
privacy_agreed_at: null,
last_active_at: null,
last_active_ip: null,
temp_banned_until: null,
pending_deletion_at: null,
pending_bulk_message_deletion_at: null,
pending_bulk_message_deletion_channel_count: null,
pending_bulk_message_deletion_message_count: null,
deletion_reason_code: null,
deletion_public_reason: null,
deletion_audit_log_reason: null,
acls: null,
first_refund_at: null,
beta_code_allowance: null,
beta_code_last_reset_at: null,
gift_inventory_server_seq: null,
gift_inventory_client_seq: null,
premium_onboarding_dismissed_at: null,
version: 1,
};
const botUser = await this.deps.userRepository.create(botUserRow);
const {
token: botToken,
hash: botTokenHash,
preview: botTokenPreview,
} = await this.deps.botAuthService.generateBotToken(applicationId);
const botTokenCreatedAt = new Date();
const clientSecret = randomBytes(32).toString('base64url');
const clientSecretHash = await argon2.hash(clientSecret);
const clientSecretCreatedAt = new Date();
const applicationRow: ApplicationRow = {
application_id: applicationId,
owner_user_id: args.ownerUserId,
name: args.name,
bot_user_id: botUserId,
bot_is_public: botIsPublic,
oauth2_redirect_uris: new Set<string>(initialRedirectUris),
client_secret_hash: clientSecretHash,
bot_token_hash: botTokenHash,
bot_token_preview: botTokenPreview,
bot_token_created_at: botTokenCreatedAt,
client_secret_created_at: clientSecretCreatedAt,
};
const application = await this.deps.applicationRepository.upsertApplication(applicationRow);
Logger.info(
{applicationId: applicationId.toString(), botUserId: botUserId.toString()},
'Successfully created application with bot user',
);
return {application, botUser, botToken, clientSecret};
}
async getApplication(applicationId: ApplicationID): Promise<Application | null> {
return this.deps.applicationRepository.getApplication(applicationId);
}
async listApplicationsByOwner(ownerUserId: UserID): Promise<Array<Application>> {
return this.deps.applicationRepository.listApplicationsByOwner(ownerUserId);
}
private async verifyOwnership(userId: UserID, applicationId: ApplicationID): Promise<Application> {
const application = await this.deps.applicationRepository.getApplication(applicationId);
if (!application) {
throw new UnknownApplicationError();
}
if (application.ownerUserId !== userId) {
throw new ApplicationNotOwnedError();
}
return application;
}
async updateApplication(args: {
userId: UserID;
applicationId: ApplicationID;
name?: string;
redirectUris?: Array<string>;
botPublic?: boolean;
}): Promise<Application> {
const application = await this.verifyOwnership(args.userId, args.applicationId);
const updatedRow: ApplicationRow = {
...application.toRow(),
name: args.name ?? application.name,
oauth2_redirect_uris: args.redirectUris ? new Set(args.redirectUris) : application.oauth2RedirectUris,
bot_is_public: args.botPublic ?? application.botIsPublic,
};
return this.deps.applicationRepository.upsertApplication(updatedRow);
}
async deleteApplication(userId: UserID, applicationId: ApplicationID): Promise<void> {
const application = await this.verifyOwnership(userId, applicationId);
if (application.hasBotUser()) {
const botUserId = application.getBotUserId()!;
await this.deps.userRepository.deleteUserSecondaryIndices(botUserId);
await this.deps.userRepository.removeFromAllGuilds(botUserId);
await this.deps.userRepository.patchUpsert(botUserId, {
username: DELETED_USERNAME,
discriminator: 0,
email: null,
email_verified: false,
phone: null,
password_hash: null,
password_last_changed_at: null,
totp_secret: null,
authenticator_types: new Set(),
avatar_hash: null,
banner_hash: null,
bio: null,
pronouns: null,
accent_color: null,
date_of_birth: null,
flags: UserFlags.DELETED,
premium_type: null,
premium_since: null,
premium_until: null,
stripe_customer_id: null,
stripe_subscription_id: null,
pending_deletion_at: null,
deletion_reason_code: null,
deletion_public_reason: null,
deletion_audit_log_reason: null,
});
await this.deps.userCacheService.invalidateUserCache(botUserId);
Logger.info(
{applicationId: applicationId.toString(), botUserId: botUserId.toString()},
'Anonymized bot user associated with application',
);
}
await this.deps.applicationRepository.deleteApplication(applicationId);
Logger.info({applicationId: applicationId.toString()}, 'Successfully deleted application');
}
async rotateBotToken(
userId: UserID,
applicationId: ApplicationID,
): Promise<{
token: string;
preview: string;
}> {
const application = await this.verifyOwnership(userId, applicationId);
if (!application.hasBotUser()) {
throw new BotUserNotFoundError();
}
const {token, hash, preview} = await this.deps.botAuthService.generateBotToken(applicationId);
const botTokenCreatedAt = new Date();
const updatedRow: ApplicationRow = {
...application.toRow(),
bot_token_hash: hash,
bot_token_preview: preview,
bot_token_created_at: botTokenCreatedAt,
};
await this.deps.applicationRepository.upsertApplication(updatedRow);
Logger.info({applicationId: applicationId.toString()}, 'Successfully rotated bot token');
const botUserId = application.getBotUserId();
if (botUserId !== null) {
await this.deps.gatewayService.terminateAllSessionsForUser({
userId: botUserId,
});
}
return {token, preview};
}
async rotateClientSecret(
userId: UserID,
applicationId: ApplicationID,
): Promise<{
clientSecret: string;
}> {
const application = await this.verifyOwnership(userId, applicationId);
const clientSecret = randomBytes(32).toString('base64url');
const clientSecretHash = await argon2.hash(clientSecret);
const clientSecretCreatedAt = new Date();
const updatedRow: ApplicationRow = {
...application.toRow(),
client_secret_hash: clientSecretHash,
client_secret_created_at: clientSecretCreatedAt,
};
await this.deps.applicationRepository.upsertApplication(updatedRow);
Logger.info({applicationId: applicationId.toString()}, 'Successfully rotated client secret');
return {clientSecret};
}
async updateBotProfile(
userId: UserID,
applicationId: ApplicationID,
args: {
username?: string;
discriminator?: number;
avatar?: string | null;
banner?: string | null;
bio?: string | null;
},
): Promise<{
user: User;
application: Application;
}> {
const application = await this.verifyOwnership(userId, applicationId);
if (!application.hasBotUser()) {
throw new BotUserNotFoundError();
}
const botUserId = application.getBotUserId()!;
const botUser = await this.deps.userRepository.findUnique(botUserId);
if (!botUser) {
throw new BotUserNotFoundError();
}
const ownerUser = await this.deps.userRepository.findUnique(userId);
const isOwnerLifetimePremium = ownerUser?.premiumType === UserPremiumTypes.LIFETIME;
if (args.discriminator !== undefined && args.discriminator !== botUser.discriminator) {
if (!isOwnerLifetimePremium) {
throw InputValidationError.create(
'discriminator',
'You must be on the Visionary lifetime plan to customize bot discriminators.',
);
}
}
const updates: Partial<UserRow> = {};
const newUsername = args.username ?? botUser.username;
const requestedDiscriminator = args.discriminator;
const usernameChanged = args.username !== undefined && args.username !== botUser.username;
const discriminatorChanged =
requestedDiscriminator !== undefined && requestedDiscriminator !== botUser.discriminator;
if (usernameChanged || discriminatorChanged) {
const result = await this.deps.discriminatorService.resolveUsernameChange({
currentUsername: botUser.username,
currentDiscriminator: botUser.discriminator,
newUsername,
isPremium: isOwnerLifetimePremium,
requestedDiscriminator,
});
if (result.username !== botUser.username) {
updates.username = result.username;
}
if (result.discriminator !== botUser.discriminator) {
updates.discriminator = result.discriminator;
}
}
updates.global_name = null;
const assetPrep = await this.prepareBotAssets({
botUser,
botUserId,
avatar: args.avatar,
banner: args.banner,
});
if (assetPrep.avatarHash !== undefined) {
updates.avatar_hash = assetPrep.avatarHash;
}
if (assetPrep.bannerHash !== undefined) {
updates.banner_hash = assetPrep.bannerHash;
}
if (args.bio !== undefined) {
updates.bio = args.bio;
}
let updatedUser: User | null = null;
try {
updatedUser = await this.deps.userRepository.patchUpsert(botUserId, updates);
} catch (err) {
await this.rollbackBotAssets(assetPrep);
throw err;
}
if (!updatedUser) {
await this.rollbackBotAssets(assetPrep);
throw new BotUserNotFoundError();
}
try {
await this.commitBotAssets(assetPrep);
} catch (err) {
await this.rollbackBotAssets(assetPrep);
throw err;
}
const updatedApplication = await this.deps.applicationRepository.getApplication(applicationId);
if (!updatedApplication) {
throw new UnknownApplicationError();
}
Logger.info(
{applicationId: applicationId.toString(), botUserId: botUserId.toString()},
'Successfully updated bot profile',
);
return {
user: updatedUser,
application: updatedApplication,
};
}
private async prepareBotAssets(params: {
botUser: User;
botUserId: UserID;
avatar?: string | null;
banner?: string | null;
}): Promise<{
avatarUpload: PreparedAssetUpload | null;
bannerUpload: PreparedAssetUpload | null;
avatarHash: string | null | undefined;
bannerHash: string | null | undefined;
}> {
const {botUser, botUserId, avatar, banner} = params;
let avatarUpload: PreparedAssetUpload | null = null;
let bannerUpload: PreparedAssetUpload | null = null;
let avatarHash: string | null | undefined;
let bannerHash: string | null | undefined;
if (avatar !== undefined) {
avatarUpload = await this.deps.entityAssetService.prepareAssetUpload({
assetType: 'avatar',
entityType: 'user',
entityId: botUserId,
previousHash: botUser.avatarHash,
base64Image: avatar,
errorPath: 'avatar',
});
avatarHash = avatarUpload.newHash;
if (avatarUpload.newHash === botUser.avatarHash) {
avatarUpload = null;
}
}
if (banner !== undefined) {
bannerUpload = await this.deps.entityAssetService.prepareAssetUpload({
assetType: 'banner',
entityType: 'user',
entityId: botUserId,
previousHash: botUser.bannerHash,
base64Image: banner,
errorPath: 'banner',
});
bannerHash = bannerUpload.newHash;
if (bannerUpload.newHash === botUser.bannerHash) {
bannerUpload = null;
}
}
return {avatarUpload, bannerUpload, avatarHash, bannerHash};
}
private async commitBotAssets(assetPrep: {
avatarUpload: PreparedAssetUpload | null;
bannerUpload: PreparedAssetUpload | null;
}) {
if (assetPrep.avatarUpload) {
await this.deps.entityAssetService.commitAssetChange({prepared: assetPrep.avatarUpload, deferDeletion: true});
}
if (assetPrep.bannerUpload) {
await this.deps.entityAssetService.commitAssetChange({prepared: assetPrep.bannerUpload, deferDeletion: true});
}
}
private async rollbackBotAssets(assetPrep: {
avatarUpload: PreparedAssetUpload | null;
bannerUpload: PreparedAssetUpload | null;
}) {
if (assetPrep.avatarUpload) {
await this.deps.entityAssetService.rollbackAssetUpload(assetPrep.avatarUpload);
}
if (assetPrep.bannerUpload) {
await this.deps.entityAssetService.rollbackAssetUpload(assetPrep.bannerUpload);
}
}
}

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 argon2 from 'argon2';
import type {ApplicationID, UserID} from '~/BrandedTypes';
import type {IApplicationRepository} from './repositories/IApplicationRepository';
export class BotAuthService {
constructor(private readonly applicationRepository: IApplicationRepository) {}
private parseBotToken(token: string): {applicationId: ApplicationID; secret: string} | null {
const parts = token.split('.');
if (parts.length !== 2) {
return null;
}
const [applicationIdStr, secret] = parts;
if (!applicationIdStr || !secret) {
return null;
}
try {
const applicationId = BigInt(applicationIdStr) as ApplicationID;
return {applicationId, secret};
} catch {
return null;
}
}
async validateBotToken(token: string): Promise<UserID | null> {
const parsed = this.parseBotToken(token);
if (!parsed) {
return null;
}
const {applicationId, secret} = parsed;
const application = await this.applicationRepository.getApplication(applicationId);
if (!application || !application.hasBotUser() || !application.botTokenHash) {
return null;
}
try {
const isValid = await argon2.verify(application.botTokenHash, secret);
return isValid ? application.getBotUserId() : null;
} catch {
return null;
}
}
async generateBotToken(applicationId: ApplicationID): Promise<{
token: string;
hash: string;
preview: string;
}> {
const secret = randomBytes(32).toString('base64url');
const hash = await argon2.hash(secret);
const preview = secret.slice(0, 8);
const token = `${applicationId.toString()}.${secret}`;
return {token, hash, preview};
}
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '~/BrandedTypes';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {User} from '~/Models';
import type {Application} from '~/models/Application';
import type {IUserRepository} from '~/user/IUserRepository';
import {mapUserToPrivateResponse} from '~/user/UserMappers';
import type {IApplicationRepository} from './repositories/IApplicationRepository';
export class BotMfaMirrorService {
constructor(
private readonly applicationRepository: IApplicationRepository,
private readonly userRepository: IUserRepository,
private readonly gatewayService: IGatewayService,
) {}
private cloneAuthenticatorTypes(source: User): Set<number> {
return source.authenticatorTypes ? new Set(source.authenticatorTypes) : new Set();
}
private hasSameAuthenticatorTypes(target: User, desired: Set<number>): boolean {
const current = target.authenticatorTypes ?? new Set<number>();
if (current.size !== desired.size) return false;
for (const value of current) {
if (!desired.has(value)) {
return false;
}
}
return true;
}
private async listApplications(ownerUserId: UserID): Promise<Array<Application>> {
return this.applicationRepository.listApplicationsByOwner(ownerUserId);
}
async syncAuthenticatorTypesForOwner(owner: User): Promise<void> {
if (owner.isBot) return;
const desiredTypes = this.cloneAuthenticatorTypes(owner);
const applications = await this.listApplications(owner.id);
await Promise.all(
applications.map(async (application) => {
if (!application.hasBotUser()) return;
const botUserId = application.getBotUserId();
if (!botUserId) return;
const botUser = await this.userRepository.findUnique(botUserId);
if (!botUser) return;
if (this.hasSameAuthenticatorTypes(botUser, desiredTypes)) {
return;
}
const updatedBotUser = await this.userRepository.patchUpsert(botUserId, {
authenticator_types: desiredTypes,
});
if (updatedBotUser) {
await this.gatewayService.dispatchPresence({
userId: botUserId,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedBotUser),
});
}
}),
);
}
}

View File

@@ -0,0 +1,351 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Context} from 'hono';
import type {HonoApp, HonoEnv} from '~/App';
import {requireSudoMode} from '~/auth/services/SudoVerificationService';
import {createApplicationID} from '~/BrandedTypes';
import {AVATAR_MAX_SIZE} from '~/Constants';
import {AccessDeniedError, BotUserNotFoundError, InvalidClientError, UnknownApplicationError} from '~/Errors';
import {UsernameNotAvailableError} from '~/infrastructure/DiscriminatorService';
import {DefaultUserOnly, LoginRequiredAllowSuspicious} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
import type {Application} from '~/models/Application';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {
createBase64StringType,
createStringType,
DiscriminatorType,
SudoVerificationSchema,
UsernameType,
z,
} from '~/Schema';
import {Validator} from '~/Validator';
import {ApplicationNotOwnedError} from './ApplicationService';
import {mapApplicationToResponse, mapBotProfileToResponse, mapBotTokenResetResponse} from './OAuth2Mappers';
import {OAuth2RedirectURICreateType, OAuth2RedirectURIUpdateType} from './OAuth2RedirectURI';
export const OAuth2ApplicationsController = (app: HonoApp) => {
const listApplicationsHandler = async (ctx: Context<HonoEnv>) => {
const userId = ctx.get('user').id;
const applications: Array<Application> = await ctx.get('applicationService').listApplicationsByOwner(userId);
const botUserIds = applications
.filter((app: Application) => app.hasBotUser())
.map((app: Application) => app.getBotUserId()!);
const botUsers = await Promise.all(botUserIds.map((botUserId) => ctx.get('userRepository').findUnique(botUserId)));
const botUserMap = new Map(botUsers.filter((u) => u !== null).map((u) => [u!.id, u!]));
return ctx.json(
applications.map((app: Application) => {
const botUser = app.hasBotUser() && app.getBotUserId() ? botUserMap.get(app.getBotUserId()!) : null;
return mapApplicationToResponse(app, {botUser: botUser ?? undefined});
}),
);
};
app.get(
'/users/@me/applications',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
listApplicationsHandler,
);
app.get(
'/oauth2/applications/@me',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
listApplicationsHandler,
);
app.post(
'/oauth2/applications',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_CREATE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator(
'json',
z.object({
name: createStringType(1, 100),
redirect_uris: z
.array(OAuth2RedirectURICreateType)
.max(10, 'Maximum of 10 redirect URIs allowed')
.optional()
.nullable()
.transform((value) => value ?? []),
bot_public: z.boolean().optional(),
}),
),
async (ctx) => {
const userId = ctx.get('user').id;
const body = ctx.req.valid('json');
const result = await ctx.get('applicationService').createApplication({
ownerUserId: userId,
name: body.name,
redirectUris: body.redirect_uris,
botPublic: body.bot_public,
});
return ctx.json(
mapApplicationToResponse(result.application, {
botUser: result.botUser,
botToken: result.botToken,
clientSecret: result.clientSecret,
}),
);
},
);
app.get(
'/oauth2/applications/:id',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
async (ctx) => {
const userId = ctx.get('user').id;
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
const application = await ctx.get('applicationRepository').getApplication(applicationId);
if (!application) {
throw new UnknownApplicationError();
}
if (application.ownerUserId !== userId) {
throw new AccessDeniedError();
}
let botUser = null;
if (application.hasBotUser()) {
const botUserId = application.getBotUserId();
if (botUserId) {
botUser = await ctx.get('userRepository').findUnique(botUserId);
}
}
return ctx.json(mapApplicationToResponse(application, {botUser}));
},
);
app.patch(
'/oauth2/applications/:id',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_UPDATE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator(
'json',
z.object({
name: createStringType(1, 100).optional(),
redirect_uris: z
.array(OAuth2RedirectURIUpdateType)
.max(10, 'Maximum of 10 redirect URIs allowed')
.optional()
.nullable()
.transform((value) => (value === undefined ? undefined : (value ?? []))),
bot_public: z.boolean().optional(),
}),
),
async (ctx) => {
const userId = ctx.get('user').id;
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
const body = ctx.req.valid('json');
try {
const updated = await ctx.get('applicationService').updateApplication({
userId,
applicationId,
name: body.name,
redirectUris: body.redirect_uris,
botPublic: body.bot_public,
});
let botUser = null;
if (updated.hasBotUser()) {
const botUserId = updated.getBotUserId();
if (botUserId) {
botUser = await ctx.get('userRepository').findUnique(botUserId);
}
}
return ctx.json(mapApplicationToResponse(updated, {botUser: botUser ?? undefined}));
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof UnknownApplicationError) {
throw err;
}
throw err;
}
},
);
app.delete(
'/oauth2/applications/:id',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_DELETE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
try {
await ctx.get('applicationService').deleteApplication(user.id, applicationId);
return ctx.body(null, 204);
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof UnknownApplicationError) {
throw err;
}
throw err;
}
},
);
app.post(
'/oauth2/applications/:id/bot/reset-token',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
try {
const {token} = await ctx.get('applicationService').rotateBotToken(user.id, applicationId);
const application = await ctx.get('applicationRepository').getApplication(applicationId);
if (!application || !application.botUserId) {
throw new BotUserNotFoundError();
}
const botUser = await ctx.get('userRepository').findUnique(application.botUserId);
if (!botUser) {
throw new BotUserNotFoundError();
}
return ctx.json(mapBotTokenResetResponse(botUser, token));
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
throw new UnknownApplicationError();
}
throw err;
}
},
);
app.post(
'/oauth2/applications/:id/client-secret/reset',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
try {
const {clientSecret} = await ctx.get('applicationService').rotateClientSecret(user.id, applicationId);
const application = await ctx.get('applicationRepository').getApplication(applicationId);
if (!application) {
throw new UnknownApplicationError();
}
return ctx.json(mapApplicationToResponse(application, {clientSecret}));
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
throw new UnknownApplicationError();
}
throw err;
}
},
);
app.patch(
'/oauth2/applications/:id/bot',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_UPDATE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator(
'json',
z.object({
username: UsernameType.optional(),
discriminator: DiscriminatorType.optional(),
avatar: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
banner: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
bio: createStringType(0, 1024).nullish(),
}),
),
async (ctx) => {
const userId = ctx.get('user').id;
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
const body = ctx.req.valid('json');
try {
const result = await ctx.get('applicationService').updateBotProfile(userId, applicationId, {
username: body.username,
discriminator: body.discriminator,
avatar: body.avatar,
banner: body.banner,
bio: body.bio,
});
return ctx.json(mapBotProfileToResponse(result.user));
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof BotUserNotFoundError) {
throw err;
}
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
throw new UnknownApplicationError();
}
if (err instanceof UsernameNotAvailableError) {
throw err;
}
throw err;
}
},
);
};

View File

@@ -0,0 +1,651 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Context} from 'hono';
import type {HonoApp, HonoEnv} from '~/App';
import {requireSudoMode} from '~/auth/services/SudoVerificationService';
import {createApplicationID, createGuildID, createRoleID} from '~/BrandedTypes';
import {ALL_PERMISSIONS, Permissions} from '~/Constants';
import {JoinSourceTypes} from '~/constants/Guild';
import {
AccessDeniedError,
BotAlreadyInGuildError,
BotUserNotFoundError,
InvalidClientError,
InvalidGrantError,
InvalidRequestError,
InvalidTokenError,
MissingPermissionsError,
NotABotApplicationError,
UnknownApplicationError,
UnknownGuildMemberError,
} from '~/Errors';
import {Logger} from '~/Logger';
import {DefaultUserOnly, LoginRequiredAllowSuspicious} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
import {
AuthorizeConsentRequest,
AuthorizeRequest,
IntrospectRequestForm,
RevokeRequestForm,
TokenRequest,
} from '~/oauth/OAuthModels';
import {parseClientCredentials} from '~/oauth/utils/parseClientCredentials';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {SudoVerificationSchema, type z} from '~/Schema';
import {mapUserToOAuthResponse, mapUserToPartialResponse} from '~/user/UserMappers';
import {Validator} from '~/Validator';
import {ApplicationNotOwnedError} from './ApplicationService';
import {mapApplicationToResponse, mapBotTokenResetResponse} from './OAuth2Mappers';
import {ACCESS_TOKEN_TTL_SECONDS} from './OAuth2Service';
type FormContext<TForm> = Context<HonoEnv, string, {out: {form: TForm}}>;
const extractBearerToken = (authHeader: string): string | null => {
const match = /^Bearer\s+(.+)$/.exec(authHeader);
return match ? match[1] : null;
};
const handleTokenExchange = async (ctx: FormContext<z.infer<typeof TokenRequest>>, logPrefix: string) => {
try {
const form = ctx.req.valid('form');
const hasAuthHeader = !!ctx.req.header('authorization');
const isAuthorizationCodeRequest = form.grant_type === 'authorization_code';
const isRefreshRequest = form.grant_type === 'refresh_token';
Logger.debug(
{
grant_type: form.grant_type,
client_id_present: form.client_id != null,
redirect_uri_present: isAuthorizationCodeRequest ? form.redirect_uri != null : undefined,
code_len: isAuthorizationCodeRequest ? form.code.length : undefined,
refresh_token_len: isRefreshRequest ? form.refresh_token.length : undefined,
auth_header_basic: hasAuthHeader && /^Basic\s+/i.test(ctx.req.header('authorization') ?? ''),
},
`${logPrefix} token request received`,
);
if (form.grant_type === 'authorization_code') {
const result = await ctx.get('oauth2Service').tokenExchange({
headersAuthorization: ctx.req.header('authorization') ?? undefined,
grantType: 'authorization_code',
code: form.code,
redirectUri: form.redirect_uri,
clientId: form.client_id ? form.client_id.toString() : undefined,
clientSecret: form.client_secret,
});
return ctx.json(result);
} else {
const result = await ctx.get('oauth2Service').tokenExchange({
headersAuthorization: ctx.req.header('authorization') ?? undefined,
grantType: 'refresh_token',
refreshToken: form.refresh_token,
clientId: form.client_id ? form.client_id.toString() : undefined,
clientSecret: form.client_secret,
});
return ctx.json(result);
}
} catch (err: unknown) {
if (err instanceof InvalidGrantError) {
Logger.warn({error: (err as Error).message}, `${logPrefix} token request failed`);
}
throw err;
}
};
const handleUserInfo = async (ctx: Context<HonoEnv>) => {
const authHeader = ctx.req.header('authorization') ?? '';
const token = extractBearerToken(authHeader);
if (!token) {
throw new InvalidTokenError();
}
const user = await ctx.get('oauth2Service').userInfo(token);
return ctx.json(user);
};
const handleRevoke = async (ctx: FormContext<z.infer<typeof RevokeRequestForm>>) => {
const body = ctx.req.valid('form');
const {clientId: clientIdStr, clientSecret: secret} = parseClientCredentials(
ctx.req.header('authorization') ?? undefined,
body.client_id,
body.client_secret,
);
if (!secret) {
throw new InvalidClientError('Missing client_secret');
}
await ctx.get('oauth2Service').revoke(body.token, body.token_type_hint ?? undefined, {
clientId: createApplicationID(BigInt(clientIdStr)),
clientSecret: secret,
});
return ctx.body(null, 200);
};
const handleIntrospect = async (ctx: FormContext<z.infer<typeof IntrospectRequestForm>>) => {
const body = ctx.req.valid('form');
const {clientId: clientIdStr, clientSecret: secret} = parseClientCredentials(
ctx.req.header('authorization') ?? undefined,
body.client_id,
body.client_secret,
);
if (!secret) {
throw new InvalidClientError('Missing client_secret');
}
const result = await ctx.get('oauth2Service').introspect(body.token, {
clientId: createApplicationID(BigInt(clientIdStr)),
clientSecret: secret,
});
return ctx.json(result);
};
export const OAuth2Controller = (app: HonoApp) => {
app.get(
'/oauth2/authorize',
RateLimitMiddleware(RateLimitConfigs.OAUTH_AUTHORIZE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator('query', AuthorizeRequest),
async (ctx) => {
const q = ctx.req.valid('query');
Logger.info(
{client_id: q.client_id?.toString?.(), scope: q.scope},
'GET /oauth2/authorize called; not supported',
);
throw new InvalidRequestError('GET /oauth2/authorize is not supported. Use POST /oauth2/authorize/consent.');
},
);
app.post(
'/oauth2/authorize/consent',
RateLimitMiddleware(RateLimitConfigs.OAUTH_AUTHORIZE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator('json', AuthorizeConsentRequest),
async (ctx) => {
const body: z.infer<typeof AuthorizeConsentRequest> = ctx.req.valid('json');
const user = ctx.get('user');
const scopeStr: string = body.scope;
const scopeSet = new Set(scopeStr.split(/\s+/).filter(Boolean));
const isBotOnly = scopeSet.size === 1 && scopeSet.has('bot');
const responseType = body.response_type ?? (isBotOnly ? undefined : 'code');
const guildId = body.guild_id ? createGuildID(body.guild_id) : null;
let requestedPermissions: bigint | null = null;
if (body.permissions !== undefined) {
try {
requestedPermissions = BigInt(body.permissions);
} catch {
throw new InvalidRequestError('permissions must be a valid integer');
}
if (requestedPermissions < 0) {
throw new InvalidRequestError('permissions must be non-negative');
}
requestedPermissions = requestedPermissions & ALL_PERMISSIONS;
}
if (!isBotOnly && responseType !== 'code') {
throw new InvalidRequestError('response_type must be code for non-bot scopes');
}
if (!isBotOnly && !body.redirect_uri) {
throw new InvalidRequestError('redirect_uri required for non-bot scopes');
}
const {redirectTo} = await ctx.get('oauth2Service').authorizeAndConsent({
clientId: body.client_id.toString(),
redirectUri: body.redirect_uri,
scope: body.scope,
state: body.state ?? undefined,
responseType: responseType as 'code' | undefined,
userId: user.id,
});
const authCode = (() => {
try {
const url = new URL(redirectTo);
return url.searchParams.get('code');
} catch {
return null;
}
})();
if (scopeSet.has('bot') && guildId) {
try {
const applicationId = createApplicationID(BigInt(body.client_id));
const application = await ctx.get('applicationRepository').getApplication(applicationId);
if (!application || !application.botUserId) {
throw new NotABotApplicationError();
}
const botUserId = application.botUserId;
const hasManageGuild = await ctx.get('gatewayService').checkPermission({
guildId,
userId: user.id,
permission: Permissions.MANAGE_GUILD,
});
if (!hasManageGuild) {
throw new MissingPermissionsError();
}
try {
await ctx.get('guildService').members.getMember({
userId: user.id,
targetId: botUserId,
guildId,
requestCache: ctx.get('requestCache'),
});
throw new BotAlreadyInGuildError();
} catch (err) {
if (!(err instanceof UnknownGuildMemberError)) {
throw err;
}
}
await ctx.get('guildService').members.addUserToGuild({
userId: botUserId,
guildId,
skipGuildLimitCheck: true,
skipBanCheck: true,
joinSourceType: JoinSourceTypes.BOT_INVITE,
requestCache: ctx.get('requestCache'),
initiatorId: user.id,
});
if (requestedPermissions && requestedPermissions > 0n) {
const role = await ctx.get('guildService').createRole({
userId: user.id,
guildId,
data: {
name: `${application.name}`,
color: 0,
permissions: requestedPermissions,
},
});
await ctx.get('guildService').members.addMemberRole({
userId: user.id,
targetId: botUserId,
guildId,
roleId: createRoleID(BigInt(role.id)),
requestCache: ctx.get('requestCache'),
});
}
} catch (err) {
if (authCode) {
await ctx.get('oauth2TokenRepository').deleteAuthorizationCode(authCode);
}
throw err;
}
}
Logger.info({redirectTo}, 'OAuth2 consent: returning redirect URL');
return ctx.json({redirect_to: redirectTo});
},
);
app.post(
'/oauth2/token',
RateLimitMiddleware(RateLimitConfigs.OAUTH_TOKEN),
Validator('form', TokenRequest),
async (ctx) => handleTokenExchange(ctx, 'OAuth2'),
);
app.get('/oauth2/userinfo', RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT), async (ctx) => {
return handleUserInfo(ctx);
});
app.post(
'/oauth2/token/revoke',
RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT),
Validator('form', RevokeRequestForm),
async (ctx) => handleRevoke(ctx),
);
app.post(
'/oauth2/introspect',
RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT),
Validator('form', IntrospectRequestForm),
async (ctx) => handleIntrospect(ctx),
);
app.get('/oauth2/@me', LoginRequiredAllowSuspicious, DefaultUserOnly, async (ctx) => {
const authHeader = ctx.req.header('authorization') ?? '';
const token = extractBearerToken(authHeader);
if (!token) {
throw new InvalidTokenError();
}
try {
const tokenData = await ctx.get('oauth2TokenRepository').getAccessToken(token);
if (!tokenData) {
throw new InvalidTokenError();
}
const application = await ctx.get('applicationRepository').getApplication(tokenData.applicationId);
if (!application) {
throw new InvalidTokenError();
}
const scopes = Array.from(tokenData.scope);
const expiresAt = new Date(tokenData.createdAt.getTime() + ACCESS_TOKEN_TTL_SECONDS * 1000);
const response: {
application: {
id: string;
name: string;
icon: null;
description: null;
bot_public: boolean;
bot_require_code_grant: boolean;
verify_key: null;
flags: number;
};
scopes: Array<string>;
expires: string;
user?: ReturnType<typeof mapUserToOAuthResponse>;
} = {
application: {
id: application.applicationId.toString(),
name: application.name,
icon: null,
description: null,
bot_public: application.botIsPublic,
bot_require_code_grant: false,
verify_key: null,
flags: 0,
},
scopes,
expires: expiresAt.toISOString(),
};
if (tokenData.userId && tokenData.scope.has('identify')) {
const user = await ctx.get('userRepository').findUnique(tokenData.userId);
if (user) {
response.user = mapUserToOAuthResponse(user, {includeEmail: tokenData.scope.has('email')});
}
}
return ctx.json(response);
} catch (err) {
if (err instanceof InvalidTokenError) {
throw err;
}
throw new InvalidTokenError();
}
});
app.get(
'/oauth2/applications/:id/public',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
async (ctx) => {
const appId = createApplicationID(BigInt(ctx.req.param('id')));
const application = await ctx.get('applicationRepository').getApplication(appId);
if (!application) {
throw new UnknownApplicationError();
}
let botUser = null;
if (application.hasBotUser() && application.getBotUserId()) {
botUser = await ctx.get('userRepository').findUnique(application.getBotUserId()!);
}
const scopes: Array<string> = [];
if (application.hasBotUser()) {
scopes.push('bot');
}
return ctx.json({
id: application.applicationId.toString(),
name: application.name,
icon: botUser?.avatarHash ?? null,
description: null,
redirect_uris: Array.from(application.oauth2RedirectUris),
scopes,
bot_public: application.botIsPublic,
bot: botUser ? mapUserToPartialResponse(botUser) : null,
});
},
);
const extractBotToken = (authHeader: string): string | null => {
const match = /^Bot\s+(.+)$/i.exec(authHeader);
return match ? match[1] : null;
};
app.get('/applications/@me', async (ctx) => {
const authHeader = ctx.req.header('authorization') ?? '';
const botToken = extractBotToken(authHeader);
if (!botToken) {
throw new InvalidTokenError();
}
const botUserId = await ctx.get('botAuthService').validateBotToken(botToken);
if (!botUserId) {
throw new InvalidTokenError();
}
const [appIdStr] = botToken.split('.');
if (!appIdStr) {
throw new InvalidTokenError();
}
const application = await ctx.get('applicationRepository').getApplication(createApplicationID(BigInt(appIdStr)));
if (!application) {
throw new InvalidTokenError();
}
const response: {
id: string;
name: string;
icon: null;
description: null;
bot_public: boolean;
bot_require_code_grant: boolean;
verify_key: null;
flags: number;
bot?: ReturnType<typeof mapUserToPartialResponse>;
} = {
id: application.applicationId.toString(),
name: application.name,
icon: null,
description: null,
bot_public: application.botIsPublic,
bot_require_code_grant: false,
verify_key: null,
flags: 0,
};
if (application.hasBotUser() && application.getBotUserId()) {
const botUser = await ctx.get('userRepository').findUnique(application.getBotUserId()!);
if (botUser) {
response.bot = mapUserToPartialResponse(botUser);
}
}
return ctx.json(response);
});
app.post(
'/oauth2/applications/:id/bot/reset-token',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
try {
const {token} = await ctx.get('applicationService').rotateBotToken(user.id, applicationId);
const application = await ctx.get('applicationRepository').getApplication(applicationId);
if (!application || !application.botUserId) {
throw new BotUserNotFoundError();
}
const botUser = await ctx.get('userRepository').findUnique(application.botUserId);
if (!botUser) {
throw new BotUserNotFoundError();
}
return ctx.json(mapBotTokenResetResponse(botUser, token));
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
throw new UnknownApplicationError();
}
throw err;
}
},
);
app.post(
'/oauth2/applications/:id/client-secret/reset',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
try {
const {clientSecret} = await ctx.get('applicationService').rotateClientSecret(user.id, applicationId);
const application = await ctx.get('applicationRepository').getApplication(applicationId);
if (!application) {
throw new UnknownApplicationError();
}
return ctx.json(mapApplicationToResponse(application, {clientSecret}));
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
throw new UnknownApplicationError();
}
throw err;
}
},
);
app.get(
'/oauth2/@me/authorizations',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
async (ctx) => {
const user = ctx.get('user');
const refreshTokens = await ctx.get('oauth2TokenRepository').listRefreshTokensForUser(user.id);
const appMap = new Map<
string,
{
applicationId: string;
scopes: Set<string>;
createdAt: Date;
application: {
id: string;
name: string;
icon: string | null;
description: null;
bot_public: boolean;
};
}
>();
for (const token of refreshTokens) {
const appIdStr = token.applicationId.toString();
const existing = appMap.get(appIdStr);
if (existing) {
for (const scope of token.scope) {
existing.scopes.add(scope);
}
if (token.createdAt < existing.createdAt) {
existing.createdAt = token.createdAt;
}
} else {
const application = await ctx.get('applicationRepository').getApplication(token.applicationId);
if (application) {
const nonBotScopes = new Set([...token.scope].filter((s) => s !== 'bot'));
if (nonBotScopes.size > 0) {
let botUser = null;
if (application.hasBotUser() && application.getBotUserId()) {
botUser = await ctx.get('userRepository').findUnique(application.getBotUserId()!);
}
appMap.set(appIdStr, {
applicationId: appIdStr,
scopes: nonBotScopes,
createdAt: token.createdAt,
application: {
id: application.applicationId.toString(),
name: application.name,
icon: botUser?.avatarHash ?? null,
description: null,
bot_public: application.botIsPublic,
},
});
}
}
}
}
const authorizations = Array.from(appMap.values()).map((entry) => ({
application: entry.application,
scopes: Array.from(entry.scopes),
authorized_at: entry.createdAt.toISOString(),
}));
return ctx.json(authorizations);
},
);
app.delete(
'/oauth2/@me/authorizations/:applicationId',
RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
async (ctx) => {
const user = ctx.get('user');
const applicationId = createApplicationID(BigInt(ctx.req.param('applicationId')));
const application = await ctx.get('applicationRepository').getApplication(applicationId);
if (!application) {
throw new UnknownApplicationError();
}
await ctx.get('oauth2TokenRepository').deleteAllTokensForUserAndApplication(user.id, applicationId);
return ctx.body(null, 204);
},
);
};

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 type {User} from '~/Models';
import type {Application} from '~/models/Application';
import {mapUserToPartialResponse} from '~/user/UserMappers';
import type {ApplicationBotResponse, ApplicationResponse} from './OAuth2Types';
export const mapBotUserToResponse = (user: User, opts?: {token?: string}): ApplicationBotResponse => {
const partial = mapUserToPartialResponse(user);
const bannerHash = !user.isBot && !user.isPremium() ? null : user.bannerHash;
return {
id: partial.id,
username: partial.username,
discriminator: partial.discriminator,
avatar: partial.avatar,
banner: bannerHash,
bio: user.bio ?? null,
token: opts?.token,
mfa_enabled: (user.authenticatorTypes?.size ?? 0) > 0,
authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : [],
};
};
export const mapApplicationToResponse = (
application: Application,
options?: {
botUser?: User | null;
botToken?: string;
clientSecret?: string | null;
},
): ApplicationResponse => {
const baseResponse: ApplicationResponse = {
id: application.applicationId.toString(),
name: application.name,
redirect_uris: Array.from(application.oauth2RedirectUris),
bot_public: application.botIsPublic,
bot_require_code_grant: false,
};
if (options?.botUser) {
baseResponse.bot = mapBotUserToResponse(options.botUser, {token: options.botToken});
}
if (options?.clientSecret) {
return {
...baseResponse,
client_secret: options.clientSecret,
};
}
return baseResponse;
};
export const mapBotTokenResetResponse = (user: User, token: string) => {
return {
token,
bot: mapBotUserToResponse(user),
};
};
export const mapBotProfileToResponse = (user: User) => {
return {
id: user.id.toString(),
username: user.username,
discriminator: user.discriminator.toString().padStart(4, '0'),
avatar: user.avatarHash,
banner: user.bannerHash,
bio: user.bio,
};
};

View File

@@ -0,0 +1,85 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {describe, expect, it} from 'vitest';
import {OAuth2RedirectURICreateType, OAuth2RedirectURIUpdateType} from './OAuth2RedirectURI';
const loopbackRedirects = [
'https://example.com/callback',
'https://example.com/callback?foo=bar',
'http://localhost:3000/callback',
'http://127.0.0.1/callback',
'http://[::1]/callback',
'http://foo.localhost/callback',
];
const deniedProtocols = [
'javascript://example.com/%0Aalert(1)',
'data://example.com/text',
'file://example.com/etc/passwd',
'vbscript://example.com/code',
'ftp://example.com/file',
'ws://example.com/socket',
'wss://example.com/socket',
'custom://example.com/path',
];
describe('OAuth2 redirect URI validation', () => {
describe('create redirect URI type', () => {
it('allows secure redirect URIs', () => {
for (const redirect of loopbackRedirects) {
const result = OAuth2RedirectURICreateType.safeParse(redirect);
expect(result.success).toBe(true);
}
});
it('rejects non-localhost http hosts', () => {
const result = OAuth2RedirectURICreateType.safeParse('http://example.com/callback');
expect(result.success).toBe(false);
});
for (const entry of deniedProtocols) {
it(`rejects ${entry.split('://')[0]} protocols`, () => {
const result = OAuth2RedirectURICreateType.safeParse(entry);
expect(result.success).toBe(false);
});
}
});
describe('update redirect URI type', () => {
it('allows http redirects for all hosts', () => {
const result = OAuth2RedirectURIUpdateType.safeParse('http://example.com/callback');
expect(result.success).toBe(true);
});
for (const redirect of loopbackRedirects) {
it(`still allows ${redirect} redirects`, () => {
const result = OAuth2RedirectURIUpdateType.safeParse(redirect);
expect(result.success).toBe(true);
});
}
for (const entry of deniedProtocols) {
it(`rejects ${entry.split('://')[0]} protocols`, () => {
const result = OAuth2RedirectURIUpdateType.safeParse(entry);
expect(result.success).toBe(false);
});
}
});
});

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 '~/Schema';
const isLoopbackHost = (hostname: string) => {
const lowercaseHost = hostname.toLowerCase();
return (
lowercaseHost === 'localhost' ||
lowercaseHost === '127.0.0.1' ||
lowercaseHost === '[::1]' ||
lowercaseHost.endsWith('.localhost')
);
};
const isValidRedirectURI = (value: string, allowAnyHttp: boolean) => {
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false;
}
if (!allowAnyHttp && url.protocol === 'http:' && !isLoopbackHost(url.hostname)) {
return false;
}
return !!url.host;
} catch {
return false;
}
};
const createRedirectURIType = (allowAnyHttp: boolean, message: string) =>
createStringType(1).refine((value) => isValidRedirectURI(value, allowAnyHttp), message);
export const OAuth2RedirectURICreateType = createRedirectURIType(
false,
'Redirect URIs must use HTTPS, or HTTP for localhost only',
);
export const OAuth2RedirectURIUpdateType = createRedirectURIType(true, 'Redirect URIs must use HTTP or HTTPS');

View File

@@ -0,0 +1,449 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomBytes} from 'node:crypto';
import argon2 from 'argon2';
import type {ApplicationID, UserID} from '~/BrandedTypes';
import {Config} from '~/Config';
import type {OAuth2AccessTokenRow, OAuth2AuthorizationCodeRow, OAuth2RefreshTokenRow} from '~/database/CassandraTypes';
import {
AccessDeniedError,
InvalidClientError,
InvalidGrantError,
InvalidRequestError,
InvalidScopeError,
InvalidTokenError,
} from '~/Errors';
import type {ICacheService} from '~/infrastructure/ICacheService';
import {Logger} from '~/Logger';
import type {Application} from '~/models/Application';
import type {IUserRepository} from '~/user/IUserRepository';
import {mapUserToOAuthResponse} from '~/user/UserMappers';
import type {OAuthScope} from './OAuthModels';
import {ApplicationRepository} from './repositories/ApplicationRepository';
import type {IApplicationRepository} from './repositories/IApplicationRepository';
import type {IOAuth2TokenRepository} from './repositories/IOAuth2TokenRepository';
import {OAuth2TokenRepository} from './repositories/OAuth2TokenRepository';
interface OAuth2ServiceDeps {
userRepository: IUserRepository;
applicationRepository?: IApplicationRepository;
oauth2TokenRepository?: IOAuth2TokenRepository;
cacheService?: ICacheService;
}
const PREFERRED_SCOPE_ORDER = ['identify', 'email', 'guilds', 'connections', 'bot', 'applications.commands'];
const sortScopes = (scope: Set<string>): Array<string> => {
return Array.from(scope).sort((a, b) => {
const ai = PREFERRED_SCOPE_ORDER.indexOf(a);
const bi = PREFERRED_SCOPE_ORDER.indexOf(b);
if (ai === -1 && bi === -1) return a.localeCompare(b);
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
};
export const ACCESS_TOKEN_TTL_SECONDS = 7 * 24 * 3600;
export class OAuth2Service {
private applications: IApplicationRepository;
private tokens: IOAuth2TokenRepository;
private static readonly ALLOWED_SCOPES = [
'identify',
'email',
'guilds',
'connections',
'bot',
'applications.commands',
];
constructor(private readonly deps: OAuth2ServiceDeps) {
this.applications = deps.applicationRepository ?? new ApplicationRepository();
this.tokens = deps.oauth2TokenRepository ?? new OAuth2TokenRepository();
}
private parseScope(scope: string): Array<OAuthScope> {
const parts = scope.split(/\s+/).filter(Boolean);
return parts as Array<OAuthScope>;
}
private validateRedirectUri(application: Application, redirectUri: string): boolean {
if (application.oauth2RedirectUris.size === 0) {
return false;
}
return application.oauth2RedirectUris.has(redirectUri);
}
async authorizeAndConsent(params: {
clientId: string;
redirectUri?: string;
scope: string;
state?: string;
codeChallenge?: string;
codeChallengeMethod?: 'S256' | 'plain';
responseType?: 'code';
userId: UserID;
}): Promise<{redirectTo: string}> {
const parsedClientId = BigInt(params.clientId) as ApplicationID;
const application = await this.applications.getApplication(parsedClientId);
if (!application) {
throw new InvalidClientError();
}
const scopeSet = new Set<string>(this.parseScope(params.scope));
for (const s of scopeSet) {
if (!OAuth2Service.ALLOWED_SCOPES.includes(s)) {
throw new InvalidScopeError();
}
}
if (scopeSet.has('bot') && !application.botIsPublic && params.userId !== application.ownerUserId) {
throw new AccessDeniedError();
}
const isBotOnly = scopeSet.size === 1 && scopeSet.has('bot');
const redirectUri = params.redirectUri;
const requireRedirect = !isBotOnly;
if (!redirectUri && requireRedirect) {
throw new InvalidRequestError('Missing redirect_uri');
}
if (redirectUri && !this.validateRedirectUri(application, redirectUri)) {
throw new InvalidRequestError('Invalid redirect_uri');
}
const resolvedRedirectUri = redirectUri ?? Config.endpoints.webApp;
let loc: URL;
try {
loc = new URL(resolvedRedirectUri);
} catch {
throw new InvalidRequestError('Invalid redirect_uri');
}
const codeRow: OAuth2AuthorizationCodeRow = {
code: randomBytes(32).toString('base64url'),
application_id: application.applicationId,
user_id: params.userId,
redirect_uri: loc.toString(),
scope: scopeSet,
nonce: null,
created_at: new Date(),
};
await this.tokens.createAuthorizationCode(codeRow);
loc.searchParams.set('code', codeRow.code);
if (params.state) {
loc.searchParams.set('state', params.state);
}
return {redirectTo: loc.toString()};
}
private basicAuth(credentialsHeader?: string): {clientId: string; clientSecret: string} | null {
if (!credentialsHeader) {
return null;
}
const m = /^Basic\s+(.+)$/.exec(credentialsHeader);
if (!m) {
return null;
}
const decoded = Buffer.from(m[1], 'base64').toString('utf8');
const idx = decoded.indexOf(':');
if (idx < 0) {
return null;
}
return {
clientId: decoded.slice(0, idx),
clientSecret: decoded.slice(idx + 1),
};
}
private async issueTokens(args: {application: Application; userId: UserID | null; scope: Set<string>}): Promise<{
accessToken: OAuth2AccessTokenRow;
refreshToken?: OAuth2RefreshTokenRow;
token_type: 'Bearer';
expires_in: number;
scope?: string;
}> {
const accessToken: OAuth2AccessTokenRow = {
token_: randomBytes(32).toString('base64url'),
application_id: args.application.applicationId,
user_id: args.userId,
scope: args.scope,
created_at: new Date(),
};
const createdAccess = await this.tokens.createAccessToken(accessToken);
let refreshToken: OAuth2RefreshTokenRow | undefined;
if (args.userId) {
const row: OAuth2RefreshTokenRow = {
token_: randomBytes(32).toString('base64url'),
application_id: args.application.applicationId,
user_id: args.userId,
scope: args.scope,
created_at: new Date(),
};
const created = await this.tokens.createRefreshToken(row);
refreshToken = created.toRow();
}
return {
accessToken: createdAccess.toRow(),
refreshToken,
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_TTL_SECONDS,
scope: sortScopes(args.scope).join(' '),
};
}
async tokenExchange(params: {
headersAuthorization?: string;
grantType: 'authorization_code' | 'refresh_token';
code?: string;
refreshToken?: string;
redirectUri?: string;
clientId?: string;
clientSecret?: string;
}): Promise<{
access_token: string;
token_type: 'Bearer';
expires_in: number;
scope?: string;
refresh_token?: string;
}> {
Logger.debug(
{
grant_type: params.grantType,
client_id_present: !!params.clientId || /^Basic\s+/.test(params.headersAuthorization ?? ''),
has_basic_auth: /^Basic\s+/.test(params.headersAuthorization ?? ''),
code_present: !!params.code,
refresh_token_present: !!params.refreshToken,
redirect_uri_present: !!params.redirectUri,
},
'OAuth2 tokenExchange start',
);
const basic = this.basicAuth(params.headersAuthorization);
const clientId = params.clientId ?? basic?.clientId ?? '';
const clientSecret = params.clientSecret ?? basic?.clientSecret;
const parsedClientId = BigInt(clientId) as ApplicationID;
const application = await this.applications.getApplication(parsedClientId);
if (!application) {
Logger.debug({client_id_len: clientId.length}, 'OAuth2 tokenExchange: unknown application');
throw new InvalidClientError();
}
if (!clientSecret) {
Logger.debug(
{application_id: application.applicationId.toString()},
'OAuth2 tokenExchange: missing client_secret',
);
throw new InvalidClientError('Missing client_secret');
}
if (application.clientSecretHash) {
const ok = await argon2.verify(application.clientSecretHash, clientSecret);
if (!ok) {
Logger.debug(
{application_id: application.applicationId.toString()},
'OAuth2 tokenExchange: client_secret verification failed',
);
throw new InvalidClientError('Invalid client_secret');
}
}
if (params.grantType === 'authorization_code') {
const code = params.code!;
const authCode = await this.tokens.getAuthorizationCode(code);
if (!authCode) {
Logger.debug({code_len: code.length}, 'OAuth2 tokenExchange: authorization code not found');
throw new InvalidGrantError();
}
if (authCode.applicationId !== application.applicationId) {
Logger.debug(
{application_id: application.applicationId.toString()},
'OAuth2 tokenExchange: code application mismatch',
);
throw new InvalidGrantError();
}
if (params.redirectUri && authCode.redirectUri !== params.redirectUri) {
Logger.debug(
{expected: authCode.redirectUri, got: params.redirectUri},
'OAuth2 tokenExchange: redirect_uri mismatch',
);
throw new InvalidGrantError();
}
await this.tokens.deleteAuthorizationCode(code);
const res = await this.issueTokens({
application,
userId: authCode.userId,
scope: authCode.scope,
});
return {
access_token: res.accessToken.token_,
token_type: 'Bearer',
expires_in: res.expires_in,
scope: res.scope,
refresh_token: res.refreshToken?.token_,
};
}
const refresh = await this.tokens.getRefreshToken(params.refreshToken!);
if (!refresh) {
throw new InvalidGrantError();
}
if (refresh.applicationId !== application.applicationId) {
throw new InvalidGrantError();
}
const res = await this.issueTokens({
application,
userId: refresh.userId,
scope: refresh.scope,
});
return {
access_token: res.accessToken.token_,
token_type: 'Bearer',
expires_in: res.expires_in,
scope: res.scope,
refresh_token: res.refreshToken?.token_,
};
}
async userInfo(accessToken: string) {
const token = await this.tokens.getAccessToken(accessToken);
if (!token || !token.userId) {
throw new InvalidTokenError();
}
const application = await this.applications.getApplication(token.applicationId);
if (!application) {
throw new InvalidTokenError();
}
const user = await this.deps.userRepository.findUnique(token.userId);
if (!user) {
throw new InvalidTokenError();
}
const includeEmail = token.scope.has('email');
return mapUserToOAuthResponse(user, {includeEmail});
}
async introspect(
tokenStr: string,
auth: {clientId: ApplicationID; clientSecret?: string | null},
): Promise<{
active: boolean;
client_id?: string;
sub?: string;
scope?: string;
token_type?: string;
exp?: number;
iat?: number;
}> {
const application = await this.applications.getApplication(auth.clientId);
if (!application) {
return {active: false};
}
if (!auth.clientSecret) {
return {active: false};
}
if (application.clientSecretHash) {
const valid = await argon2.verify(application.clientSecretHash, auth.clientSecret);
if (!valid) {
return {active: false};
}
}
const token = await this.tokens.getAccessToken(tokenStr);
if (!token) {
return {active: false};
}
if (token.applicationId !== application.applicationId) {
return {active: false};
}
return {
active: true,
client_id: token.applicationId.toString(),
sub: token.userId ? token.userId.toString() : undefined,
scope: sortScopes(token.scope).join(' '),
token_type: 'Bearer',
exp: Math.floor((token.createdAt.getTime() + ACCESS_TOKEN_TTL_SECONDS * 1000) / 1000),
iat: Math.floor(token.createdAt.getTime() / 1000),
};
}
async revoke(
tokenStr: string,
tokenTypeHint: 'access_token' | 'refresh_token' | undefined,
auth: {clientId: ApplicationID; clientSecret?: string | null},
): Promise<void> {
const application = await this.applications.getApplication(auth.clientId);
if (!application) {
throw new InvalidClientError();
}
if (application.clientSecretHash) {
const valid = auth.clientSecret ? await argon2.verify(application.clientSecretHash, auth.clientSecret) : false;
if (!valid) {
throw new InvalidClientError('Invalid client_secret');
}
}
if (tokenTypeHint === 'refresh_token') {
const refresh = await this.tokens.getRefreshToken(tokenStr);
if (refresh && refresh.applicationId === application.applicationId) {
await this.tokens.deleteRefreshToken(tokenStr, application.applicationId, refresh.userId);
return;
}
}
const access = await this.tokens.getAccessToken(tokenStr);
if (access && access.applicationId === application.applicationId) {
await this.tokens.deleteAccessToken(tokenStr, application.applicationId, access.userId);
return;
}
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface ApplicationBotResponse {
id: string;
username: string;
discriminator: string;
avatar?: string | null;
banner?: string | null;
bio: string | null;
token?: string;
mfa_enabled?: boolean;
authenticator_types?: Array<number>;
}
export interface ApplicationResponse {
id: string;
name: string;
redirect_uris: Array<string>;
bot_public: boolean;
bot_require_code_grant: boolean;
client_secret?: string;
bot?: ApplicationBotResponse;
}

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 {createStringType, Int64Type, z} from '~/Schema';
const RedirectURIString = createStringType(1).refine((value) => {
try {
const u = new URL(value);
return !!u.protocol && !!u.host;
} catch {
return false;
}
}, 'Invalid URL format');
export const OAuthScopes = ['identify', 'email', 'guilds', 'bot', 'applications.commands'] as const;
export type OAuthScope = (typeof OAuthScopes)[number];
export const AuthorizeRequest = z.object({
response_type: z.literal('code').optional(),
client_id: Int64Type,
redirect_uri: RedirectURIString.optional(),
scope: createStringType(1),
state: createStringType(1).optional(),
prompt: z.enum(['consent', 'none']).optional(),
guild_id: Int64Type.optional(),
permissions: z.string().optional(),
disable_guild_select: z.enum(['true', 'false']).optional(),
});
export const AuthorizeConsentRequest = z.object({
response_type: z.string().optional(),
client_id: Int64Type,
redirect_uri: RedirectURIString.optional(),
scope: createStringType(1),
state: createStringType(1).optional(),
permissions: z.string().optional(),
guild_id: Int64Type.optional(),
});
export const TokenRequest = z.discriminatedUnion('grant_type', [
z.object({
grant_type: z.literal('authorization_code'),
code: createStringType(1),
redirect_uri: RedirectURIString,
client_id: Int64Type.optional(),
client_secret: createStringType(1).optional(),
}),
z.object({
grant_type: z.literal('refresh_token'),
refresh_token: createStringType(1),
client_id: Int64Type.optional(),
client_secret: createStringType(1).optional(),
}),
]);
export const IntrospectRequestForm = z.object({
token: createStringType(1),
client_id: Int64Type.optional(),
client_secret: createStringType(1).optional(),
});
export const RevokeRequestForm = z.object({
token: createStringType(1),
token_type_hint: z.enum(['access_token', 'refresh_token']).optional(),
client_id: Int64Type.optional(),
client_secret: createStringType(1).optional(),
});
export type AuthorizeRequest = z.infer<typeof AuthorizeRequest>;
export type TokenRequest = z.infer<typeof TokenRequest>;

View File

@@ -0,0 +1,134 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import argon2 from 'argon2';
import {createApplicationID, createUserID} from '~/BrandedTypes';
import {Config} from '~/Config';
import type {ApplicationRow} from '~/database/types/OAuth2Types';
import {Logger} from '~/Logger';
import {ApplicationRepository} from '~/oauth/repositories/ApplicationRepository';
import {z} from '~/Schema';
const OAuth2ConfigSchema = z.object({
clientIdStr: z.string().min(1),
clientSecret: z.string().min(1),
redirectUri: z.string().url(),
});
type OAuth2Config = z.infer<typeof OAuth2ConfigSchema>;
function getAdminOAuth2Config(): OAuth2Config | null {
Logger.info(
{
ADMIN_OAUTH2_AUTO_CREATE: Config.adminOauth2.autoCreate,
CLIENT_ID_SET: !!Config.adminOauth2.clientId,
CLIENT_SECRET_SET: !!Config.adminOauth2.clientSecret,
},
'getAdminOAuth2Config check',
);
if (!Config.adminOauth2.autoCreate) {
return null;
}
const clientIdStr = Config.adminOauth2.clientId;
const clientSecret = Config.adminOauth2.clientSecret;
if (!clientIdStr || !clientSecret) {
Logger.info(
'Skipping admin OAuth2 client auto-create; set ADMIN_OAUTH2_CLIENT_ID and ADMIN_OAUTH2_CLIENT_SECRET to enable.',
);
return null;
}
const redirectUri = Config.adminOauth2.redirectUri ?? 'http://127.0.0.1:8001/oauth2_callback';
const parseResult = OAuth2ConfigSchema.safeParse({clientIdStr, clientSecret, redirectUri});
if (!parseResult.success) {
Logger.error({errors: parseResult.error.issues}, 'Invalid admin OAuth2 configuration');
return null;
}
return parseResult.data;
}
async function upsertAdminOAuth2Client(repo: ApplicationRepository, config: OAuth2Config): Promise<void> {
Logger.info({clientId: config.clientIdStr, redirectUri: config.redirectUri}, 'Upserting admin OAuth2 client...');
const applicationId = createApplicationID(BigInt(config.clientIdStr));
const existing = await repo.getApplication(applicationId);
Logger.info({existing: !!existing}, 'Checked for existing admin application');
const ownerUserId = createUserID(-1n);
const now = new Date();
const secretHash = await argon2.hash(config.clientSecret);
if (existing) {
const base = existing.toRow();
const row: ApplicationRow = {
...base,
client_secret_hash: secretHash,
client_secret_created_at: now,
oauth2_redirect_uris: new Set<string>([config.redirectUri]),
bot_is_public: base.bot_is_public ?? false,
};
await repo.upsertApplication(row);
Logger.info(
{application_id: applicationId.toString(), redirect_uris: [config.redirectUri]},
'Updated admin OAuth2 application',
);
return;
}
const row: ApplicationRow = {
application_id: applicationId,
owner_user_id: ownerUserId,
name: 'Fluxer Admin',
bot_user_id: null,
bot_is_public: false,
oauth2_redirect_uris: new Set<string>([config.redirectUri]),
client_secret_hash: secretHash,
client_secret_created_at: now,
bot_token_hash: null,
bot_token_preview: null,
bot_token_created_at: null,
};
await repo.upsertApplication(row);
Logger.info({application_id: applicationId.toString()}, 'Created admin OAuth2 application');
}
export async function initializeOAuth(): Promise<void> {
try {
Logger.info('Initializing OAuth applications...');
const repo = new ApplicationRepository();
const adminCfg = getAdminOAuth2Config();
Logger.info({adminCfg: !!adminCfg}, 'Admin OAuth2 config loaded');
if (adminCfg) {
await upsertAdminOAuth2Client(repo, adminCfg);
}
Logger.info('OAuth application initialization complete');
} catch (err) {
Logger.error(err, 'Failed to auto-create OAuth2 application(s)');
}
}

View File

@@ -0,0 +1,107 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ApplicationID, UserID} from '~/BrandedTypes';
import {BatchBuilder, buildPatchFromData, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import {APPLICATION_COLUMNS} from '~/database/CassandraTypes';
import type {ApplicationByOwnerRow, ApplicationRow} from '~/database/types/OAuth2Types';
import {Application} from '~/models/Application';
import {Applications, ApplicationsByOwner} from '~/Tables';
import type {IApplicationRepository} from './IApplicationRepository';
const SELECT_APPLICATION_CQL = Applications.selectCql({
where: Applications.where.eq('application_id'),
});
const SELECT_APPLICATION_IDS_BY_OWNER_CQL = ApplicationsByOwner.selectCql({
columns: ['application_id'],
where: ApplicationsByOwner.where.eq('owner_user_id'),
});
const FETCH_APPLICATIONS_BY_IDS_CQL = Applications.selectCql({
where: Applications.where.in('application_id', 'application_ids'),
});
export class ApplicationRepository implements IApplicationRepository {
async getApplication(applicationId: ApplicationID): Promise<Application | null> {
const row = await fetchOne<ApplicationRow>(SELECT_APPLICATION_CQL, {application_id: applicationId});
return row ? new Application(row) : null;
}
async listApplicationsByOwner(ownerUserId: UserID): Promise<Array<Application>> {
const ids = await fetchMany<ApplicationByOwnerRow>(SELECT_APPLICATION_IDS_BY_OWNER_CQL, {
owner_user_id: ownerUserId,
});
if (ids.length === 0) {
return [];
}
const rows = await fetchMany<ApplicationRow>(FETCH_APPLICATIONS_BY_IDS_CQL, {
application_ids: ids.map((r) => r.application_id),
});
return rows.map((r) => new Application(r));
}
async upsertApplication(data: ApplicationRow, oldData?: ApplicationRow | null): Promise<Application> {
const applicationId = data.application_id;
const result = await executeVersionedUpdate<ApplicationRow, 'application_id'>(
async () => {
if (oldData !== undefined) return oldData;
return await fetchOne<ApplicationRow>(SELECT_APPLICATION_CQL, {application_id: applicationId});
},
(current) => ({
pk: {application_id: applicationId},
patch: buildPatchFromData(data, current, APPLICATION_COLUMNS, ['application_id']),
}),
Applications,
{onFailure: 'log'},
);
const batch = new BatchBuilder();
batch.addPrepared(
ApplicationsByOwner.upsertAll({
owner_user_id: data.owner_user_id,
application_id: data.application_id,
}),
);
await batch.execute();
return new Application({...data, version: result.finalVersion});
}
async deleteApplication(applicationId: ApplicationID): Promise<void> {
const application = await this.getApplication(applicationId);
if (!application) {
return;
}
const batch = new BatchBuilder();
batch.addPrepared(Applications.deleteByPk({application_id: applicationId}));
batch.addPrepared(
ApplicationsByOwner.deleteByPk({
owner_user_id: application.ownerUserId,
application_id: applicationId,
}),
);
await batch.execute();
}
}

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 '~/BrandedTypes';
import type {ApplicationRow} from '~/database/types/OAuth2Types';
import type {Application} from '~/models/Application';
export interface IApplicationRepository {
getApplication(applicationId: ApplicationID): Promise<Application | null>;
listApplicationsByOwner(ownerUserId: UserID): Promise<Array<Application>>;
upsertApplication(data: ApplicationRow, oldData?: ApplicationRow | null): Promise<Application>;
deleteApplication(applicationId: ApplicationID): Promise<void>;
}

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 '~/BrandedTypes';
import type {
OAuth2AccessTokenRow,
OAuth2AuthorizationCodeRow,
OAuth2RefreshTokenRow,
} from '~/database/types/OAuth2Types';
import type {OAuth2AccessToken} from '~/models/OAuth2AccessToken';
import type {OAuth2AuthorizationCode} from '~/models/OAuth2AuthorizationCode';
import type {OAuth2RefreshToken} from '~/models/OAuth2RefreshToken';
export interface IOAuth2TokenRepository {
createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise<OAuth2AuthorizationCode>;
getAuthorizationCode(code: string): Promise<OAuth2AuthorizationCode | null>;
deleteAuthorizationCode(code: string): Promise<void>;
createAccessToken(data: OAuth2AccessTokenRow): Promise<OAuth2AccessToken>;
getAccessToken(token: string): Promise<OAuth2AccessToken | null>;
deleteAccessToken(token: string, applicationId: ApplicationID, userId: UserID | null): Promise<void>;
deleteAllAccessTokensForUser(userId: UserID): Promise<void>;
createRefreshToken(data: OAuth2RefreshTokenRow): Promise<OAuth2RefreshToken>;
getRefreshToken(token: string): Promise<OAuth2RefreshToken | null>;
deleteRefreshToken(token: string, applicationId: ApplicationID, userId: UserID): Promise<void>;
deleteAllRefreshTokensForUser(userId: UserID): Promise<void>;
listRefreshTokensForUser(userId: UserID): Promise<Array<OAuth2RefreshToken>>;
deleteAllTokensForUserAndApplication(userId: UserID, applicationId: ApplicationID): Promise<void>;
}

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 '~/BrandedTypes';
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {
OAuth2AccessTokenByUserRow,
OAuth2AccessTokenRow,
OAuth2AuthorizationCodeRow,
OAuth2RefreshTokenByUserRow,
OAuth2RefreshTokenRow,
} from '~/database/types/OAuth2Types';
import {OAuth2AccessToken} from '~/models/OAuth2AccessToken';
import {OAuth2AuthorizationCode} from '~/models/OAuth2AuthorizationCode';
import {OAuth2RefreshToken} from '~/models/OAuth2RefreshToken';
import {
OAuth2AccessTokens,
OAuth2AccessTokensByUser,
OAuth2AuthorizationCodes,
OAuth2RefreshTokens,
OAuth2RefreshTokensByUser,
} from '~/Tables';
import type {IOAuth2TokenRepository} from './IOAuth2TokenRepository';
const SELECT_AUTHORIZATION_CODE = OAuth2AuthorizationCodes.selectCql({
where: OAuth2AuthorizationCodes.where.eq('code'),
});
const SELECT_ACCESS_TOKEN = OAuth2AccessTokens.selectCql({
where: OAuth2AccessTokens.where.eq('token_'),
});
const SELECT_ACCESS_TOKENS_BY_USER = OAuth2AccessTokensByUser.selectCql({
columns: ['token_'],
where: OAuth2AccessTokensByUser.where.eq('user_id'),
});
const SELECT_REFRESH_TOKEN = OAuth2RefreshTokens.selectCql({
where: OAuth2RefreshTokens.where.eq('token_'),
});
const SELECT_REFRESH_TOKENS_BY_USER = OAuth2RefreshTokensByUser.selectCql({
columns: ['token_'],
where: OAuth2RefreshTokensByUser.where.eq('user_id'),
});
export class OAuth2TokenRepository implements IOAuth2TokenRepository {
async createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise<OAuth2AuthorizationCode> {
await upsertOne(OAuth2AuthorizationCodes.insert(data));
return new OAuth2AuthorizationCode(data);
}
async getAuthorizationCode(code: string): Promise<OAuth2AuthorizationCode | null> {
const row = await fetchOne<OAuth2AuthorizationCodeRow>(SELECT_AUTHORIZATION_CODE, {code});
return row ? new OAuth2AuthorizationCode(row) : null;
}
async deleteAuthorizationCode(code: string): Promise<void> {
await deleteOneOrMany(OAuth2AuthorizationCodes.deleteByPk({code}));
}
async createAccessToken(data: OAuth2AccessTokenRow): Promise<OAuth2AccessToken> {
const batch = new BatchBuilder();
batch.addPrepared(OAuth2AccessTokens.insert(data));
if (data.user_id !== null) {
batch.addPrepared(
OAuth2AccessTokensByUser.insert({
user_id: data.user_id,
token_: data.token_,
}),
);
}
await batch.execute();
return new OAuth2AccessToken(data);
}
async getAccessToken(token: string): Promise<OAuth2AccessToken | null> {
const row = await fetchOne<OAuth2AccessTokenRow>(SELECT_ACCESS_TOKEN, {token_: token});
return row ? new OAuth2AccessToken(row) : null;
}
async deleteAccessToken(token: string, _applicationId: ApplicationID, userId: UserID | null): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(OAuth2AccessTokens.deleteByPk({token_: token}));
if (userId !== null) {
batch.addPrepared(OAuth2AccessTokensByUser.deleteByPk({user_id: userId, token_: token}));
}
await batch.execute();
}
async deleteAllAccessTokensForUser(userId: UserID): Promise<void> {
const tokens = await fetchMany<OAuth2AccessTokenByUserRow>(SELECT_ACCESS_TOKENS_BY_USER, {
user_id: userId,
});
if (tokens.length === 0) {
return;
}
const batch = new BatchBuilder();
for (const tokenRow of tokens) {
batch.addPrepared(OAuth2AccessTokens.deleteByPk({token_: tokenRow.token_}));
batch.addPrepared(OAuth2AccessTokensByUser.deleteByPk({user_id: userId, token_: tokenRow.token_}));
}
await batch.execute();
}
async createRefreshToken(data: OAuth2RefreshTokenRow): Promise<OAuth2RefreshToken> {
const batch = new BatchBuilder();
batch.addPrepared(OAuth2RefreshTokens.insert(data));
batch.addPrepared(
OAuth2RefreshTokensByUser.insert({
user_id: data.user_id,
token_: data.token_,
}),
);
await batch.execute();
return new OAuth2RefreshToken(data);
}
async getRefreshToken(token: string): Promise<OAuth2RefreshToken | null> {
const row = await fetchOne<OAuth2RefreshTokenRow>(SELECT_REFRESH_TOKEN, {token_: token});
return row ? new OAuth2RefreshToken(row) : null;
}
async deleteRefreshToken(token: string, _applicationId: ApplicationID, userId: UserID): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(OAuth2RefreshTokens.deleteByPk({token_: token}));
batch.addPrepared(OAuth2RefreshTokensByUser.deleteByPk({user_id: userId, token_: token}));
await batch.execute();
}
async deleteAllRefreshTokensForUser(userId: UserID): Promise<void> {
const tokens = await fetchMany<OAuth2RefreshTokenByUserRow>(SELECT_REFRESH_TOKENS_BY_USER, {
user_id: userId,
});
if (tokens.length === 0) {
return;
}
const batch = new BatchBuilder();
for (const tokenRow of tokens) {
batch.addPrepared(OAuth2RefreshTokens.deleteByPk({token_: tokenRow.token_}));
batch.addPrepared(OAuth2RefreshTokensByUser.deleteByPk({user_id: userId, token_: tokenRow.token_}));
}
await batch.execute();
}
async listRefreshTokensForUser(userId: UserID): Promise<Array<OAuth2RefreshToken>> {
const tokenRefs = await fetchMany<OAuth2RefreshTokenByUserRow>(SELECT_REFRESH_TOKENS_BY_USER, {
user_id: userId,
});
if (tokenRefs.length === 0) {
return [];
}
const tokens: Array<OAuth2RefreshToken> = [];
for (const tokenRef of tokenRefs) {
const row = await fetchOne<OAuth2RefreshTokenRow>(SELECT_REFRESH_TOKEN, {token_: tokenRef.token_});
if (row) {
tokens.push(new OAuth2RefreshToken(row));
}
}
return tokens;
}
async deleteAllTokensForUserAndApplication(userId: UserID, applicationId: ApplicationID): Promise<void> {
const accessTokenRefs = await fetchMany<OAuth2AccessTokenByUserRow>(SELECT_ACCESS_TOKENS_BY_USER, {
user_id: userId,
});
const refreshTokenRefs = await fetchMany<OAuth2RefreshTokenByUserRow>(SELECT_REFRESH_TOKENS_BY_USER, {
user_id: userId,
});
const batch = new BatchBuilder();
for (const tokenRef of accessTokenRefs) {
const row = await fetchOne<OAuth2AccessTokenRow>(SELECT_ACCESS_TOKEN, {token_: tokenRef.token_});
if (row && row.application_id === applicationId) {
batch.addPrepared(OAuth2AccessTokens.deleteByPk({token_: tokenRef.token_}));
batch.addPrepared(OAuth2AccessTokensByUser.deleteByPk({user_id: userId, token_: tokenRef.token_}));
}
}
for (const tokenRef of refreshTokenRefs) {
const row = await fetchOne<OAuth2RefreshTokenRow>(SELECT_REFRESH_TOKEN, {token_: tokenRef.token_});
if (row && row.application_id === applicationId) {
batch.addPrepared(OAuth2RefreshTokens.deleteByPk({token_: tokenRef.token_}));
batch.addPrepared(OAuth2RefreshTokensByUser.deleteByPk({user_id: userId, token_: tokenRef.token_}));
}
}
await batch.execute();
}
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {z} from '~/Schema';
const BasicAuthScheme = z
.string()
.regex(/^Basic\s+/i)
.transform((val) => val.replace(/^Basic\s+/i, ''));
interface ParsedClientCredentials {
clientId: string;
clientSecret?: string;
}
export function parseClientCredentials(
authorizationHeader: string | undefined,
bodyClientId?: bigint,
bodyClientSecret?: string,
): ParsedClientCredentials {
const bodyClientIdStr = bodyClientId?.toString() ?? '';
if (authorizationHeader) {
const parseResult = BasicAuthScheme.safeParse(authorizationHeader);
if (parseResult.success) {
try {
const decoded = Buffer.from(parseResult.data, 'base64').toString('utf8');
const colonIndex = decoded.indexOf(':');
if (colonIndex >= 0) {
const id = decoded.slice(0, colonIndex);
const secret = decoded.slice(colonIndex + 1);
return {
clientId: id || bodyClientIdStr,
clientSecret: secret || bodyClientSecret,
};
}
} catch {}
}
}
return {
clientId: bodyClientIdStr,
clientSecret: bodyClientSecret,
};
}