-
Notifications
You must be signed in to change notification settings - Fork 76
feat: expose jwt generation for managed wallets #2019
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
import { faker } from "@faker-js/faker"; | ||
import { mock } from "jest-mock-extended"; | ||
|
||
import { UserSeeder } from "../../../../test/seeders/user.seeder"; | ||
import { UserWalletSeeder } from "../../../../test/seeders/user-wallet.seeder"; | ||
import type { AuthService } from "../../../auth/services/auth.service"; | ||
import type { UserWalletRepository } from "../../../billing/repositories"; | ||
import type { UserOutput } from "../../../user/repositories/user/user.repository"; | ||
import type { CreateJwtTokenRequest } from "../../http-schemas/jwt-token.schema"; | ||
import type { ProviderJwtTokenService } from "../../services/provider-jwt-token/provider-jwt-token.service"; | ||
import { JwtTokenController } from "./jwt-token.controller"; | ||
|
||
describe(JwtTokenController.name, () => { | ||
describe("createJwtToken", () => { | ||
it("creates JWT token successfully when user has wallet", async () => { | ||
const user = UserSeeder.create(); | ||
const { controller, authService, userWalletRepository, providerJwtTokenService, jwtToken, wallet } = setup({ user }); | ||
|
||
authService.currentUser = user; | ||
userWalletRepository.accessibleBy.mockReturnThis(); | ||
userWalletRepository.findOneByUserId.mockResolvedValue(wallet); | ||
providerJwtTokenService.generateJwtToken.mockResolvedValue(jwtToken); | ||
|
||
const payload = createPayload(); | ||
const result = await controller.createJwtToken(payload); | ||
|
||
expect(result).toEqual({ token: jwtToken }); | ||
expect(userWalletRepository.accessibleBy).toHaveBeenCalledWith(authService.ability, "sign"); | ||
expect(userWalletRepository.findOneByUserId).toHaveBeenCalledWith(user.id); | ||
expect(providerJwtTokenService.generateJwtToken).toHaveBeenCalledWith({ | ||
walletId: wallet.id, | ||
leases: payload.leases, | ||
ttl: payload.ttl | ||
}); | ||
}); | ||
|
||
it("throws 401 when user is not authenticated", async () => { | ||
const { controller, authService } = setup(); | ||
|
||
authService.currentUser = undefined as any; | ||
|
||
await expect(controller.createJwtToken(createPayload())).rejects.toThrow("Unauthorized"); | ||
}); | ||
|
||
it("throws 400 when user has no wallet", async () => { | ||
const user = UserSeeder.create(); | ||
const { controller, authService, userWalletRepository } = setup({ user }); | ||
|
||
authService.currentUser = user; | ||
userWalletRepository.accessibleBy.mockReturnThis(); | ||
userWalletRepository.findOneByUserId.mockResolvedValue(undefined); | ||
|
||
await expect(controller.createJwtToken(createPayload())).rejects.toThrow("User does not have a wallet"); | ||
}); | ||
Comment on lines
+37
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Drop the Casting to - const user = UserSeeder.create();
- const { controller, authService, userWalletRepository, providerJwtTokenService, jwtToken, wallet } = setup({ user });
-
- authService.currentUser = user;
+ const user = UserSeeder.create();
+ const { controller, authService, userWalletRepository, providerJwtTokenService, jwtToken, wallet } = setup({ user });
…
- const { controller, authService } = setup();
-
- authService.currentUser = undefined as any;
+ const { controller, authService } = setup();
…
function setup(input?: { user?: UserOutput }) {
const authService = mock<AuthService>();
+ if (input?.user) {
+ authService.currentUser = input.user;
+ } That keeps the tests readable and compliant with the repo rules. As per coding guidelines.
🤖 Prompt for AI Agents
|
||
}); | ||
|
||
function setup(input?: { user?: UserOutput }) { | ||
const authService = mock<AuthService>(); | ||
const userWalletRepository = mock<UserWalletRepository>(); | ||
const providerJwtTokenService = mock<ProviderJwtTokenService>(); | ||
|
||
const controller = new JwtTokenController(providerJwtTokenService, authService, userWalletRepository); | ||
|
||
const wallet = UserWalletSeeder.create({ userId: input?.user?.id }); | ||
const jwtToken = faker.string.alphanumeric(64); | ||
|
||
return { | ||
controller, | ||
authService, | ||
userWalletRepository, | ||
providerJwtTokenService, | ||
jwtToken, | ||
wallet | ||
}; | ||
} | ||
|
||
function createPayload(): CreateJwtTokenRequest { | ||
return { | ||
ttl: faker.number.int({ min: 3600, max: 86400 }), | ||
leases: { | ||
access: "full", | ||
scope: ["send-manifest", "get-manifest"] | ||
} | ||
}; | ||
} | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import assert from "http-assert"; | ||
import { singleton } from "tsyringe"; | ||
|
||
import { AuthService } from "@src/auth/services/auth.service"; | ||
import { UserWalletRepository } from "@src/billing/repositories"; | ||
import { CreateJwtTokenRequest, CreateJwtTokenResponse } from "../../http-schemas/jwt-token.schema"; | ||
import { ProviderJwtTokenService } from "../../services/provider-jwt-token/provider-jwt-token.service"; | ||
|
||
@singleton() | ||
export class JwtTokenController { | ||
constructor( | ||
private readonly providerJwtTokenService: ProviderJwtTokenService, | ||
private readonly authService: AuthService, | ||
private readonly userWalletRepository: UserWalletRepository | ||
) {} | ||
|
||
async createJwtToken(payload: CreateJwtTokenRequest): Promise<CreateJwtTokenResponse> { | ||
assert(this.authService.currentUser, 401); | ||
|
||
const wallet = await this.userWalletRepository.accessibleBy(this.authService.ability, "sign").findOneByUserId(this.authService.currentUser.id); | ||
assert(wallet, 400, "User does not have a wallet"); | ||
|
||
const jwtToken = await this.providerJwtTokenService.generateJwtToken({ | ||
walletId: wallet.id, | ||
leases: payload.leases, | ||
ttl: payload.ttl | ||
}); | ||
|
||
return { | ||
token: jwtToken | ||
}; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import type { JwtTokenPayload } from "@akashnetwork/chain-sdk/web"; | ||
import { z } from "zod"; | ||
|
||
const AccessScopeSchema = z.enum(["send-manifest", "get-manifest", "logs", "shell", "events", "status", "restart", "hostname-migrate", "ip-migrate"]); | ||
|
||
const BaseLeasePermissionSchema = z.object({ | ||
provider: z.string() | ||
}); | ||
|
||
const FullAccessPermissionSchema = BaseLeasePermissionSchema.extend({ | ||
access: z.literal("full") | ||
}); | ||
|
||
const ScopedAccessPermissionSchema = BaseLeasePermissionSchema.extend({ | ||
access: z.literal("scoped"), | ||
scope: z.array(AccessScopeSchema) | ||
}); | ||
|
||
const GranularAccessPermissionSchema = BaseLeasePermissionSchema.extend({ | ||
access: z.literal("granular"), | ||
deployments: z.array( | ||
z.object({ | ||
dseq: z.number(), | ||
scope: z.array(AccessScopeSchema), | ||
gseq: z.number().optional(), | ||
oseq: z.number().optional(), | ||
services: z.array(z.string()).optional() | ||
}) | ||
) | ||
}); | ||
|
||
const LeasePermissionSchema = z.discriminatedUnion("access", [FullAccessPermissionSchema, ScopedAccessPermissionSchema, GranularAccessPermissionSchema]); | ||
|
||
const FullAccessSchema = z.object({ | ||
access: z.literal("full"), | ||
scope: z.array(AccessScopeSchema).optional() | ||
}); | ||
|
||
const GranularAccessSchema = z.object({ | ||
access: z.literal("granular"), | ||
permissions: z.array(LeasePermissionSchema) | ||
}); | ||
|
||
const LeasesSchema = z.discriminatedUnion("access", [FullAccessSchema, GranularAccessSchema]) satisfies z.ZodType<JwtTokenPayload["leases"]>; | ||
|
||
export const CreateJwtTokenRequestSchema = z.object({ | ||
ttl: z.number().int().positive(), | ||
leases: LeasesSchema | ||
}); | ||
|
||
export type CreateJwtTokenRequest = z.infer<typeof CreateJwtTokenRequestSchema>; | ||
|
||
export const CreateJwtTokenResponseSchema = z.object({ | ||
token: z.string() | ||
}); | ||
export type CreateJwtTokenResponse = z.infer<typeof CreateJwtTokenResponseSchema>; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
import { createRoute } from "@hono/zod-openapi"; | ||
import { container } from "tsyringe"; | ||
import { z } from "zod"; | ||
|
||
import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; | ||
import { JwtTokenController } from "@src/provider/controllers/jwt-token/jwt-token.controller"; | ||
import { CreateJwtTokenRequestSchema, CreateJwtTokenResponseSchema } from "@src/provider/http-schemas/jwt-token.schema"; | ||
|
||
export const providerJwtTokenRouter = new OpenApiHonoHandler(); | ||
|
||
providerJwtTokenRouter.openapi( | ||
createRoute({ | ||
method: "post", | ||
path: "/v1/create-jwt-token", | ||
summary: "Create new JWT token for managed wallet", | ||
tags: ["JWT Token"], | ||
request: { | ||
body: { | ||
content: { | ||
"application/json": { | ||
schema: z.object({ | ||
data: CreateJwtTokenRequestSchema | ||
}) | ||
} | ||
} | ||
} | ||
}, | ||
responses: { | ||
201: { | ||
description: "JWT token created successfully", | ||
content: { | ||
"application/json": { | ||
schema: z.object({ | ||
data: CreateJwtTokenResponseSchema | ||
}) | ||
} | ||
} | ||
} | ||
} | ||
}), | ||
async function routeCreateJwtToken(c) { | ||
const body = c.req.valid("json"); | ||
const result = await container.resolve(JwtTokenController).createJwtToken(body.data); | ||
return c.json({ data: result }, 201); | ||
} | ||
); |
Original file line number | Diff line number | Diff line change | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -1,4 +1,4 @@ | ||||||||||||||
import { AccessScope, JwtToken, JwtTokenPayload } from "@akashnetwork/jwt"; | ||||||||||||||
import { JwtTokenManager, JwtTokenPayload } from "@akashnetwork/chain-sdk"; | ||||||||||||||
import { minutesToSeconds } from "date-fns"; | ||||||||||||||
import { inject, singleton } from "tsyringe"; | ||||||||||||||
import * as uuid from "uuid"; | ||||||||||||||
|
@@ -12,7 +12,7 @@ import { JWT_MODULE, JWTModule } from "@src/provider/providers/jwt.provider"; | |||||||||||||
const JWT_TOKEN_TTL_IN_SECONDS = 30; | ||||||||||||||
|
||||||||||||||
type JwtTokenWithAddress = { | ||||||||||||||
jwtToken: JwtToken; | ||||||||||||||
jwtTokenManager: JwtTokenManager; | ||||||||||||||
address: string; | ||||||||||||||
}; | ||||||||||||||
|
||||||||||||||
|
@@ -22,6 +22,8 @@ type GenerateJwtTokenParams = { | |||||||||||||
ttl?: number; | ||||||||||||||
}; | ||||||||||||||
|
||||||||||||||
type AccessScope = Extract<Extract<JwtTokenPayload["leases"], { access: "granular" }>["permissions"][number], { access: "scoped" }>["scope"][number]; | ||||||||||||||
|
||||||||||||||
@singleton() | ||||||||||||||
export class ProviderJwtTokenService { | ||||||||||||||
constructor( | ||||||||||||||
|
@@ -31,10 +33,10 @@ export class ProviderJwtTokenService { | |||||||||||||
) {} | ||||||||||||||
|
||||||||||||||
async generateJwtToken({ walletId, leases, ttl = JWT_TOKEN_TTL_IN_SECONDS }: GenerateJwtTokenParams) { | ||||||||||||||
const { jwtToken, address } = await this.getJwtToken(walletId); | ||||||||||||||
const { jwtTokenManager, address } = await this.getJwtToken(walletId); | ||||||||||||||
const now = Math.floor(Date.now() / 1000); | ||||||||||||||
|
||||||||||||||
return await jwtToken.createToken({ | ||||||||||||||
return await jwtTokenManager.generateToken({ | ||||||||||||||
version: "v1", | ||||||||||||||
exp: now + ttl, | ||||||||||||||
nbf: now, | ||||||||||||||
|
@@ -48,10 +50,10 @@ export class ProviderJwtTokenService { | |||||||||||||
@Memoize({ ttlInSeconds: minutesToSeconds(5) }) | ||||||||||||||
private async getJwtToken(walletId: number): Promise<JwtTokenWithAddress> { | ||||||||||||||
const wallet = this.walletFactory(this.billingConfigService.get("MASTER_WALLET_MNEMONIC"), walletId); | ||||||||||||||
const akashWallet = await this.jwtModule.createSignArbitraryAkashWallet(await wallet.getInstance(), walletId); | ||||||||||||||
const jwtToken = new this.jwtModule.JwtToken(akashWallet); | ||||||||||||||
const akashWallet = await this.jwtModule.createSignArbitraryAkashWallet((await wallet.getInstance()) as any, walletId); | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove unsafe The As per coding guidelines: "Never use type any or cast to type any. Always define the proper TypeScript types." Apply this approach to fix the type safety: - const akashWallet = await this.jwtModule.createSignArbitraryAkashWallet((await wallet.getInstance()) as any, walletId);
+ const walletInstance = await wallet.getInstance();
+ const akashWallet = await this.jwtModule.createSignArbitraryAkashWallet(walletInstance, walletId); Then ensure 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||
const jwtTokenManager = new this.jwtModule.JwtTokenManager(akashWallet); | ||||||||||||||
|
||||||||||||||
return { jwtToken, address: akashWallet.address }; | ||||||||||||||
return { jwtTokenManager, address: akashWallet.address }; | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
getGranularLeases({ provider, scope }: { provider: string; scope: AccessScope[] }): JwtTokenPayload["leases"] { | ||||||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Pin the alpha version more strictly.
Using
^1.0.0-alpha.3
allows npm to install any version >= 1.0.0-alpha.3 and < 2.0.0, including future alpha releases that may introduce breaking changes. For pre-release versions, use exact pinning or tilde (~
) to limit updates to patch-level changes only.Apply this diff to pin the version more strictly:
Alternatively, if you want to allow patch-level updates:
📝 Committable suggestion
🤖 Prompt for AI Agents