Skip to content

Commit

Permalink
Sync all user data to local db table (#825)
Browse files Browse the repository at this point in the history
Change approach for how we store user data.
  • Loading branch information
henrikskog authored Mar 8, 2024
1 parent f6f4279 commit 98deb99
Show file tree
Hide file tree
Showing 22 changed files with 329 additions and 38 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ env:
FAGKOM_STRIPE_SECRET_KEY: ${{ secrets.FAGKOM_STRIPE_SECRET_KEY }}
FAGKOM_STRIPE_WEBHOOK_SECRET: ${{ secrets.FAGKOM_STRIPE_WEBHOOK_SECRET }}
S3_BUCKET_MONOWEB: ${{ secrets.S3_BUCKET_MONOWEB }}
GTX_AUTH0_CLIENT_ID: ${{ secrets.GTX_AUTH0_CLIENT_ID }}
GTX_AUTH0_CLIENT_SECRET: ${{ secrets.GTX_AUTH0_CLIENT_SECRET }}
GTX_AUTH0_ISSUER: ${{ secrets.GTX_AUTH0_ISSUER }}

jobs:
build:
Expand Down
1 change: 1 addition & 0 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@dotkomonline/core": "workspace:*",
"@dotkomonline/env": "workspace:*",
"@dotkomonline/db": "workspace:*",
"@dotkomonline/types": "workspace:*",
"next": "^14.0.3",
"next-auth": "^4.24.5",
"react": "^18.2.0",
Expand Down
38 changes: 34 additions & 4 deletions packages/auth/src/auth-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@ import { type ServiceLayer } from "@dotkomonline/core"
import { type DefaultSession, type DefaultUser, type User, type NextAuthOptions } from "next-auth"
import Auth0Provider from "next-auth/providers/auth0"

interface Auth0IdTokenClaims {
given_name: string
family_name: string
nickname: string
name: string
picture: string
gender: string
updated_at: string
email: string
email_verified: boolean
iss: string
aud: string
iat: number
exp: number
sub: string
sid: string
}

declare module "next-auth" {
interface Session extends DefaultSession {
user: User
Expand All @@ -14,6 +32,8 @@ declare module "next-auth" {
name: string
email: string
image?: string
givenName?: string
familyName?: string
}
}

Expand All @@ -38,11 +58,13 @@ export const getAuthOptions = ({
clientId: oidcClientId,
clientSecret: oidcClientSecret,
issuer: oidcIssuer,
profile: (profile): User => ({
profile: (profile: Auth0IdTokenClaims): User => ({
id: profile.sub,
name: `${profile.given_name} ${profile.family_name}`,
name: profile.name,
email: profile.email,
image: profile.picture ?? undefined,
// givenName: profile.given_name,
// familyName: profile.family_name,
}),
}),
],
Expand All @@ -52,10 +74,18 @@ export const getAuthOptions = ({
callbacks: {
async session({ session, token }) {
if (token.sub) {
let user = await core.userService.getUserBySubject(token.sub)
const user = await core.userService.getUserBySubject(token.sub)

if (user === undefined) {
user = await core.userService.createUser({ auth0Sub: token.sub, studyYear: -1 })
const newUser = await core.auth0SynchronizationService.createUser(token)

session.user.id = newUser.id
session.sub = token.sub
return session
}

await core.auth0SynchronizationService.synchronizeUser(user)

session.user.id = user.id
session.sub = token.sub
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { getLogger } from "@dotkomonline/logger"

export const logger = getLogger("core")
5 changes: 5 additions & 0 deletions packages/core/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { addWeeks, addYears } from "date-fns"
export const getUserMock = (defaults: Partial<UserWrite> = {}): UserWrite => ({
auth0Sub: "8697a463-46fe-49c2-b74c-f6cc98358298",
studyYear: 0,
email: "[email protected]",
// givenName: "Test",
// familyName: "User",
name: "Test User",
lastSyncedAt: null,
...defaults,
})

Expand Down
4 changes: 3 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
"dependencies": {
"@dotkomonline/env": "workspace:*",
"@dotkomonline/db": "workspace:*",
"@dotkomonline/logger": "workspace:*",
"date-fns": "^2.30.0",
"kysely": "^0.26.3",
"stripe": "^13.11.0",
"zod": "^3.22.4",
"@aws-sdk/client-s3": "^3.507.0",
"@aws-sdk/s3-presigned-post": "^3.507.0",
"ulid": "^2.3.0"
"ulid": "^2.3.0",
"auth0": "^4.3.1"
},
"peerDependencies": {
"next": "^14.0.3"
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/lib/auth0-repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { OidcUser } from "@dotkomonline/types"
import { ManagementApiError, ManagementClient } from "auth0"

export interface Auth0Repository {
getBySubject(sub: string): Promise<OidcUser | null>
}

export class Auth0RepositoryImpl implements Auth0Repository {
constructor(private readonly client: ManagementClient) {}
async getBySubject(sub: string) {
try {
const res = await this.client.users.get({
id: sub,
})

const parsed = OidcUser.parse({
email: res.data.email,
subject: res.data.user_id,
name: res.data.name,
// familyName: res.data.family_name,
// givenName: res.data.given_name,
})

return parsed
} catch (e) {
if (e instanceof ManagementApiError) {
if (e.errorCode === "inexistent_user") {
return null
}
}

// Error was caused by other reasons than user not existing
throw e
}
}
}
83 changes: 83 additions & 0 deletions packages/core/src/lib/auth0-synchronization-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { User, UserWrite } from "@dotkomonline/types"
import { UserService } from "../modules/user/user-service"
import { Auth0Repository } from "./auth0-repository"
import { logger } from "../../logger"

// Id token returned from Auth0. We don't want core to depend on next-auth, so we duplicate the type here.
type Auth0IdToken = {
email?: string | null
sub?: string | null
name?: string | null
givenName?: string | null
familyName?: string | null
}

/**
* Synchronize users in a local database user table with Auth0.
*/
export interface Auth0SynchronizationService {
/**
* If no record of the user exists in the local database, save it to the database.
*/
createUser: (token: Auth0IdToken) => Promise<User>

/**
* Synchronize record of user in database with user data from Auth0.
*/
synchronizeUser: (user: User) => Promise<User | null>
}

export class Auth0SynchronizationServiceImpl implements Auth0SynchronizationService {
constructor(
private readonly userService: UserService,
private readonly auth0Repository: Auth0Repository
) {}

// TODO: Include givenName and familyName when we gather this from our users.
async createUser(token: Auth0IdToken) {
if (
!token.email ||
!token.sub ||
!token.name
// || !token.givenName || !token.familyName
) {
throw new Error("Missing user data in claims")
}

const userData: UserWrite = {
auth0Sub: token.sub,
studyYear: -1,
email: token.email,
name: token.name,
// givenName: token.givenName,
// familyName: token.familyName,
lastSyncedAt: new Date(),
}

return this.userService.createUser(userData)
}

// if user.updatedAt is more than 1 day ago, update user
async synchronizeUser(user: User) {
const oneDay = 1000 * 60 * 60 * 24
const oneDayAgo = new Date(Date.now() - oneDay)
if (!user.lastSyncedAt || user.lastSyncedAt < oneDayAgo) {
logger.log("info", "Synchronizing user with Auth0", { userId: user.id })
const idpUser = await this.auth0Repository.getBySubject(user.auth0Sub)

if (idpUser === null) {
throw new Error("User does not exist in Auth0")
}

return this.userService.updateUser(user.id, {
email: idpUser.email,
name: idpUser.name,
lastSyncedAt: new Date(),
// givenName: idpUser.givenName,
// familyName: idpUser.familyName,
})
}

return null
}
}
18 changes: 18 additions & 0 deletions packages/core/src/modules/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ import {
import { type UserRepository, UserRepositoryImpl } from "./user/user-repository"
import { type UserService, UserServiceImpl } from "./user/user-service"
import { type S3Repository, s3RepositoryImpl } from "../lib/s3/s3-repository"
import { Auth0RepositoryImpl, Auth0Repository } from "../lib/auth0-repository"
import { ManagementClient } from "auth0"
import { env } from "@dotkomonline/env"
import { Auth0SynchronizationService, Auth0SynchronizationServiceImpl } from "../lib/auth0-synchronization-service"

export type ServiceLayer = Awaited<ReturnType<typeof createServiceLayer>>

Expand All @@ -68,6 +72,13 @@ export interface ServerLayerOptions {

export const createServiceLayer = async ({ db }: ServerLayerOptions) => {
const s3Repository: S3Repository = new s3RepositoryImpl()
const auth0ManagementClient = new ManagementClient({
domain: env.GTX_AUTH0_ISSUER,
clientSecret: env.GTX_AUTH0_CLIENT_SECRET,
clientId: env.GTX_AUTH0_CLIENT_ID,
})
const auth0Repository: Auth0Repository = new Auth0RepositoryImpl(auth0ManagementClient)

const eventRepository: EventRepository = new EventRepositoryImpl(db)
const committeeRepository: CommitteeRepository = new CommitteeRepositoryImpl(db)
const jobListingRepository: JobListingRepository = new JobListingRepositoryImpl(db)
Expand Down Expand Up @@ -139,6 +150,11 @@ export const createServiceLayer = async ({ db }: ServerLayerOptions) => {
articleTagLinkRepository
)

const auth0SynchronizationService: Auth0SynchronizationService = new Auth0SynchronizationServiceImpl(
userService,
auth0Repository
)

return {
userService,
eventService,
Expand All @@ -157,5 +173,7 @@ export const createServiceLayer = async ({ db }: ServerLayerOptions) => {
jobListingService,
offlineService,
articleService,
auth0Repository,
auth0SynchronizationService,
}
}
1 change: 0 additions & 1 deletion packages/core/src/modules/event/attendance-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ export class AttendanceRepositoryImpl implements AttendanceRepository {
.returningAll()
.executeTakeFirstOrThrow()
.catch((err) => console.log(err))
console.log({ res })
return AttendeeSchema.parse(res)
}

Expand Down
38 changes: 16 additions & 22 deletions packages/core/src/modules/user/__test__/user-service.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ import { ulid } from "ulid"
import { createEnvironment } from "@dotkomonline/env"
import { createKysely } from "@dotkomonline/db"
import { createServiceLayer, type ServiceLayer } from "../../core"
import { UserWrite } from "@dotkomonline/types"

const fakeUser = (subject?: string): UserWrite => ({
auth0Sub: subject ?? crypto.randomUUID(),
studyYear: 0,
email: "[email protected]",
// givenName: "Test",
// familyName: "User",
name: "Test User",
lastSyncedAt: null,
})

describe("users", () => {
let core: ServiceLayer
Expand All @@ -18,35 +29,21 @@ describe("users", () => {
const none = await core.userService.getAllUsers(100)
expect(none).toHaveLength(0)

const user = await core.userService.createUser({
auth0Sub: crypto.randomUUID(),
studyYear: 0,
})
const user = await core.userService.createUser(fakeUser())

const users = await core.userService.getAllUsers(100)
expect(users).toContainEqual(user)
})

it("will not allow two users the same subject", async () => {
const subject = crypto.randomUUID()
const first = await core.userService.createUser({
auth0Sub: subject,
studyYear: 0,
})
const first = await core.userService.createUser(fakeUser(subject))
expect(first).toBeDefined()
await expect(
core.userService.createUser({
auth0Sub: subject,
studyYear: 0,
})
).rejects.toThrow()
await expect(core.userService.createUser(fakeUser(subject))).rejects.toThrow()
})

it("will find users by their user id", async () => {
const user = await core.userService.createUser({
auth0Sub: crypto.randomUUID(),
studyYear: 0,
})
const user = await core.userService.createUser(fakeUser())

const match = await core.userService.getUserById(user.id)
expect(match).toEqual(user)
Expand All @@ -60,10 +57,7 @@ describe("users", () => {
auth0Sub: crypto.randomUUID(),
})
).rejects.toThrow()
const user = await core.userService.createUser({
auth0Sub: crypto.randomUUID(),
studyYear: 0,
})
const user = await core.userService.createUser(fakeUser())
const updated = await core.userService.updateUser(user.id, {
auth0Sub: crypto.randomUUID(),
})
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/modules/user/user-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export class UserRepositoryImpl implements UserRepository {
const user = await this.db
.updateTable("owUser")
.set(data)
.set({
updatedAt: new Date(),
})
.where("id", "=", id)
.returningAll()
.executeTakeFirstOrThrow()
Expand Down
6 changes: 5 additions & 1 deletion packages/db/src/db.generated.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ColumnType } from "kysely"
import type { ColumnType } from "kysely"

export type EventStatus = "ATTENDANCE" | "NO_LIMIT" | "PUBLIC" | "TBA"

Expand Down Expand Up @@ -192,8 +192,12 @@ export interface Offline {
export interface OwUser {
auth0Sub: string | null
createdAt: Generated<Timestamp>
email: string
id: Generated<string>
lastSyncedAt: Timestamp | null
name: string
studyYear: Generated<number>
updatedAt: Generated<Timestamp>
}

export interface Payment {
Expand Down
Loading

0 comments on commit 98deb99

Please sign in to comment.