From d97f9ce16639e27a61d103329128ef7df65b555e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=20Gramn=C3=A6s=20Tjernshaugen?= Date: Mon, 30 Sep 2024 22:41:16 +0200 Subject: [PATCH 01/19] Remove auth0 sync --- packages/auth/src/auth-options.ts | 4 - packages/core/src/modules/core.ts | 7 -- .../auth0-synchronization-service.e2e-spec.ts | 90 --------------- .../user/__test__/user-service.e2e-spec.ts | 6 - .../user/auth0-synchronization-service.ts | 106 ------------------ .../core/src/modules/user/user-service.ts | 2 +- .../src/modules/user/user-router.ts | 2 +- 7 files changed, 2 insertions(+), 215 deletions(-) delete mode 100644 packages/core/src/modules/user/__test__/auth0-synchronization-service.e2e-spec.ts delete mode 100644 packages/core/src/modules/user/auth0-synchronization-service.ts diff --git a/packages/auth/src/auth-options.ts b/packages/auth/src/auth-options.ts index 5c5f0bde9..938e2b5e5 100644 --- a/packages/auth/src/auth-options.ts +++ b/packages/auth/src/auth-options.ts @@ -74,10 +74,6 @@ export const getAuthOptions = ({ callbacks: { async session({ session, token }) { if (token.sub) { - await core.auth0SynchronizationService.populateUserWithFakeData(token.sub, token.email) // Remove when we have real data - const user = await core.auth0SynchronizationService.ensureUserLocalDbIsSynced(token.sub, new Date()) - - session.user.id = user.auth0Id session.sub = token.sub return session } diff --git a/packages/core/src/modules/core.ts b/packages/core/src/modules/core.ts index 6c4661a95..618a860d3 100644 --- a/packages/core/src/modules/core.ts +++ b/packages/core/src/modules/core.ts @@ -65,7 +65,6 @@ import { type ProductRepository, ProductRepositoryImpl } from "./payment/product import { type ProductService, ProductServiceImpl } from "./payment/product-service" import { type RefundRequestRepository, RefundRequestRepositoryImpl } from "./payment/refund-request-repository" import { type RefundRequestService, RefundRequestServiceImpl } from "./payment/refund-request-service" -import { type Auth0SynchronizationService, Auth0SynchronizationServiceImpl } from "./user/auth0-synchronization-service" import { type NotificationPermissionsRepository, NotificationPermissionsRepositoryImpl, @@ -150,11 +149,6 @@ export const createServiceLayer = async ({ db }: ServerLayerOptions) => { notificationPermissionsRepository ) - const auth0SynchronizationService: Auth0SynchronizationService = new Auth0SynchronizationServiceImpl( - userService, - auth0Repository - ) - const eventCommitteeService: EventCommitteeService = new EventCommitteeServiceImpl(committeeOrganizerRepository) const committeeService: CommitteeService = new CommitteeServiceImpl(committeeRepository) const jobListingService: JobListingService = new JobListingServiceImpl( @@ -249,6 +243,5 @@ export const createServiceLayer = async ({ db }: ServerLayerOptions) => { attendeeService, interestGroupRepository, interestGroupService, - auth0SynchronizationService, } } diff --git a/packages/core/src/modules/user/__test__/auth0-synchronization-service.e2e-spec.ts b/packages/core/src/modules/user/__test__/auth0-synchronization-service.e2e-spec.ts deleted file mode 100644 index 38d889b79..000000000 --- a/packages/core/src/modules/user/__test__/auth0-synchronization-service.e2e-spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Database } from "@dotkomonline/db" -import { createEnvironment } from "@dotkomonline/env" -import type { ManagementClient } from "auth0" -import { addHours } from "date-fns" -import type { Kysely } from "kysely" -import { describe, expect, it } from "vitest" -import { mockDeep } from "vitest-mock-extended" -import { mockAuth0UserResponse } from "../../../../mock" -import { createServiceLayerForTesting } from "../../../../vitest-integration.setup" -import { type Auth0Repository, Auth0RepositoryImpl } from "../../external/auth0-repository" -import { type Auth0SynchronizationService, Auth0SynchronizationServiceImpl } from "../auth0-synchronization-service" -import { - type NotificationPermissionsRepository, - NotificationPermissionsRepositoryImpl, -} from "../notification-permissions-repository" -import { type PrivacyPermissionsRepository, PrivacyPermissionsRepositoryImpl } from "../privacy-permissions-repository" -import { type UserRepository, UserRepositoryImpl } from "../user-repository" -import { type UserService, UserServiceImpl } from "../user-service" - -interface ServerLayerOptions { - db: Kysely - auth0MgmtClient: ManagementClient -} - -const createServiceLayer = async ({ db, auth0MgmtClient }: ServerLayerOptions) => { - const auth0Repository: Auth0Repository = new Auth0RepositoryImpl(auth0MgmtClient) - - const userRepository: UserRepository = new UserRepositoryImpl(db) - const privacyPermissionsRepository: PrivacyPermissionsRepository = new PrivacyPermissionsRepositoryImpl(db) - const notificationPermissionsRepository: NotificationPermissionsRepository = - new NotificationPermissionsRepositoryImpl(db) - - const userService: UserService = new UserServiceImpl( - userRepository, - privacyPermissionsRepository, - notificationPermissionsRepository - ) - - const auth0SynchronizationService: Auth0SynchronizationService = new Auth0SynchronizationServiceImpl( - userService, - auth0Repository - ) - - return { - userService, - auth0Repository, - auth0SynchronizationService, - } -} - -describe("auth0 sync service", () => { - it("verifies synchronization works", async () => { - // Set up test db and service layer with a mocked Auth0 management client. - const env = createEnvironment() - const context = await createServiceLayerForTesting(env, "auth0_synchronization") - const auth0Mock = mockDeep() - const core = await createServiceLayer({ db: context.kysely, auth0MgmtClient: auth0Mock }) - - const auth0Id = "auth0|00000000-0000-0000-0000-000000000000" - const email = "starting-email@local.com" - const now = new Date("2021-01-01T00:00:00Z") - - const updatedWithFakeDataUser = mockAuth0UserResponse({ email, auth0Id }, 200) - auth0Mock.users.get.mockResolvedValue(updatedWithFakeDataUser) - - // first sync down to the local db. Should create user row in the db and populate with fake data. - const syncedUser = await core.auth0SynchronizationService.ensureUserLocalDbIsSynced(auth0Id, now) - - const dbUser = await core.userService.getById(syncedUser.id) - expect(dbUser).toEqual(syncedUser) - - // Simulate an email change in the Auth0 dashboard or something. - const updatedMail = "changed-in-dashboard@local.com" - auth0Mock.users.get.mockResolvedValue(mockAuth0UserResponse({ email: updatedMail }, 200)) - - // Run synchroinization again, simulating doing it 1hr later. However, since the user was just synced, the synchronization should not occur. - const oneHourLater = addHours(now, 1) - const updatedDbUser = await core.auth0SynchronizationService.ensureUserLocalDbIsSynced(auth0Id, oneHourLater) - expect(updatedDbUser).not.toHaveProperty("email", updatedMail) - - // Attempt to sync the user again 25 hours after first sync. This time, the synchronization should occur. - const twentyFiveHoursLater = addHours(now, 25) - await core.auth0SynchronizationService.ensureUserLocalDbIsSynced(auth0Id, twentyFiveHoursLater) - - expect(updatedDbUser).not.toHaveProperty("email", updatedMail) - - // Clean up the testing context. - await context.cleanup() - }) -}) diff --git a/packages/core/src/modules/user/__test__/user-service.e2e-spec.ts b/packages/core/src/modules/user/__test__/user-service.e2e-spec.ts index e2da48935..c90f861b3 100644 --- a/packages/core/src/modules/user/__test__/user-service.e2e-spec.ts +++ b/packages/core/src/modules/user/__test__/user-service.e2e-spec.ts @@ -9,7 +9,6 @@ import { type CleanupFunction, createServiceLayerForTesting } from "../../../../ import type { Database } from "@dotkomonline/db" import type { Kysely } from "kysely" import { type Auth0Repository, Auth0RepositoryImpl } from "../../external/auth0-repository" -import { type Auth0SynchronizationService, Auth0SynchronizationServiceImpl } from "../auth0-synchronization-service" import { type NotificationPermissionsRepository, NotificationPermissionsRepositoryImpl, @@ -39,11 +38,6 @@ const createServiceLayer = async ({ db, auth0MgmtClient }: ServerLayerOptions) = notificationPermissionsRepository ) - const syncedUserService: Auth0SynchronizationService = new Auth0SynchronizationServiceImpl( - userService, - auth0Repository - ) - return { userService, auth0Repository, diff --git a/packages/core/src/modules/user/auth0-synchronization-service.ts b/packages/core/src/modules/user/auth0-synchronization-service.ts deleted file mode 100644 index 8aa2f983b..000000000 --- a/packages/core/src/modules/user/auth0-synchronization-service.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { type Logger, getLogger } from "@dotkomonline/logger" -import type { User, UserWrite } from "@dotkomonline/types" -import { addDays } from "date-fns" -import { Auth0UserNotFoundError } from "../external/auth0-errors" -import type { Auth0Repository } from "../external/auth0-repository" -import type { UserService } from "./user-service" - -export interface Auth0SynchronizationService { - updateUserInAuth0AndLocalDb(payload: UserWrite): Promise - ensureUserLocalDbIsSynced(sub: string, now: Date): Promise - // The frontend for onboarding users with fake data is not implemented yet. This is a temporary solution for DX purposes so we can work with users with poulate data. - // When the onboarding is implemented, this method should be removed. - populateUserWithFakeData(auth0Id: string, email?: string | null): Promise -} - -// Until we have gather this data from the user, this fake data is used as the initial data for new users -const FAKE_USER_EXTRA_SIGNUP_DATA: Omit = { - givenName: "firstName", - familyName: "lastName", - middleName: "middleName", - name: "firstName middleName lastName", - allergies: ["allergy1", "allergy2"], - picture: "https://example.com/image.jpg", - studyYear: -1, - lastSyncedAt: new Date(), - phone: "12345678", - gender: "male", -} - -export class Auth0SynchronizationServiceImpl implements Auth0SynchronizationService { - private readonly logger: Logger = getLogger(Auth0SynchronizationServiceImpl.name) - constructor( - private readonly userService: UserService, - private readonly auth0Repository: Auth0Repository - ) {} - - async populateUserWithFakeData(auth0Id: string, email?: string | null) { - if (!email) { - throw new Error("Did not get email in jwt") - } - - try { - // This fails if the user already exists - const user = await this.userService.create({ - ...FAKE_USER_EXTRA_SIGNUP_DATA, - email: email, - auth0Id: auth0Id, - }) - - await this.updateUserInAuth0AndLocalDb(user) - - this.logger.info("info", "Populated user with fake data", { userId: user.id }) - } catch (error) { - // User already exists, ignore duplicate key value violates unique constraint error from postgres - } - } - - async updateUserInAuth0AndLocalDb(data: UserWrite) { - const result = await this.auth0Repository.update(data.auth0Id, data) - await this.synchronizeUserAuth0ToLocalDb(result) - return result - } - - private async synchronizeUserAuth0ToLocalDb(userAuth0: User) { - this.logger.info("Synchronizing user with Auth0 id %O", { userId: userAuth0.auth0Id }) - - const updatedUser: User = { - ...userAuth0, - lastSyncedAt: new Date(), - } - - const userDb = await this.userService.getByAuth0Id(userAuth0.auth0Id) - - if (userDb === null) { - this.logger.info("User does not exist in local db, creating user for user %O", userAuth0.name) - return this.userService.create(updatedUser) - } - - this.logger.info("Updating user in local db for user %O", userAuth0.name) - return this.userService.update(updatedUser) - } - - /** - * Syncs down user if not synced within the last 24 hours. - * @param auth0UserId The Auth0 subject of the user to synchronize. - * @returns User - */ - async ensureUserLocalDbIsSynced(auth0UserId: string, now: Date) { - const user = await this.userService.getByAuth0Id(auth0UserId) - - const oneDayAgo = addDays(now, -1) - const userDoesNotNeedSync = user !== null && oneDayAgo < user.lastSyncedAt - - if (userDoesNotNeedSync) { - return user - } - - const userAuth0 = await this.auth0Repository.getByAuth0UserId(auth0UserId) - - if (userAuth0 === null) { - throw new Auth0UserNotFoundError(auth0UserId) - } - - return this.synchronizeUserAuth0ToLocalDb(userAuth0) - } -} diff --git a/packages/core/src/modules/user/user-service.ts b/packages/core/src/modules/user/user-service.ts index dfdbebad6..4dbeac380 100644 --- a/packages/core/src/modules/user/user-service.ts +++ b/packages/core/src/modules/user/user-service.ts @@ -22,7 +22,7 @@ export interface UserService { ): Promise searchByFullName(searchQuery: string, take: number, cursor?: Cursor): Promise create(data: UserWrite): Promise - update(data: User): Promise + update(data: UserWrite): Promise getByAuth0Id(auth0Id: string): Promise } diff --git a/packages/gateway-trpc/src/modules/user/user-router.ts b/packages/gateway-trpc/src/modules/user/user-router.ts index a3101cb30..e865abc47 100644 --- a/packages/gateway-trpc/src/modules/user/user-router.ts +++ b/packages/gateway-trpc/src/modules/user/user-router.ts @@ -14,7 +14,7 @@ export const userRouter = t.router({ }) ) .mutation(async ({ input: changes, ctx }) => - ctx.auth0SynchronizationService.updateUserInAuth0AndLocalDb(changes.data) + ctx.userService.update(changes.data) ), getPrivacyPermissionssByUserId: protectedProcedure .input(z.string()) From 70b6ece2eed37e48c4aed3d0b72f2494a097ee63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=20Gramn=C3=A6s=20Tjernshaugen?= Date: Wed, 2 Oct 2024 13:36:19 +0200 Subject: [PATCH 02/19] Add feide authentication --- apps/web/next-env.d.ts | 2 +- apps/web/package.json | 1 + apps/web/src/app/feide/callback/route.ts | 125 ++++++++++++++++ apps/web/src/app/feide/route.ts | 13 ++ apps/web/src/app/settings/page.tsx | 11 +- .../components/SettingsLanding.tsx | 50 ------- .../components/SettingsProfile.tsx | 139 ++++++++++++++++++ .../views/SettingsView/components/index.tsx | 2 +- apps/web/src/utils/trpc/serverClient.ts | 12 +- packages/auth/src/auth-options.ts | 9 +- packages/env/index.d.ts | 3 + packages/env/src/env.mjs | 8 + .../src/modules/user/user-router.ts | 7 +- pnpm-lock.yaml | 97 +++--------- turbo.json | 5 +- 15 files changed, 338 insertions(+), 146 deletions(-) create mode 100644 apps/web/src/app/feide/callback/route.ts create mode 100644 apps/web/src/app/feide/route.ts delete mode 100644 apps/web/src/components/views/SettingsView/components/SettingsLanding.tsx create mode 100644 apps/web/src/components/views/SettingsView/components/SettingsProfile.tsx diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index fd36f9494..725dd6f24 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -3,4 +3,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index bc171c0ca..1039075d3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -47,6 +47,7 @@ "next-auth": "^4.24.5", "next-superjson-plugin": "^0.6.0", "next-themes": "^0.3.0", + "openid-client": "^5.7.0", "pg": "^8.11.3", "qrcode.react": "^3.1.0", "react": "^18.2.0", diff --git a/apps/web/src/app/feide/callback/route.ts b/apps/web/src/app/feide/callback/route.ts new file mode 100644 index 000000000..9e2c62ece --- /dev/null +++ b/apps/web/src/app/feide/callback/route.ts @@ -0,0 +1,125 @@ +import { authOptions } from "@dotkomonline/auth/src/web.app"; +import { getServerSession } from "next-auth"; +import { env } from "@dotkomonline/env"; +import { NextRequest, NextResponse } from "next/server"; +import { Issuer } from "openid-client"; +import jwt from "jsonwebtoken"; +import { z } from "zod"; + +const GroupSchema = z.object({ + id: z.string(), + type: z.string(), + displayName: z.string(), +}) + +const ProfileSchema = z.object({ + norEduPersonLegalName: z.string(), + uid: z.array(z.string()), + sn: z.array(z.string()).length(1), + givenName: z.array(z.string()).length(1), +}) + +async function getFeideInformation(access_token: string) { + const groups_response = await fetch("https://groups-api.dataporten.no/groups/me/groups?show_all=true", { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + + if (!groups_response.ok) { + throw new Error("Failed to get groups: " + await groups_response.text()); + } + + const groups = z.array(GroupSchema).parse(await groups_response.json()); + + const subjects = groups + .filter((group) => group.type === "fc:fs:emne") + .map((group) => ({code: group.id.split(":").slice(5)[0], name: group.displayName})); + + const studyPrograms = groups + .filter((group) => group.type === "fc:fs:prg") + .map((group) => ({code: group.id.split(":").slice(5)[0], name: group.displayName})); + + return {subjects, studyPrograms}; +} + +const JWTSchema = z.object({ + name: z.string(), + firstName: z.string(), + lastName: z.string(), + ntnu_username: z.string(), + subjects: z.array(z.object({code: z.string(), name: z.string()})), + studyPrograms: z.array(z.object({code: z.string(), name: z.string()})), +}) + +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + + if (session === null) { + return Response.redirect("/", 302); + } + + const code = new URL(request.url).searchParams.get("code"); + + if (code === null) { + return new Response("Missing code", { status: 400 }); + } + + const issuer = await Issuer.discover("https://auth.dataporten.no") + const client = new issuer.Client({ + client_id: env.DATAPORTEN_CLIENT_ID, + client_secret: env.DATAPORTEN_CLIENT_SECRET, + redirect_uris: [env.DATAPORTEN_REDIRECT_URI], + }); + + const tokenSet = await client.grant({ + code, + redirect_uri: env.DATAPORTEN_REDIRECT_URI, + grant_type: "authorization_code", + }); + + if (!tokenSet || !tokenSet.access_token) { + return new Response("Failed to get token", { status: 500 }); + } + + const feideInformation = await getFeideInformation(tokenSet.access_token); + + const profile_response = await fetch("https://api.dataporten.no/userinfo/v1/userinfo", { + headers: { + Authorization: `Bearer ${tokenSet.access_token}`, + }, + }); + + if (!profile_response.ok) { + return new Response("Failed to get profile: " + await profile_response.text(), { status: 500 }); + } + + const profile = ProfileSchema.parse(await profile_response.json()); + + const token = jwt.sign( + { + name: profile.norEduPersonLegalName, + firstName: profile.givenName[0], + lastName: profile.sn[0], + ntnu_username: profile.uid[0], + subjects: feideInformation.subjects, + studyPrograms: feideInformation.studyPrograms, + }, + env.NEXTAUTH_SECRET, + { expiresIn: "1d" } + ); + + const response = NextResponse.redirect( + new URL("/settings", request.url).toString(), 302 + ); + + response.cookies.set("FeideProfileJWT", token, { + maxAge: Date.now() + 1000 * 60 * 60 * 24, + }); + + response.headers.set("Set-Cookie", "test=1"); + + response.cookies.set("test", "1"); + + return response; +} diff --git a/apps/web/src/app/feide/route.ts b/apps/web/src/app/feide/route.ts new file mode 100644 index 000000000..a815cd067 --- /dev/null +++ b/apps/web/src/app/feide/route.ts @@ -0,0 +1,13 @@ +import { redirect } from "next/navigation"; +import { env } from "@dotkomonline/env"; + +export async function GET(request: Request) { + const params = new URLSearchParams(); + + params.append("client_id", env.DATAPORTEN_CLIENT_ID); + params.append("response_type", "code"); + params.append("scope", "openid userid-feide userid-name profile groups email"); + params.append("redirect_uri", env.DATAPORTEN_REDIRECT_URI); + + redirect("https://auth.dataporten.no/oauth/authorization?" + params.toString()); +} diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 200b5c8d0..201ffc179 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -1,15 +1,20 @@ -import { SettingsLanding } from "@/components/views/SettingsView/components" +import { ExistingProfile } from "@/components/views/SettingsView/components" +import { getServerClient } from "@/utils/trpc/serverClient" +import { authOptions } from "@dotkomonline/auth/src/web.app" import { getServerSession } from "next-auth" import { redirect } from "next/navigation" const SettingsPage = async () => { - const session = await getServerSession() + const trpc = await getServerClient() + const session = await getServerSession(authOptions) if (session === null) { redirect("/") } - return + const user = await trpc.user.getMe() + + return } export default SettingsPage diff --git a/apps/web/src/components/views/SettingsView/components/SettingsLanding.tsx b/apps/web/src/components/views/SettingsView/components/SettingsLanding.tsx deleted file mode 100644 index fa9dfc007..000000000 --- a/apps/web/src/components/views/SettingsView/components/SettingsLanding.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import AvatarImgChange from "@/app/settings/components/ChangeAvatar" -import { CountryCodeSelect } from "@/app/settings/components/CountryCodeSelect" -import { TextInput, Textarea } from "@dotkomonline/ui" -import type { NextPage } from "next" -import type { User } from "next-auth" - -interface FormInputProps { - title: string - children?: JSX.Element -} - -const FormInput: React.FC = ({ title, children }) => ( -
-
{title}:
-
{children}
-
-) - -const Landing: NextPage<{ user: User }> = ({ user }) => { - return ( -
-
- -
- -
- - -
-
- - - - -
- - -
-
- -