diff --git a/backend/prisma/migrations/20241007181823_oauth_id_token/migration.sql b/backend/prisma/migrations/20241007181823_oauth_id_token/migration.sql new file mode 100644 index 00000000..2a461cc5 --- /dev/null +++ b/backend/prisma/migrations/20241007181823_oauth_id_token/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "RefreshToken" ADD COLUMN "oauthIDToken" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c8c9c497..6750d750 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 { diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index c4de5dfa..496e9c92 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -275,6 +275,10 @@ const configVariables: ConfigVariables = { type: "string", defaultValue: "", }, + "oidc-signOut": { + type: "boolean", + defaultValue: "false", + }, "oidc-usernameClaim": { type: "string", defaultValue: "", diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 949cd693..bc1cca1b 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -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, }); @@ -185,6 +185,10 @@ export class AuthController { maxAge: -1, secure: isSecure, }); + + if (typeof redirectURI === "string") { + return { redirectURI: redirectURI.toString() }; + } } @Post("totp/enable") diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 05a6e11f..d3fe5847 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -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"; @@ -7,6 +7,7 @@ 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: [ @@ -14,6 +15,7 @@ import { UserModule } from "../user/user.module"; global: true, }), EmailModule, + forwardRef(() => OAuthModule), UserModule, ], controllers: [AuthController], diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 20c78867..a14fe248 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,6 +1,8 @@ import { BadRequestException, ForbiddenException, + forwardRef, + Inject, Injectable, Logger, UnauthorizedException, @@ -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() @@ -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); @@ -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); @@ -127,6 +131,7 @@ export class AuthService { const { refreshToken, refreshTokenId } = await this.createRefreshToken( user.id, + oauth?.idToken, ); const accessToken = await this.createAccessToken(user, refreshTokenId); @@ -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(); + } + } + } } } @@ -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, }, }); diff --git a/backend/src/oauth/dto/oauthSignIn.dto.ts b/backend/src/oauth/dto/oauthSignIn.dto.ts index 3efe889c..2dd6d929 100644 --- a/backend/src/oauth/dto/oauthSignIn.dto.ts +++ b/backend/src/oauth/dto/oauthSignIn.dto.ts @@ -4,4 +4,5 @@ export interface OAuthSignInDto { providerUsername: string; email: string; isAdmin?: boolean; + idToken?: string; } diff --git a/backend/src/oauth/oauth.module.ts b/backend/src/oauth/oauth.module.ts index bdb22b1e..4d552d44 100644 --- a/backend/src/oauth/oauth.module.ts +++ b/backend/src/oauth/oauth.module.ts @@ -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"; @@ -51,6 +51,7 @@ import { MicrosoftProvider } from "./provider/microsoft.provider"; inject: ["OAUTH_PROVIDERS"], }, ], - imports: [AuthModule], + imports: [forwardRef(() => AuthModule)], + exports: [OAuthService], }) export class OAuthModule {} diff --git a/backend/src/oauth/oauth.service.ts b/backend/src/oauth/oauth.service.ts index daef0ab1..872ad590 100644 --- a/backend/src/oauth/oauth.service.ts +++ b/backend/src/oauth/oauth.service.ts @@ -1,4 +1,4 @@ -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"; @@ -6,14 +6,16 @@ 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>, ) {} private readonly logger = new Logger(OAuthService.name); @@ -27,6 +29,16 @@ export class OAuthService { .map(([platform, _]) => platform); } + availableProviders(): Record> { + 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: { @@ -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); @@ -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( diff --git a/backend/src/oauth/provider/discord.provider.ts b/backend/src/oauth/provider/discord.provider.ts index 7391b17f..e9ee2138 100644 --- a/backend/src/oauth/provider/discord.provider.ts +++ b/backend/src/oauth/provider/discord.provider.ts @@ -91,6 +91,7 @@ export class DiscordProvider implements OAuthProvider { providerId: user.id, providerUsername: user.global_name ?? user.username, email: user.email, + idToken: `discord:${token.idToken}`, }; } diff --git a/backend/src/oauth/provider/genericOidc.provider.ts b/backend/src/oauth/provider/genericOidc.provider.ts index 9c83bccd..3348321f 100644 --- a/backend/src/oauth/provider/genericOidc.provider.ts +++ b/backend/src/oauth/provider/genericOidc.provider.ts @@ -197,6 +197,7 @@ export abstract class GenericOidcProvider implements OAuthProvider { providerId: idTokenData.sub, providerUsername: username, ...(isAdmin !== undefined && { isAdmin }), + idToken: `${this.name}:${token.idToken}`, }; } @@ -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 { diff --git a/backend/src/oauth/provider/github.provider.ts b/backend/src/oauth/provider/github.provider.ts index 1c9eb87a..8e970559 100644 --- a/backend/src/oauth/provider/github.provider.ts +++ b/backend/src/oauth/provider/github.provider.ts @@ -61,6 +61,7 @@ export class GitHubProvider implements OAuthProvider { providerId: user.id.toString(), providerUsername: user.name ?? user.login, email, + idToken: `github:${token.idToken}`, }; } diff --git a/frontend/src/i18n/translations/de-DE.ts b/frontend/src/i18n/translations/de-DE.ts index cf97d778..5c0a781d 100644 --- a/frontend/src/i18n/translations/de-DE.ts +++ b/frontend/src/i18n/translations/de-DE.ts @@ -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", diff --git a/frontend/src/i18n/translations/en-US.ts b/frontend/src/i18n/translations/en-US.ts index 8306d503..94dc4715 100644 --- a/frontend/src/i18n/translations/en-US.ts +++ b/frontend/src/i18n/translations/en-US.ts @@ -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.", diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index f1554923..3198ec7a 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -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 () => {