Skip to content

feat(oauth): Option to logout from OpenID Connect provider #631

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

Merged
merged 1 commit into from
Oct 14, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "RefreshToken" ADD COLUMN "oauthIDToken" TEXT;
2 changes: 2 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ model RefreshToken {

userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

oauthIDToken String? // prefixed with the ID of the issuing OAuth provider, separated by a colon
}

model LoginToken {
Expand Down
4 changes: 4 additions & 0 deletions backend/prisma/seed/config.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ const configVariables: ConfigVariables = {
type: "string",
defaultValue: "",
},
"oidc-signOut": {
type: "boolean",
defaultValue: "false",
},
"oidc-usernameClaim": {
type: "string",
defaultValue: "",
Expand Down
8 changes: 6 additions & 2 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,10 @@ export class AuthController {
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
await this.authService.signOut(request.cookies.access_token);
const redirectURI = await this.authService.signOut(request.cookies.access_token);

const isSecure = this.config.get("general.appUrl").startsWith("https");
response.cookie("access_token", "accessToken", {
response.cookie("access_token", "", {
maxAge: -1,
secure: isSecure,
});
Expand All @@ -185,6 +185,10 @@ export class AuthController {
maxAge: -1,
secure: isSecure,
});

if (typeof redirectURI === "string") {
return { redirectURI: redirectURI.toString() };
}
}

@Post("totp/enable")
Expand Down
4 changes: 3 additions & 1 deletion backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Module } from "@nestjs/common";
import { forwardRef, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { EmailModule } from "src/email/email.module";
import { AuthController } from "./auth.controller";
Expand All @@ -7,13 +7,15 @@ import { AuthTotpService } from "./authTotp.service";
import { JwtStrategy } from "./strategy/jwt.strategy";
import { LdapService } from "./ldap.service";
import { UserModule } from "../user/user.module";
import { OAuthModule } from "../oauth/oauth.module";

@Module({
imports: [
JwtModule.register({
global: true,
}),
EmailModule,
forwardRef(() => OAuthModule),
UserModule,
],
controllers: [AuthController],
Expand Down
41 changes: 37 additions & 4 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
BadRequestException,
ForbiddenException,
forwardRef,
Inject,
Injectable,
Logger,
UnauthorizedException,
Expand All @@ -17,7 +19,8 @@ import { PrismaService } from "src/prisma/prisma.service";
import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto";
import { LdapService } from "./ldap.service";
import { inspect } from "util";
import { GenericOidcProvider } from "../oauth/provider/genericOidc.provider";
import { OAuthService } from "../oauth/oauth.service";
import { UserSevice } from "../user/user.service";

@Injectable()
Expand All @@ -29,6 +32,7 @@ export class AuthService {
private emailService: EmailService,
private ldapService: LdapService,
private userService: UserSevice,
@Inject(forwardRef(() => OAuthService)) private oAuthService: OAuthService,
) {}
private readonly logger = new Logger(AuthService.name);

Expand Down Expand Up @@ -113,12 +117,12 @@ export class AuthService {
throw new UnauthorizedException("Wrong email or password");
}

async generateToken(user: User, isOAuth = false) {
async generateToken(user: User, oauth?: { idToken?: string }) {
// TODO: Make all old loginTokens invalid when a new one is created
// Check if the user has TOTP enabled
if (
user.totpVerified &&
!(isOAuth && this.config.get("oauth.ignoreTotp"))
!(oauth && this.config.get("oauth.ignoreTotp"))
) {
const loginToken = await this.createLoginToken(user.id);

Expand All @@ -127,6 +131,7 @@ export class AuthService {

const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id,
oauth?.idToken,
);
const accessToken = await this.createAccessToken(user, refreshTokenId);

Expand Down Expand Up @@ -225,12 +230,39 @@ export class AuthService {
}) || {};

if (refreshTokenId) {
const oauthIDToken = await this.prisma.refreshToken
.findFirst({ select: { oauthIDToken: true }, where: { id: refreshTokenId } })
.then((refreshToken) => refreshToken?.oauthIDToken)
.catch((e) => {
// Ignore error if refresh token doesn't exist
if (e.code != "P2025") throw e;
});
await this.prisma.refreshToken
.delete({ where: { id: refreshTokenId } })
.catch((e) => {
// Ignore error if refresh token doesn't exist
if (e.code != "P2025") throw e;
});

if (typeof oauthIDToken === "string") {
const [providerName, idTokenHint] = oauthIDToken.split(":");
const provider = this.oAuthService.availableProviders()[providerName];
let signOutFromProviderSupportedAndActivated = false;
try {
signOutFromProviderSupportedAndActivated = this.config.get(`oauth.${providerName}-signOut`);
} catch (_) {
// Ignore error if the provider is not supported or if the provider sign out is not activated
}
if (provider instanceof GenericOidcProvider && signOutFromProviderSupportedAndActivated) {
const configuration = await provider.getConfiguration();
if (configuration.frontchannel_logout_supported && URL.canParse(configuration.end_session_endpoint)) {
const redirectURI = new URL(configuration.end_session_endpoint);
redirectURI.searchParams.append("id_token_hint", idTokenHint);
redirectURI.searchParams.append("client_id", this.config.get(`oauth.${providerName}-clientId`));
return redirectURI.toString();
}
}
}
}
}

Expand All @@ -249,13 +281,14 @@ export class AuthService {
);
}

async createRefreshToken(userId: string) {
async createRefreshToken(userId: string, idToken?: string) {
const { id, token } = await this.prisma.refreshToken.create({
data: {
userId,
expiresAt: moment()
.add(this.config.get("general.sessionDuration"), "hours")
.toDate(),
oauthIDToken: idToken,
},
});

Expand Down
1 change: 1 addition & 0 deletions backend/src/oauth/dto/oauthSignIn.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export interface OAuthSignInDto {
providerUsername: string;
email: string;
isAdmin?: boolean;
idToken?: string;
}
5 changes: 3 additions & 2 deletions backend/src/oauth/oauth.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Module } from "@nestjs/common";
import { forwardRef, Module } from "@nestjs/common";
import { OAuthController } from "./oauth.controller";
import { OAuthService } from "./oauth.service";
import { AuthModule } from "../auth/auth.module";
Expand Down Expand Up @@ -51,6 +51,7 @@ import { MicrosoftProvider } from "./provider/microsoft.provider";
inject: ["OAUTH_PROVIDERS"],
},
],
imports: [AuthModule],
imports: [forwardRef(() => AuthModule)],
exports: [OAuthService],
})
export class OAuthModule {}
20 changes: 16 additions & 4 deletions backend/src/oauth/oauth.service.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common";
import { User } from "@prisma/client";
import { nanoid } from "nanoid";
import { AuthService } from "../auth/auth.service";
import { ConfigService } from "../config/config.service";
import { PrismaService } from "../prisma/prisma.service";
import { OAuthSignInDto } from "./dto/oauthSignIn.dto";
import { ErrorPageException } from "./exceptions/errorPage.exception";
import { OAuthProvider } from "./provider/oauthProvider.interface";

@Injectable()
export class OAuthService {
constructor(
private prisma: PrismaService,
private config: ConfigService,
private auth: AuthService,
@Inject(forwardRef(() => AuthService)) private auth: AuthService,
@Inject("OAUTH_PLATFORMS") private platforms: string[],
@Inject("OAUTH_PROVIDERS") private oAuthProviders: Record<string, OAuthProvider<unknown>>,
) {}
private readonly logger = new Logger(OAuthService.name);

Expand All @@ -27,6 +29,16 @@ export class OAuthService {
.map(([platform, _]) => platform);
}

availableProviders(): Record<string, OAuthProvider<unknown>> {
return Object.fromEntries(Object.entries(this.oAuthProviders)
.map(([providerName, provider]) => [
[providerName, provider],
this.config.get(`oauth.${providerName}-enabled`),
])
.filter(([_, enabled]) => enabled)
.map(([provider, _]) => provider));
}

async status(user: User) {
const oauthUsers = await this.prisma.oAuthUser.findMany({
select: {
Expand Down Expand Up @@ -55,7 +67,7 @@ export class OAuthService {
},
});
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
return this.auth.generateToken(updatedUser, true);
return this.auth.generateToken(updatedUser, { idToken: user.idToken });
}

return this.signUp(user, ip);
Expand Down Expand Up @@ -156,7 +168,7 @@ export class OAuthService {
},
});
await this.updateIsAdmin(user);
return this.auth.generateToken(existingUser, true);
return this.auth.generateToken(existingUser, { idToken: user.idToken });
}

const result = await this.auth.signUp(
Expand Down
1 change: 1 addition & 0 deletions backend/src/oauth/provider/discord.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
providerId: user.id,
providerUsername: user.global_name ?? user.username,
email: user.email,
idToken: `discord:${token.idToken}`,
};
}

