diff --git a/.github/workflows/invite-guest.yml b/.github/workflows/invite-guest.yml
index e04d7347..b1e4904b 100644
--- a/.github/workflows/invite-guest.yml
+++ b/.github/workflows/invite-guest.yml
@@ -16,7 +16,7 @@ on:
description: E-mail address to send invitation to
required: true
roles:
- description: Comma-separated list of roles
+ description: Comma-separated list of repositories user needs access to
required: true
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN_SHAPE_DOCS }}
diff --git a/__test__/auth/AccessTokenService.test.ts b/__test__/auth/AccessTokenService.test.ts
index 4ad0edbf..9c0024c5 100644
--- a/__test__/auth/AccessTokenService.test.ts
+++ b/__test__/auth/AccessTokenService.test.ts
@@ -1,86 +1,121 @@
import { AccessTokenService } from "../../src/features/auth/domain"
-import { OAuthToken } from "../../src/features/auth/domain"
-test("It gets the access token for the user", async () => {
- let readUserID: string | undefined
+test("It reads the access token for a guest user", async () => {
+ let didReadAccessToken = false
const sut = new AccessTokenService({
- userIdReader: {
- async getUserId() {
- return "1234"
+ isGuestReader: {
+ async getIsGuest() {
+ return true
}
},
- repository: {
- async get(userId) {
- readUserID = userId
- return { accessToken: "foo", refreshToken: "bar" }
+ guestAccessTokenService: {
+ async getAccessToken() {
+ didReadAccessToken = true
+ return "oldAccessToken"
},
- async set() {},
- async delete() {},
+ async refreshAccessToken() {
+ return "newAccessToken"
+ }
},
- refresher: {
- async refreshOAuthToken() {
- return { accessToken: "foo", refreshToken: "bar" }
+ hostAccessTokenService: {
+ async getAccessToken() {
+ return "oldAccessToken"
+ },
+ async refreshAccessToken() {
+ return "newAccessToken"
}
}
})
- const accessToken = await sut.getAccessToken()
- expect(readUserID).toBe("1234")
- expect(accessToken).toBe("foo")
+ await sut.getAccessToken()
+ expect(didReadAccessToken).toBeTruthy()
})
-test("It refreshes OAuth using stored refresh token", async () => {
- let usedRefreshToken: string | undefined
+test("It refreshes the access token for a guest user", async () => {
+ let didRefreshAccessToken = false
const sut = new AccessTokenService({
- userIdReader: {
- async getUserId() {
- return "1234"
+ isGuestReader: {
+ async getIsGuest() {
+ return true
}
},
- repository: {
- async get() {
- return { accessToken: "oldAccessToken", refreshToken: "oldRefreshToken" }
+ guestAccessTokenService: {
+ async getAccessToken() {
+ return "oldAccessToken"
},
- async set() {},
- async delete() {},
+ async refreshAccessToken() {
+ didRefreshAccessToken = true
+ return "newAccessToken"
+ }
},
- refresher: {
- async refreshOAuthToken(refreshToken) {
- usedRefreshToken = refreshToken
- return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" }
+ hostAccessTokenService: {
+ async getAccessToken() {
+ return "oldAccessToken"
+ },
+ async refreshAccessToken() {
+ return "newAccessToken"
}
}
})
await sut.refreshAccessToken("oldAccessToken")
- expect(usedRefreshToken).toBe("oldRefreshToken")
+ expect(didRefreshAccessToken).toBeTruthy()
})
-test("It stores the new OAuth token for the user", async () => {
- let storedUserId: string | undefined
- let storedOAuthToken: OAuthToken | undefined
+test("It reads the access token for a host user", async () => {
+ let didReadAccessToken = false
const sut = new AccessTokenService({
- userIdReader: {
- async getUserId() {
- return "1234"
+ isGuestReader: {
+ async getIsGuest() {
+ return false
}
},
- repository: {
- async get() {
- return { accessToken: "oldAccessToken", refreshToken: "oldRefreshToken" }
+ guestAccessTokenService: {
+ async getAccessToken() {
+ return "oldAccessToken"
},
- async set(userId, oAuthToken) {
- storedUserId = userId
- storedOAuthToken = oAuthToken
+ async refreshAccessToken() {
+ return "newAccessToken"
+ }
+ },
+ hostAccessTokenService: {
+ async getAccessToken() {
+ didReadAccessToken = true
+ return "oldAccessToken"
+ },
+ async refreshAccessToken() {
+ return "newAccessToken"
+ }
+ }
+ })
+ await sut.getAccessToken()
+ expect(didReadAccessToken).toBeTruthy()
+})
+
+test("It refreshes the access token for a host user", async () => {
+ let didRefreshAccessToken = false
+ const sut = new AccessTokenService({
+ isGuestReader: {
+ async getIsGuest() {
+ return false
+ }
+ },
+ guestAccessTokenService: {
+ async getAccessToken() {
+ return "oldAccessToken"
},
- async delete() {},
+ async refreshAccessToken() {
+ return "newAccessToken"
+ }
},
- refresher: {
- async refreshOAuthToken() {
- return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" }
+ hostAccessTokenService: {
+ async getAccessToken() {
+ return "oldAccessToken"
+ },
+ async refreshAccessToken() {
+ didRefreshAccessToken = true
+ return "newAccessToken"
}
}
})
- await sut.refreshAccessToken("foo")
- expect(storedUserId).toBe("1234")
- expect(storedOAuthToken?.accessToken).toBe("newAccessToken")
- expect(storedOAuthToken?.refreshToken).toBe("newRefreshToken")
+ await sut.refreshAccessToken("oldAccessToken")
+ expect(didRefreshAccessToken).toBeTruthy()
})
diff --git a/__test__/auth/CachingRepositoryAccessReaderConfig.ts b/__test__/auth/CachingRepositoryAccessReaderConfig.ts
new file mode 100644
index 00000000..8833cc64
--- /dev/null
+++ b/__test__/auth/CachingRepositoryAccessReaderConfig.ts
@@ -0,0 +1,94 @@
+import { CachingRepositoryAccessReaderConfig } from "../../src/features/auth/domain"
+
+test("It fetches repository names for user if they are not cached", async () => {
+ let didFetchRepositoryNames = false
+ let requestedUserId: string | undefined
+ const sut = new CachingRepositoryAccessReaderConfig({
+ repository: {
+ async get() {
+ return null
+ },
+ async set() {},
+ async setExpiring() {},
+ async delete() {}
+ },
+ repositoryAccessReader: {
+ async getRepositoryNames(userId: string) {
+ didFetchRepositoryNames = true
+ requestedUserId = userId
+ return []
+ }
+ }
+ })
+ await sut.getRepositoryNames("1234")
+ expect(didFetchRepositoryNames).toBeTruthy()
+ expect(requestedUserId).toEqual("1234")
+})
+
+test("It does not fetch repository names if they are cached", async () => {
+ let didFetchRepositoryNames = false
+ const sut = new CachingRepositoryAccessReaderConfig({
+ repository: {
+ async get() {
+ return "[\"foo\"]"
+ },
+ async set() {},
+ async setExpiring() {},
+ async delete() {}
+ },
+ repositoryAccessReader: {
+ async getRepositoryNames() {
+ didFetchRepositoryNames = true
+ return []
+ }
+ }
+ })
+ await sut.getRepositoryNames("1234")
+ expect(didFetchRepositoryNames).toBeFalsy()
+})
+
+test("It caches fetched repository names for user", async () => {
+ let cachedUserId: string | undefined
+ let cachedRepositoryNames: string | undefined
+ const sut = new CachingRepositoryAccessReaderConfig({
+ repository: {
+ async get() {
+ return null
+ },
+ async set(userId, value) {
+ cachedUserId = userId
+ cachedRepositoryNames = value
+ },
+ async setExpiring() {},
+ async delete() {}
+ },
+ repositoryAccessReader: {
+ async getRepositoryNames() {
+ return ["foo"]
+ }
+ }
+ })
+ await sut.getRepositoryNames("1234")
+ expect(cachedUserId).toEqual("1234")
+ expect(cachedRepositoryNames).toEqual("[\"foo\"]")
+})
+
+test("It decodes cached repository names", async () => {
+ const sut = new CachingRepositoryAccessReaderConfig({
+ repository: {
+ async get() {
+ return "[\"foo\",\"bar\"]"
+ },
+ async set() {},
+ async setExpiring() {},
+ async delete() {}
+ },
+ repositoryAccessReader: {
+ async getRepositoryNames() {
+ return []
+ }
+ }
+ })
+ const repositoryNames = await sut.getRepositoryNames("1234")
+ expect(repositoryNames).toEqual(["foo", "bar"])
+})
diff --git a/__test__/auth/CachingUserIdentityProviderReader.test.ts b/__test__/auth/CachingUserIdentityProviderReader.test.ts
new file mode 100644
index 00000000..0075f9a3
--- /dev/null
+++ b/__test__/auth/CachingUserIdentityProviderReader.test.ts
@@ -0,0 +1,63 @@
+import { CachingUserIdentityProviderReader } from "../../src/features/auth/domain"
+import { UserIdentityProvider } from "../../src/features/auth/domain"
+
+test("It fetches user identity provider if it is not cached", async () => {
+ let didFetchUserIdentityProvider = false
+ const sut = new CachingUserIdentityProviderReader({
+ async get() {
+ return null
+ },
+ async set() {},
+ async setExpiring() {},
+ async delete() {}
+ }, {
+ async getUserIdentityProvider() {
+ didFetchUserIdentityProvider = true
+ return UserIdentityProvider.GITHUB
+ },
+ })
+ await sut.getUserIdentityProvider("foo")
+ expect(didFetchUserIdentityProvider).toBeTruthy()
+})
+
+test("It does not fetch user identity provider if it is cached", async () => {
+ let didFetchUserIdentityProvider = false
+ const sut = new CachingUserIdentityProviderReader({
+ async get() {
+ return UserIdentityProvider.GITHUB
+ },
+ async set() {},
+ async setExpiring() {},
+ async delete() {}
+ }, {
+ async getUserIdentityProvider() {
+ didFetchUserIdentityProvider = true
+ return UserIdentityProvider.GITHUB
+ },
+ })
+ await sut.getUserIdentityProvider("foo")
+ expect(didFetchUserIdentityProvider).toBeFalsy()
+})
+
+test("It caches fetched user identity provider for user", async () => {
+ let cachedUserId: string | undefined
+ let cachedUserIdentityProvider: string | undefined
+ const sut = new CachingUserIdentityProviderReader({
+ async get() {
+ return null
+ },
+ async set() {},
+ async setExpiring(userId, userIdentityProvider) {
+ cachedUserId = userId
+ cachedUserIdentityProvider = userIdentityProvider
+ },
+ async delete() {}
+ }, {
+ async getUserIdentityProvider() {
+ return UserIdentityProvider.USERNAME_PASSWORD
+ },
+ })
+ await sut.getUserIdentityProvider("1234")
+ expect(cachedUserId).toBe("1234")
+ expect(cachedUserIdentityProvider).toBe(UserIdentityProvider.USERNAME_PASSWORD.toString())
+})
diff --git a/__test__/auth/CompositeLogInHandler.test.ts b/__test__/auth/CompositeLogInHandler.test.ts
new file mode 100644
index 00000000..c342ffbb
--- /dev/null
+++ b/__test__/auth/CompositeLogInHandler.test.ts
@@ -0,0 +1,24 @@
+import { CompositeLogInHandler } from "../../src/features/auth/domain"
+
+test("It invokes all log in handlers for user", async () => {
+ let userId1: string | undefined
+ let userId2: string | undefined
+ let userId3: string | undefined
+ const sut = new CompositeLogInHandler([{
+ async handleLogIn(userId) {
+ userId1 = userId
+ }
+ }, {
+ async handleLogIn(userId) {
+ userId2 = userId
+ }
+ }, {
+ async handleLogIn(userId) {
+ userId3 = userId
+ }
+ }])
+ await sut.handleLogIn("1234")
+ expect(userId1).toEqual("1234")
+ expect(userId2).toEqual("1234")
+ expect(userId3).toEqual("1234")
+})
diff --git a/__test__/auth/CredentialsTransferringLogInHandler.test.ts b/__test__/auth/CredentialsTransferringLogInHandler.test.ts
index 43f2d956..f1061b40 100644
--- a/__test__/auth/CredentialsTransferringLogInHandler.test.ts
+++ b/__test__/auth/CredentialsTransferringLogInHandler.test.ts
@@ -1,12 +1,43 @@
import { CredentialsTransferringLogInHandler } from "../../src/features/auth/domain"
-test("It transfers credentials", async () => {
- let didTransferCredentials = false
+test("It transfers credentials for guest", async () => {
+ let didTransferGuestCredentials = false
const sut = new CredentialsTransferringLogInHandler({
- async transferCredentials() {
- didTransferCredentials = true
+ isUserGuestReader: {
+ async getIsUserGuest() {
+ return true
+ }
+ },
+ guestCredentialsTransferrer: {
+ async transferCredentials() {
+ didTransferGuestCredentials = true
+ }
+ },
+ hostCredentialsTransferrer: {
+ async transferCredentials() {}
}
})
await sut.handleLogIn("1234")
- expect(didTransferCredentials).toBeTruthy()
+ expect(didTransferGuestCredentials).toBeTruthy()
+})
+
+test("It transfers credentials for host", async () => {
+ let didTransferHostCredentials = false
+ const sut = new CredentialsTransferringLogInHandler({
+ isUserGuestReader: {
+ async getIsUserGuest() {
+ return false
+ }
+ },
+ guestCredentialsTransferrer: {
+ async transferCredentials() {}
+ },
+ hostCredentialsTransferrer: {
+ async transferCredentials() {
+ didTransferHostCredentials = true
+ }
+ }
+ })
+ await sut.handleLogIn("1234")
+ expect(didTransferHostCredentials).toBeTruthy()
})
diff --git a/__test__/auth/GuestAccessTokenService.test.ts b/__test__/auth/GuestAccessTokenService.test.ts
new file mode 100644
index 00000000..ceeceb4a
--- /dev/null
+++ b/__test__/auth/GuestAccessTokenService.test.ts
@@ -0,0 +1,52 @@
+import { GuestAccessTokenService } from "../../src/features/auth/domain"
+
+test("It gets the access token for the user", async () => {
+ let readUserId: string | undefined
+ const sut = new GuestAccessTokenService({
+ userIdReader: {
+ async getUserId() {
+ return "1234"
+ }
+ },
+ repository: {
+ async get(userId) {
+ readUserId = userId
+ return "foo"
+ },
+ async setExpiring() {}
+ },
+ dataSource: {
+ async getAccessToken() {
+ return "foo"
+ }
+ }
+ })
+ const accessToken = await sut.getAccessToken()
+ expect(readUserId).toBe("1234")
+ expect(accessToken).toBe("foo")
+})
+
+test("It refreshes access token on demand when there is no cached access token", async () => {
+ let didRefreshAccessToken = false
+ const sut = new GuestAccessTokenService({
+ userIdReader: {
+ async getUserId() {
+ return "1234"
+ }
+ },
+ repository: {
+ async get() {
+ return null
+ },
+ async setExpiring() {}
+ },
+ dataSource: {
+ async getAccessToken() {
+ didRefreshAccessToken = true
+ return "foo"
+ }
+ }
+ })
+ await sut.getAccessToken()
+ expect(didRefreshAccessToken).toBeTruthy()
+})
diff --git a/__test__/auth/HostAccessTokenService.test.ts b/__test__/auth/HostAccessTokenService.test.ts
new file mode 100644
index 00000000..79fcaf72
--- /dev/null
+++ b/__test__/auth/HostAccessTokenService.test.ts
@@ -0,0 +1,86 @@
+import { HostAccessTokenService } from "../../src/features/auth/domain"
+import { OAuthToken } from "../../src/features/auth/domain"
+
+test("It gets the access token for the user", async () => {
+ let readUserID: string | undefined
+ const sut = new HostAccessTokenService({
+ userIdReader: {
+ async getUserId() {
+ return "1234"
+ }
+ },
+ repository: {
+ async get(userId) {
+ readUserID = userId
+ return { accessToken: "foo", refreshToken: "bar" }
+ },
+ async set() {},
+ async delete() {},
+ },
+ refresher: {
+ async refreshOAuthToken() {
+ return { accessToken: "foo", refreshToken: "bar" }
+ }
+ }
+ })
+ const accessToken = await sut.getAccessToken()
+ expect(readUserID).toBe("1234")
+ expect(accessToken).toBe("foo")
+})
+
+test("It refreshes OAuth using stored refresh token", async () => {
+ let usedRefreshToken: string | undefined
+ const sut = new HostAccessTokenService({
+ userIdReader: {
+ async getUserId() {
+ return "1234"
+ }
+ },
+ repository: {
+ async get() {
+ return { accessToken: "oldAccessToken", refreshToken: "oldRefreshToken" }
+ },
+ async set() {},
+ async delete() {},
+ },
+ refresher: {
+ async refreshOAuthToken(refreshToken) {
+ usedRefreshToken = refreshToken
+ return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" }
+ }
+ }
+ })
+ await sut.refreshAccessToken("oldAccessToken")
+ expect(usedRefreshToken).toBe("oldRefreshToken")
+})
+
+test("It stores the new OAuth token for the user", async () => {
+ let storedUserId: string | undefined
+ let storedOAuthToken: OAuthToken | undefined
+ const sut = new HostAccessTokenService({
+ userIdReader: {
+ async getUserId() {
+ return "1234"
+ }
+ },
+ repository: {
+ async get() {
+ return { accessToken: "oldAccessToken", refreshToken: "oldRefreshToken" }
+ },
+ async set(userId, oAuthToken) {
+ storedUserId = userId
+ storedOAuthToken = oAuthToken
+ },
+ async delete() {},
+ },
+ refresher: {
+ async refreshOAuthToken() {
+ return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" }
+ }
+ }
+ })
+ await sut.refreshAccessToken("foo")
+ expect(storedUserId).toBe("1234")
+ expect(storedOAuthToken?.accessToken).toBe("newAccessToken")
+ expect(storedOAuthToken?.refreshToken).toBe("newRefreshToken")
+})
diff --git a/__test__/auth/CredentialsTransferrer.test.ts b/__test__/auth/HostCredentialsTransferrer.test.ts
similarity index 90%
rename from __test__/auth/CredentialsTransferrer.test.ts
rename to __test__/auth/HostCredentialsTransferrer.test.ts
index 4a405803..a5988aa8 100644
--- a/__test__/auth/CredentialsTransferrer.test.ts
+++ b/__test__/auth/HostCredentialsTransferrer.test.ts
@@ -1,9 +1,9 @@
-import { CredentialsTransferrer } from "../../src/features/auth/domain"
+import { HostCredentialsTransferrer } from "../../src/features/auth/domain"
import { OAuthToken } from "../../src/features/auth/domain"
test("It fetches refresh token for specified user", async () => {
let fetchedUserId: string | undefined
- const sut = new CredentialsTransferrer({
+ const sut = new HostCredentialsTransferrer({
refreshTokenReader: {
async getRefreshToken(userId) {
fetchedUserId = userId
@@ -29,7 +29,7 @@ test("It fetches refresh token for specified user", async () => {
test("It refreshes the fetched refresh token", async () => {
let refreshedRefreshToken: string | undefined
- const sut = new CredentialsTransferrer({
+ const sut = new HostCredentialsTransferrer({
refreshTokenReader: {
async getRefreshToken() {
return "helloworld"
@@ -56,7 +56,7 @@ test("It refreshes the fetched refresh token", async () => {
test("It stores the refreshed auth token for the user", async () => {
let storedAuthToken: OAuthToken | undefined
let storedUserId: string | undefined
- const sut = new CredentialsTransferrer({
+ const sut = new HostCredentialsTransferrer({
refreshTokenReader: {
async getRefreshToken() {
return "helloworld"
diff --git a/__test__/auth/IsUserGuestReader.test.ts b/__test__/auth/IsUserGuestReader.test.ts
new file mode 100644
index 00000000..2d604818
--- /dev/null
+++ b/__test__/auth/IsUserGuestReader.test.ts
@@ -0,0 +1,22 @@
+import { IsUserGuestReader } from "../../src/features/auth/domain"
+import { UserIdentityProvider } from "../../src/features/auth/domain"
+
+test("It does not consider a user to be a guest if they are logged in with GitHub", async () => {
+ const sut = new IsUserGuestReader({
+ async getUserIdentityProvider() {
+ return UserIdentityProvider.GITHUB
+ }
+ })
+ const isGuest = await sut.getIsUserGuest("foo")
+ expect(isGuest).toBeFalsy()
+})
+
+test("It considers user a to be a guest if they are logged in with username and password", async () => {
+ const sut = new IsUserGuestReader({
+ async getUserIdentityProvider() {
+ return UserIdentityProvider.USERNAME_PASSWORD
+ }
+ })
+ const isGuest = await sut.getIsUserGuest("foo")
+ expect(isGuest).toBeTruthy()
+})
diff --git a/__test__/auth/OAuthTokenRepository.test.ts b/__test__/auth/OAuthTokenRepository.test.ts
index b5786016..2bcac8a0 100644
--- a/__test__/auth/OAuthTokenRepository.test.ts
+++ b/__test__/auth/OAuthTokenRepository.test.ts
@@ -11,6 +11,7 @@ test("It reads the auth token for the specified user", async () => {
})
},
async set() {},
+ async setExpiring() {},
async delete() {}
})
await sut.get("1234")
@@ -24,7 +25,8 @@ test("It stores the auth token for the specified user", async () => {
async get() {
return ""
},
- async set(userId, data) {
+ async set() {},
+ async setExpiring(userId, data) {
storedUserId = userId
storedJSON = data
},
@@ -48,6 +50,7 @@ test("It deletes the auth token for the specified user", async () => {
return ""
},
async set() {},
+ async setExpiring() {},
async delete(userId) {
deletedUserId = userId
}
diff --git a/__test__/auth/RemoveInvitedFlagLogInHandler.test.ts b/__test__/auth/RemoveInvitedFlagLogInHandler.test.ts
new file mode 100644
index 00000000..e4a276cd
--- /dev/null
+++ b/__test__/auth/RemoveInvitedFlagLogInHandler.test.ts
@@ -0,0 +1,18 @@
+import { RemoveInvitedFlagLogInHandler } from "../../src/features/auth/domain"
+
+test("It removes invited flag from specified user", async () => {
+ let updatedUserId: string | undefined
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+ let updatedMetadata: {[key: string]: any} | undefined
+ const sut = new RemoveInvitedFlagLogInHandler({
+ async updateMetadata(userId, metadata) {
+ updatedUserId = userId
+ updatedMetadata = metadata
+ }
+ })
+ await sut.handleLogIn("1234")
+ expect(updatedUserId).toEqual("1234")
+ expect(updatedMetadata).toEqual({
+ has_pending_invitation: false
+ })
+})
diff --git a/__test__/auth/RepositoryRestrictingAccessTokenDataSource.ts b/__test__/auth/RepositoryRestrictingAccessTokenDataSource.ts
new file mode 100644
index 00000000..c4a26be7
--- /dev/null
+++ b/__test__/auth/RepositoryRestrictingAccessTokenDataSource.ts
@@ -0,0 +1,20 @@
+import { RepositoryRestrictingAccessTokenDataSource } from "../../src/features/auth/domain"
+
+test("It limits access to the fetched repositories", async () => {
+ let restrictingRepositoryNames: string[] | undefined
+ const sut = new RepositoryRestrictingAccessTokenDataSource({
+ repositoryAccessReader: {
+ async getRepositoryNames() {
+ return ["foo", "bar"]
+ }
+ },
+ dataSource: {
+ async getAccessToken(repositoryNames) {
+ restrictingRepositoryNames = repositoryNames
+ return "secret"
+ },
+ }
+ })
+ await sut.getAccessToken("1234")
+ expect(restrictingRepositoryNames).toEqual(["foo", "bar"])
+})
diff --git a/__test__/common/session/SessionValidator.test.ts b/__test__/common/session/SessionValidator.test.ts
new file mode 100644
index 00000000..057bd6cd
--- /dev/null
+++ b/__test__/common/session/SessionValidator.test.ts
@@ -0,0 +1,49 @@
+import { SessionValidator } from "../../../src/common"
+
+test("It validates a host user", async () => {
+ let didValidateHostUser = false
+ const sut = new SessionValidator({
+ isGuestReader: {
+ async getIsGuest() {
+ return false
+ }
+ },
+ guestSessionValidator: {
+ async validateSession() {
+ return true
+ },
+ },
+ hostSessionValidator: {
+ async validateSession() {
+ didValidateHostUser = true
+ return true
+ },
+ }
+ })
+ await sut.validateSession()
+ expect(didValidateHostUser).toBeTruthy()
+})
+
+test("It validates a guest user", async () => {
+ let didValidateGuestUser = false
+ const sut = new SessionValidator({
+ isGuestReader: {
+ async getIsGuest() {
+ return true
+ }
+ },
+ guestSessionValidator: {
+ async validateSession() {
+ didValidateGuestUser = true
+ return true
+ },
+ },
+ hostSessionValidator: {
+ async validateSession() {
+ return true
+ },
+ }
+ })
+ await sut.validateSession()
+ expect(didValidateGuestUser).toBeTruthy()
+})
diff --git a/__test__/common/userData/KeyValueUserDataRepository.test.ts b/__test__/common/userData/KeyValueUserDataRepository.test.ts
index 869403b4..3dc7cb04 100644
--- a/__test__/common/userData/KeyValueUserDataRepository.test.ts
+++ b/__test__/common/userData/KeyValueUserDataRepository.test.ts
@@ -8,6 +8,7 @@ test("It reads the expected key", async () => {
return ""
},
async set() {},
+ async setExpiring() {},
async delete() {}
}, "foo")
await sut.get("123")
@@ -23,12 +24,31 @@ test("It stores values under the expected key", async () => {
async set(key) {
storedKey = key
},
+ async setExpiring() {},
async delete() {}
}, "foo")
await sut.set("123", "bar")
expect(storedKey).toBe("foo[123]")
})
+test("It stores values under the expected key with expected time to live", async () => {
+ let storedKey: string | undefined
+ let storedTimeToLive: number | undefined
+ const sut = new KeyValueUserDataRepository({
+ async get() {
+ return ""
+ },
+ async set() {},
+ async setExpiring(key, _value, timeToLive) {
+ storedKey = key
+ storedTimeToLive = timeToLive
+ },
+ async delete() {}
+ }, "foo")
+ await sut.setExpiring("123", "bar", 24 * 3600)
+ expect(storedKey).toBe("foo[123]")
+ expect(storedTimeToLive).toBe(24 * 3600)
+})
test("It deletes the expected key", async () => {
let deletedKey: string | undefined
@@ -37,6 +57,7 @@ test("It deletes the expected key", async () => {
return ""
},
async set() {},
+ async setExpiring() {},
async delete(key) {
deletedKey = key
}
diff --git a/src/app/[...slug]/page.tsx b/src/app/[...slug]/page.tsx
index 2193779a..cfcb585e 100644
--- a/src/app/[...slug]/page.tsx
+++ b/src/app/[...slug]/page.tsx
@@ -1,5 +1,5 @@
-import { getProjectId, getSpecificationId, getVersionId } from "@/common/utils/url"
-import SessionOAuthTokenBarrier from "@/features/auth/view/SessionOAuthTokenBarrier"
+import { getProjectId, getSpecificationId, getVersionId } from "../../common"
+import SessionAccessTokenBarrier from "@/features/auth/view/SessionAccessTokenBarrier"
import ProjectsPage from "@/features/projects/view/ProjectsPage"
import { projectRepository } from "@/composition"
@@ -8,14 +8,14 @@ type PageParams = { slug: string | string[] }
export default async function Page({ params }: { params: PageParams }) {
const url = getURL(params)
return (
-
+
-
+
)
}
diff --git a/src/app/api/auth/[auth0]/route.ts b/src/app/api/auth/[auth0]/route.ts
index aeaf8b83..2023b606 100644
--- a/src/app/api/auth/[auth0]/route.ts
+++ b/src/app/api/auth/[auth0]/route.ts
@@ -16,7 +16,8 @@ const afterCallback: AfterCallbackAppRoute = async (_req, session) => {
return session
}
-const onError: AppRouterOnError = async () => {
+const onError: AppRouterOnError = async (_req, error) => {
+ console.error(error)
const url = new URL(SHAPE_DOCS_BASE_URL + "/api/auth/forceLogout")
return NextResponse.redirect(url)
}
diff --git a/src/app/api/github/blob/[owner]/[repository]/[...path]/route.ts b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts
similarity index 79%
rename from src/app/api/github/blob/[owner]/[repository]/[...path]/route.ts
rename to src/app/api/blob/[owner]/[repository]/[...path]/route.ts
index 9c8b595c..2dd2d532 100644
--- a/src/app/api/github/blob/[owner]/[repository]/[...path]/route.ts
+++ b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts
@@ -17,17 +17,12 @@ export async function GET(req: NextRequest, { params }: { params: GetBlobParams
})
const url = new URL(item.downloadURL)
const imageRegex = /\.(jpg|jpeg|png|webp|avif|gif)$/;
-
+ const file = await fetch(url).then(r => r.blob())
+ const headers = new Headers()
if (new RegExp(imageRegex).exec(path)) {
- const file = await fetch(url).then(r => r.blob());
- const headers = new Headers();
const cacheExpirationInSeconds = 60 * 60 * 24 * 30 // 30 days
-
headers.set("Content-Type", "image/*");
- headers.set("Cache-Control", `max-age=${cacheExpirationInSeconds}`);
-
- return new NextResponse(file, { status: 200, headers })
- } else {
- return NextResponse.redirect(url)
+ headers.set("Cache-Control", `max-age=${cacheExpirationInSeconds}`)
}
+ return new NextResponse(file, { status: 200, headers })
}
diff --git a/src/app/api/user/projects/route.ts b/src/app/api/user/projects/route.ts
index 799f13f6..64a96f68 100644
--- a/src/app/api/user/projects/route.ts
+++ b/src/app/api/user/projects/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"
import { projectDataSource } from "@/composition"
-import { UnauthorizedError, InvalidSessionError } from "@/common/errors"
+import { UnauthorizedError, InvalidSessionError } from "../../../../common"
export async function GET() {
try {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 1760b426..48ad9fee 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -3,8 +3,8 @@ import type { Metadata } from "next"
import { UserProvider } from "@auth0/nextjs-auth0/client"
import { config as fontAwesomeConfig } from "@fortawesome/fontawesome-svg-core"
import { CssBaseline } from "@mui/material"
-import ThemeRegistry from "@/common/theme/ThemeRegistry"
-import ErrorHandler from "@/common/errors/client/ErrorHandler"
+import ThemeRegistry from "../common/theme/ThemeRegistry"
+import ErrorHandler from "../common/errors/client/ErrorHandler"
import "@fortawesome/fontawesome-svg-core/styles.css"
fontAwesomeConfig.autoAddCss = false
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 9fcd36ad..55087f2f 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,11 +1,11 @@
-import SessionOAuthTokenBarrier from "@/features/auth/view/SessionOAuthTokenBarrier"
+import SessionAccessTokenBarrier from "@/features/auth/view/SessionAccessTokenBarrier"
import ProjectsPage from "@/features/projects/view/ProjectsPage"
import { projectRepository } from "@/composition"
export default async function Page() {
return (
-
+
-
+
)
}
diff --git a/src/common/errors/client/ErrorHandler.tsx b/src/common/errors/client/ErrorHandler.tsx
index 20ba19a3..61260578 100644
--- a/src/common/errors/client/ErrorHandler.tsx
+++ b/src/common/errors/client/ErrorHandler.tsx
@@ -1,7 +1,7 @@
"use client"
import { SWRConfig } from "swr"
-import { FetcherError } from "@/common/utils/fetcher"
+import { FetcherError } from "@/common"
export default function ErrorHandler({
children
diff --git a/src/common/keyValueStore/IKeyValueStore.ts b/src/common/keyValueStore/IKeyValueStore.ts
index 8b604f56..470b8558 100644
--- a/src/common/keyValueStore/IKeyValueStore.ts
+++ b/src/common/keyValueStore/IKeyValueStore.ts
@@ -1,5 +1,10 @@
export default interface IKeyValueStore {
get(key: string): Promise
set(key: string, data: string | number | Buffer): Promise
+ setExpiring(
+ key: string,
+ data: string | number | Buffer,
+ timeToLive: number
+ ): Promise
delete(key: string): Promise
}
diff --git a/src/common/keyValueStore/RedisKeyValueStore.ts b/src/common/keyValueStore/RedisKeyValueStore.ts
index 4a552154..8c4c5401 100644
--- a/src/common/keyValueStore/RedisKeyValueStore.ts
+++ b/src/common/keyValueStore/RedisKeyValueStore.ts
@@ -16,6 +16,14 @@ export default class RedisKeyValueStore implements IKeyValueStore {
await this.redis.set(key, data)
}
+ async setExpiring(
+ key: string,
+ data: string | number | Buffer,
+ timeToLive: number
+ ): Promise {
+ await this.redis.setex(key, timeToLive, data)
+ }
+
async delete(key: string): Promise {
await this.redis.del(key)
}
diff --git a/src/common/mutex/SessionMutexFactory.ts b/src/common/mutex/SessionMutexFactory.ts
index 72554464..c84b02de 100644
--- a/src/common/mutex/SessionMutexFactory.ts
+++ b/src/common/mutex/SessionMutexFactory.ts
@@ -1,7 +1,7 @@
import IKeyedMutexFactory from "./IKeyedMutexFactory"
import IMutex from "./IMutex"
import IMutexFactory from "./IMutexFactory"
-import ISession from "@/common/session/ISession"
+import { ISession } from "@/common"
export default class SessionMutexFactory implements IMutexFactory {
private readonly mutexFactory: IKeyedMutexFactory
diff --git a/src/common/session/AlwaysValidSessionValidator.ts b/src/common/session/AlwaysValidSessionValidator.ts
new file mode 100644
index 00000000..3f55486a
--- /dev/null
+++ b/src/common/session/AlwaysValidSessionValidator.ts
@@ -0,0 +1,7 @@
+import ISessionValidator from "./ISessionValidator"
+
+export default class AlwaysValidSessionValidator implements ISessionValidator {
+ async validateSession(): Promise {
+ return true
+ }
+}
\ No newline at end of file
diff --git a/src/common/session/Auth0Session.ts b/src/common/session/Auth0Session.ts
index 4db56f72..10c9e02c 100644
--- a/src/common/session/Auth0Session.ts
+++ b/src/common/session/Auth0Session.ts
@@ -1,8 +1,19 @@
import { getSession } from "@auth0/nextjs-auth0"
-import { UnauthorizedError } from "../../common/errors"
+import { UnauthorizedError } from "../../common"
import ISession from "./ISession"
+import IIsUserGuestReader from "@/features/auth/domain/userIdentityProvider/IsUserGuestReader"
+
+export type Auth0SessionConfig = {
+ readonly isUserGuestReader: IIsUserGuestReader
+}
export default class Auth0Session implements ISession {
+ private readonly isUserGuestReader: IIsUserGuestReader
+
+ constructor(config: Auth0SessionConfig) {
+ this.isUserGuestReader = config.isUserGuestReader
+ }
+
async getUserId(): Promise {
const session = await getSession()
if (!session) {
@@ -10,4 +21,9 @@ export default class Auth0Session implements ISession {
}
return session.user.sub
}
-}
\ No newline at end of file
+
+ async getIsGuest(): Promise {
+ const userId = await this.getUserId()
+ return await this.isUserGuestReader.getIsUserGuest(userId)
+ }
+}
diff --git a/src/common/session/ISession.ts b/src/common/session/ISession.ts
index 6481c29c..2496223f 100644
--- a/src/common/session/ISession.ts
+++ b/src/common/session/ISession.ts
@@ -1,4 +1,4 @@
export default interface ISession {
getUserId(): Promise
+ getIsGuest(): Promise
}
-
diff --git a/src/common/session/SessionValidator.ts b/src/common/session/SessionValidator.ts
new file mode 100644
index 00000000..4b3da8c9
--- /dev/null
+++ b/src/common/session/SessionValidator.ts
@@ -0,0 +1,32 @@
+import ISessionValidator from "./ISessionValidator"
+
+interface IIsGuestReader {
+ getIsGuest(): Promise
+}
+
+type SessionValidatorConfig = {
+ readonly isGuestReader: IIsGuestReader
+ readonly guestSessionValidator: ISessionValidator
+ readonly hostSessionValidator: ISessionValidator
+}
+
+export default class SessionValidator implements ISessionValidator {
+ private readonly isGuestReader: IIsGuestReader
+ private readonly guestSessionValidator: ISessionValidator
+ private readonly hostSessionValidator: ISessionValidator
+
+ constructor(config: SessionValidatorConfig) {
+ this.isGuestReader = config.isGuestReader
+ this.guestSessionValidator = config.guestSessionValidator
+ this.hostSessionValidator = config.hostSessionValidator
+ }
+
+ async validateSession(): Promise {
+ const isGuest = await this.isGuestReader.getIsGuest()
+ if (isGuest) {
+ return await this.guestSessionValidator.validateSession()
+ } else {
+ return await this.hostSessionValidator.validateSession()
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/common/session/index.ts b/src/common/session/index.ts
index 5e920e71..4d19783f 100644
--- a/src/common/session/index.ts
+++ b/src/common/session/index.ts
@@ -1,3 +1,5 @@
+export { default as AlwaysValidSessionValidator } from "./AlwaysValidSessionValidator"
export { default as GitHubOrganizationSessionValidator } from "./GitHubOrganizationSessionValidator"
export type { default as ISession } from "./ISession"
export type { default as ISessionValidator } from "./ISessionValidator"
+export { default as SessionValidator } from "./SessionValidator"
diff --git a/src/common/userData/IUserDataRepository.ts b/src/common/userData/IUserDataRepository.ts
index cefadae4..8fca5d8e 100644
--- a/src/common/userData/IUserDataRepository.ts
+++ b/src/common/userData/IUserDataRepository.ts
@@ -1,5 +1,6 @@
export default interface IUserDataRepository {
get(userId: string): Promise
set(userId: string, value: T): Promise
+ setExpiring(userId: string, value: T, timeToLive: number): Promise
delete(userId: string): Promise
}
diff --git a/src/common/userData/KeyValueUserDataRepository.ts b/src/common/userData/KeyValueUserDataRepository.ts
index 58bd4fa6..b6318099 100644
--- a/src/common/userData/KeyValueUserDataRepository.ts
+++ b/src/common/userData/KeyValueUserDataRepository.ts
@@ -18,6 +18,10 @@ export default class KeyValueUserDataRepository implements IUserDataRepository {
+ await this.store.setExpiring(this.getKey(userId), value, timeToLive)
+ }
+
async delete(userId: string): Promise {
await this.store.delete(this.getKey(userId))
}
diff --git a/src/composition.ts b/src/composition.ts
index b0fc8bc5..46dce15b 100644
--- a/src/composition.ts
+++ b/src/composition.ts
@@ -1,37 +1,52 @@
import Auth0Session from "@/common/session/Auth0Session"
import RedisKeyedMutexFactory from "@/common/mutex/RedisKeyedMutexFactory"
import RedisKeyValueStore from "@/common/keyValueStore/RedisKeyValueStore"
-
import {
AccessTokenRefreshingGitHubClient,
+ AlwaysValidSessionValidator,
GitHubClient,
- GitHubOrganizationSessionValidator,
+ GitHubOrganizationSessionValidator,
KeyValueUserDataRepository,
- SessionMutexFactory
+ SessionMutexFactory,
+ SessionValidator
} from "@/common"
import {
+ GitHubProjectDataSource
+} from "@/features/projects/data"
+import {
+ CachingProjectDataSource,
+ ForgivingProjectDataSource,
+ ProjectRepository,
+ SessionValidatingProjectDataSource
+} from "@/features/projects/domain"
+import {
+ GitHubOAuthTokenRefresher,
+ GitHubInstallationAccessTokenDataSource,
+ Auth0MetadataUpdater,
Auth0RefreshTokenReader,
- GitHubOAuthTokenRefresher
+ Auth0RepositoryAccessReader,
+ Auth0UserIdentityProviderReader
} from "@/features/auth/data"
import {
AccessTokenService,
+ CachingRepositoryAccessReaderConfig,
+ CachingUserIdentityProviderReader,
+ CompositeLogInHandler,
CompositeLogOutHandler,
- CredentialsTransferrer,
CredentialsTransferringLogInHandler,
ErrorIgnoringLogOutHandler,
+ GuestAccessTokenService,
+ NullObjectCredentialsTransferrer,
+ HostAccessTokenService,
+ HostCredentialsTransferrer,
+ IsUserGuestReader,
LockingAccessTokenService,
- OnlyStaleRefreshingAccessTokenService,
OAuthTokenRepository,
+ OnlyStaleRefreshingAccessTokenService,
+ RemoveInvitedFlagLogInHandler,
+ RepositoryRestrictingAccessTokenDataSource,
UserDataCleanUpLogOutHandler
} from "@/features/auth/domain"
-import {
- GitHubProjectDataSource
-} from "@/features/projects/data"
-import {
- CachingProjectDataSource,
- ProjectRepository,
- SessionValidatingProjectDataSource
-} from "@/features/projects/domain"
const {
AUTH0_MANAGEMENT_DOMAIN,
@@ -45,18 +60,71 @@ const {
REDIS_URL
} = process.env
-const gitHubPrivateKey = Buffer.from(GITHUB_PRIVATE_KEY_BASE_64, "base64").toString("utf-8")
+const auth0ManagementCredentials = {
+ domain: AUTH0_MANAGEMENT_DOMAIN,
+ clientId: AUTH0_MANAGEMENT_CLIENT_ID,
+ clientSecret: AUTH0_MANAGEMENT_CLIENT_SECRET
+}
+
+const gitHubAppCredentials = {
+ appId: GITHUB_APP_ID,
+ clientId: GITHUB_CLIENT_ID,
+ clientSecret: GITHUB_CLIENT_SECRET,
+ privateKey: Buffer
+ .from(GITHUB_PRIVATE_KEY_BASE_64, "base64")
+ .toString("utf-8")
+}
+
+const userIdentityProviderRepository = new KeyValueUserDataRepository(
+ new RedisKeyValueStore(REDIS_URL),
+ "userIdentityProvider"
+)
+
+export const userIdentityProviderReader = new CachingUserIdentityProviderReader(
+ userIdentityProviderRepository,
+ new Auth0UserIdentityProviderReader(auth0ManagementCredentials)
+)
-export const session = new Auth0Session()
+export const session = new Auth0Session({
+ isUserGuestReader: new IsUserGuestReader(userIdentityProviderReader)
+})
-export const oAuthTokenRepository = new OAuthTokenRepository(
+const oAuthTokenRepository = new OAuthTokenRepository(
new KeyValueUserDataRepository(
new RedisKeyValueStore(REDIS_URL),
"authToken"
)
)
-const accessTokenService = new LockingAccessTokenService(
+const gitHubOAuthTokenRefresher = new GitHubOAuthTokenRefresher({
+ clientId: gitHubAppCredentials.clientId,
+ clientSecret: gitHubAppCredentials.clientSecret
+})
+
+const accessTokenRepository = new KeyValueUserDataRepository(
+ new RedisKeyValueStore(REDIS_URL),
+ "accessToken"
+)
+
+const guestRepositoryAccessRepository = new KeyValueUserDataRepository(
+ new RedisKeyValueStore(REDIS_URL),
+ "guestRepositoryAccess"
+)
+
+const guestAccessTokenDataSource = new RepositoryRestrictingAccessTokenDataSource({
+ repositoryAccessReader: new CachingRepositoryAccessReaderConfig({
+ repository: guestRepositoryAccessRepository,
+ repositoryAccessReader: new Auth0RepositoryAccessReader({
+ ...auth0ManagementCredentials
+ })
+ }),
+ dataSource: new GitHubInstallationAccessTokenDataSource({
+ ...gitHubAppCredentials,
+ organization: GITHUB_ORGANIZATION_NAME
+ })
+})
+
+export const accessTokenService = new LockingAccessTokenService(
new SessionMutexFactory(
new RedisKeyedMutexFactory(REDIS_URL),
session,
@@ -64,21 +132,23 @@ const accessTokenService = new LockingAccessTokenService(
),
new OnlyStaleRefreshingAccessTokenService(
new AccessTokenService({
- userIdReader: session,
- repository: oAuthTokenRepository,
- refresher: new GitHubOAuthTokenRefresher({
- clientId: GITHUB_CLIENT_ID,
- clientSecret: GITHUB_CLIENT_SECRET
+ isGuestReader: session,
+ guestAccessTokenService: new GuestAccessTokenService({
+ userIdReader: session,
+ repository: accessTokenRepository,
+ dataSource: guestAccessTokenDataSource
+ }),
+ hostAccessTokenService: new HostAccessTokenService({
+ userIdReader: session,
+ repository: oAuthTokenRepository,
+ refresher: gitHubOAuthTokenRefresher
})
})
)
)
export const gitHubClient = new GitHubClient({
- appId: GITHUB_APP_ID,
- clientId: GITHUB_CLIENT_ID,
- clientSecret: GITHUB_CLIENT_SECRET,
- privateKey: gitHubPrivateKey,
+ ...gitHubAppCredentials,
accessTokenReader: accessTokenService
})
@@ -87,10 +157,14 @@ export const userGitHubClient = new AccessTokenRefreshingGitHubClient(
gitHubClient
)
-export const sessionValidator = new GitHubOrganizationSessionValidator(
- userGitHubClient,
- GITHUB_ORGANIZATION_NAME
-)
+export const sessionValidator = new SessionValidator({
+ isGuestReader: session,
+ guestSessionValidator: new AlwaysValidSessionValidator(),
+ hostSessionValidator: new GitHubOrganizationSessionValidator(
+ userGitHubClient,
+ GITHUB_ORGANIZATION_NAME
+ )
+})
const projectUserDataRepository = new KeyValueUserDataRepository(
new RedisKeyValueStore(REDIS_URL),
@@ -105,33 +179,43 @@ export const projectRepository = new ProjectRepository(
export const projectDataSource = new CachingProjectDataSource(
new SessionValidatingProjectDataSource(
sessionValidator,
- new GitHubProjectDataSource(
- userGitHubClient,
- GITHUB_ORGANIZATION_NAME
- )
+ new ForgivingProjectDataSource({
+ accessTokenReader: accessTokenService,
+ projectDataSource: new GitHubProjectDataSource(
+ userGitHubClient,
+ GITHUB_ORGANIZATION_NAME
+ )
+ })
),
projectRepository
)
-export const logInHandler = new CredentialsTransferringLogInHandler(
- new CredentialsTransferrer({
- refreshTokenReader: new Auth0RefreshTokenReader({
- domain: AUTH0_MANAGEMENT_DOMAIN,
- clientId: AUTH0_MANAGEMENT_CLIENT_ID,
- clientSecret: AUTH0_MANAGEMENT_CLIENT_SECRET,
- connection: "github"
- }),
- oAuthTokenRefresher: new GitHubOAuthTokenRefresher({
- clientId: GITHUB_CLIENT_ID,
- clientSecret: GITHUB_CLIENT_SECRET
- }),
- oAuthTokenRepository: oAuthTokenRepository
- })
-)
+export const logInHandler = new CompositeLogInHandler([
+ new CredentialsTransferringLogInHandler({
+ isUserGuestReader: new IsUserGuestReader(
+ userIdentityProviderReader
+ ),
+ guestCredentialsTransferrer: new NullObjectCredentialsTransferrer(),
+ hostCredentialsTransferrer: new HostCredentialsTransferrer({
+ refreshTokenReader: new Auth0RefreshTokenReader({
+ ...auth0ManagementCredentials,
+ connection: "github"
+ }),
+ oAuthTokenRefresher: gitHubOAuthTokenRefresher,
+ oAuthTokenRepository: oAuthTokenRepository
+ })
+ }),
+ new RemoveInvitedFlagLogInHandler(
+ new Auth0MetadataUpdater({ ...auth0ManagementCredentials })
+ )
+])
export const logOutHandler = new ErrorIgnoringLogOutHandler(
new CompositeLogOutHandler([
new UserDataCleanUpLogOutHandler(session, projectUserDataRepository),
- new UserDataCleanUpLogOutHandler(session, oAuthTokenRepository)
+ new UserDataCleanUpLogOutHandler(session, userIdentityProviderRepository),
+ new UserDataCleanUpLogOutHandler(session, guestRepositoryAccessRepository),
+ new UserDataCleanUpLogOutHandler(session, oAuthTokenRepository),
+ new UserDataCleanUpLogOutHandler(session, accessTokenRepository)
])
)
diff --git a/src/features/auth/data/Auth0MetadataUpdater.ts b/src/features/auth/data/Auth0MetadataUpdater.ts
new file mode 100644
index 00000000..88ffb524
--- /dev/null
+++ b/src/features/auth/data/Auth0MetadataUpdater.ts
@@ -0,0 +1,28 @@
+import { ManagementClient } from "auth0"
+
+type Auth0MetadataUpdaterConfig = {
+ readonly domain: string
+ readonly clientId: string
+ readonly clientSecret: string
+}
+
+export default class Auth0MetadataUpdater {
+ private readonly managementClient: ManagementClient
+
+ constructor(config: Auth0MetadataUpdaterConfig) {
+ this.managementClient = new ManagementClient({
+ domain: config.domain,
+ clientId: config.clientId,
+ clientSecret: config.clientSecret
+ })
+ }
+
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+ async updateMetadata(userId: string, metadata: {[key: string]: any}): Promise {
+ await this.managementClient.users.update({
+ id: userId
+ }, {
+ app_metadata: metadata
+ })
+ }
+}
diff --git a/src/features/auth/data/Auth0RefreshTokenReader.ts b/src/features/auth/data/Auth0RefreshTokenReader.ts
index 642aebc2..cafe3edc 100644
--- a/src/features/auth/data/Auth0RefreshTokenReader.ts
+++ b/src/features/auth/data/Auth0RefreshTokenReader.ts
@@ -1,5 +1,5 @@
import { ManagementClient } from "auth0"
-import { UnauthorizedError } from "@/common/errors"
+import { UnauthorizedError } from "@/common"
interface Auth0RefreshTokenReaderConfig {
readonly domain: string
diff --git a/src/features/auth/data/Auth0RepositoryAccessReader.ts b/src/features/auth/data/Auth0RepositoryAccessReader.ts
new file mode 100644
index 00000000..1d923d61
--- /dev/null
+++ b/src/features/auth/data/Auth0RepositoryAccessReader.ts
@@ -0,0 +1,24 @@
+import { ManagementClient } from "auth0"
+
+type Auth0RepositoryAccessReaderConfig = {
+ readonly domain: string
+ readonly clientId: string
+ readonly clientSecret: string
+}
+
+export default class Auth0RepositoryAccessReader {
+ private readonly managementClient: ManagementClient
+
+ constructor(config: Auth0RepositoryAccessReaderConfig) {
+ this.managementClient = new ManagementClient({
+ domain: config.domain,
+ clientId: config.clientId,
+ clientSecret: config.clientSecret
+ })
+ }
+
+ async getRepositoryNames(userId: string): Promise {
+ const response = await this.managementClient.users.getRoles({ id: userId })
+ return response.data.map(e => e.name)
+ }
+}
diff --git a/src/features/auth/data/Auth0UserIdentityProviderReader.ts b/src/features/auth/data/Auth0UserIdentityProviderReader.ts
new file mode 100644
index 00000000..dfd56b98
--- /dev/null
+++ b/src/features/auth/data/Auth0UserIdentityProviderReader.ts
@@ -0,0 +1,40 @@
+import { ManagementClient } from "auth0"
+import UserIdentityProvider from "../domain/userIdentityProvider/UserIdentityProvider"
+import IUserIdentityProviderReader from "../domain/userIdentityProvider/IUserIdentityProviderReader"
+import { UnauthorizedError } from "@/common"
+
+interface Auth0UserIdentityProviderReaderConfig {
+ readonly domain: string
+ readonly clientId: string
+ readonly clientSecret: string
+}
+
+export default class Auth0UserIdentityProviderReader implements IUserIdentityProviderReader {
+ private readonly managementClient: ManagementClient
+
+ constructor(config: Auth0UserIdentityProviderReaderConfig) {
+ this.managementClient = new ManagementClient({
+ domain: config.domain,
+ clientId: config.clientId,
+ clientSecret: config.clientSecret
+ })
+ }
+
+ async getUserIdentityProvider(userId: string): Promise {
+ const response = await this.managementClient.users.get({ id: userId })
+ const identities = response.data.identities
+ const gitHubIdentity = identities.find(e => {
+ return e.connection.toLowerCase() === "github"
+ })
+ const usernamePasswordIdentity = identities.find(e => {
+ return e.connection.toLowerCase() === "username-password-authentication"
+ })
+ if (gitHubIdentity) {
+ return UserIdentityProvider.GITHUB
+ } else if (usernamePasswordIdentity) {
+ return UserIdentityProvider.USERNAME_PASSWORD
+ } else {
+ throw new UnauthorizedError()
+ }
+ }
+}
diff --git a/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts b/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts
new file mode 100644
index 00000000..7a958619
--- /dev/null
+++ b/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts
@@ -0,0 +1,50 @@
+import { Octokit } from "octokit"
+import { createAppAuth } from "@octokit/auth-app"
+
+type GitHubInstallationAccessTokenRefresherConfig = {
+ readonly appId: string
+ readonly clientId: string
+ readonly clientSecret: string
+ readonly privateKey: string
+ readonly organization: string
+}
+
+export default class GitHubInstallationAccessTokenRefresher {
+ private readonly config: GitHubInstallationAccessTokenRefresherConfig
+
+ constructor(config: GitHubInstallationAccessTokenRefresherConfig) {
+ this.config = config
+ }
+
+ async getAccessToken(repositoryNames: string[]): Promise {
+ const auth = createAppAuth({
+ appId: this.config.appId,
+ clientId: this.config.clientId,
+ clientSecret: this.config.clientSecret,
+ privateKey: this.config.privateKey
+ })
+ const appAuth = await auth({ type: "app" })
+ const octokit = new Octokit({ auth: appAuth.token })
+ const response = await octokit.rest.apps.getOrgInstallation({
+ org: this.config.organization
+ })
+ const installation = response.data
+ try {
+ const installationAuth = await auth({
+ type: "installation",
+ installationId: installation.id,
+ repositoryNames: repositoryNames
+ })
+ return installationAuth.token
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+ } catch (error: any) {
+ if (error.status && error.status == 422) {
+ // One or more of the repositories do not exist. We log the error
+ // and create an access token with access to know repositories.
+ console.error("Cannot log in user as one or more repositories do not exist: " + repositoryNames.join(", "))
+ console.error(error)
+ }
+ throw error
+ }
+ }
+}
diff --git a/src/features/auth/data/index.ts b/src/features/auth/data/index.ts
index 91e3b14e..8090466b 100644
--- a/src/features/auth/data/index.ts
+++ b/src/features/auth/data/index.ts
@@ -1,2 +1,6 @@
+export { default as Auth0MetadataUpdater } from "./Auth0MetadataUpdater"
export { default as Auth0RefreshTokenReader } from "./Auth0RefreshTokenReader"
+export { default as Auth0RepositoryAccessReader } from "./Auth0RepositoryAccessReader"
+export { default as Auth0UserIdentityProviderReader } from "./Auth0UserIdentityProviderReader"
+export { default as GitHubInstallationAccessTokenDataSource } from "./GitHubInstallationAccessTokenDataSource"
export { default as GitHubOAuthTokenRefresher } from "./GitHubOAuthTokenRefresher"
diff --git a/src/features/auth/domain/accessToken/AccessTokenService.ts b/src/features/auth/domain/accessToken/AccessTokenService.ts
index 729f40c3..095aef31 100644
--- a/src/features/auth/domain/accessToken/AccessTokenService.ts
+++ b/src/features/auth/domain/accessToken/AccessTokenService.ts
@@ -1,39 +1,42 @@
import IAccessTokenService from "./IAccessTokenService"
-import IOAuthTokenRepository from "../oAuthToken/IOAuthTokenRepository"
-import IOAuthTokenRefresher from "../oAuthToken/IOAuthTokenRefresher"
-export interface IUserIDReader {
- getUserId(): Promise
+export interface IIsGuestReader {
+ getIsGuest(): Promise
}
-type AccessTokenServiceConfig = {
- readonly userIdReader: IUserIDReader
- readonly repository: IOAuthTokenRepository
- readonly refresher: IOAuthTokenRefresher
+interface AccessTokenServiceConfig {
+ readonly isGuestReader: IIsGuestReader
+ readonly guestAccessTokenService: IAccessTokenService
+ readonly hostAccessTokenService: IAccessTokenService
}
export default class AccessTokenService implements IAccessTokenService {
- private readonly userIdReader: IUserIDReader
- private readonly repository: IOAuthTokenRepository
- private readonly refresher: IOAuthTokenRefresher
+ private readonly isGuestReader: IIsGuestReader
+ private readonly guestAccessTokenService: IAccessTokenService
+ private readonly hostAccessTokenService: IAccessTokenService
constructor(config: AccessTokenServiceConfig) {
- this.userIdReader = config.userIdReader
- this.repository = config.repository
- this.refresher = config.refresher
+ this.isGuestReader = config.isGuestReader
+ this.guestAccessTokenService = config.guestAccessTokenService
+ this.hostAccessTokenService = config.hostAccessTokenService
}
async getAccessToken(): Promise {
- const userId = await this.userIdReader.getUserId()
- const oAuthToken = await this.repository.get(userId)
- return oAuthToken.accessToken
+ const service = await this.getService()
+ return await service.getAccessToken()
}
- async refreshAccessToken(_accessToken: string): Promise {
- const userId = await this.userIdReader.getUserId()
- const oAuthToken = await this.repository.get(userId)
- const newOAuthToken = await this.refresher.refreshOAuthToken(oAuthToken.refreshToken)
- await this.repository.set(userId, newOAuthToken)
- return newOAuthToken.accessToken
+ async refreshAccessToken(accessToken: string): Promise {
+ const service = await this.getService()
+ return await service.refreshAccessToken(accessToken)
}
-}
+
+ private async getService() {
+ const isGuest = await this.isGuestReader.getIsGuest()
+ if (isGuest) {
+ return this.guestAccessTokenService
+ } else {
+ return this.hostAccessTokenService
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/features/auth/domain/accessToken/GuestAccessTokenService.ts b/src/features/auth/domain/accessToken/GuestAccessTokenService.ts
new file mode 100644
index 00000000..d2e1a059
--- /dev/null
+++ b/src/features/auth/domain/accessToken/GuestAccessTokenService.ts
@@ -0,0 +1,53 @@
+import IAccessTokenService from "./IAccessTokenService"
+
+export interface IUserIDReader {
+ getUserId(): Promise
+}
+
+export interface Repository {
+ get(userId: string): Promise
+ setExpiring(userId: string, token: string, timeToLive: number): Promise
+}
+
+export interface DataSource {
+ getAccessToken(userId: string): Promise
+}
+
+export type GuestAccessTokenServiceConfig = {
+ readonly userIdReader: IUserIDReader
+ readonly repository: Repository
+ readonly dataSource: DataSource
+}
+
+export default class GuestAccessTokenService implements IAccessTokenService {
+ private readonly userIdReader: IUserIDReader
+ private readonly repository: Repository
+ private readonly dataSource: DataSource
+
+ constructor(config: GuestAccessTokenServiceConfig) {
+ this.userIdReader = config.userIdReader
+ this.repository = config.repository
+ this.dataSource = config.dataSource
+ }
+
+ async getAccessToken(): Promise {
+ const userId = await this.userIdReader.getUserId()
+ const accessToken = await this.repository.get(userId)
+ if (!accessToken) {
+ // We fetch the access token for guests on demand.
+ return await this.getNewAccessToken()
+ }
+ return accessToken
+ }
+
+ async refreshAccessToken(_accessToken: string): Promise {
+ return await this.getNewAccessToken()
+ }
+
+ private async getNewAccessToken(): Promise {
+ const userId = await this.userIdReader.getUserId()
+ const newAccessToken = await this.dataSource.getAccessToken(userId)
+ await this.repository.setExpiring(userId, newAccessToken, 7 * 24 * 3600)
+ return newAccessToken
+ }
+}
\ No newline at end of file
diff --git a/src/features/auth/domain/accessToken/HostAccessTokenService.ts b/src/features/auth/domain/accessToken/HostAccessTokenService.ts
new file mode 100644
index 00000000..7b400082
--- /dev/null
+++ b/src/features/auth/domain/accessToken/HostAccessTokenService.ts
@@ -0,0 +1,39 @@
+import IAccessTokenService from "./IAccessTokenService"
+import IOAuthTokenRepository from "../oAuthToken/IOAuthTokenRepository"
+import IOAuthTokenRefresher from "../oAuthToken/IOAuthTokenRefresher"
+
+export interface IUserIDReader {
+ getUserId(): Promise
+}
+
+type HostAccessTokenServiceConfig = {
+ readonly userIdReader: IUserIDReader
+ readonly repository: IOAuthTokenRepository
+ readonly refresher: IOAuthTokenRefresher
+}
+
+export default class HostAccessTokenService implements IAccessTokenService {
+ private readonly userIdReader: IUserIDReader
+ private readonly repository: IOAuthTokenRepository
+ private readonly refresher: IOAuthTokenRefresher
+
+ constructor(config: HostAccessTokenServiceConfig) {
+ this.userIdReader = config.userIdReader
+ this.repository = config.repository
+ this.refresher = config.refresher
+ }
+
+ async getAccessToken(): Promise {
+ const userId = await this.userIdReader.getUserId()
+ const oAuthToken = await this.repository.get(userId)
+ return oAuthToken.accessToken
+ }
+
+ async refreshAccessToken(_accessToken: string): Promise {
+ const userId = await this.userIdReader.getUserId()
+ const oAuthToken = await this.repository.get(userId)
+ const newOAuthToken = await this.refresher.refreshOAuthToken(oAuthToken.refreshToken)
+ await this.repository.set(userId, newOAuthToken)
+ return newOAuthToken.accessToken
+ }
+}
\ No newline at end of file
diff --git a/src/features/auth/domain/accessToken/LockingAccessTokenService.ts b/src/features/auth/domain/accessToken/LockingAccessTokenService.ts
index cda75ecf..5717bdcc 100644
--- a/src/features/auth/domain/accessToken/LockingAccessTokenService.ts
+++ b/src/features/auth/domain/accessToken/LockingAccessTokenService.ts
@@ -1,4 +1,4 @@
-import IMutexFactory from "@/common/mutex/IMutexFactory"
+import { IMutexFactory } from "@/common"
import IAccessTokenService from "./IAccessTokenService"
import withMutex from "../../../../common/mutex/withMutex"
diff --git a/src/features/auth/domain/accessToken/index.ts b/src/features/auth/domain/accessToken/index.ts
index f23b4d05..fd3cb104 100644
--- a/src/features/auth/domain/accessToken/index.ts
+++ b/src/features/auth/domain/accessToken/index.ts
@@ -1,4 +1,5 @@
-export { default as AccessTokenService } from "./AccessTokenService"
-export type { default as IAccessTokenService } from "./IAccessTokenService"
+export { default as GuestAccessTokenService } from "./GuestAccessTokenService"
+export { default as HostAccessTokenService } from "./HostAccessTokenService"
export { default as LockingAccessTokenService } from "./LockingAccessTokenService"
export { default as OnlyStaleRefreshingAccessTokenService } from "./OnlyStaleRefreshingAccessTokenService"
+export { default as AccessTokenService } from "./AccessTokenService"
diff --git a/src/features/auth/domain/credentialsTransfer/CredentialsTransferrer.ts b/src/features/auth/domain/credentialsTransfer/HostCredentialsTransferrer.ts
similarity index 85%
rename from src/features/auth/domain/credentialsTransfer/CredentialsTransferrer.ts
rename to src/features/auth/domain/credentialsTransfer/HostCredentialsTransferrer.ts
index c061a2cb..d66a0534 100644
--- a/src/features/auth/domain/credentialsTransfer/CredentialsTransferrer.ts
+++ b/src/features/auth/domain/credentialsTransfer/HostCredentialsTransferrer.ts
@@ -6,18 +6,18 @@ export interface IRefreshTokenReader {
getRefreshToken(userId: string): Promise
}
-type CredentialsTransferrerConfig = {
+type HostCredentialsTransferrerConfig = {
readonly refreshTokenReader: IRefreshTokenReader
readonly oAuthTokenRefresher: IOAuthTokenRefresher
readonly oAuthTokenRepository: IOAuthTokenRepository
}
-export default class CredentialsTransferrer implements ICredentialsTransferrer {
+export default class HostCredentialsTransferrer implements ICredentialsTransferrer {
private readonly refreshTokenReader: IRefreshTokenReader
private readonly oAuthTokenRefresher: IOAuthTokenRefresher
private readonly oAuthTokenRepository: IOAuthTokenRepository
- constructor(config: CredentialsTransferrerConfig) {
+ constructor(config: HostCredentialsTransferrerConfig) {
this.refreshTokenReader = config.refreshTokenReader
this.oAuthTokenRefresher = config.oAuthTokenRefresher
this.oAuthTokenRepository = config.oAuthTokenRepository
diff --git a/src/features/auth/domain/credentialsTransfer/NullObjectCredentialsTransferrer.ts b/src/features/auth/domain/credentialsTransfer/NullObjectCredentialsTransferrer.ts
new file mode 100644
index 00000000..2b02e044
--- /dev/null
+++ b/src/features/auth/domain/credentialsTransfer/NullObjectCredentialsTransferrer.ts
@@ -0,0 +1,5 @@
+import ICredentialsTransferrer from "./ICredentialsTransferrer"
+
+export default class NullObjectCredentialsTransferrer implements ICredentialsTransferrer {
+ async transferCredentials(_userId: string): Promise {}
+}
diff --git a/src/features/auth/domain/credentialsTransfer/index.ts b/src/features/auth/domain/credentialsTransfer/index.ts
index d59f555e..3961d607 100644
--- a/src/features/auth/domain/credentialsTransfer/index.ts
+++ b/src/features/auth/domain/credentialsTransfer/index.ts
@@ -1,2 +1,2 @@
-export { default as CredentialsTransferrer } from "./CredentialsTransferrer"
-export type { default as ICredentialsTransferrer } from "./ICredentialsTransferrer"
+export { default as HostCredentialsTransferrer } from "./HostCredentialsTransferrer"
+export { default as NullObjectCredentialsTransferrer } from "./NullObjectCredentialsTransferrer"
diff --git a/src/features/auth/domain/index.ts b/src/features/auth/domain/index.ts
index bf537f9c..63cbe382 100644
--- a/src/features/auth/domain/index.ts
+++ b/src/features/auth/domain/index.ts
@@ -3,3 +3,5 @@ export * from "./credentialsTransfer"
export * from "./logIn"
export * from "./logOut"
export * from "./oAuthToken"
+export * from "./repositoryAccess"
+export * from "./userIdentityProvider"
diff --git a/src/features/auth/domain/logIn/CompositeLogInHandler.ts b/src/features/auth/domain/logIn/CompositeLogInHandler.ts
new file mode 100644
index 00000000..169be4c2
--- /dev/null
+++ b/src/features/auth/domain/logIn/CompositeLogInHandler.ts
@@ -0,0 +1,14 @@
+import ILogInHandler from "./ILogInHandler"
+
+export default class CompositeLogInHandler implements ILogInHandler {
+ private readonly handlers: ILogInHandler[]
+
+ constructor(handlers: ILogInHandler[]) {
+ this.handlers = handlers
+ }
+
+ async handleLogIn(userId: string): Promise {
+ const promises = this.handlers.map(e => e.handleLogIn(userId))
+ await Promise.all(promises)
+ }
+}
diff --git a/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts b/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts
index 618b2713..ae967eb5 100644
--- a/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts
+++ b/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts
@@ -1,18 +1,34 @@
import ICredentialsTransferrer from "../credentialsTransfer/ICredentialsTransferrer"
+import IIsUserGuestReader from "../userIdentityProvider/IIsUserGuestReader"
import ILogInHandler from "./ILogInHandler"
export interface IRefreshTokenReader {
getRefreshToken(userId: string): Promise
}
+type CredentialsTransferringLogInHandlerConfig = {
+ readonly isUserGuestReader: IIsUserGuestReader
+ readonly guestCredentialsTransferrer: ICredentialsTransferrer
+ readonly hostCredentialsTransferrer: ICredentialsTransferrer
+}
+
export default class CredentialsTransferringLogInHandler implements ILogInHandler {
- private readonly credentialsTransferrer: ICredentialsTransferrer
+ private readonly isUserGuestReader: IIsUserGuestReader
+ private readonly guestCredentialsTransferrer: ICredentialsTransferrer
+ private readonly hostCredentialsTransferrer: ICredentialsTransferrer
- constructor(credentialsTransferrer: ICredentialsTransferrer) {
- this.credentialsTransferrer = credentialsTransferrer
+ constructor(config: CredentialsTransferringLogInHandlerConfig) {
+ this.isUserGuestReader = config.isUserGuestReader
+ this.guestCredentialsTransferrer = config.guestCredentialsTransferrer
+ this.hostCredentialsTransferrer = config.hostCredentialsTransferrer
}
async handleLogIn(userId: string): Promise {
- await this.credentialsTransferrer.transferCredentials(userId)
+ const isGuest = await this.isUserGuestReader.getIsUserGuest(userId)
+ if (isGuest) {
+ await this.guestCredentialsTransferrer.transferCredentials(userId)
+ } else {
+ await this.hostCredentialsTransferrer.transferCredentials(userId)
+ }
}
}
diff --git a/src/features/auth/domain/logIn/RemoveInvitedFlagLogInHandler.ts b/src/features/auth/domain/logIn/RemoveInvitedFlagLogInHandler.ts
new file mode 100644
index 00000000..c1565e9d
--- /dev/null
+++ b/src/features/auth/domain/logIn/RemoveInvitedFlagLogInHandler.ts
@@ -0,0 +1,20 @@
+import ILogInHandler from "./ILogInHandler"
+
+export interface IMetadataUpdater {
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+ updateMetadata(userId: string, metadata: {[key: string]: any}): Promise
+}
+
+export default class RemoveInvitedFlagLogInHandler implements ILogInHandler {
+ private readonly metadataUpdater: IMetadataUpdater
+
+ constructor(metadataUpdater: IMetadataUpdater) {
+ this.metadataUpdater = metadataUpdater
+ }
+
+ async handleLogIn(userId: string): Promise {
+ await this.metadataUpdater.updateMetadata(userId, {
+ has_pending_invitation: false
+ })
+ }
+}
diff --git a/src/features/auth/domain/logIn/index.ts b/src/features/auth/domain/logIn/index.ts
index 47d8d206..ec771fa7 100644
--- a/src/features/auth/domain/logIn/index.ts
+++ b/src/features/auth/domain/logIn/index.ts
@@ -1,2 +1,4 @@
+export { default as CompositeLogInHandler } from "./CompositeLogInHandler"
export { default as CredentialsTransferringLogInHandler } from "./CredentialsTransferringLogInHandler"
export type { default as ILogInHandler } from "./ILogInHandler"
+export { default as RemoveInvitedFlagLogInHandler } from "./RemoveInvitedFlagLogInHandler"
diff --git a/src/features/auth/domain/oAuthToken/OAuthTokenRepository.ts b/src/features/auth/domain/oAuthToken/OAuthTokenRepository.ts
index a28bc29a..678f83bd 100644
--- a/src/features/auth/domain/oAuthToken/OAuthTokenRepository.ts
+++ b/src/features/auth/domain/oAuthToken/OAuthTokenRepository.ts
@@ -1,6 +1,4 @@
-import ZodJSONCoder from "../../../../common/utils/ZodJSONCoder"
-import IUserDataRepository from "@/common/userData/IUserDataRepository"
-import { UnauthorizedError } from "../../../../common/errors"
+import { IUserDataRepository, UnauthorizedError, ZodJSONCoder } from "../../../../common"
import IOAuthTokenRepository from "./IOAuthTokenRepository"
import OAuthToken, { OAuthTokenSchema } from "./OAuthToken"
@@ -21,7 +19,7 @@ export default class OAuthTokenRepository implements IOAuthTokenRepository {
async set(userId: string, token: OAuthToken): Promise {
const string = ZodJSONCoder.encode(OAuthTokenSchema, token)
- await this.repository.set(userId, string)
+ await this.repository.setExpiring(userId, string, 6 * 30 * 24 * 3600)
}
async delete(userId: string): Promise {
diff --git a/src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReaderConfig.ts b/src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReaderConfig.ts
new file mode 100644
index 00000000..02405b43
--- /dev/null
+++ b/src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReaderConfig.ts
@@ -0,0 +1,57 @@
+import { z } from "zod"
+import { ZodJSONCoder, IUserDataRepository } from "../../../../common"
+
+export const RepositoryNamesContainerSchema = z.string().array()
+
+interface IRepositoryAccessReader {
+ getRepositoryNames(userId: string): Promise
+}
+
+type CachingRepositoryAccessReaderConfig = {
+ readonly repository: IRepositoryNameRepository
+ readonly repositoryAccessReader: IRepositoryAccessReader
+}
+
+type IRepositoryNameRepository = IUserDataRepository
+
+export default class CachingRepositoryAccessReader {
+ private readonly repository: IRepositoryNameRepository
+ private readonly repositoryAccessReader: IRepositoryAccessReader
+
+ constructor(config: CachingRepositoryAccessReaderConfig) {
+ this.repository = config.repository
+ this.repositoryAccessReader = config.repositoryAccessReader
+ }
+
+ async getRepositoryNames(userId: string): Promise {
+ const cachedValue = await this.getCachedRepositoryNames(userId)
+ if (cachedValue) {
+ return cachedValue
+ }
+ return await this.refreshRepositoryNames(userId)
+ }
+
+ private async getCachedRepositoryNames(userId: string): Promise {
+ const str = await this.repository.get(userId)
+ if (!str) {
+ return null
+ }
+ try {
+ return ZodJSONCoder.decode(RepositoryNamesContainerSchema, str)
+ } catch (error: unknown) {
+ console.error(error)
+ return null
+ }
+ }
+
+ private async refreshRepositoryNames(userId: string): Promise {
+ const repositoryNames = await this.repositoryAccessReader.getRepositoryNames(userId)
+ try {
+ const str = ZodJSONCoder.encode(RepositoryNamesContainerSchema, repositoryNames)
+ await this.repository.setExpiring(userId, str, 7 * 24 * 3600)
+ } catch (error: unknown) {
+ console.error(error)
+ }
+ return repositoryNames
+ }
+}
diff --git a/src/features/auth/domain/repositoryAccess/RepositoryRestrictingAccessTokenDataSource.ts b/src/features/auth/domain/repositoryAccess/RepositoryRestrictingAccessTokenDataSource.ts
new file mode 100644
index 00000000..0bb4b4d2
--- /dev/null
+++ b/src/features/auth/domain/repositoryAccess/RepositoryRestrictingAccessTokenDataSource.ts
@@ -0,0 +1,27 @@
+interface IRepositoryAccessReader {
+ getRepositoryNames(userId: string): Promise
+}
+
+interface IAccessTokenDataSource {
+ getAccessToken(repositoryNames: string[]): Promise
+}
+
+type RepositoryRestrictingAccessTokenDataSourceConfig = {
+ readonly repositoryAccessReader: IRepositoryAccessReader
+ readonly dataSource: IAccessTokenDataSource
+}
+
+export default class RepositoryRestrictingAccessTokenDataSource {
+ private readonly repositoryAccessReader: IRepositoryAccessReader
+ private readonly dataSource: IAccessTokenDataSource
+
+ constructor(config: RepositoryRestrictingAccessTokenDataSourceConfig) {
+ this.repositoryAccessReader = config.repositoryAccessReader
+ this.dataSource = config.dataSource
+ }
+
+ async getAccessToken(userId: string): Promise {
+ const repositoryNames = await this.repositoryAccessReader.getRepositoryNames(userId)
+ return await this.dataSource.getAccessToken(repositoryNames)
+ }
+}
diff --git a/src/features/auth/domain/repositoryAccess/index.ts b/src/features/auth/domain/repositoryAccess/index.ts
new file mode 100644
index 00000000..3f3e42b7
--- /dev/null
+++ b/src/features/auth/domain/repositoryAccess/index.ts
@@ -0,0 +1,2 @@
+export { default as CachingRepositoryAccessReaderConfig } from "./CachingRepositoryAccessReaderConfig"
+export { default as RepositoryRestrictingAccessTokenDataSource } from "./RepositoryRestrictingAccessTokenDataSource"
diff --git a/src/features/auth/domain/userIdentityProvider/CachingUserIdentityProviderReader.ts b/src/features/auth/domain/userIdentityProvider/CachingUserIdentityProviderReader.ts
new file mode 100644
index 00000000..86456036
--- /dev/null
+++ b/src/features/auth/domain/userIdentityProvider/CachingUserIdentityProviderReader.ts
@@ -0,0 +1,26 @@
+import { IUserDataRepository } from "@/common"
+import IUserIdentityProviderReader from "./IUserIdentityProviderReader"
+import UserIdentityProvider from "./UserIdentityProvider"
+
+type Repository = IUserDataRepository
+
+export default class CachingUserIdentityProviderReader implements IUserIdentityProviderReader {
+ private readonly repository: Repository
+ private readonly reader: IUserIdentityProviderReader
+
+ constructor(repository: Repository, reader: IUserIdentityProviderReader) {
+ this.repository = repository
+ this.reader = reader
+ }
+
+ async getUserIdentityProvider(userId: string): Promise {
+ const cachedValue = await this.repository.get(userId)
+ if (cachedValue) {
+ return cachedValue as UserIdentityProvider
+ } else {
+ const userIdentity = await this.reader.getUserIdentityProvider(userId)
+ await this.repository.setExpiring(userId, userIdentity.toString(), 7 * 24 * 3600)
+ return userIdentity
+ }
+ }
+}
diff --git a/src/features/auth/domain/userIdentityProvider/IIsUserGuestReader.ts b/src/features/auth/domain/userIdentityProvider/IIsUserGuestReader.ts
new file mode 100644
index 00000000..da34aa0d
--- /dev/null
+++ b/src/features/auth/domain/userIdentityProvider/IIsUserGuestReader.ts
@@ -0,0 +1,3 @@
+export default interface IIsUserGuestReader {
+ getIsUserGuest(userId: string): Promise
+}
\ No newline at end of file
diff --git a/src/features/auth/domain/userIdentityProvider/IUserIdentityProviderReader.ts b/src/features/auth/domain/userIdentityProvider/IUserIdentityProviderReader.ts
new file mode 100644
index 00000000..7b70ffec
--- /dev/null
+++ b/src/features/auth/domain/userIdentityProvider/IUserIdentityProviderReader.ts
@@ -0,0 +1,5 @@
+import UserIdentityProvider from "./UserIdentityProvider"
+
+export default interface IUserIdentityProviderReader {
+ getUserIdentityProvider(userId: string): Promise
+}
diff --git a/src/features/auth/domain/userIdentityProvider/IsUserGuestReader.ts b/src/features/auth/domain/userIdentityProvider/IsUserGuestReader.ts
new file mode 100644
index 00000000..d2e00648
--- /dev/null
+++ b/src/features/auth/domain/userIdentityProvider/IsUserGuestReader.ts
@@ -0,0 +1,16 @@
+import IIsUserGuestReader from "./IIsUserGuestReader"
+import IUserIdentityProviderReader from "./IUserIdentityProviderReader"
+import UserIdentityProvider from "./UserIdentityProvider"
+
+export default class IsUserGuestReader implements IIsUserGuestReader {
+ private readonly userIdentityProviderReader: IUserIdentityProviderReader
+
+ constructor(userIdentityProviderReader: IUserIdentityProviderReader) {
+ this.userIdentityProviderReader = userIdentityProviderReader
+ }
+
+ async getIsUserGuest(userId: string): Promise {
+ const userIdentityProvider = await this.userIdentityProviderReader.getUserIdentityProvider(userId)
+ return userIdentityProvider == UserIdentityProvider.USERNAME_PASSWORD
+ }
+}
\ No newline at end of file
diff --git a/src/features/auth/domain/userIdentityProvider/UserIdentityProvider.ts b/src/features/auth/domain/userIdentityProvider/UserIdentityProvider.ts
new file mode 100644
index 00000000..394cafa4
--- /dev/null
+++ b/src/features/auth/domain/userIdentityProvider/UserIdentityProvider.ts
@@ -0,0 +1,6 @@
+enum UserIdentityProvider {
+ GITHUB = "github",
+ USERNAME_PASSWORD = "username_password"
+}
+
+export default UserIdentityProvider
diff --git a/src/features/auth/domain/userIdentityProvider/index.ts b/src/features/auth/domain/userIdentityProvider/index.ts
new file mode 100644
index 00000000..74b56b57
--- /dev/null
+++ b/src/features/auth/domain/userIdentityProvider/index.ts
@@ -0,0 +1,3 @@
+export { default as CachingUserIdentityProviderReader } from "./CachingUserIdentityProviderReader"
+export { default as IsUserGuestReader } from "./IsUserGuestReader"
+export { default as UserIdentityProvider } from "./UserIdentityProvider"
diff --git a/src/features/auth/view/SessionAccessTokenBarrier.tsx b/src/features/auth/view/SessionAccessTokenBarrier.tsx
new file mode 100644
index 00000000..58996582
--- /dev/null
+++ b/src/features/auth/view/SessionAccessTokenBarrier.tsx
@@ -0,0 +1,19 @@
+import { ReactNode } from "react"
+import { redirect } from "next/navigation"
+import { session, accessTokenService } from "@/composition"
+
+export default async function SessionAccessTokenBarrier({
+ children
+}: {
+ children: ReactNode
+}) {
+ try {
+ const isGuest = await session.getIsGuest()
+ if (!isGuest) {
+ await accessTokenService.getAccessToken()
+ }
+ return <>{children}>
+ } catch {
+ redirect("/api/auth/logout")
+ }
+}
diff --git a/src/features/auth/view/SessionOAuthTokenBarrier.tsx b/src/features/auth/view/SessionOAuthTokenBarrier.tsx
deleted file mode 100644
index 158ec381..00000000
--- a/src/features/auth/view/SessionOAuthTokenBarrier.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { ReactNode } from "react"
-import { redirect } from "next/navigation"
-import { session, oAuthTokenRepository } from "@/composition"
-
-export default async function SessionOAuthTokenBarrier({
- children
-}: {
- children: ReactNode
-}) {
- try {
- const userId = await session.getUserId()
- await oAuthTokenRepository.get(userId)
- return <>{children}>
- } catch {
- redirect("/api/auth/logout")
- }
-}
diff --git a/src/features/hooks/data/GitHubPullRequestCommentRepository.ts b/src/features/hooks/data/GitHubPullRequestCommentRepository.ts
index 59b60d62..3e074d59 100644
--- a/src/features/hooks/data/GitHubPullRequestCommentRepository.ts
+++ b/src/features/hooks/data/GitHubPullRequestCommentRepository.ts
@@ -1,4 +1,4 @@
-import IGitHubClient from "@/common/github/IGitHubClient"
+import { IGitHubClient } from "@/common"
import IPullRequestCommentRepository, {
GetPullRequestCommentsOperation,
AddPullRequestCommentOperation,
diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts
index a66a6f27..e055b4e3 100644
--- a/src/features/projects/data/GitHubProjectDataSource.ts
+++ b/src/features/projects/data/GitHubProjectDataSource.ts
@@ -1,4 +1,4 @@
-import IGitHubClient from "@/common/github/IGitHubClient"
+import { IGitHubClient } from "@/common"
import {
Project,
IProjectConfig,
@@ -238,6 +238,6 @@ export default class GitHubProjectDataSource implements IProjectDataSource {
}
private getGitHubBlobURL(owner: string, repository: string, path: string, ref: string): string {
- return `/api/github/blob/${owner}/${repository}/${path}?ref=${ref}`
+ return `/api/blob/${owner}/${repository}/${path}?ref=${ref}`
}
}
diff --git a/src/features/projects/domain/ForgivingProjectDataSource.ts b/src/features/projects/domain/ForgivingProjectDataSource.ts
new file mode 100644
index 00000000..82f5c40f
--- /dev/null
+++ b/src/features/projects/domain/ForgivingProjectDataSource.ts
@@ -0,0 +1,33 @@
+import Project from "./Project"
+import IProjectDataSource from "./IProjectDataSource"
+
+interface IAccessTokenReader {
+ getAccessToken(): Promise
+}
+
+type ForgivingProjectDataSourceConfig = {
+ readonly accessTokenReader: IAccessTokenReader
+ readonly projectDataSource: IProjectDataSource
+}
+
+export default class ForgivingProjectDataSource implements IProjectDataSource {
+ private readonly accessTokenReader: IAccessTokenReader
+ private readonly projectDataSource: IProjectDataSource
+
+ constructor(config: ForgivingProjectDataSourceConfig) {
+ this.accessTokenReader = config.accessTokenReader
+ this.projectDataSource = config.projectDataSource
+ }
+
+ async getProjects(): Promise {
+ try {
+ await this.accessTokenReader.getAccessToken()
+ } catch {
+ // If we cannot get an access token, we show an empty list of projects.
+ // It is common for guest users that we cannot get an access token because they
+ // have been incorrectly configured to have access to non-existing repositories.
+ return []
+ }
+ return this.projectDataSource.getProjects()
+ }
+}
diff --git a/src/features/projects/domain/ProjectRepository.ts b/src/features/projects/domain/ProjectRepository.ts
index 1fb96b0c..c55e9341 100644
--- a/src/features/projects/domain/ProjectRepository.ts
+++ b/src/features/projects/domain/ProjectRepository.ts
@@ -1,20 +1,24 @@
-import { ZodJSONCoder, ISession, IUserDataRepository } from "../../../common"
+import { IUserDataRepository, ZodJSONCoder } from "../../../common"
import IProjectRepository from "./IProjectRepository"
import Project, { ProjectSchema } from "./Project"
+interface IUserIDReader {
+ getUserId(): Promise
+}
+
type Repository = IUserDataRepository
export default class ProjectRepository implements IProjectRepository {
- private readonly session: ISession
+ private readonly userIDReader: IUserIDReader
private readonly repository: Repository
- constructor(session: ISession, repository: Repository) {
- this.session = session
+ constructor(userIDReader: IUserIDReader, repository: Repository) {
+ this.userIDReader = userIDReader
this.repository = repository
}
async get(): Promise {
- const userId = await this.session.getUserId()
+ const userId = await this.userIDReader.getUserId()
const string = await this.repository.get(userId)
if (!string) {
return undefined
@@ -23,13 +27,13 @@ export default class ProjectRepository implements IProjectRepository {
}
async set(projects: Project[]): Promise {
- const userId = await this.session.getUserId()
+ const userId = await this.userIDReader.getUserId()
const string = ZodJSONCoder.encode(ProjectSchema.array(), projects)
- await this.repository.set(userId, string)
+ await this.repository.setExpiring(userId, string, 30 * 24 * 3600)
}
async delete(): Promise {
- const userId = await this.session.getUserId()
+ const userId = await this.userIDReader.getUserId()
await this.repository.delete(userId)
}
}
diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts
index c70203d7..f770e7c7 100644
--- a/src/features/projects/domain/index.ts
+++ b/src/features/projects/domain/index.ts
@@ -1,4 +1,5 @@
export { default as CachingProjectDataSource } from "./CachingProjectDataSource"
+export { default as ForgivingProjectDataSource } from "./ForgivingProjectDataSource"
export { default as getSelection } from "./getSelection"
export type { default as IProjectConfig } from "./IProjectConfig"
export type { default as IProjectDataSource } from "./IProjectDataSource"
diff --git a/src/features/projects/view/ProjectsPage.tsx b/src/features/projects/view/ProjectsPage.tsx
index d01941f6..5e5118d4 100644
--- a/src/features/projects/view/ProjectsPage.tsx
+++ b/src/features/projects/view/ProjectsPage.tsx
@@ -1,3 +1,4 @@
+import { session } from "@/composition"
import { ProjectRepository } from "../domain"
import ClientProjectsPage from "./client/ProjectsPage"
@@ -12,9 +13,11 @@ export default async function ProjectsPage({
versionId?: string
specificationId?: string
}) {
+ const isGuest = await session.getIsGuest()
const projects = await projectRepository.get()
return (
-
+
/
- {specification.editURL &&
+ {enableGitHubLinks && specification.editURL &&
+
{text}
)