Skip to content

Commit

Permalink
Implement OpenID Connect Authentication Strategy (works with Keycloak…
Browse files Browse the repository at this point in the history
…, Authentik etc.)
  • Loading branch information
AmruthPillai committed Jan 13, 2025
1 parent 0f8f2fe commit eb7813a
Show file tree
Hide file tree
Showing 20 changed files with 320 additions and 18 deletions.
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,12 @@ STORAGE_SKIP_BUCKET_CHECK=false
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GOOGLE_CALLBACK_URL=http://localhost:5173/api/auth/google/callback

# OpenID (Optional)
# OPENID_AUTHORIZATION_URL=
# OPENID_CALLBACK_URL=http://localhost:5173/api/auth/openid/callback
# OPENID_CLIENT_ID=
# OPENID_CLIENT_SECRET=
# OPENID_ISSUER=
# OPENID_TOKEN_URL=
# OPENID_USER_INFO_URL=
15 changes: 14 additions & 1 deletion apps/client/src/pages/auth/_components/social-auth.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 changes: 18 additions & 0 deletions apps/server/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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],
};
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/auth/guards/openid.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";

@Injectable()
export class OpenIDGuard extends AuthGuard("openid") {}
74 changes: 74 additions & 0 deletions apps/server/src/auth/strategy/openid.strategy.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
}
13 changes: 11 additions & 2 deletions apps/server/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 10 additions & 0 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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"),
}),

Check warning

Code scanning / CodeQL

Clear text transmission of sensitive cookie Medium

Sensitive cookie sent without enforcing SSL encryption.
);

// CORS
app.enableCors({
credentials: true,
Expand Down
2 changes: 1 addition & 1 deletion libs/dto/src/auth/providers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createZodDto } from "nestjs-zod/dto";
import { z } from "zod";

const authProvidersSchema = z.array(z.enum(["email", "github", "google"]));
const authProvidersSchema = z.array(z.enum(["email", "github", "google", "openid"]));

export class AuthProvidersDto extends createZodDto(authProvidersSchema) {}
2 changes: 1 addition & 1 deletion libs/dto/src/user/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const userSchema = z.object({
locale: z.string().default("en-US"),
emailVerified: z.boolean().default(false),
twoFactorEnabled: z.boolean().default(false),
provider: z.enum(["email", "github", "google"]).default("email"),
provider: z.enum(["email", "github", "google", "openid"]).default("email"),
createdAt: dateSchema,
updatedAt: dateSchema,
});
Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@reactive-resume/source",
"description": "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.",
"version": "4.3.6",
"version": "4.3.7",
"license": "MIT",
"private": true,
"author": {
Expand Down Expand Up @@ -62,6 +62,7 @@
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.1",
"@types/file-saver": "^2.0.7",
"@types/jest": "^29.5.14",
"@types/lodash.debounce": "^4.0.9",
Expand All @@ -75,6 +76,7 @@
"@types/passport-github2": "^1.2.9",
"@types/passport-google-oauth20": "^2.0.16",
"@types/passport-local": "^1.0.38",
"@types/passport-openidconnect": "^0.1.3",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
Expand Down Expand Up @@ -171,7 +173,7 @@
"@radix-ui/react-visually-hidden": "^1.1.1",
"@sindresorhus/slugify": "^2.2.1",
"@swc/helpers": "^0.5.15",
"@tanstack/react-query": "^5.64.0",
"@tanstack/react-query": "^5.64.1",
"@tiptap/extension-highlight": "^2.11.2",
"@tiptap/extension-image": "^2.11.2",
"@tiptap/extension-link": "^2.11.2",
Expand All @@ -191,6 +193,7 @@
"cookie-parser": "^1.4.7",
"dayjs": "^1.11.13",
"deepmerge": "^4.3.1",
"express-session": "^1.18.1",
"file-saver": "^2.0.5",
"framer-motion": "^11.17.0",
"fuzzy": "^0.1.3",
Expand All @@ -214,6 +217,7 @@
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-openidconnect": "^0.1.2",
"pdf-lib": "^1.17.1",
"prisma": "^5.22.0",
"prismjs": "^1.29.0",
Expand Down
Loading

0 comments on commit eb7813a

Please sign in to comment.