Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(oauth): limited discord server sign-in #346

Merged
merged 4 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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