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