1
0
mirror of https://github.com/stonith404/pingvin-share.git synced 2024-11-15 03:50:11 +01:00

feat(oauth): Add option to logout from OpenID Connect provider

* Fixes #598

Signed-off-by: Marvin A. Ruder <signed@mruder.dev>
This commit is contained in:
Marvin A. Ruder 2024-10-14 18:16:47 +02:00 committed by GitHub
parent 104cc06145
commit 2b3ce3ffd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 88 additions and 15 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "RefreshToken" ADD COLUMN "oauthIDToken" TEXT;

View File

@ -40,6 +40,8 @@ model RefreshToken {
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) 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 { model LoginToken {

View File

@ -275,6 +275,10 @@ const configVariables: ConfigVariables = {
type: "string", type: "string",
defaultValue: "", defaultValue: "",
}, },
"oidc-signOut": {
type: "boolean",
defaultValue: "false",
},
"oidc-usernameClaim": { "oidc-usernameClaim": {
type: "string", type: "string",
defaultValue: "", defaultValue: "",

View File

@ -172,10 +172,10 @@ export class AuthController {
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) response: Response, @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"); const isSecure = this.config.get("general.appUrl").startsWith("https");
response.cookie("access_token", "accessToken", { response.cookie("access_token", "", {
maxAge: -1, maxAge: -1,
secure: isSecure, secure: isSecure,
}); });
@ -185,6 +185,10 @@ export class AuthController {
maxAge: -1, maxAge: -1,
secure: isSecure, secure: isSecure,
}); });
if (typeof redirectURI === "string") {
return { redirectURI: redirectURI.toString() };
}
} }
@Post("totp/enable") @Post("totp/enable")

View File

@ -1,4 +1,4 @@
import { Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { EmailModule } from "src/email/email.module"; import { EmailModule } from "src/email/email.module";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
@ -7,6 +7,7 @@ import { AuthTotpService } from "./authTotp.service";
import { JwtStrategy } from "./strategy/jwt.strategy"; import { JwtStrategy } from "./strategy/jwt.strategy";
import { LdapService } from "./ldap.service"; import { LdapService } from "./ldap.service";
import { UserModule } from "../user/user.module"; import { UserModule } from "../user/user.module";
import { OAuthModule } from "../oauth/oauth.module";
@Module({ @Module({
imports: [ imports: [
@ -14,6 +15,7 @@ import { UserModule } from "../user/user.module";
global: true, global: true,
}), }),
EmailModule, EmailModule,
forwardRef(() => OAuthModule),
UserModule, UserModule,
], ],
controllers: [AuthController], controllers: [AuthController],

View File

@ -1,6 +1,8 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
forwardRef,
Inject,
Injectable, Injectable,
Logger, Logger,
UnauthorizedException, UnauthorizedException,
@ -17,7 +19,8 @@ import { PrismaService } from "src/prisma/prisma.service";
import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto";
import { LdapService } from "./ldap.service"; 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"; import { UserSevice } from "../user/user.service";
@Injectable() @Injectable()
@ -29,6 +32,7 @@ export class AuthService {
private emailService: EmailService, private emailService: EmailService,
private ldapService: LdapService, private ldapService: LdapService,
private userService: UserSevice, private userService: UserSevice,
@Inject(forwardRef(() => OAuthService)) private oAuthService: OAuthService,
) {} ) {}
private readonly logger = new Logger(AuthService.name); private readonly logger = new Logger(AuthService.name);
@ -113,12 +117,12 @@ export class AuthService {
throw new UnauthorizedException("Wrong email or password"); 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 // TODO: Make all old loginTokens invalid when a new one is created
// Check if the user has TOTP enabled // Check if the user has TOTP enabled
if ( if (
user.totpVerified && user.totpVerified &&
!(isOAuth && this.config.get("oauth.ignoreTotp")) !(oauth && this.config.get("oauth.ignoreTotp"))
) { ) {
const loginToken = await this.createLoginToken(user.id); const loginToken = await this.createLoginToken(user.id);
@ -127,6 +131,7 @@ export class AuthService {
const { refreshToken, refreshTokenId } = await this.createRefreshToken( const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id, user.id,
oauth?.idToken,
); );
const accessToken = await this.createAccessToken(user, refreshTokenId); const accessToken = await this.createAccessToken(user, refreshTokenId);
@ -225,12 +230,39 @@ export class AuthService {
}) || {}; }) || {};
if (refreshTokenId) { 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 await this.prisma.refreshToken
.delete({ where: { id: refreshTokenId } }) .delete({ where: { id: refreshTokenId } })
.catch((e) => { .catch((e) => {
// Ignore error if refresh token doesn't exist // Ignore error if refresh token doesn't exist
if (e.code != "P2025") throw e; 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({ const { id, token } = await this.prisma.refreshToken.create({
data: { data: {
userId, userId,
expiresAt: moment() expiresAt: moment()
.add(this.config.get("general.sessionDuration"), "hours") .add(this.config.get("general.sessionDuration"), "hours")
.toDate(), .toDate(),
oauthIDToken: idToken,
}, },
}); });

View File

@ -4,4 +4,5 @@ export interface OAuthSignInDto {
providerUsername: string; providerUsername: string;
email: string; email: string;
isAdmin?: boolean; isAdmin?: boolean;
idToken?: string;
} }

View File

@ -1,4 +1,4 @@
import { Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { OAuthController } from "./oauth.controller"; import { OAuthController } from "./oauth.controller";
import { OAuthService } from "./oauth.service"; import { OAuthService } from "./oauth.service";
import { AuthModule } from "../auth/auth.module"; import { AuthModule } from "../auth/auth.module";
@ -51,6 +51,7 @@ import { MicrosoftProvider } from "./provider/microsoft.provider";
inject: ["OAUTH_PROVIDERS"], inject: ["OAUTH_PROVIDERS"],
}, },
], ],
imports: [AuthModule], imports: [forwardRef(() => AuthModule)],
exports: [OAuthService],
}) })
export class OAuthModule {} export class OAuthModule {}