Expand Down
3 changes: 3 additions & 0 deletions backend/src/oauth/provider/genericOidc.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
providerId: idTokenData.sub,
providerUsername: username,
...(isAdmin !== undefined && { isAdmin }),
idToken: `${this.name}:${token.idToken}`,
};
}

Expand Down Expand Up @@ -251,6 +252,8 @@ export interface OidcConfiguration {
id_token_signing_alg_values_supported: string[];
scopes_supported?: string[];
claims_supported?: string[];
frontchannel_logout_supported?: boolean;
end_session_endpoint?: string;
}

export interface OidcJwk {
Expand Down
1 change: 1 addition & 0 deletions backend/src/oauth/provider/github.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class GitHubProvider implements OAuthProvider<GitHubToken> {
providerId: user.id.toString(),
providerUsername: user.name ?? user.login,
email,
idToken: `github:${token.idToken}`,
};
}

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/translations/de-DE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,8 @@ export default {
"admin.config.oauth.oidc-enabled.description": "OpenID Connect Anmeldung erlaubt",
"admin.config.oauth.oidc-discovery-uri": "OpenID Verbindung Discovery URL",
"admin.config.oauth.oidc-discovery-uri.description": "Discovery-URL der OpenID OAuth App",
"admin.config.oauth.oidc-sign-out": "Abmelden von OpenID Connect",
"admin.config.oauth.oidc-sign-out.description": "Wenn aktiviert, wird der Benutzer mit der „Abmelden“-Schaltfläche vom OpenID-Connect-Provider abgemeldet.",
"admin.config.oauth.oidc-username-claim": "OpenID Connect Benutzername anfordern",
"admin.config.oauth.oidc-username-claim.description": "Benutzername im OpenID Token. Leer lassen, wenn du nicht weißt, was diese Konfiguration bedeutet.",
"admin.config.oauth.oidc-role-path": "Path to roles in OpenID Connect token",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/translations/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,9 @@ export default {
"admin.config.oauth.oidc-discovery-uri": "OpenID Connect Discovery URI",
"admin.config.oauth.oidc-discovery-uri.description":
"Discovery URI of the OpenID Connect OAuth app",
"admin.config.oauth.oidc-sign-out": "Sign out from OpenID Connect",
"admin.config.oauth.oidc-sign-out.description":
"Whether the “Sign out” button will sign out from the OpenID Connect provider",
"admin.config.oauth.oidc-username-claim": "OpenID Connect username claim",
"admin.config.oauth.oidc-username-claim.description":
"Username claim in OpenID Connect ID token. Leave it blank if you don't know what this config is.",
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ const signUp = async (email: string, username: string, password: string) => {
};

const signOut = async () => {
await api.post("/auth/signOut");
window.location.reload();
const response = await api.post("/auth/signOut");

if (URL.canParse(response.data?.redirectURI)) window.location.href = response.data.redirectURI;
else window.location.reload();
};

const refreshAccessToken = async () => {
Expand Down
Loading