@stacknet/userutils
v0.8.2
Published
Reusable auth, billing, and security utilities for StackNet stacks and applications
Maintainers
Readme
@stacknet/userutils
Authentication, session management, and billing for StackNet apps.
Retained subscription Checkout
useSubscription(apiBaseUrl, {authKey, successUrl, cancelUrl}) keeps its existing
subscription/cancellation views and delegates new subscriptions to the canonical
Checkout intent engine. Pass a public session identity key that changes on sign-in
transitions; it only invalidates displayed state. The existing authenticated
subscription read supplies the canonical customer and selected stack. Neither the
key nor an owner header authenticates the payer. The BFF compares the retained
expected owner to the current verified original credential before forwarding.
The returned checkout exposes canStart, pending, current, offers,
check(), resume(), and archive(). Display recovery controls whenever a
pending request exists. Reload only reads current and exact saved status. An
explicit resume uses the same body/UUID; it never creates a replacement after
unknown/404. An exact funded/expired result can be archived only after a fresh
canonical current read is empty. Rejected/unavailable is held, not discarded.
For apps with another verified scope source, use useCustomerCheckout with
{scope, endpoint, successUrl, cancelUrl}. The default IndexedDB store commits the
full selected offer/hash/return URLs/UUID before POST and atomically claims it
across tabs. Storage failure prevents submission. Scope changes never move drafts.
Return URLs are retained unchanged and checked by Rust's origin binding; the
server no longer derives or replaces them from Host or canonicalOrigin.
Mount the existing createBillingProxy Checkout handlers at these routes:
| App route | Factory handler |
|---|---|
| GET /api/billing/checkout/offers | checkout.offers.GET |
| GET /api/billing/checkout/intents/current | checkout.current.GET |
| POST /api/billing/checkout/intents | checkout.intents.POST |
| GET /api/billing/checkout/intents/:id | checkout.intent.GET(request, id) |
| POST /api/billing/checkout/intents/:id/resume | checkout.resume.POST(request, id) |
The existing subscribe.POST also accepts the complete retained intent. A
plan-only request is rejected. Existing signature verification, CSRF and user
rate limiting remain. Only explicit user action may navigate an exact open
result's Checkout URL. All customer funds remain stack subscription credits.
Actual browser IndexedDB and paired authority/deployment acceptance remain
release checks; synthetic source tests do not verify a processor payment.
Canonical network profile updates
The profile intent helpers retain a complete update in IndexedDB before sending it: the original mutation UUID, expected canonical revision, username, avatar and bio. The draft is scoped to the service endpoint, authenticated owner and explicit subscription stack. Storage failure prevents the request; a lost or unverifiable response keeps the original draft for exact action recovery.
Geoff's profile proxy forwards the original customer credential and configured stack. A successful update requires the canonical profile and the existing 10-million-token fee receipt to match that mutation. Public reads retain the legacy profile fields and add canonical revision metadata without duplicating avatar content. Supported data images are PNG, JPEG, WebP, GIF and AVIF, up to 5 MiB decoded; HTTP(S) image URLs are also supported.
These helpers and proxy are a source integration slice. The complete settings
and SDK callers and real browser IndexedDB validation must be paired before
release. ProfileRevisionConflict retains a submitted update. After the user
chooses to review a replacement, retireRejectedProfileIntent performs a fresh
exact-action lookup and requires the authority-verified immutable no-effect
outcome to match the full saved request fingerprint and scope before clearing
that UUID. The caller then loads the latest profile and lets the user review a
new save. A generic 409, 404 or timeout cannot retire an update. A returned public
key is not a new trust root; Rust verifies its configured authority signer.
Google One Tap, OAuth (Google, Discord, Telegram, X), wallet login (Solana, Ethereum), cross-domain SSO via auth bridge, CSRF protection, and billing/subscription hooks.
Install
pnpm add @stacknet/userutilsQuick Start
1. Set up environment variables
# .env.local
AUTH_SECRET=your-hmac-secret-min-32-chars
NEXT_PUBLIC_STACK_ID=stk_your_stack_id
NEXT_PUBLIC_STACKNET_URL=https://stacknet.magma-rpc.com2. Create API routes
Three server-side routes are required. These handle JWT signing, session validation, and logout — keeping secrets off the client.
// app/api/auth/callback/route.ts
import { createAuthCallback } from '@stacknet/userutils/server'
const handler = createAuthCallback({
authSecret: process.env.AUTH_SECRET!,
stacknetUrl: process.env.NEXT_PUBLIC_STACKNET_URL || 'https://stacknet.magma-rpc.com',
stackId: process.env.NEXT_PUBLIC_STACK_ID!,
secureCookies: process.env.NODE_ENV === 'production',
})
export async function POST(request: Request) {
return handler(request)
}// app/api/auth/session/route.ts
import { createSessionHandler } from '@stacknet/userutils/server'
const handler = createSessionHandler({
authSecret: process.env.AUTH_SECRET!,
secureCookies: process.env.NODE_ENV === 'production',
})
export async function GET(request: Request) {
return handler(request)
}// app/api/auth/logout/route.ts
import { createLogoutHandler } from '@stacknet/userutils/server'
const handler = createLogoutHandler({
stacknetUrl: process.env.NEXT_PUBLIC_STACKNET_URL || 'https://stacknet.magma-rpc.com',
secureCookies: process.env.NODE_ENV === 'production',
})
export async function POST(request: Request) {
return handler(request)
}3. Wrap your app with the provider
// components/auth-provider.tsx
'use client'
import { UserUtilsProvider } from '@stacknet/userutils/components'
const config = {
apiBaseUrl: '', // same-origin — hooks call your /api/auth/* routes
stackId: process.env.NEXT_PUBLIC_STACK_ID,
stacknetUrl: process.env.NEXT_PUBLIC_STACKNET_URL,
theme: 'dark',
}
export function AuthProvider({ children }) {
return (
<UserUtilsProvider config={config}>
{children}
</UserUtilsProvider>
)
}// app/layout.tsx
import { AuthProvider } from '../components/auth-provider'
export default function RootLayout({ children }) {
return (
<html>
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
)
}4. Add a connect page
// app/connect/page.tsx
'use client'
import { ConnectWidget } from '@stacknet/userutils/components'
export default function ConnectPage() {
return (
<ConnectWidget
config={{
apiBaseUrl: '',
stackId: process.env.NEXT_PUBLIC_STACK_ID,
stacknetUrl: process.env.NEXT_PUBLIC_STACKNET_URL,
}}
onSuccess={() => { window.location.href = '/' }}
showWallets={['phantom', 'metamask']}
showOTP={false}
/>
)
}5. Show auth state in your header
// components/header.tsx
'use client'
import { useSession, useStackAuth } from '@stacknet/userutils/hooks'
import Link from 'next/link'
export function Header() {
const { isAuthenticated, loading } = useSession()
const { session, logout, wallet } = useStackAuth()
const displayAddress = wallet.address
? `${wallet.address.slice(0, 4)}...${wallet.address.slice(-4)}`
: session?.userId?.slice(0, 8) ?? ''
if (loading) return <div>Loading...</div>
if (isAuthenticated) {
return (
<div>
<span>{displayAddress}</span>
<button onClick={logout}>Logout</button>
</div>
)
}
return <Link href="/connect">Log in</Link>
}The JWT auto-refreshes when /api/auth/session is called and the token is within 5 minutes of expiry.
Telegram sign-in
Telegram is one more network sign-in method, a peer of Phantom, MetaMask
and Google. The stack's configuration only picks which bot runs the Telegram
UI. StackNet verifies the Telegram data with that bot's token, derives the
network globalId from the Telegram user id, and mints the same network
session as the Google path. The resulting identity is network-wide. This
package runs the Telegram UI and forwards to StackNet. It never holds a bot
token and never checks the Telegram HMAC itself.
1. Stack-level configuration (on StackNet)
Add a telegram entry to the stack's oauthProviders:
| Field | Value |
| --- | --- |
| provider | "telegram" |
| clientId | the bot's username, e.g. my_app_bot (public) |
| clientSecretKey | the stack secret that holds the bot token from @BotFather (never sent to the browser or this app) |
| enabled | true |
Visibility is decided by StackNet. userutils reads the stack's public
view (GET /api/v2/stacks/{stackId}, the same endpoint useStackConfig
uses) and shows Telegram only when oauthProviders has an entry with
provider: "telegram" and enabled: true. A stack can still hide it with
features.oauthAuth: false. App config and env never decide it, and
initiate is never called just to probe. The config is cached in memory for
a minute and shared by every sign-in surface on the page. While it loads,
the Telegram option is not rendered. If the fetch fails, Telegram stays
hidden (fail closed) and the other methods keep their existing fallback. If
initiate later answers NOT_CONFIGURED (the stack config changed), the
option is removed and a short notice is shown.
const { providers, loading, isEnabled } = useStackSignInMethods({ stackId })
isEnabled('telegram') // also 'google', 'apple', 'discord', 'twitter', 'phantom', 'metamask', 'otp'2. BotFather /setdomain
In @BotFather, run /setdomain for the bot and set it to the app's domain
(e.g. geoff.ai). Telegram refuses to authenticate on any other origin.
3. Server routes (Next.js App Router)
// lib/telegram-login.ts
import { createTelegramLoginHandler } from '@stacknet/userutils/server'
export const telegramLogin = createTelegramLoginHandler({
authSecret: process.env.AUTH_SECRET!,
stacknetUrl: process.env.NEXT_PUBLIC_STACKNET_URL || 'https://stacknet.magma-rpc.com',
stackId: process.env.NEXT_PUBLIC_STACK_ID!, // fixed server-side; clients cannot pick a stack
serviceKey: process.env.STACKNET_STACK_SERVICE_KEY, // optional stack-scoped key
secureCookies: process.env.NODE_ENV === 'production',
})// app/api/auth/telegram/start/route.ts
import { telegramLogin } from '@/lib/telegram-login'
export const POST = (request: Request) => telegramLogin.start(request)// app/api/auth/telegram/complete/route.ts
import { telegramLogin } from '@/lib/telegram-login'
export const POST = (request: Request) => telegramLogin.complete(request)startcalls StackNetPOST /api/v2/stacks/{stackId}/auth/oauth/telegram/initiateand returns{ botUsername, botId?, state }. It also sets a 10-minute HttpOnlystackauth_tg_statecookie that binds the state to this browser.completetakes{ authData, state }. It requires the state cookie to match, accepts only the documented Telegram fields (id,first_name,last_name,username,photo_url,auth_date,hash) with bounded sizes and a body of at most 4 KiB, and rejectsauth_dateolder than 15 minutes (StackNet's verify window). It forwards the Telegram object unchanged to StackNet.../auth/oauth/telegram/verifyand sets the same cookies as the OAuth callback (stackauth_jwt,stackauth_session, CSRF). The session'sauthMethodisoauth:telegram. Itsaddressis the numeric Telegram id, so show a name from your profile data rather than the address.- Each attempt uses a fresh StackNet state (single use, 10-minute TTL).
- Both are rate limited per IP (10/min by default; pass
{ rateLimiter }). - Errors are
{ error, code }withcodeone ofrate_limited,invalid_request,invalid_auth_data,expired,invalid_state,not_configured,verification_failed,upstream_error.
4. Client
ConnectWidget lists Telegram automatically when the stack config has an
enabled telegram provider, with the same button style as the other methods.
Pass telegramRequestWriteAccess to ask for permission for the bot to message
the user (off by default).
On its own:
import { TelegramLoginButton } from '@stacknet/userutils/components'
<TelegramLoginButton // renders null unless the stack enables telegram
stackId={process.env.NEXT_PUBLIC_STACK_ID}
onSuccess={() => { window.location.href = '/' }}
// requestWriteAccess // default false
// unavailable="disabled" // default "hide": renders nothing without stack config
/>Or the hook (useTelegramLogin), or useStackAuth().authenticateTelegram()
(also reached by authenticateOAuth('telegram')). Failures are
TelegramLoginError with code: not_configured, script_load_failed,
cancelled, popup_blocked, container_required, unsupported_environment,
rate_limited, start_failed, verify_failed. A user cancel does not set
the hook's error. useStackAuth records the sign-in on the auth bridge
(chain: 'telegram', address = Telegram id), like the wallet methods.
Popup or widget. When StackNet returns the numeric botId, the click opens
Telegram's popup (Telegram.Login.auth). The start data and script are
fetched on intent (pointer enter or focus), never on mount, so the popup opens
within the click gesture. Each attempt uses a fresh state. Without
botId, the client falls back to Telegram's embedded widget button
(data-telegram-login). TelegramLoginButton and ConnectWidget render it in
place. With the hook, pass a container element to login(container).
5. Content-Security-Policy
script-src 'self' https://telegram.org;
frame-src https://oauth.telegram.org;
connect-src 'self' https://oauth.telegram.org;
img-src 'self' https://t.me https://*.telegram.org; # only if you show photo_urlThe popup flow needs no inline script and no unsafe-eval. The widget
fallback does: Telegram's script evaluates the data-onauth attribute, so it
needs 'unsafe-eval' in script-src. Prefer the popup (StackNet supplies
botId) under a strict CSP.
The popup talks to its opener with postMessage, so do not send
Cross-Origin-Opener-Policy: same-origin on the sign-in page. Use
same-origin-allow-popups or leave COOP unset.
Wait list (allowlist stacks)
When a stack admits users from an allowlist, a refused sign-in is recorded on
StackNet as an access request and the user sees one neutral notice,
AUTH_WAITLIST_MESSAGE ("You've been added to the wait list"; the older
TELEGRAM_WAITLIST_MESSAGE export is an alias). ConnectWidget renders it as
data-testid="connect-waitlisted" for every provider.
Browser OAuth redirect. StackNet sends the browser back to the return URL the flow started with (the
redirectUrigiven to/api/auth/oauth/start, by default/api/auth/oauth/callback) withauth_error=waitlisted|cancelled|failed&provider=<p>. Add a GET handler there that forwards to your sign-in page:// app/api/auth/oauth/callback/route.ts const { handleCallback, handleReturn } = createOAuthHandlers(serverConfig) export const POST = handleCallback export const GET = (request: Request) => handleReturn(request, { signInPath: '/connect' })On that page
useStackAuthreads the params once (oauthReturn), setserrorto the wait-list notice (orOAUTH_FAILED_MESSAGE; a cancel shows nothing), and removes them withhistory.replaceState, so a reload does not show it again. ConnectWidget opens on the notice; Back dismisses it (dismissOAuthReturn).JSON callback.
handleCallback/exchangemap StackNet's 403AUTH_DENIEDto403 { error: AUTH_WAITLIST_MESSAGE, code: 'waitlisted' }, andauthenticateOAuthCallbackshows the same notice.Telegram.
waitlistederror code, same notice.Wallets (Phantom/Solflare, MetaMask). StackNet refuses a wallet that is not allowed at the challenge (403
AUTH_DENIED) and again after the signature. Either refusal shows the same notice:createAuthCallbackmaps the verify-time 403 to403 { error: AUTH_WAITLIST_MESSAGE, code: 'waitlisted' }, and the core client'sgetChallenge/loginWeb3throw an error with that message andcode: 'waitlisted'. Other challenge failures show a neutral message (walletChallengeErrorMessage);isWalletAuthRefusaltells the two apart.
Hooks
useSession()
Reads the public session cookie. No network calls.
const { session, loading, isAuthenticated, refresh, readSession } = useSession()
// session: { userId, address, chain, expiresAt, planId, authMethod } | null
// isAuthenticated: boolean (session exists and not expired)useStackAuth(config?)
Full auth engine — wallet connection, challenge/sign/verify, logout, bridge.
const {
session, // PublicSession | null
isAuthenticated, // boolean
wallet, // { connected, address, chain, provider }
loading, // boolean (auth in progress)
error, // string | null
authenticateSolana, // (provider?: 'phantom' | 'solflare') => Promise<boolean>
authenticateEVM, // () => Promise<boolean>
authenticateOTP, // (code: string) => Promise<boolean>
authenticateTelegram, // (opts?: { requestWriteAccess?, container? }) => Promise<boolean>
logout, // () => Promise<void>
refresh, // () => Promise<any>
stackId, // effective stack ID
bridge, // { ready, known, identity, identityCount, resolvedStackId }
} = useStackAuth({
apiBaseUrl: '',
stackId: 'stk_...',
stacknetUrl: 'https://stacknet.magma-rpc.com',
autoConnect: false, // true = auto-login if bridge has known identity
})Components
ConnectWidget
Drop-in wallet connect UI.
<ConnectWidget
config={config} // UserUtilsConfig (required)
onSuccess={() => {}} // Called after successful auth
title="Connect" // Heading text
showWallets={['phantom', 'metamask']} // Which wallets to show
showOTP={false} // Show access code option
telegramRequestWriteAccess={false} // Telegram: ask to let the bot message the user
className="" // CSS classes
/>UserUtilsProvider
React context provider. Wrap your app with this.
<UserUtilsProvider config={config} callbacks={callbacks?}>
{children}
</UserUtilsProvider>Security
- HttpOnly cookies prevent XSS token theft
- SameSite=Lax prevents CSRF on state-changing requests
- Secure flag enforces HTTPS in production
- CSRF double-submit validates cookie matches header on mutations
- Short JWT expiry (15 min) with transparent auto-refresh
- Rate limiting on auth callback (10 req/min per IP)
- Constant-time comparison for JWT signature validation
License
MIT
