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
@@ -1,5 +1,5 @@
import { t } from "@lingui/macro";
import { GithubLogo, GoogleLogo } from "@phosphor-icons/react";
import { Fingerprint, GithubLogo, GoogleLogo } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui";
import { useAuthProviders } from "@/client/services/auth/providers";
@@ -32,6 +32,19 @@ export const SocialAuth = () => {
</a>
</Button>
)}
{providers.includes("openid") && (
<Button
asChild
size="lg"
className="w-full !bg-[#dc2626] !text-white hover:!bg-[#dc2626]/80"
>
<a href="/api/auth/openid">
<Fingerprint className="mr-3 size-4" />
{t`OpenID`}
</a>
</Button>
)}
</div>
);
};
+18
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) {
+30
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],
};
+8
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;
}
@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class OpenIDGuard extends AuthGuard("openid") {}
@@ -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);
}
}
}
}
+11 -2
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>;
+10
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,