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:
parent
104cc06145
commit
2b3ce3ffd2
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "RefreshToken" ADD COLUMN "oauthIDToken" TEXT;
|
@ -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 {
|
||||
|
@ -275,6 +275,10 @@ const configVariables: ConfigVariables = {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-signOut": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"oidc-usernameClaim": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
|
@ -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")
|
||||
|
@ -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],
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -4,4 +4,5 @@ export interface OAuthSignInDto {
|
||||
providerUsername: string;
|
||||
email: string;
|
||||
isAdmin?: boolean;
|
||||
idToken?: string;
|
||||
}
|
||||
|
@ -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 {}
|
||||
|
@ -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<string, OAuthProvider<unknown>>,
|
||||
) {}
|
||||
private readonly logger = new Logger(OAuthService.name);
|
||||
|
||||
@ -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: {
|
||||
@ -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(
|
||||
|
@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -197,6 +197,7 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
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 {
|
||||
|
@ -61,6 +61,7 @@ export class GitHubProvider implements OAuthProvider<GitHubToken> {
|
||||
providerId: user.id.toString(),
|
||||
providerUsername: user.name ?? user.login,
|
||||
email,
|
||||
idToken: `github:${token.idToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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.",
|
||||
|
@ -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 () => {
|
||||
|
Loading…
Reference in New Issue
Block a user