View File

@ -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 { User } from "@prisma/client";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { AuthService } from "../auth/auth.service"; import { AuthService } from "../auth/auth.service";
@ -6,14 +6,16 @@ import { ConfigService } from "../config/config.service";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { OAuthSignInDto } from "./dto/oauthSignIn.dto"; import { OAuthSignInDto } from "./dto/oauthSignIn.dto";
import { ErrorPageException } from "./exceptions/errorPage.exception"; import { ErrorPageException } from "./exceptions/errorPage.exception";
import { OAuthProvider } from "./provider/oauthProvider.interface";
@Injectable() @Injectable()
export class OAuthService { export class OAuthService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private config: ConfigService, private config: ConfigService,
private auth: AuthService, @Inject(forwardRef(() => AuthService)) private auth: AuthService,
@Inject("OAUTH_PLATFORMS") private platforms: string[], @Inject("OAUTH_PLATFORMS") private platforms: string[],
@Inject("OAUTH_PROVIDERS") private oAuthProviders: Record<string, OAuthProvider<unknown>>,
) {} ) {}
private readonly logger = new Logger(OAuthService.name); private readonly logger = new Logger(OAuthService.name);
@ -27,6 +29,16 @@ export class OAuthService {
.map(([platform, _]) => platform); .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) { async status(user: User) {
const oauthUsers = await this.prisma.oAuthUser.findMany({ const oauthUsers = await this.prisma.oAuthUser.findMany({
select: { select: {
@ -55,7 +67,7 @@ export class OAuthService {
}, },
}); });
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`); 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); return this.signUp(user, ip);
@ -156,7 +168,7 @@ export class OAuthService {
}, },
}); });
await this.updateIsAdmin(user); await this.updateIsAdmin(user);
return this.auth.generateToken(existingUser, true); return this.auth.generateToken(existingUser, { idToken: user.idToken });
} }
const result = await this.auth.signUp( const result = await this.auth.signUp(

View File

@ -91,6 +91,7 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
providerId: user.id, providerId: user.id,
providerUsername: user.global_name ?? user.username, providerUsername: user.global_name ?? user.username,
email: user.email, email: user.email,
idToken: `discord:${token.idToken}`,
}; };
} }

View File

@ -197,6 +197,7 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
providerId: idTokenData.sub, providerId: idTokenData.sub,
providerUsername: username, providerUsername: username,
...(isAdmin !== undefined && { isAdmin }), ...(isAdmin !== undefined && { isAdmin }),
idToken: `${this.name}:${token.idToken}`,
}; };
} }
@ -251,6 +252,8 @@ export interface OidcConfiguration {
id_token_signing_alg_values_supported: string[]; id_token_signing_alg_values_supported: string[];
scopes_supported?: string[]; scopes_supported?: string[];
claims_supported?: string[]; claims_supported?: string[];
frontchannel_logout_supported?: boolean;
end_session_endpoint?: string;
} }
export interface OidcJwk { export interface OidcJwk {

View File

@ -61,6 +61,7 @@ export class GitHubProvider implements OAuthProvider<GitHubToken> {
providerId: user.id.toString(), providerId: user.id.toString(),
providerUsername: user.name ?? user.login, providerUsername: user.name ?? user.login,
email, email,
idToken: `github:${token.idToken}`,
}; };
} }

View File

@ -392,6 +392,8 @@ export default {
"admin.config.oauth.oidc-enabled.description": "OpenID Connect Anmeldung erlaubt", "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": "OpenID Verbindung Discovery URL",
"admin.config.oauth.oidc-discovery-uri.description": "Discovery-URL der OpenID OAuth App", "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": "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-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", "admin.config.oauth.oidc-role-path": "Path to roles in OpenID Connect token",

View File

@ -547,6 +547,9 @@ export default {
"admin.config.oauth.oidc-discovery-uri": "OpenID Connect Discovery URI", "admin.config.oauth.oidc-discovery-uri": "OpenID Connect Discovery URI",
"admin.config.oauth.oidc-discovery-uri.description": "admin.config.oauth.oidc-discovery-uri.description":
"Discovery URI of the OpenID Connect OAuth app", "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": "OpenID Connect username claim",
"admin.config.oauth.oidc-username-claim.description": "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.", "Username claim in OpenID Connect ID token. Leave it blank if you don't know what this config is.",

View File

@ -29,8 +29,10 @@ const signUp = async (email: string, username: string, password: string) => {
}; };
const signOut = async () => { const signOut = async () => {
await api.post("/auth/signOut"); const response = await api.post("/auth/signOut");
window.location.reload();
if (URL.canParse(response.data?.redirectURI)) window.location.href = response.data.redirectURI;
else window.location.reload();
}; };
const refreshAccessToken = async () => { const refreshAccessToken = async () => {