diff --git a/.env.sandbox.docker-compose-dev b/.env.sandbox.docker-compose-dev index 0b87791a1..994a66358 100644 --- a/.env.sandbox.docker-compose-dev +++ b/.env.sandbox.docker-compose-dev @@ -3,6 +3,7 @@ AkashSandboxDatabaseCS=postgres://postgres:password@db:5432/console-akash-sandbo UserDatabaseCS=postgres://postgres:password@db:5432/console-users Network=sandbox ActiveChain=akashSandbox +POSTGRES_DB_URI: postgres://postgres:password@db:5432/console-users # Deploy Web API_BASE_URL: http://api:3000 diff --git a/apps/api/.env.functional.test b/apps/api/.env.functional.test index 4709ec36d..934bcc6e7 100644 --- a/apps/api/.env.functional.test +++ b/apps/api/.env.functional.test @@ -11,3 +11,4 @@ DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT=5000000 TRIAL_ALLOWANCE_DENOM=uakt LOG_LEVEL=debug BILLING_ENABLED=true +ANONYMOUS_USER_TOKEN_SECRET=ANONYMOUS_USER_TOKEN_SECRET \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 0e6abe1f9..1c626739d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -50,6 +50,7 @@ "@opentelemetry/instrumentation-pino": "^0.41.0", "@opentelemetry/sdk-node": "^0.52.1", "@sentry/node": "^7.55.2", + "@types/jsonwebtoken": "^9.0.6", "@ucast/core": "^1.10.2", "async-sema": "^3.1.1", "axios": "^1.7.2", @@ -65,6 +66,7 @@ "http-errors": "^2.0.0", "human-interval": "^2.0.1", "js-sha256": "^0.9.0", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "markdown-to-txt": "^2.0.1", "memory-cache": "^0.2.0", diff --git a/apps/api/src/auth/config/env.config.ts b/apps/api/src/auth/config/env.config.ts new file mode 100644 index 000000000..6e6f5aa9b --- /dev/null +++ b/apps/api/src/auth/config/env.config.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +const envSchema = z.object({ + ANONYMOUS_USER_TOKEN_SECRET: z.string() +}); + +export const envConfig = envSchema.parse(process.env); diff --git a/apps/api/src/auth/config/index.ts b/apps/api/src/auth/config/index.ts new file mode 100644 index 000000000..dcf71b9a6 --- /dev/null +++ b/apps/api/src/auth/config/index.ts @@ -0,0 +1,3 @@ +import { envConfig } from "./env.config"; + +export const config = envConfig; diff --git a/apps/api/src/auth/providers/config.provider.ts b/apps/api/src/auth/providers/config.provider.ts new file mode 100644 index 000000000..ed32b61ac --- /dev/null +++ b/apps/api/src/auth/providers/config.provider.ts @@ -0,0 +1,11 @@ +import { container, inject } from "tsyringe"; + +import { config } from "@src/auth/config"; + +export const AUTH_CONFIG = "AUTH_CONFIG"; + +container.register(AUTH_CONFIG, { useValue: config }); + +export type AuthConfig = typeof config; + +export const InjectAuthConfig = () => inject(AUTH_CONFIG); diff --git a/apps/api/src/auth/providers/index.ts b/apps/api/src/auth/providers/index.ts new file mode 100644 index 000000000..ea88549fa --- /dev/null +++ b/apps/api/src/auth/providers/index.ts @@ -0,0 +1 @@ +import "./config.provider"; diff --git a/apps/api/src/auth/services/auth-token/auth-token.service.ts b/apps/api/src/auth/services/auth-token/auth-token.service.ts new file mode 100644 index 000000000..574b5b676 --- /dev/null +++ b/apps/api/src/auth/services/auth-token/auth-token.service.ts @@ -0,0 +1,37 @@ +import jwt from "jsonwebtoken"; +import { singleton } from "tsyringe"; +import { z } from "zod"; + +import { AuthConfig, InjectAuthConfig } from "@src/auth/providers/config.provider"; + +@singleton() +export class AuthTokenService { + private readonly PayloadSchema = z.object({ + sub: z.string(), + type: z.literal("ANONYMOUS") + }); + + constructor(@InjectAuthConfig() private readonly config: AuthConfig) {} + + signTokenFor(input: { userId: string }): string { + return jwt.sign({ sub: input.userId, type: "ANONYMOUS" }, this.config.ANONYMOUS_USER_TOKEN_SECRET); + } + + async getValidUserId(bearer: string): Promise { + const token = bearer.replace(/^Bearer\s+/i, ""); + const payload = await this.decodeToken(token); + + if (payload) { + jwt.verify(token, this.config.ANONYMOUS_USER_TOKEN_SECRET); + + return payload.sub; + } + } + + private async decodeToken(token: string): Promise | undefined> { + const payload = jwt.decode(token); + const { success, data } = await this.PayloadSchema.safeParseAsync(payload); + + return success ? data : undefined; + } +} diff --git a/apps/api/src/auth/services/auth.interceptor.ts b/apps/api/src/auth/services/auth.interceptor.ts index 86ebb5d0b..8146e2fab 100644 --- a/apps/api/src/auth/services/auth.interceptor.ts +++ b/apps/api/src/auth/services/auth.interceptor.ts @@ -3,6 +3,7 @@ import { singleton } from "tsyringe"; import { AbilityService } from "@src/auth/services/ability/ability.service"; import { AuthService } from "@src/auth/services/auth.service"; +import { AuthTokenService } from "@src/auth/services/auth-token/auth-token.service"; import type { HonoInterceptor } from "@src/core/types/hono-interceptor.type"; import { kvStore } from "@src/middlewares/userMiddleware"; import { UserRepository } from "@src/user/repositories"; @@ -14,28 +15,32 @@ export class AuthInterceptor implements HonoInterceptor { constructor( private readonly abilityService: AbilityService, private readonly userRepository: UserRepository, - private readonly authService: AuthService + private readonly authService: AuthService, + private readonly anonymousUserAuthService: AuthTokenService ) {} intercept() { return async (c: Context, next: Next) => { - const userId = await this.authenticate(c); + const bearer = c.req.header("authorization"); - if (userId) { - const currentUser = await this.userRepository.findByUserId(userId); + const anonymousUserId = bearer && (await this.anonymousUserAuthService.getValidUserId(bearer)); + + if (anonymousUserId) { + const currentUser = await this.userRepository.findAnonymousById(anonymousUserId); this.authService.currentUser = currentUser; - this.authService.ability = currentUser ? this.abilityService.getAbilityFor("REGULAR_USER", currentUser) : this.abilityService.EMPTY_ABILITY; + this.authService.ability = currentUser ? this.abilityService.getAbilityFor("REGULAR_ANONYMOUS_USER", currentUser) : this.abilityService.EMPTY_ABILITY; return await next(); } - const anonymousUserId = c.req.header("x-anonymous-user-id"); - if (anonymousUserId) { - const currentUser = await this.userRepository.findAnonymousById(anonymousUserId); + const userId = bearer && (await this.getValidUserId(bearer, c)); + + if (userId) { + const currentUser = await this.userRepository.findByUserId(userId); this.authService.currentUser = currentUser; - this.authService.ability = currentUser ? this.abilityService.getAbilityFor("REGULAR_ANONYMOUS_USER", currentUser) : this.abilityService.EMPTY_ABILITY; + this.authService.ability = currentUser ? this.abilityService.getAbilityFor("REGULAR_USER", currentUser) : this.abilityService.EMPTY_ABILITY; return await next(); } @@ -46,15 +51,10 @@ export class AuthInterceptor implements HonoInterceptor { }; } - private async authenticate(c: Context) { - const jwtToken = c.req.header("Authorization")?.replace(/Bearer\s+/i, ""); - - if (!jwtToken?.length) { - return; - } - - const jwks = await getJwks(env.Auth0JWKSUri || c.env.JWKS_URI, useKVStore(kvStore || c.env?.VERIFY_RSA_JWT), c.env?.VERIFY_RSA_JWT_JWKS_CACHE_KEY); - const result = await verify(jwtToken, jwks); + private async getValidUserId(bearer: string, c: Context) { + const token = bearer.replace(/^Bearer\s+/i, ""); + const jwks = await getJwks(env.Auth0JWKSUri || c.env?.JWKS_URI, useKVStore(kvStore || c.env?.VERIFY_RSA_JWT), c.env?.VERIFY_RSA_JWT_JWKS_CACHE_KEY); + const result = await verify(token, jwks); return (result.payload as { sub: string }).sub; } diff --git a/apps/api/src/routers/userRouter.ts b/apps/api/src/routers/userRouter.ts index 3c043d7cd..696fc1e9f 100644 --- a/apps/api/src/routers/userRouter.ts +++ b/apps/api/src/routers/userRouter.ts @@ -1,6 +1,9 @@ -import { Hono } from "hono"; +import { Context, Hono } from "hono"; +import assert from "http-assert"; +import { container } from "tsyringe"; import * as uuid from "uuid"; +import { AuthTokenService } from "@src/auth/services/auth-token/auth-token.service"; import { getCurrentUserId, optionalUserMiddleware, requiredUserMiddleware } from "@src/middlewares/userMiddleware"; import { addTemplateFavorite, @@ -114,11 +117,10 @@ userRequiredRouter.delete("/removeAddressName/:address", async c => { userRequiredRouter.post("/tokenInfo", async c => { const userId = getCurrentUserId(c); - const anonymousUserId = c.req.header("x-anonymous-user-id"); const { wantedUsername, email, emailVerified, subscribedToNewsletter } = await c.req.json(); const settings = await getSettingsOrInit({ - anonymousUserId, + anonymousUserId: await extractAnonymousUserId(c), userId: userId, wantedUsername, email: email, @@ -129,6 +131,17 @@ userRequiredRouter.post("/tokenInfo", async c => { return c.json(settings); }); +async function extractAnonymousUserId(c: Context) { + const anonymousBearer = c.req.header("x-anonymous-authorization"); + + if (anonymousBearer) { + const anonymousUserId = await container.resolve(AuthTokenService).getValidUserId(anonymousBearer); + assert(anonymousUserId, 401, "Invalid anonymous user token"); + + return anonymousUserId; + } +} + userRequiredRouter.put("/updateSettings", async c => { const userId = getCurrentUserId(c); const { username, subscribedToNewsletter, bio, youtubeUsername, twitterUsername, githubUsername } = await c.req.json(); diff --git a/apps/api/src/user/controllers/user/user.controller.ts b/apps/api/src/user/controllers/user/user.controller.ts index b8758da53..dd4d6d3af 100644 --- a/apps/api/src/user/controllers/user/user.controller.ts +++ b/apps/api/src/user/controllers/user/user.controller.ts @@ -2,6 +2,7 @@ import assert from "http-assert"; import { singleton } from "tsyringe"; import { AuthService, Protected } from "@src/auth/services/auth.service"; +import { AuthTokenService } from "@src/auth/services/auth-token/auth-token.service"; import { UserRepository } from "@src/user/repositories"; import { GetUserParams } from "@src/user/routes/get-anonymous-user/get-anonymous-user.router"; import { AnonymousUserResponseOutput } from "@src/user/schemas/user.schema"; @@ -10,12 +11,15 @@ import { AnonymousUserResponseOutput } from "@src/user/schemas/user.schema"; export class UserController { constructor( private readonly userRepository: UserRepository, - private readonly authService: AuthService + private readonly authService: AuthService, + private readonly anonymousUserAuthService: AuthTokenService ) {} async create(): Promise { + const user = await this.userRepository.create(); return { - data: await this.userRepository.create() + data: user, + token: this.anonymousUserAuthService.signTokenFor({ userId: user.id }) }; } diff --git a/apps/api/src/user/schemas/user.schema.ts b/apps/api/src/user/schemas/user.schema.ts index e544ec45c..338231d4d 100644 --- a/apps/api/src/user/schemas/user.schema.ts +++ b/apps/api/src/user/schemas/user.schema.ts @@ -5,7 +5,8 @@ export const AnonymousUserResponseOutputSchema = z.object({ .object({ id: z.string().openapi({}) }) - .openapi({}) + .openapi({}), + token: z.string().openapi({}) }); export type AnonymousUserResponseOutput = z.infer; diff --git a/apps/api/test/functional/anonymous-user.spec.ts b/apps/api/test/functional/anonymous-user.spec.ts index d784f32ab..b9ed5bb36 100644 --- a/apps/api/test/functional/anonymous-user.spec.ts +++ b/apps/api/test/functional/anonymous-user.spec.ts @@ -9,13 +9,16 @@ describe("Users", () => { const schema = container.resolve(USER_SCHEMA); const db = container.resolve(POSTGRES_DB); let user: AnonymousUserResponseOutput["data"]; + let token: AnonymousUserResponseOutput["token"]; beforeEach(async () => { const userResponse = await app.request("/v1/anonymous-users", { method: "POST", headers: new Headers({ "Content-Type": "application/json" }) }); - user = (await userResponse.json()).data; + const body = await userResponse.json(); + user = body.data; + token = body.token; }); afterEach(async () => { @@ -32,7 +35,7 @@ describe("Users", () => { it("should retrieve a user", async () => { const getUserResponse = await app.request(`/v1/anonymous-users/${user.id}`, { method: "GET", - headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": user.id }) + headers: new Headers({ "Content-Type": "application/json", authorization: `Bearer ${token}` }) }); const retrievedUser = await getUserResponse.json(); @@ -54,10 +57,10 @@ describe("Users", () => { method: "POST", headers: new Headers({ "Content-Type": "application/json" }) }); - const { data: differentUser } = await differentUserResponse.json(); + const { token: differentUserToken } = await differentUserResponse.json(); const res = await app.request(`/v1/anonymous-users/${user.id}`, { method: "GET", - headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": differentUser.id }) + headers: new Headers({ "Content-Type": "application/json", authorization: `Bearer ${differentUserToken}` }) }); expect(res.status).toBe(404); diff --git a/apps/api/test/functional/create-wallet.spec.ts b/apps/api/test/functional/create-wallet.spec.ts index f83a81c2b..40b14720c 100644 --- a/apps/api/test/functional/create-wallet.spec.ts +++ b/apps/api/test/functional/create-wallet.spec.ts @@ -27,9 +27,10 @@ describe("wallets", () => { headers: new Headers({ "Content-Type": "application/json" }) }); const { - data: { id: userId } + data: { id: userId }, + token } = await userResponse.json(); - const headers = new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": userId }); + const headers = new Headers({ "Content-Type": "application/json", authorization: `Bearer ${token}` }); const createWalletResponse = await app.request("/v1/wallets", { method: "POST", body: JSON.stringify({ data: { userId } }), diff --git a/apps/api/test/functional/sign-and-broadcast-tx.spec.ts b/apps/api/test/functional/sign-and-broadcast-tx.spec.ts index f07a51f6a..a934672dd 100644 --- a/apps/api/test/functional/sign-and-broadcast-tx.spec.ts +++ b/apps/api/test/functional/sign-and-broadcast-tx.spec.ts @@ -24,11 +24,11 @@ describe("Tx Sign", () => { describe("POST /v1/tx", () => { it("should create a wallet for a user", async () => { - const { user, wallet } = await walletService.createUserAndWallet(); + const { user, token, wallet } = await walletService.createUserAndWallet(); const res = await app.request("/v1/tx", { method: "POST", body: await createMessagePayload(user.id, wallet.address), - headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": user.id }) + headers: new Headers({ "Content-Type": "application/json", authorization: `Bearer ${token}` }) }); expect(res.status).toBe(200); @@ -53,11 +53,11 @@ describe("Tx Sign", () => { method: "POST", headers: new Headers({ "Content-Type": "application/json" }) }); - const { data: differentUser } = await differentUserResponse.json(); + const { token } = await differentUserResponse.json(); const res = await app.request("/v1/tx", { method: "POST", body: await createMessagePayload(user.id, wallet.address), - headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": differentUser.id }) + headers: new Headers({ "Content-Type": "application/json", authorization: `Bearer ${token}` }) }); expect(res.status).toBe(404); diff --git a/apps/api/test/functional/wallets-refill.spec.ts b/apps/api/test/functional/wallets-refill.spec.ts index 5dda193e2..c7fba35e9 100644 --- a/apps/api/test/functional/wallets-refill.spec.ts +++ b/apps/api/test/functional/wallets-refill.spec.ts @@ -29,8 +29,8 @@ describe("Wallets Refill", () => { it("should refill draining wallets", async () => { const prepareRecords = Array.from({ length: 15 }).map(async () => { const records = await walletService.createUserAndWallet(); - const user = records.user; - let wallet = records.wallet; + const { user, token } = records; + let { wallet } = records; expect(wallet.creditAmount).toBe(config.TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT + config.TRIAL_FEES_ALLOWANCE_AMOUNT); const limits = { @@ -49,20 +49,20 @@ describe("Wallets Refill", () => { }, { returning: true } ); - wallet = await walletService.getWalletByUserId(user.id); + wallet = await walletService.getWalletByUserId(user.id, token); expect(wallet.creditAmount).toBe(config.DEPLOYMENT_ALLOWANCE_REFILL_THRESHOLD + config.FEE_ALLOWANCE_REFILL_THRESHOLD); expect(wallet.isTrialing).toBe(true); - return { user, wallet }; + return { user, token, wallet }; }); const records = await Promise.all(prepareRecords); await walletController.refillWallets(); await Promise.all( - records.map(async ({ wallet, user }) => { - wallet = await walletService.getWalletByUserId(user.id); + records.map(async ({ wallet, token, user }) => { + wallet = await walletService.getWalletByUserId(user.id, token); expect(wallet.creditAmount).toBe(config.DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT + config.FEE_ALLOWANCE_REFILL_AMOUNT); expect(wallet.isTrialing).toBe(false); }) diff --git a/apps/api/test/services/wallet.service.ts b/apps/api/test/services/wallet.service.ts index a01da5617..f72a5714d 100644 --- a/apps/api/test/services/wallet.service.ts +++ b/apps/api/test/services/wallet.service.ts @@ -8,22 +8,22 @@ export class WalletService { method: "POST", headers: new Headers({ "Content-Type": "application/json" }) }); - const { data: user } = await userResponse.json(); + const { data: user, token } = await userResponse.json(); const walletResponse = await this.app.request("/v1/wallets", { method: "POST", body: JSON.stringify({ data: { userId: user.id } }), - headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": user.id }) + headers: new Headers({ "Content-Type": "application/json", authorization: `Bearer ${token}` }) }); const { data: wallet } = await walletResponse.json(); - return { user, wallet }; + return { user, token, wallet }; } - async getWalletByUserId(userId: string): Promise<{ id: number; address: string; creditAmount: number }> { + async getWalletByUserId(userId: string, token: string): Promise<{ id: number; address: string; creditAmount: number }> { const walletResponse = await this.app.request(`/v1/wallets?userId=${userId}`, { - headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": userId }) + headers: new Headers({ "Content-Type": "application/json", authorization: `Bearer ${token}` }) }); const { data } = await walletResponse.json(); diff --git a/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx b/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx index 144be6ee2..ffc228baa 100644 --- a/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx +++ b/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx @@ -15,9 +15,11 @@ import { SnackbarKey, useSnackbar } from "notistack"; import { LoadingState, TransactionModal } from "@src/components/layout/TransactionModal"; import { useAnonymousUser } from "@src/context/AnonymousUserProvider/AnonymousUserProvider"; import { useAllowance } from "@src/hooks/useAllowance"; +import { useCustomUser } from "@src/hooks/useCustomUser"; import { useUsdcDenom } from "@src/hooks/useDenom"; import { useManagedWallet } from "@src/hooks/useManagedWallet"; import { getSelectedNetwork, useSelectedNetwork } from "@src/hooks/useSelectedNetwork"; +import { useUser } from "@src/hooks/useUser"; import { useWhen } from "@src/hooks/useWhen"; import { txHttpService } from "@src/services/http/http.service"; import { AnalyticsEvents } from "@src/utils/analytics"; @@ -77,8 +79,7 @@ export const WalletProvider = ({ children }) => { const router = useRouter(); const { settings } = useSettings(); const usdcIbcDenom = useUsdcDenom(); - - const { user } = useAnonymousUser(); + const user = useUser(); const userWallet = useSelectedChain(); const { wallet: managedWallet, isLoading, create, refetch } = useManagedWallet(); @@ -186,7 +187,7 @@ export const WalletProvider = ({ children }) => { let txResult: TxOutput; try { - if (user && managedWallet) { + if (user.id && managedWallet) { const mainMessage = msgs.find(msg => msg.typeUrl in MESSAGE_STATES); if (mainMessage) { diff --git a/apps/deploy-web/src/hooks/useManagedWallet.ts b/apps/deploy-web/src/hooks/useManagedWallet.ts index 06b2d09ad..6c25ff0f1 100644 --- a/apps/deploy-web/src/hooks/useManagedWallet.ts +++ b/apps/deploy-web/src/hooks/useManagedWallet.ts @@ -1,17 +1,14 @@ import { useEffect, useMemo } from "react"; import { envConfig } from "@src/config/env.config"; -import { useCustomUser } from "@src/hooks/useCustomUser"; -import { useStoredAnonymousUser } from "@src/hooks/useStoredAnonymousUser"; +import { useUser } from "@src/hooks/useUser"; import { useCreateManagedWalletMutation, useManagedWalletQuery } from "@src/queries/useManagedWalletQuery"; import { deleteManagedWalletFromStorage, updateStorageManagedWallet } from "@src/utils/walletUtils"; const isBillingEnabled = envConfig.NEXT_PUBLIC_BILLING_ENABLED; export const useManagedWallet = () => { - const { user: registeredUser } = useCustomUser(); - const { user: anonymousUser } = useStoredAnonymousUser(); - const user = useMemo(() => registeredUser || anonymousUser, [registeredUser, anonymousUser]); + const user = useUser(); const { data: queried, isFetched, isLoading: isFetching, refetch } = useManagedWalletQuery(isBillingEnabled && user?.id); const { mutate: create, data: created, isLoading: isCreating, isSuccess: isCreated } = useCreateManagedWalletMutation(); diff --git a/apps/deploy-web/src/hooks/useStoredAnonymousUser.ts b/apps/deploy-web/src/hooks/useStoredAnonymousUser.ts index 56d8be039..cfd97336c 100644 --- a/apps/deploy-web/src/hooks/useStoredAnonymousUser.ts +++ b/apps/deploy-web/src/hooks/useStoredAnonymousUser.ts @@ -4,7 +4,7 @@ import { envConfig } from "@src/config/env.config"; import { useCustomUser } from "@src/hooks/useCustomUser"; import { useWhen } from "@src/hooks/useWhen"; import { useAnonymousUserQuery, UserOutput } from "@src/queries/useAnonymousUserQuery"; -import { ANONYMOUS_USER_KEY } from "@src/utils/constants"; +import { ANONYMOUS_USER_KEY, ANONYMOUS_USER_TOKEN_KEY } from "@src/utils/constants"; type UseApiUserResult = { user?: UserOutput; @@ -16,12 +16,20 @@ const storedAnonymousUser: UserOutput | undefined = storedAnonymousUserStr ? JSO export const useStoredAnonymousUser = (): UseApiUserResult => { const { user: registeredUser, isLoading: isLoadingRegisteredUser } = useCustomUser(); - const { user, isLoading } = useAnonymousUserQuery(storedAnonymousUser?.id, { + const { user, isLoading, token } = useAnonymousUserQuery(storedAnonymousUser?.id, { enabled: envConfig.NEXT_PUBLIC_BILLING_ENABLED && !registeredUser && !isLoadingRegisteredUser }); useWhen(user, () => localStorage.setItem("anonymous-user", JSON.stringify(user))); - useWhen(registeredUser?.id, () => localStorage.removeItem(ANONYMOUS_USER_KEY)); + useWhen(registeredUser?.id, () => { + localStorage.removeItem(ANONYMOUS_USER_KEY); + localStorage.removeItem(ANONYMOUS_USER_TOKEN_KEY); + }); + useWhen(token, () => { + if (token) { + localStorage.setItem(ANONYMOUS_USER_TOKEN_KEY, token); + } + }); return useMemo( () => ({ diff --git a/apps/deploy-web/src/hooks/useUser.ts b/apps/deploy-web/src/hooks/useUser.ts new file mode 100644 index 000000000..99bc77333 --- /dev/null +++ b/apps/deploy-web/src/hooks/useUser.ts @@ -0,0 +1,12 @@ +import { useMemo } from "react"; + +import { useCustomUser } from "@src/hooks/useCustomUser"; +import { useStoredAnonymousUser } from "@src/hooks/useStoredAnonymousUser"; +import { CustomUserProfile } from "@src/types/user"; + +export const useUser = (): CustomUserProfile => { + const { user: registeredUser } = useCustomUser(); + const { user: anonymousUser } = useStoredAnonymousUser(); + + return useMemo(() => registeredUser || anonymousUser, [registeredUser, anonymousUser]); +}; diff --git a/apps/deploy-web/src/pages/api/auth/[...auth0].ts b/apps/deploy-web/src/pages/api/auth/[...auth0].ts index cde995f80..32ecd6f02 100644 --- a/apps/deploy-web/src/pages/api/auth/[...auth0].ts +++ b/apps/deploy-web/src/pages/api/auth/[...auth0].ts @@ -26,10 +26,10 @@ export default handleAuth({ Authorization: `Bearer ${session.accessToken}` }); - const anonymousId = req.headers["x-anonymous-user-id"]; + const anonymousAuthorization = req.headers.authorization; - if (anonymousId) { - headers.set("x-anonymous-user-id", anonymousId); + if (anonymousAuthorization) { + headers.set("x-anonymous-authorization", anonymousAuthorization); } const userSettings = await axios.post( diff --git a/apps/deploy-web/src/queries/useAnonymousUserQuery.ts b/apps/deploy-web/src/queries/useAnonymousUserQuery.ts index 3e7145cdc..bca5b1791 100644 --- a/apps/deploy-web/src/queries/useAnonymousUserQuery.ts +++ b/apps/deploy-web/src/queries/useAnonymousUserQuery.ts @@ -18,11 +18,12 @@ export interface UserOutput { } export function useAnonymousUserQuery(id?: string, options?: { enabled?: boolean }) { - const [userState, setUserState] = useState<{ user?: UserOutput; isLoading: boolean }>({ isLoading: !!options?.enabled }); + const [userState, setUserState] = useState<{ user?: UserOutput; isLoading: boolean; token?: string }>({ isLoading: !!options?.enabled }); useWhen(options?.enabled && !userState.user, async () => { - const fetched = await userHttpService.getOrCreateAnonymousUser(id); - setUserState({ user: fetched, isLoading: false }); + const { data: fetched, ...rest } = await userHttpService.getOrCreateAnonymousUser(id); + const token = "token" in rest ? rest.token : undefined; + setUserState({ user: fetched, token, isLoading: false }); }); return userState; diff --git a/apps/deploy-web/src/services/auth/auth.service.ts b/apps/deploy-web/src/services/auth/auth.service.ts index 0b8a51b4e..6f4b4b8cd 100644 --- a/apps/deploy-web/src/services/auth/auth.service.ts +++ b/apps/deploy-web/src/services/auth/auth.service.ts @@ -1,6 +1,6 @@ import { InternalAxiosRequestConfig } from "axios"; -import { ANONYMOUS_USER_KEY } from "@src/utils/constants"; +import { ANONYMOUS_USER_TOKEN_KEY } from "@src/utils/constants"; export class AuthService { constructor() { @@ -8,11 +8,10 @@ export class AuthService { } withAnonymousUserHeader(config: InternalAxiosRequestConfig) { - const user = localStorage.getItem(ANONYMOUS_USER_KEY); - const anonymousUserId = user ? JSON.parse(user).id : undefined; + const token = localStorage.getItem(ANONYMOUS_USER_TOKEN_KEY); - if (anonymousUserId) { - config.headers.set("x-anonymous-user-id", anonymousUserId); + if (token) { + config.headers.set("authorization", `Bearer ${token}`); } else { config.baseURL = "/api/proxy"; } diff --git a/apps/deploy-web/src/utils/constants.ts b/apps/deploy-web/src/utils/constants.ts index 07984d8cb..63084f679 100644 --- a/apps/deploy-web/src/utils/constants.ts +++ b/apps/deploy-web/src/utils/constants.ts @@ -203,3 +203,4 @@ export const monacoOptions = { export const txFeeBuffer = 10000; // 10000 uAKT export const ANONYMOUS_USER_KEY = "anonymous-user"; +export const ANONYMOUS_USER_TOKEN_KEY = "anonymous-user-auth"; diff --git a/package-lock.json b/package-lock.json index 4a782d554..132de4315 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "@opentelemetry/instrumentation-pino": "^0.41.0", "@opentelemetry/sdk-node": "^0.52.1", "@sentry/node": "^7.55.2", + "@types/jsonwebtoken": "^9.0.6", "@ucast/core": "^1.10.2", "async-sema": "^3.1.1", "axios": "^1.7.2", @@ -64,6 +65,7 @@ "http-errors": "^2.0.0", "human-interval": "^2.0.1", "js-sha256": "^0.9.0", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "markdown-to-txt": "^2.0.1", "memory-cache": "^0.2.0", @@ -19778,6 +19780,14 @@ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", + "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/lodash": { "version": "4.17.7", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", @@ -23781,6 +23791,11 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-0.1.2.tgz", @@ -26532,6 +26547,14 @@ "wcwidth": ">=1.0.1" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -33413,6 +33436,27 @@ "node": "*" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsrsasign": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-11.1.0.tgz", @@ -33486,6 +33530,25 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/jwt-decode": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", @@ -34221,16 +34284,40 @@ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" }, "node_modules/lodash.memoize": { "version": "4.1.2", @@ -34243,6 +34330,11 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", diff --git a/packages/http-sdk/src/user-http/user-http.service.ts b/packages/http-sdk/src/user-http/user-http.service.ts index ccd6c7ab3..ec97812be 100644 --- a/packages/http-sdk/src/user-http/user-http.service.ts +++ b/packages/http-sdk/src/user-http/user-http.service.ts @@ -1,5 +1,5 @@ -import { ApiHttpService } from "@akashnetwork/http-sdk"; -import { AxiosRequestConfig } from "axios"; +import { ApiOutput, HttpService } from "@akashnetwork/http-sdk"; +import type { AxiosRequestConfig } from "axios"; import memoize from "lodash/memoize"; export interface UserOutput { @@ -16,23 +16,28 @@ export interface UserOutput { githubUsername?: string; } -export class UserHttpService extends ApiHttpService { +export type UserCreateResponse = { + data: UserOutput; + token: string; +}; + +export class UserHttpService extends HttpService { constructor(config?: AxiosRequestConfig) { super(config); this.getOrCreateAnonymousUser = memoize(this.getOrCreateAnonymousUser.bind(this)); } - async getOrCreateAnonymousUser(id?: string) { + async getOrCreateAnonymousUser(id?: string): Promise> { return await (id ? this.getAnonymousUser(id) : this.createAnonymousUser()); } private async createAnonymousUser() { - return this.extractApiData(await this.post("/v1/anonymous-users")); + return this.extractData(await this.post("/v1/anonymous-users")); } private async getAnonymousUser(id: string) { try { - return this.extractApiData(await this.get(`/v1/anonymous-users/${id}`)); + return this.extractData(await this.get>(`/v1/anonymous-users/${id}`)); } catch (error) { if (error.response?.status === 404) { return this.createAnonymousUser();