pingohub-sdk
v1.0.0
Published
Token verification and login URL helpers for tools integrating with Pingohub IAM
Downloads
145
Maintainers
Readme
Pingohub SDK
Helper functions for integrating any internal tool with the Pingohub IAM system.
Copy src/index.ts directly into your project — it has zero dependencies and works in any runtime that has the Web Crypto API (Cloudflare Workers, Node.js 18+, Bun, Deno, browsers).
What Pingohub does
Pingohub is a central login gateway. When a user visits a protected tool without a session, the tool redirects them to Pingohub. After the user authenticates, Pingohub checks their access and issues a short-lived HS256 JWT signed with that tool's unique client_secret. The tool then validates the token and creates its own session.
User → tool.example.com (no session)
→ Redirect to Pingohub /token?tool_id=...&redirect_uri=...&state=...
→ User logs in (email/password or Google)
→ Pingohub checks access
→ Redirect to tool.example.com/iam/callback?token=<jwt>&state=<state>
→ Tool calls verifyToken(token, clientSecret)
→ Tool sets its own session cookie
→ User is inEnvironment variables your tool needs
Get these from the Pingohub admin dashboard under Tools:
IAM_BASE_URL=https://pingohub.pingolearn.app # Pingohub deployment URL
IAM_TOOL_ID=<uuid> # Your tool's registered ID
IAM_CLIENT_SECRET=<secret> # Your tool's signing secret (keep private)SDK functions
verifyToken(token, clientSecret)
Verifies a JWT issued by Pingohub for your tool. Throws on invalid signature, expiry, or malformed token.
import { verifyToken, type IAMTokenPayload } from './sdk/src/index'
const payload: IAMTokenPayload = await verifyToken(token, IAM_CLIENT_SECRET)
// payload.sub — user UUID
// payload.email — user email address
// payload.name — user display name
// payload.tool_id — the tool this token was issued for
// payload.iat — issued-at (Unix seconds)
// payload.exp — expires-at (Unix seconds, tokens live 1 hour)Throws:
"Invalid token format"— not a three-part JWT"Invalid token signature"— wrong secret or tampered token"Token expired"—expis in the past
buildLoginUrl(iamBaseUrl, toolId, callbackUrl, state?)
Builds the redirect URL to send unauthenticated users to the Pingohub login page.
import { buildLoginUrl } from './sdk/src/index'
const loginUrl = buildLoginUrl(
'https://pingohub.pingolearn.app',
IAM_TOOL_ID,
'https://my-tool.pingolearn.com/iam/callback',
csrfState // optional, but recommended
)
// → https://pingohub.pingolearn.app/token?tool_id=...&redirect_uri=...&state=...Full integration pattern
Cloudflare Worker example
import { verifyToken, buildLoginUrl } from './sdk/src/index'
const IAM_BASE_URL = env.IAM_BASE_URL // https://pingohub.pingolearn.app
const IAM_TOOL_ID = env.IAM_TOOL_ID // your tool's UUID
const IAM_SECRET = env.IAM_CLIENT_SECRET // your tool's client_secret
const MY_URL = 'https://my-tool.pingolearn.com'
const CALLBACK_PATH = '/iam/callback'
const TOKEN_COOKIE = 'iam_token'
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
// 1. Handle the callback from Pingohub after login
if (url.pathname === CALLBACK_PATH) {
return handleCallback(request, env)
}
// 2. Check for an existing tool session token
const token = getCookie(request, TOKEN_COOKIE)
if (token) {
try {
const user = await verifyToken(token, IAM_SECRET)
// Token valid — proceed with the request, attach user to context
return handleProtectedRequest(request, user)
} catch {
// Token expired or invalid — fall through to redirect
}
}
// 3. No valid token — redirect to Pingohub
const state = crypto.randomUUID()
// Store state in a short-lived cookie for CSRF verification
const loginUrl = buildLoginUrl(IAM_BASE_URL, IAM_TOOL_ID, `${MY_URL}${CALLBACK_PATH}`, state)
return Response.redirect(loginUrl, 302)
}
}
async function handleCallback(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
const token = url.searchParams.get('token')
const state = url.searchParams.get('state')
if (!token) return new Response('Missing token', { status: 400 })
// Verify the state matches what you stored (CSRF protection)
const storedState = getCookie(request, 'iam_state')
if (state !== storedState) return new Response('Invalid state', { status: 403 })
// Verify the JWT
const payload = await verifyToken(token, IAM_SECRET)
// Set a session cookie with the token (or your own session ID)
return new Response(null, {
status: 302,
headers: {
Location: '/',
'Set-Cookie': `${TOKEN_COOKIE}=${token}; HttpOnly; Secure; SameSite=Lax; Max-Age=3600`,
}
})
}
function getCookie(request: Request, name: string): string | null {
const cookies = request.headers.get('Cookie') || ''
const match = cookies.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
return match ? decodeURIComponent(match[1]) : null
}Next.js / Node.js middleware example
// middleware.ts (Next.js)
import { NextRequest, NextResponse } from 'next/server'
import { verifyToken, buildLoginUrl } from '@/sdk' // adjust path as needed
const IAM_BASE_URL = process.env.IAM_BASE_URL!
const IAM_TOOL_ID = process.env.IAM_TOOL_ID!
const IAM_SECRET = process.env.IAM_CLIENT_SECRET!
const CALLBACK_URL = `${process.env.NEXT_PUBLIC_APP_URL}/iam/callback`
export async function middleware(req: NextRequest) {
// Skip the callback route itself
if (req.nextUrl.pathname === '/iam/callback') return NextResponse.next()
const token = req.cookies.get('iam_token')?.value
if (token) {
try {
await verifyToken(token, IAM_SECRET)
return NextResponse.next()
} catch { /* fall through */ }
}
const state = crypto.randomUUID()
const res = NextResponse.redirect(buildLoginUrl(IAM_BASE_URL, IAM_TOOL_ID, CALLBACK_URL, state))
res.cookies.set('iam_state', state, { httpOnly: true, maxAge: 300 })
return res
}
export const config = { matcher: ['/((?!_next|favicon.ico).*)'] }// app/iam/callback/route.ts
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { verifyToken } from '@/sdk'
export async function GET(req: Request) {
const url = new URL(req.url)
const token = url.searchParams.get('token')!
const state = url.searchParams.get('state')
const cookieStore = await cookies()
if (state !== cookieStore.get('iam_state')?.value) {
return new Response('Invalid state', { status: 403 })
}
await verifyToken(token, process.env.IAM_CLIENT_SECRET!) // throws if invalid
cookieStore.set('iam_token', token, { httpOnly: true, secure: true, maxAge: 3600 })
redirect('/')
}Alternative: server-side verification via API
If you can't bundle the SDK (e.g. a Python or Go service), call the /verify endpoint instead:
POST https://pingohub.pingolearn.app/verify
Content-Type: application/json
{
"token": "<jwt from callback>",
"tool_id": "<your-tool-id>"
}Success response (200):
{
"valid": true,
"user": {
"sub": "user-uuid",
"email": "[email protected]",
"name": "User Name"
}
}Failure response (401):
{ "valid": false, "error": "Token expired" }Note: /verify does not require authentication — it uses the tool_id to look up the tool's secret internally and validate the signature server-side.
Token details
| Field | Value |
|---|---|
| Algorithm | HS256 (HMAC-SHA256) |
| Lifetime | 1 hour (exp - iat = 3600) |
| Signed with | Tool's client_secret from the Pingohub dashboard |
| Rotation | Rotating the secret in the dashboard immediately invalidates all outstanding tokens for that tool. Users will be transparently re-authenticated on their next request since their Pingohub session is still valid. |
Security checklist
- Always verify the
stateparameter in your callback to prevent CSRF attacks. - Never expose
IAM_CLIENT_SECRETto the browser — keep it server-side only. - Store the JWT in an
HttpOnlycookie, notlocalStorageor a JS-accessible cookie. - Check
payload.tool_idmatches your ownIAM_TOOL_IDif you verify manually (the SDK does not check this — use the/verifyendpoint or add the check yourself if needed). - Re-verify on sensitive actions — the token is valid for 1 hour; for write operations you may want to check expiry more aggressively.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| Invalid token signature | Wrong IAM_CLIENT_SECRET or the secret was rotated | Re-fetch the secret from the Pingohub dashboard and update your env var |
| Token expired | Token is older than 1 hour | User needs to re-authenticate; redirect to buildLoginUrl(...) |
| redirect_uri does not match registered tool URL | Callback URL does not start with the tool's registered URL | Update the tool's URL in the Pingohub admin dashboard to match your callback's origin |
| Unknown tool | tool_id is wrong | Copy the exact tool ID from the Pingohub admin dashboard |
| User lands on /unauthorized | User has no access grant for this tool | Go to Admin → Teams or Admin → Users and grant access |
