Skip to content

Commit

Permalink
feat(oauth): limited discord server sign-in (#346)
Browse files Browse the repository at this point in the history
* feat(oauth): limited discord server sign-in

* fix: typo

* style: change undefined to optional

* style: remove conditional operator
  • Loading branch information
zz5840 authored Nov 30, 2023
1 parent d9a9523 commit 5f94c72
Show file tree
Hide file tree
Showing 7 changed files with 89 additions and 21 deletions.
8 changes: 6 additions & 2 deletions backend/prisma/seed/config.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ const configVariables: ConfigVariables = {
type: "boolean",
defaultValue: "false",
},
"discord-limitedGuild": {
type: "string",
defaultValue: "",
},
"discord-clientId": {
type: "string",
defaultValue: "",
Expand Down Expand Up @@ -262,8 +266,8 @@ async function migrateConfigVariables() {
for (const existingConfigVariable of existingConfigVariables) {
const configVariable =
configVariables[existingConfigVariable.category]?.[
existingConfigVariable.name
];
existingConfigVariable.name
];
if (!configVariable) {
await prisma.config.delete({
where: {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/oauth/exceptions/errorPage.exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class ErrorPageException extends Error {
*/
constructor(
public readonly key: string = "default",
public readonly redirect: string = "/",
public readonly redirect?: string,
public readonly params?: string[],
) {
super("error");
Expand Down
17 changes: 15 additions & 2 deletions backend/src/oauth/filter/errorPageException.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,27 @@ export class ErrorPageExceptionFilter implements ExceptionFilter {
constructor(private config: ConfigService) {}

catch(exception: ErrorPageException, host: ArgumentsHost) {
this.logger.error(exception);
this.logger.error(
JSON.stringify({
error: exception.key,
params: exception.params,
redirect: exception.redirect,
}),
);

const ctx = host.switchToHttp();
const response = ctx.getResponse();

const url = new URL(`${this.config.get("general.appUrl")}/error`);
url.searchParams.set("redirect", exception.redirect);
url.searchParams.set("error", exception.key);
if (exception.redirect) {
url.searchParams.set("redirect", exception.redirect);
} else {
const redirect = ctx.getRequest().cookies.access_token
? "/account"
: "/auth/signIn";
url.searchParams.set("redirect", redirect);
}
if (exception.params) {
url.searchParams.set("params", exception.params.join(","));
}
Expand Down
54 changes: 46 additions & 8 deletions backend/src/oauth/provider/discord.provider.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
import { Injectable } from "@nestjs/common";
import fetch from "node-fetch";
import { ConfigService } from "../../config/config.service";
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
import { ConfigService } from "../../config/config.service";
import { BadRequestException, Injectable } from "@nestjs/common";
import fetch from "node-fetch";

import { ErrorPageException } from "../exceptions/errorPage.exception";
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
@Injectable()
export class DiscordProvider implements OAuthProvider<DiscordToken> {
constructor(private config: ConfigService) {}

getAuthEndpoint(state: string): Promise<string> {
let scope = "identify email";
if (this.config.get("oauth.discord-limitedGuild")) {
scope += " guilds";
}
return Promise.resolve(
"https://discord.com/api/oauth2/authorize?" +
new URLSearchParams({
client_id: this.config.get("oauth.discord-clientId"),
redirect_uri:
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
response_type: "code",
state: state,
scope: "identify email",
state,
scope,
}).toString(),
);
}
Expand Down Expand Up @@ -69,7 +73,14 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
});
const user = (await res.json()) as DiscordUser;
if (user.verified === false) {
throw new BadRequestException("Unverified account.");
throw new ErrorPageException("unverified_account", undefined, [
"provider_discord",
]);
}

const guild = this.config.get("oauth.discord-limitedGuild");
if (guild) {
await this.checkLimitedGuild(token, guild);
}

return {
Expand All @@ -79,6 +90,24 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
email: user.email,
};
}

async checkLimitedGuild(token: OAuthToken<DiscordToken>, guildId: string) {
try {
const res = await fetch("https://discord.com/api/v10/users/@me/guilds", {
method: "get",
headers: {
Accept: "application/json",
Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}`,
},
});
const guilds = (await res.json()) as DiscordPartialGuild[];
if (!guilds.some((guild) => guild.id === guildId)) {
throw new ErrorPageException("discord_guild_permission_denied");
}
} catch {
throw new ErrorPageException("discord_guild_permission_denied");
}
}
}

export interface DiscordToken {
Expand All @@ -96,3 +125,12 @@ export interface DiscordUser {
email: string;
verified: boolean;
}

export interface DiscordPartialGuild {
id: string;
name: string;
icon: string;
owner: boolean;
permissions: string;
features: string[];
}
11 changes: 9 additions & 2 deletions backend/src/oauth/provider/genericOidc.provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BadRequestException } from "@nestjs/common";
import { Logger } from "@nestjs/common";
import fetch from "node-fetch";
import { ConfigService } from "../../config/config.service";
import { JwtService } from "@nestjs/jwt";
Expand All @@ -7,11 +7,15 @@ import { nanoid } from "nanoid";
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
import { ErrorPageException } from "../exceptions/errorPage.exception";

export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
protected discoveryUri: string;
private configuration: OidcConfigurationCache;
private jwk: OidcJwkCache;
private logger: Logger = new Logger(
Object.getPrototypeOf(this).constructor.name,
);

protected constructor(
protected name: string,
Expand Down Expand Up @@ -112,7 +116,10 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
const nonce = await this.cache.get(key);
await this.cache.del(key);
if (nonce !== idTokenData.nonce) {
throw new BadRequestException("Invalid token");
this.logger.error(
`Invalid nonce. Expected ${nonce}, but got ${idTokenData.nonce}`,
);
throw new ErrorPageException("invalid_token");
}

return {
Expand Down
13 changes: 7 additions & 6 deletions backend/src/oauth/provider/github.provider.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
import { Injectable } from "@nestjs/common";
import fetch from "node-fetch";
import { ConfigService } from "../../config/config.service";
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
import { ConfigService } from "../../config/config.service";
import fetch from "node-fetch";
import { BadRequestException, Injectable } from "@nestjs/common";
import { ErrorPageException } from "../exceptions/errorPage.exception";
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";

@Injectable()
export class GitHubProvider implements OAuthProvider<GitHubToken> {
Expand Down Expand Up @@ -48,12 +49,12 @@ export class GitHubProvider implements OAuthProvider<GitHubToken> {

async getUserInfo(token: OAuthToken<GitHubToken>): Promise<OAuthSignInDto> {
if (!token.scope.includes("user:email")) {
throw new BadRequestException("No email permission granted");
throw new ErrorPageException("no_email", undefined, ["provider_github"]);
}
const user = await this.getGitHubUser(token);
const email = await this.getGitHubEmail(token);
if (!email) {
throw new BadRequestException("No email found");
throw new ErrorPageException("no_email", undefined, ["provider_github"]);
}

return {
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/translations/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,8 @@ export default {
"admin.config.oauth.microsoft-client-secret.description": "Client secret of the Microsoft OAuth app",
"admin.config.oauth.discord-enabled": "Discord",
"admin.config.oauth.discord-enabled.description": "Whether Discord login is enabled",
"admin.config.oauth.discord-limited-guild": "Discord limited server ID",
"admin.config.oauth.discord-limited-guild.description": "Limit signing in to users in a specific server. Leave it blank to disable.",
"admin.config.oauth.discord-client-id": "Discord Client ID",
"admin.config.oauth.discord-client-id.description": "Client ID of the Discord OAuth app",
"admin.config.oauth.discord-client-secret": "Discord Client secret",
Expand All @@ -496,10 +498,13 @@ export default {
"error.msg.default": "Something went wrong.",
"error.msg.access_denied": "You canceled the authentication process, please try again.",
"error.msg.expired_token": "The authentication process took too long, please try again.",
"error.msg.invalid_token": "Internal Error",
"error.msg.no_user": "User linked to this {0} account doesn't exist.",
"error.msg.no_email": "Can't get email address from this {0} account.",
"error.msg.already_linked": "This {0} account is already linked to another account.",
"error.msg.not_linked": "This {0} account haven't linked to any account yet.",
"error.msg.unverified_account": "This {0} account is unverified, please try again after verification.",
"error.msg.discord_guild_permission_denied": "You are not allowed to sign in.",
"error.param.provider_github": "GitHub",
"error.param.provider_google": "Google",
"error.param.provider_microsoft": "Microsoft",
Expand Down

0 comments on commit 5f94c72

Please sign in to comment.