Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove Passport #2809

Merged
merged 29 commits into from
Feb 17, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
57a00e0
Remove passport
fraxachun Nov 22, 2024
463d7f3
Update packages/api/cms-api/src/auth/services/static-authed-user.auth…
fraxachun Nov 28, 2024
481e411
Merge next
fraxachun Dec 2, 2024
9fdf4f8
Add tests
fraxachun Dec 2, 2024
94602e1
Add tests
fraxachun Dec 2, 2024
3070ae0
Add @nestjs/jwt as peer dependency
fraxachun Dec 2, 2024
c2f7ea5
Add JwtAuthService to demo
fraxachun Dec 2, 2024
12419de
Merge remote-tracking branch 'origin/next' into remove-passport
fraxachun Dec 2, 2024
41d52ee
Merge remote-tracking branch 'origin/next' into remove-passport
fraxachun Dec 13, 2024
daab325
Update packages/api/cms-api/src/auth/util/auth-guard.providers.ts
fraxachun Dec 18, 2024
ff8f140
Update packages/api/cms-api/src/auth/services/basic.auth-service.ts
fraxachun Dec 18, 2024
33bf4f0
Merge remote-tracking branch 'origin/remove-passport' into remove-pas…
fraxachun Dec 18, 2024
d1a1b3a
Merge remote-tracking branch 'origin/next' into remove-passport
fraxachun Dec 18, 2024
8bd18ee
Remove jsonwebtoken dependency
fraxachun Dec 18, 2024
84316fd
Add migration guide
fraxachun Dec 18, 2024
ebf1e7d
Merge remote-tracking branch 'origin/next' into remove-passport
fraxachun Dec 18, 2024
8326884
Add changeset
fraxachun Dec 18, 2024
6f3f9e3
Fix typo
fraxachun Dec 18, 2024
a39e806
Update docs/docs/migration/migration-from-v7-to-v8.md
fraxachun Dec 19, 2024
7b66355
Add note to migration guide
fraxachun Dec 19, 2024
ae27da5
Merge remote-tracking branch 'origin/next' into remove-passport
fraxachun Dec 19, 2024
f8e57ec
Merge remote-tracking branch 'origin/next' into remove-passport
fraxachun Dec 19, 2024
0f52509
Merge remote-tracking branch 'origin/next' into remove-passport
fraxachun Dec 19, 2024
f864172
Merge remote-tracking branch 'origin/next' into remove-passport
fraxachun Dec 20, 2024
aa15c4a
Fix docs
fraxachun Dec 20, 2024
2cf4ff2
Merge remote-tracking branch 'origin/next' into remove-passport
fraxachun Feb 6, 2025
9938b0c
Merge branch 'next' into remove-passport
fraxachun Feb 6, 2025
2a237f5
Merge branch 'next' into remove-passport
fraxachun Feb 14, 2025
9a73b06
Remove dependency
fraxachun Feb 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 7 additions & 12 deletions demo/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@
"@mikro-orm/migrations": "^5.9.8",
"@mikro-orm/nestjs": "^5.2.3",
"@mikro-orm/postgresql": "^5.9.8",
"@nestjs/apollo": "^10.0.0",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.0.0",
"@nestjs/core": "^9.0.0",
"@nestjs/graphql": "^10.0.0",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/apollo": "^12.2.1",
"@nestjs/common": "^10.4.8",
"@nestjs/core": "^10.4.8",
"@nestjs/graphql": "^12.2.1",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.4.8",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/core": "^1.26.0",
Expand All @@ -55,11 +55,7 @@
"graphql": "^16.6.0",
"graphql-scalars": "^1.23.0",
"helmet": "^4.6.0",
"image-size": "^0.9.0",
"mime": "^2.0.0",
"multer": "^1.0.0",
"nestjs-console": "^8.0.0",
"node-fetch": "^2.0.0",
"nestjs-console": "^9.0.0",
"reflect-metadata": "^0.1.13",
"response-time": "^2.3.2",
"rimraf": "^3.0.0",
Expand All @@ -79,7 +75,6 @@
"@types/express": "^4.0.0",
"@types/faker": "^5.0.0",
"@types/node": "^22.0.0",
"@types/pg": "^8.0.0",
"@types/response-time": "^2.3.8",
"@types/rimraf": "^3.0.0",
"@types/uuid": "^8.0.0",
Expand Down
4 changes: 2 additions & 2 deletions demo/api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](
"""
scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")

type User {
type UserPermissionsUser {
id: String!
name: String!
email: String!
Expand All @@ -49,7 +49,7 @@ type CurrentUser {
email: String!
permissions: [CurrentUserPermission!]!
impersonated: Boolean
authenticatedUser: User
authenticatedUser: UserPermissionsUser
permissionsForScope(scope: JSONObject!): [String!]!
}

Expand Down
12 changes: 11 additions & 1 deletion demo/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { CometAuthGuard, createAuthGuardProviders, createAuthResolver, createBasicAuthService, createStaticUserAuthService } from "@comet/cms-api";
import {
CometAuthGuard,
createAuthGuardProviders,
createAuthResolver,
createBasicAuthService,
createJwtAuthService,
createStaticUserAuthService,
} from "@comet/cms-api";
import { DynamicModule, Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { JwtModule } from "@nestjs/jwt";
import { Config } from "@src/config/config";

import { AccessControlService } from "./access-control.service";
Expand All @@ -20,6 +28,7 @@ export class AuthModule {
username: SYSTEM_USER_NAME,
password: config.auth.systemUserPassword,
}),
createJwtAuthService({ verifyOptions: { secret: "secret" } }), // for testing purposes, send header "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyIiwiaWF0IjoxNTE2MjM5MDIyfQ.fG9j2rVOgunoya_njgn9w1t8muFlrpE9ffJ9i8sJYsQ"
createStaticUserAuthService({ staticUser: staticUsers[0] }),
),
createAuthResolver(),
Expand All @@ -31,6 +40,7 @@ export class AuthModule {
AccessControlService,
],
exports: [UserService, AccessControlService],
imports: [JwtModule],
};
}
}
5 changes: 3 additions & 2 deletions packages/api/cms-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@
"@fast-csv/parse": "^4.3.6",
"@golevelup/nestjs-discovery": "^4.0.2",
"@hapi/accept": "^5.0.2",
"@nestjs/jwt": "^9.0.0",
"@nestjs/mapped-types": "^1.2.2",
"@nestjs/mapped-types": "^2.0.6",
"@opentelemetry/api": "^1.9.0",
"@smithy/node-http-handler": "3.1.4",
"@types/get-image-colors": "^4.0.0",
Expand Down Expand Up @@ -89,6 +88,7 @@
"@nestjs/common": "^10.4.8",
"@nestjs/core": "^10.4.8",
"@nestjs/graphql": "^12.2.1",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.4.8",
"@nestjs/testing": "^10.4.8",
"@sentry/node": "^7.0.0",
Expand Down Expand Up @@ -131,6 +131,7 @@
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/graphql": "^12.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.0.0",
"@sentry/node": "^7.0.0",
"class-validator": "^0.11.1 || ^0.12.0 || ^0.13.0",
Expand Down
4 changes: 2 additions & 2 deletions packages/api/cms-api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](
"""
scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")

type User {
type UserPermissionsUser {
id: String!
name: String!
email: String!
Expand All @@ -45,7 +45,7 @@ type CurrentUser {
email: String!
permissions: [CurrentUserPermission!]!
impersonated: Boolean
authenticatedUser: User
authenticatedUser: UserPermissionsUser
permissionsForScope(scope: JSONObject!): [String!]!
}

Expand Down
48 changes: 48 additions & 0 deletions packages/api/cms-api/src/auth/services/basic.auth-service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { UnauthorizedException } from "@nestjs/common";

import { createBasicAuthService } from "./basic.auth-service";

describe("createBasicAuthService", () => {
const createService = (username: string, password: string) => createBasicAuthService({ username, password });
const instantianteService = (username: string, password: string) => new (createBasicAuthService({ username, password }))();
const mockRequest = (authorizationHeader: string) =>
jest.fn().mockReturnValue({ header: jest.fn().mockImplementation(() => authorizationHeader) })();

it("throws error on wrong configuration", async () => {
expect(() => createService("vivid", "")).toThrowError();
expect(() => createService("", "planet")).toThrowError();
expect(() => createService("", "")).toThrowError();
});

it("returns undefined on unknown header", async () => {
const service = instantianteService("vivid", "planet");
expect(service.authenticateUser(mockRequest("Bearer"))).toBeUndefined();
expect(service.authenticateUser(mockRequest("Bearer xxx"))).toBeUndefined();
expect(service.authenticateUser(mockRequest("BasicAuth xxx"))).toBeUndefined();
expect(service.authenticateUser(mockRequest(""))).toBeUndefined();
});

it("returns undefined on non decodable payload", async () => {
const service = instantianteService("vivid", "planet");
expect(service.authenticateUser(mockRequest("Basic #"))).toBeUndefined();
});

it("returns undefined on wrong username", async () => {
const service = instantianteService("vivid", "planet");
expect(service.authenticateUser(mockRequest(`Basic ${btoa("vivit:planet")}`))).toBeUndefined();
expect(service.authenticateUser(mockRequest(`Basic ${btoa("vivit:planed")}`))).toBeUndefined();
});

it("throws UnauthorizedException on wrong password", async () => {
const service = instantianteService("vivid", "planet");
expect(() => service.authenticateUser(mockRequest(`Basic ${btoa("vivid:foo")}`))).toThrow(UnauthorizedException);
expect(() => service.authenticateUser(mockRequest(`Basic ${btoa("vivid::")}`))).toThrow(UnauthorizedException);
expect(() => service.authenticateUser(mockRequest(`Basic ${btoa("vivid")}`))).toThrow(UnauthorizedException);
expect(() => service.authenticateUser(mockRequest(`Basic ${btoa("vivid:planetfoo")}`))).toThrow(UnauthorizedException);
});

it("returns username on correct authentication", async () => {
const service = instantianteService("vivid", "planet");
expect(service.authenticateUser(mockRequest(`Basic ${btoa("vivid:planet")}`))).toBe("vivid");
});
});
72 changes: 72 additions & 0 deletions packages/api/cms-api/src/auth/services/jwt.auth-service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";

import { createJwtAuthService, JwtAuthServiceOptions } from "./jwt.auth-service";

describe("createJwtAuthService", () => {
const instantianteService = (options: JwtAuthServiceOptions) => new (createJwtAuthService(options))(new JwtService());
const mockRequest = (authorizationHeader: string) =>
jest.fn().mockReturnValue({ header: jest.fn().mockImplementation(() => authorizationHeader) })();

// Default token from jwt.io:
// {
// "sub": "1234567890",
// "name": "John Doe",
// "iat": 1516239022
// }
const token =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o";

it("returns undefined on unknown header", async () => {
const service = instantianteService({});
expect(await service.authenticateUser(mockRequest(""))).toBeUndefined();
expect(await service.authenticateUser(mockRequest("xxx"))).toBeUndefined();
expect(await service.authenticateUser(mockRequest("Bearer"))).toBeUndefined();
});

it("throws Error on missing secret password", async () => {
const service = instantianteService({});
await expect(() => service.authenticateUser(mockRequest(`Bearer ${token}`))).rejects.toThrow("secret or public key must be provided");
});

it("throws UnauthorizedException on malformed JWT", async () => {
const service = instantianteService({ verifyOptions: { secret: "secret" } });
await expect(() => service.authenticateUser(mockRequest(`Bearer XXX`))).rejects.toThrow(UnauthorizedException);
});

it("throws UnauthorizedException on wrong secret", async () => {
const service = instantianteService({ verifyOptions: { secret: "wrong secret" } });
await expect(() => service.authenticateUser(mockRequest(`Bearer ${token}`))).rejects.toThrow(UnauthorizedException);
});

it("throws UnauthorizedException on expired jwt", async () => {
const expiredToken =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjJ9.dK_h9vUldnsPtDnTil_YuzaPZT-vMODIfX_nyXDADVE";
const service = instantianteService({ verifyOptions: { secret: "secret" } });
await expect(() => service.authenticateUser(mockRequest(`Bearer ${expiredToken}`))).rejects.toThrow(UnauthorizedException);
});

it("throws UnauthorizedException on missing sub", async () => {
const tokenWithoutSub =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.8nYFUX869Y1mnDDDU4yL11aANgVRuifoxrE8BHZY1iE";
const service = instantianteService({ verifyOptions: { secret: "secret" } });
await expect(() => service.authenticateUser(mockRequest(`Bearer ${tokenWithoutSub}`))).rejects.toThrow(UnauthorizedException);
});

it("verifies token", async () => {
const service = instantianteService({ verifyOptions: { secret: "secret" } });
expect(await service.authenticateUser(mockRequest(`Bearer ${token}`))).toBe("1234567890");
});

it("verifies token and returns user", async () => {
const service = instantianteService({
verifyOptions: { secret: "secret" },
convertJwtToUser: (jwt) => ({ id: jwt.sub, name: jwt.name, email: "test@comet-dxp.com" }),
});
expect(await service.authenticateUser(mockRequest(`Bearer ${token}`))).toStrictEqual({
id: "1234567890",
name: "John Doe",
email: "test@comet-dxp.com",
});
});
});
26 changes: 18 additions & 8 deletions packages/api/cms-api/src/auth/services/jwt.auth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { AuthServiceInterface } from "../util/auth-service.interface";

type JwtPayload = { [key: string]: unknown };

interface JwtAuthServiceOptions {
export interface JwtAuthServiceOptions {
jwksOptions?: JwksRsa.Options;
verifyOptions?: JwtVerifyOptions;
tokenHeaderName?: string;
convertJwtToUser?: (jwt: JwtPayload) => Promise<User> | User;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
convertJwtToUser?: (jwt: any) => Promise<User> | User;
}

export function createJwtAuthService({ jwksOptions, verifyOptions, ...options }: JwtAuthServiceOptions): Type<AuthServiceInterface> {
Expand All @@ -24,28 +25,37 @@ export function createJwtAuthService({ jwksOptions, verifyOptions, ...options }:
if (jwksOptions) this.jwksClient = new JwksClient(jwksOptions);
}

async authenticateUser(request: Request) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async authenticateUser<T extends JwtPayload>(request: Request) {
const token = this.extractTokenFromRequest(request);
if (!token) return;

if (this.jwksClient) {
if (!verifyOptions) verifyOptions = {};
verifyOptions.secret = await this.loadSecretFromJwks(token);
}
let jwt: JwtPayload;
if (!verifyOptions?.secret) {
throw new Error("secret or public key must be provided");
}
let jwt: T;
try {
jwt = await this.jwtService.verifyAsync(token, verifyOptions);
jwt = await this.jwtService.verifyAsync<T>(token, verifyOptions);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
if (e.name === "JsonWebTokenError") {
if (e.name === "JsonWebTokenError" || e.name == "TokenExpiredError") {
throw new UnauthorizedException(e.message);
}
throw e;
}

if (options.convertJwtToUser) return options.convertJwtToUser(jwt);
if (options.convertJwtToUser) {
return options.convertJwtToUser(jwt);
}

if (typeof jwt.sub !== "string" || !jwt.sub) {
throw new UnauthorizedException("No sub found in JWT. Please implement `convertJwtToUser`");
}

if (typeof jwt.sub !== "string") throw new UnauthorizedException("No sub found in JWT. Please implement `convertJwtToUser`");
return jwt.sub;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@ export function createStaticUserAuthService(config: StaticUserAuthServiceConfig)
@Injectable()
class StaticUserAuthService implements AuthServiceInterface {
async authenticateUser() {
if (typeof config.staticUser === "string") {
return config.staticUser;
}
return config.staticUser;
}
}
Expand Down
1 change: 0 additions & 1 deletion packages/api/cms-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,4 +257,3 @@ export {
UserPermissionsUserServiceInterface,
Users,
} from "./user-permissions/user-permissions.types";
export { JwtModule } from "@nestjs/jwt";
Loading