Implement OpenID Connect Authentication Strategy (works with Keycloak, Authentik etc.)

This commit is contained in:
Amruth Pillai
2025-01-13 15:56:29 +01:00
parent 0f8f2fe560
commit eb7813ac6f
20 changed files with 320 additions and 18 deletions

View File

@ -35,6 +35,7 @@ import { GitHubGuard } from "./guards/github.guard";
import { GoogleGuard } from "./guards/google.guard";
import { JwtGuard } from "./guards/jwt.guard";
import { LocalGuard } from "./guards/local.guard";
import { OpenIDGuard } from "./guards/openid.guard";
import { RefreshGuard } from "./guards/refresh.guard";
import { TwoFactorGuard } from "./guards/two-factor.guard";
import { getCookieOptions } from "./utils/cookie";
@ -147,6 +148,23 @@ export class AuthController {
return this.handleAuthenticationResponse(user, response, false, true);
}
@ApiTags("OAuth", "OpenID")
@Get("openid")
@UseGuards(OpenIDGuard)
openidLogin() {
return;
}
@ApiTags("OAuth", "OpenID")
@Get("openid/callback")
@UseGuards(OpenIDGuard)
async openidCallback(
@User() user: UserWithSecrets,
@Res({ passthrough: true }) response: Response,
) {
return this.handleAuthenticationResponse(user, response, false, true);
}
@Post("refresh")
@UseGuards(RefreshGuard)
async refresh(@User() user: UserWithSecrets, @Res({ passthrough: true }) response: Response) {

View File

@ -14,6 +14,7 @@ import { GitHubStrategy } from "./strategy/github.strategy";
import { GoogleStrategy } from "./strategy/google.strategy";
import { JwtStrategy } from "./strategy/jwt.strategy";
import { LocalStrategy } from "./strategy/local.strategy";
import { OpenIDStrategy } from "./strategy/openid.strategy";
import { RefreshStrategy } from "./strategy/refresh.strategy";
import { TwoFactorStrategy } from "./strategy/two-factor.strategy";
@ -63,6 +64,35 @@ export class AuthModule {
}
},
},
{
provide: OpenIDStrategy,
inject: [ConfigService, UserService],
useFactory: (configService: ConfigService<Config>, userService: UserService) => {
try {
const authorizationURL = configService.getOrThrow("OPENID_AUTHORIZATION_URL");
const callbackURL = configService.getOrThrow("OPENID_CALLBACK_URL");
const clientID = configService.getOrThrow("OPENID_CLIENT_ID");
const clientSecret = configService.getOrThrow("OPENID_CLIENT_SECRET");
const issuer = configService.getOrThrow("OPENID_ISSUER");
const tokenURL = configService.getOrThrow("OPENID_TOKEN_URL");
const userInfoURL = configService.getOrThrow("OPENID_USER_INFO_URL");
return new OpenIDStrategy(
authorizationURL,
callbackURL,
clientID,
clientSecret,
issuer,
tokenURL,
userInfoURL,
userService,
);
} catch {
return new DummyStrategy();
}
},
},
],
exports: [AuthService],
};

View File

@ -199,6 +199,14 @@ export class AuthService {
providers.push("google");
}
if (
this.configService.get("OPENID_CLIENT_ID") &&
this.configService.get("OPENID_CLIENT_SECRET") &&
this.configService.get("OPENID_CALLBACK_URL")
) {
providers.push("openid");
}
return providers;
}

View File

@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class OpenIDGuard extends AuthGuard("openid") {}

View File

@ -0,0 +1,74 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { User } from "@prisma/client";
import { ErrorMessage, processUsername } from "@reactive-resume/utils";
import { Profile, Strategy, StrategyOptions } from "passport-openidconnect";
import { UserService } from "@/server/user/user.service";
@Injectable()
export class OpenIDStrategy extends PassportStrategy(Strategy, "openid") {
constructor(
readonly authorizationURL: string,
readonly callbackURL: string,
readonly clientID: string,
readonly clientSecret: string,
readonly issuer: string,
readonly tokenURL: string,
readonly userInfoURL: string,
private readonly userService: UserService,
) {
super({
authorizationURL,
callbackURL,
clientID,
clientSecret,
issuer,
tokenURL,
userInfoURL,
scope: "openid email profile",
} as StrategyOptions);
}
async validate(
issuer: unknown,
profile: Profile,
done: (err?: string | Error | null, user?: Express.User, info?: unknown) => void,
) {
const { displayName, emails, photos, username } = profile;
const email = emails?.[0].value ?? `${username}@github.com`;
const picture = photos?.[0].value;
let user: User | null = null;
if (!email) throw new BadRequestException(ErrorMessage.InvalidCredentials);
try {
const user =
(await this.userService.findOneByIdentifier(email)) ??
(username && (await this.userService.findOneByIdentifier(username)));
if (!user) throw new Error(ErrorMessage.InvalidCredentials);
done(null, user);
} catch {
try {
user = await this.userService.create({
email,
picture,
locale: "en-US",
name: displayName,
provider: "openid",
emailVerified: true, // auto-verify emails
username: processUsername(username ?? email.split("@")[0]),
secrets: { create: {} },
});
done(null, user);
} catch {
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
}
}
}
}

View File

@ -63,15 +63,24 @@ export const configSchema = z.object({
.default("false")
.transform((s) => s !== "false" && s !== "0"),
// GitHub (OAuth)
// GitHub (OAuth, Optional)
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
GITHUB_CALLBACK_URL: z.string().url().optional(),
// Google (OAuth)
// Google (OAuth, Optional)
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
GOOGLE_CALLBACK_URL: z.string().url().optional(),
// OpenID (Optional)
OPENID_AUTHORIZATION_URL: z.string().url().optional(),
OPENID_CALLBACK_URL: z.string().url().optional(),
OPENID_CLIENT_ID: z.string().optional(),
OPENID_CLIENT_SECRET: z.string().optional(),
OPENID_ISSUER: z.string().optional(),
OPENID_TOKEN_URL: z.string().url().optional(),
OPENID_USER_INFO_URL: z.string().url().optional(),
});
export type Config = z.infer<typeof configSchema>;

View File

@ -4,6 +4,7 @@ import { NestFactory } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import cookieParser from "cookie-parser";
import session from "express-session";
import helmet from "helmet";
import { patchNestJsSwagger } from "nestjs-zod";
@ -21,6 +22,15 @@ async function bootstrap() {
// Cookie Parser
app.use(cookieParser());
// Session
app.use(
session({
resave: false,
saveUninitialized: false,
secret: configService.getOrThrow("ACCESS_TOKEN_SECRET"),
}),
);
// CORS
app.enableCors({
credentials: true,