lens-convex-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jean Ayala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,357 @@
1
+ # lens-convex-auth
2
+
3
+ Drop-in sign-in for [Lens](https://lens.xyz) apps: a **thirdweb** wallet, **Lens account** sign-in and
4
+ creation, and **Convex** auth, wired to one session.
5
+
6
+ - A `Sign in` button that connects any wallet thirdweb supports (injected, WalletConnect, email, passkeys, …)
7
+ - An account dialog that lists the wallet's Lens accounts, or creates one (username, name, bio, picture)
8
+ - Works on mobile wallets: challenges are fetched ahead of time, so the tap that signs can open the wallet app
9
+ - Lens tokens are kept fresh in the background and shared by the Lens SDK, Convex, and your API routes
10
+ - Convex functions know which Lens account is calling (`requireLensAccount(ctx)`)
11
+
12
+ ```
13
+ wallet (thirdweb) ──▶ Lens challenge ──▶ signature ──▶ Lens tokens (localStorage, auto-refreshed)
14
+ │
15
+ POST /api/convex-token ◀─────┘ Lens ID token (verified against Lens JWKS)
16
+ │
17
+ ▼
18
+ short-lived RS256 token, subject = Lens account ──▶ Convex (customJwt provider)
19
+ ```
20
+
21
+ Convex can't verify Lens ID tokens directly (Lens signs them without a `kid` header), so your server
22
+ verifies the Lens ID token and issues a short-lived token that Convex trusts.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pnpm add lens-convex-auth thirdweb convex viem @lens-protocol/client @lens-protocol/react @lens-protocol/metadata @lens-chain/storage-client
28
+ ```
29
+
30
+ Requires React 19. The components are styled with Tailwind CSS v4 and
31
+ [shadcn/ui](https://ui.shadcn.com) theme variables, so they follow your app's theme.
32
+
33
+ ## Setup (Next.js)
34
+
35
+ ### 1. Environment
36
+
37
+ ```bash
38
+ # .env.local
39
+ NEXT_PUBLIC_THIRDWEB_CLIENT_ID= # https://thirdweb.com/dashboard
40
+ NEXT_PUBLIC_LENS_APP_ADDRESS= # https://developer.lens.xyz/apps
41
+ NEXT_PUBLIC_CONVEX_URL= # written by `npx convex dev`
42
+ CONVEX_AUTH_PRIVATE_KEY= # step 2
43
+ ```
44
+
45
+ ### 2. Keys for Convex tokens
46
+
47
+ ```bash
48
+ npx lens-convex-auth keys
49
+ ```
50
+
51
+ It prints two values:
52
+
53
+ - `CONVEX_AUTH_PRIVATE_KEY`: add it to `.env.local` (and your host's env, e.g. Vercel). Keep it secret.
54
+ - `CONVEX_AUTH_JWKS`: set it on your Convex deployment (quote the value):
55
+
56
+ ```bash
57
+ npx convex env set CONVEX_AUTH_JWKS 'data:text/plain;charset=utf-8;base64,...'
58
+ ```
59
+
60
+ Generate one pair per environment (dev, prod), and never copy a private key between them.
61
+
62
+ ### 3. Create the auth instance
63
+
64
+ ```ts
65
+ // lib/auth.ts
66
+ import { createLensAuth } from "lens-convex-auth"
67
+
68
+ export const auth = createLensAuth({
69
+ thirdwebClientId: process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID!,
70
+ lensAppAddress: process.env.NEXT_PUBLIC_LENS_APP_ADDRESS!,
71
+ // environment: "testnet",
72
+ })
73
+ ```
74
+
75
+ ### 4. Add the provider
76
+
77
+ ```tsx
78
+ // app/providers.tsx
79
+ "use client"
80
+
81
+ import { ConvexReactClient } from "convex/react"
82
+ import { LensAuthProvider } from "lens-convex-auth/react"
83
+ import { auth } from "@/lib/auth"
84
+
85
+ const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!)
86
+
87
+ export function Providers({ children }: { children: React.ReactNode }) {
88
+ return (
89
+ <LensAuthProvider auth={auth} convex={convex}>
90
+ {children}
91
+ </LensAuthProvider>
92
+ )
93
+ }
94
+ ```
95
+
96
+ ```tsx
97
+ // app/layout.tsx
98
+ import { Providers } from "./providers"
99
+
100
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
101
+ return (
102
+ <html lang="en" suppressHydrationWarning>
103
+ <body>
104
+ <Providers>{children}</Providers>
105
+ </body>
106
+ </html>
107
+ )
108
+ }
109
+ ```
110
+
111
+ `LensAuthProvider` includes `ThirdwebProvider`, `LensProvider` and `ConvexProviderWithAuth`, so don't add
112
+ them again. Lens React hooks and Convex hooks work anywhere inside it.
113
+
114
+ ### 5. Add the token route
115
+
116
+ ```ts
117
+ // app/api/convex-token/route.ts
118
+ import { createConvexTokenHandler } from "lens-convex-auth/server"
119
+
120
+ export const POST = createConvexTokenHandler({
121
+ lensAppAddress: process.env.NEXT_PUBLIC_LENS_APP_ADDRESS!,
122
+ })
123
+ ```
124
+
125
+ ### 6. Trust it in Convex
126
+
127
+ ```ts
128
+ // convex/auth.config.ts
129
+ import { lensAuthProvider } from "lens-convex-auth/convex"
130
+
131
+ export default {
132
+ providers: [lensAuthProvider({ jwks: process.env.CONVEX_AUTH_JWKS })],
133
+ }
134
+ ```
135
+
136
+ ### 7. Let Tailwind see the components
137
+
138
+ ```css
139
+ /* app/globals.css */
140
+ @import "tailwindcss";
141
+ @import "tw-animate-css"; /* optional: dialog animations */
142
+ @source "../node_modules/lens-convex-auth/dist";
143
+ ```
144
+
145
+ The path is relative to the CSS file. The components use the shadcn variables (`--background`,
146
+ `--primary`, `--muted-foreground`, …), which `npx shadcn init` adds.
147
+
148
+ ### 8. Use it
149
+
150
+ ```tsx
151
+ import { SignInButton } from "lens-convex-auth/react"
152
+
153
+ export function Navbar() {
154
+ return (
155
+ <nav className="flex justify-between">
156
+ <span>My app</span>
157
+ <SignInButton />
158
+ </nav>
159
+ )
160
+ }
161
+ ```
162
+
163
+ Connecting a wallet opens the account dialog, which stays open until the user signs in to an account
164
+ (or disconnects). The button then becomes `Logout`.
165
+
166
+ In Convex functions:
167
+
168
+ ```ts
169
+ // convex/posts.ts
170
+ import { mutation, query } from "./_generated/server"
171
+ import { v } from "convex/values"
172
+ import { getLensAccount, requireLensAccount } from "lens-convex-auth/convex"
173
+
174
+ export const create = mutation({
175
+ args: { text: v.string() },
176
+ handler: async (ctx, { text }) => {
177
+ const account = await requireLensAccount(ctx) // throws when signed out
178
+ await ctx.db.insert("posts", { account, text })
179
+ },
180
+ })
181
+
182
+ export const mine = query({
183
+ handler: async (ctx) => {
184
+ const account = await getLensAccount(ctx) // null when signed out
185
+ if (!account) return []
186
+ return ctx.db.query("posts").withIndex("by_account", (q) => q.eq("account", account)).collect()
187
+ },
188
+ })
189
+ ```
190
+
191
+ In components:
192
+
193
+ ```tsx
194
+ "use client"
195
+ import { useLensAuth } from "lens-convex-auth/react"
196
+
197
+ export function Me() {
198
+ const { status, account, wallet, signOut, openAccountDialog } = useLensAuth()
199
+ if (status !== "signed-in") return null
200
+ return <button onClick={openAccountDialog}>@{account?.username ?? account?.address}</button>
201
+ }
202
+ ```
203
+
204
+ ## Recipes
205
+
206
+ ### Create a user row on sign-in
207
+
208
+ `onSignIn` runs after the user signs in from the dialog, once Convex has accepted the session, so it can
209
+ call authenticated functions:
210
+
211
+ ```tsx
212
+ <LensAuthProvider
213
+ auth={auth}
214
+ convex={convex}
215
+ onSignIn={() => convex.mutation(api.users.ensureCurrentUser, {})}
216
+ >
217
+ ```
218
+
219
+ ```ts
220
+ // convex/users.ts
221
+ export const ensureCurrentUser = mutation({
222
+ args: {},
223
+ handler: async (ctx) => {
224
+ const account = await requireLensAccount(ctx)
225
+ const existing = await ctx.db.query("users").withIndex("by_account", (q) => q.eq("account", account)).unique()
226
+ return existing?._id ?? (await ctx.db.insert("users", { account, createdAt: Date.now() }))
227
+ },
228
+ })
229
+ ```
230
+
231
+ ### Protect your own API routes
232
+
233
+ On the client, `auth.fetch` sends the Lens ID token and retries once with a refreshed token on 401:
234
+
235
+ ```ts
236
+ const res = await auth.fetch("/api/me")
237
+ ```
238
+
239
+ On the server, `getLensSession` verifies it:
240
+
241
+ ```ts
242
+ // app/api/me/route.ts
243
+ import { getLensSession } from "lens-convex-auth/server"
244
+
245
+ export async function GET(request: Request) {
246
+ const session = await getLensSession(request, { lensAppAddress: process.env.NEXT_PUBLIC_LENS_APP_ADDRESS! })
247
+ if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 })
248
+ return Response.json({ account: session.account })
249
+ }
250
+ ```
251
+
252
+ ### Call Convex from the server as the user
253
+
254
+ ```ts
255
+ import { fetchMutation } from "convex/nextjs"
256
+ import { createConvexToken, getLensSession } from "lens-convex-auth/server"
257
+
258
+ const session = await getLensSession(request, { lensAppAddress })
259
+ if (session) await fetchMutation(api.users.ensureCurrentUser, {}, { token: await createConvexToken(session) })
260
+ ```
261
+
262
+ ### Your own dialog placement or UI
263
+
264
+ Pass `accountDialog={false}` and render `<AccountDialog />` where you want it. `CreateAccountForm` is
265
+ exported too, and the lower-level building blocks (`auth.requestChallenge`, `auth.authenticate`,
266
+ `createLensAccount`, `uploadAccountMetadata`, …) are in the main entry.
267
+
268
+ ### Without Convex
269
+
270
+ Omit the `convex` prop. Everything else works the same.
271
+
272
+ ## API
273
+
274
+ ### `createLensAuth(options)` (`lens-convex-auth`)
275
+
276
+ | Option | Default | |
277
+ | --- | --- | --- |
278
+ | `thirdwebClientId` | required | thirdweb client ID |
279
+ | `lensAppAddress` | required | Your Lens app. Tokens are issued for it. |
280
+ | `environment` | `"mainnet"` | `"mainnet"` or `"testnet"` |
281
+ | `lensChainRpcUrl` | thirdweb's RPC | Lens Chain RPC URL for thirdweb |
282
+ | `storagePrefix` | `"lens_"` | Prefix of the localStorage keys the session is stored under |
283
+ | `convexTokenEndpoint` | `"/api/convex-token"` | Where the token route lives |
284
+
285
+ Returns `auth` with `thirdwebClient`, `chain`, `lensClient`, `tokens`, `getSession()`,
286
+ `resumeSession()`, `requestChallenge()`, `authenticate()`, `logout()`, `fetch()` and `fetchConvexToken()`.
287
+
288
+ ### `<LensAuthProvider>` (`lens-convex-auth/react`)
289
+
290
+ | Prop | |
291
+ | --- | --- |
292
+ | `auth` | From `createLensAuth` |
293
+ | `convex` | Your `ConvexReactClient` (optional) |
294
+ | `onSignIn(account)` | Called after a sign-in from the dialog, once Convex accepts it |
295
+ | `accountDialog` | Render the account dialog (default `true`) |
296
+
297
+ ### `useLensAuth()`
298
+
299
+ `{ status, isLoading, isSignedIn, wallet, account, signOut, openAccountDialog, auth }`
300
+
301
+ `status` is `"loading"` (first render and SSR), `"signed-out"`, `"choosing-account"` (wallet connected,
302
+ no Lens account yet) or `"signed-in"`. `account` is `{ address, username }`.
303
+
304
+ ### `<SignInButton>`
305
+
306
+ | Prop | Default | |
307
+ | --- | --- | --- |
308
+ | `label` | `"Sign in"` | |
309
+ | `signOutLabel` | `"Logout"` | |
310
+ | `theme` | page theme | thirdweb modal theme: a `dark`/`light` class on `<html>`, else the system's |
311
+ | `className` | | For the loading and logout buttons |
312
+ | `connectButtonProps` | | Passed to thirdweb's `ConnectButton` (`wallets`, `connectModal`, …) |
313
+
314
+ thirdweb's `ConnectButton` (~480KB gzipped of wallet SDKs) is loaded on demand, so it stays out of
315
+ your pages' initial bundle.
316
+
317
+ ### Server (`lens-convex-auth/server`)
318
+
319
+ - `createConvexTokenHandler(options)`: `POST` handler using the standard `Request`/`Response`
320
+ - `getLensSession(request, options)`: `{ account, expiresAt } | null` from `Authorization: Bearer`
321
+ - `verifyLensIdToken(idToken, options)`: the same, from a token
322
+ - `createConvexToken(session, options?)`: a Convex token for the account
323
+
324
+ Options: `lensAppAddress` (required), `environment`, and for Convex tokens `privateKey` (default
325
+ `process.env.CONVEX_AUTH_PRIVATE_KEY`), `issuer`, `audience`, `keyId`. If you change `issuer` or
326
+ `audience`, pass the same values to `lensAuthProvider`.
327
+
328
+ ### Convex (`lens-convex-auth/convex`)
329
+
330
+ - `lensAuthProvider({ jwks, issuer?, audience? })`: provider for `convex/auth.config.ts`
331
+ - `getLensAccount(ctx)`: the caller's Lens account (lowercased) or `null`
332
+ - `requireLensAccount(ctx, message?)`: the same, or throws a `ConvexError`
333
+
334
+ ### CLI
335
+
336
+ - `npx lens-convex-auth keys [--kid <id>]`: generates a key pair for Convex tokens
337
+
338
+ ## Troubleshooting
339
+
340
+ **Turbopack fails to resolve `@x402/*` packages.** Recent `@coinbase/cdp-sdk` versions (a
341
+ thirdweb dependency) import optional packages Turbopack can't resolve. Pin it in your
342
+ `pnpm-workspace.yaml` (or `overrides` in `package.json`):
343
+
344
+ ```yaml
345
+ overrides:
346
+ '@coinbase/cdp-sdk': 1.52.0
347
+ ```
348
+
349
+ **Convex says the user isn't authenticated.** Check that `CONVEX_AUTH_JWKS` on the deployment and
350
+ `CONVEX_AUTH_PRIVATE_KEY` on the server come from the same `keys` run, and that `issuer`/`audience` match
351
+ on both sides if you changed them.
352
+
353
+ **The dialog is unstyled.** Tailwind isn't scanning the package: add the `@source` line from step 7.
354
+
355
+ ## License
356
+
357
+ MIT
@@ -0,0 +1,102 @@
1
+ import * as _lens_protocol_client from '@lens-protocol/client';
2
+ import { PublicClient, UUID, SessionClient } from '@lens-protocol/client';
3
+ import * as thirdweb_chains from 'thirdweb/chains';
4
+ import { ThirdwebClient } from 'thirdweb';
5
+ import { Chain } from 'viem';
6
+
7
+ type LensEnvironmentName = "mainnet" | "testnet";
8
+ declare function lensApiOrigin(environment?: LensEnvironmentName): string;
9
+ declare const LENS_CHAIN_IDS: Record<LensEnvironmentName, number>;
10
+
11
+ interface TokenData {
12
+ accessToken: string;
13
+ idToken: string;
14
+ refreshToken: string;
15
+ expiresAt: number;
16
+ }
17
+ type LensCredentials = Pick<TokenData, "accessToken" | "idToken" | "refreshToken">;
18
+ declare const TOKENS_CHANGED_EVENT = "lens-convex-auth:tokens-changed";
19
+ declare class TokenService {
20
+ private options;
21
+ readonly storageKeys: {
22
+ accessToken: string;
23
+ idToken: string;
24
+ refreshToken: string;
25
+ expiresAt: string;
26
+ };
27
+ private refreshPromise;
28
+ constructor(options: {
29
+ storagePrefix: string;
30
+ graphqlUrl: string;
31
+ });
32
+ storeTokens(tokens: TokenData): void;
33
+ storeCredentials(credentials: LensCredentials): void;
34
+ getStoredTokens(): TokenData | null;
35
+ clearTokens(): void;
36
+ getValidAccessToken(): Promise<string | null>;
37
+ getFreshIdToken(forceRefresh?: boolean): Promise<string | null>;
38
+ isAuthenticated(): Promise<boolean>;
39
+ refreshTokens(): Promise<TokenData | null>;
40
+ private performRefresh;
41
+ }
42
+
43
+ type LensAuthOptions = {
44
+ /** thirdweb client ID (https://thirdweb.com/dashboard) */
45
+ thirdwebClientId: string;
46
+ /** Your Lens app address (https://developer.lens.xyz/apps). Tokens are issued for this app. */
47
+ lensAppAddress: string;
48
+ /** Defaults to "mainnet" */
49
+ environment?: LensEnvironmentName;
50
+ /** Lens Chain RPC for thirdweb. Defaults to thirdweb's RPC. */
51
+ lensChainRpcUrl?: string;
52
+ /** Prefix of the localStorage keys the session is stored under. Defaults to "lens_". */
53
+ storagePrefix?: string;
54
+ /** Route that exchanges a Lens ID token for a Convex token. Defaults to "/api/convex-token". */
55
+ convexTokenEndpoint?: string;
56
+ };
57
+ type LensLoginRole = "ACCOUNT_OWNER" | "ACCOUNT_MANAGER";
58
+ type LensChallenge = {
59
+ id: UUID;
60
+ text: string;
61
+ };
62
+ type MessageSigner = {
63
+ signMessage: (args: {
64
+ message: string;
65
+ }) => Promise<string>;
66
+ };
67
+ type LensSessionInfo = {
68
+ account: string;
69
+ sessionId: string;
70
+ };
71
+ type LensAuth = ReturnType<typeof createLensAuth>;
72
+ declare const CHALLENGE_REFRESH_MS: number;
73
+ declare const CHALLENGE_MAX_AGE_MS: number;
74
+ declare function opensWalletWithDeepLink(): boolean;
75
+ declare function signLensChallenge(challenge: LensChallenge, signer: MessageSigner): Promise<string>;
76
+ declare function createLensAuth(options: LensAuthOptions): {
77
+ environment: LensEnvironmentName;
78
+ appAddress: string;
79
+ storagePrefix: string;
80
+ thirdwebClient: ThirdwebClient;
81
+ chain: Readonly<thirdweb_chains.ChainOptions & {
82
+ rpc: string;
83
+ }>;
84
+ viemChain: Chain;
85
+ lensClient: PublicClient<_lens_protocol_client.Context>;
86
+ lensEnvironment: _lens_protocol_client.EnvironmentConfig;
87
+ tokens: TokenService;
88
+ getSession: () => LensSessionInfo | null;
89
+ resumeSession: () => Promise<boolean>;
90
+ requestChallenge: (opts: {
91
+ role: LensLoginRole;
92
+ account: string;
93
+ signer: string;
94
+ }) => Promise<LensChallenge>;
95
+ signChallenge: typeof signLensChallenge;
96
+ authenticate: (challenge: LensChallenge, signature: string) => Promise<SessionClient>;
97
+ logout: () => Promise<void>;
98
+ fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
99
+ fetchConvexToken: (forceRefresh?: boolean) => Promise<string | null>;
100
+ };
101
+
102
+ export { CHALLENGE_MAX_AGE_MS as C, type LensChallenge as L, type MessageSigner as M, TokenService as T, type LensAuth as a, type LensEnvironmentName as b, CHALLENGE_REFRESH_MS as c, LENS_CHAIN_IDS as d, type LensAuthOptions as e, type LensCredentials as f, type LensLoginRole as g, type LensSessionInfo as h, TOKENS_CHANGED_EVENT as i, type TokenData as j, createLensAuth as k, lensApiOrigin as l, opensWalletWithDeepLink as o, signLensChallenge as s };