Every integration has the same three parts:
signIn from its click handler.redirectUri. The SDK uses it only when the browser blocks the popup and the whole tab goes to Paspo and back.POST /auth/paspo with:{ "code": "…", "code_verifier": "…", "nonce": "…", "via": "fedcm" }
Call signIn directly in the click handler. An await before it (fetching config, checking a session) makes the browser treat the popup as unsolicited and block it. Load anything you need before the click.
redirectUri must be on the same origin as the page with the button: the Paspo window delivers the result to that origin. https://app.example.com button → https://app.example.com/auth/paspo/callback.
No build step — the CDN script and a few lines of JavaScript.
<button id="paspo-signin" type="button">Sign in with Paspo</button>
<script src="https://paspo.id/sdk/paspo.min.js"></script>
<script>
const CLIENT_ID = "YOUR_CLIENT_ID";
const REDIRECT_URI = location.origin + "/auth/paspo/callback";
async function exchange({ code, state, via }) {
const response = await fetch("/auth/paspo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
code_verifier: Paspo.takeVerifier(state),
nonce: Paspo.takeNonce(state),
via,
}),
});
if (!response.ok) throw new Error("Sign-in failed");
}
const button = document.getElementById("paspo-signin");
button.addEventListener("click", () => {
button.disabled = true;
Paspo.signIn({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
scope: "openid email",
onSuccess: (result) =>
exchange(result)
.then(() => location.assign("/"))
.catch(() => (button.disabled = false)),
onError: (error) => {
button.disabled = false;
console.error(error.error, error.error_description);
},
onClose: () => (button.disabled = false),
});
});
</script>
<script src="https://paspo.id/sdk/paspo.min.js"></script>
<script>
const params = new URLSearchParams(location.search);
const code = params.get("code");
const state = params.get("state");
if (code) {
fetch("/auth/paspo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
code_verifier: Paspo.takeVerifier(state),
nonce: Paspo.takeNonce(state),
}),
}).then((response) => location.replace(response.ok ? "/" : "/login?error=paspo"));
} else {
location.replace("/login?error=" + encodeURIComponent(params.get("error") || "paspo"));
}
</script>
Works with Vite, Create React App, Remix and any React 17+ setup.
npm install @pingocean/paspo-sdk
import { signIn, takeNonce, takeVerifier } from "@pingocean/paspo-sdk";
export const PASPO_CLIENT_ID = import.meta.env.VITE_PASPO_CLIENT_ID;
export const PASPO_REDIRECT_URI = `${window.location.origin}/auth/paspo/callback`;
export async function exchangePaspoCode({ code, state, via }: { code: string; state: string; via?: string }) {
const response = await fetch("/auth/paspo", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ code, code_verifier: takeVerifier(state), nonce: takeNonce(state), via }),
});
if (!response.ok) throw new Error("Paspo sign-in failed");
return response.json();
}
export { signIn };
import { useState } from "react";
import { PASPO_CLIENT_ID, PASPO_REDIRECT_URI, exchangePaspoCode, signIn } from "./paspo";
export function PaspoButton({ onSignedIn }: { onSignedIn: (user: unknown) => void }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const handleClick = () => {
setBusy(true);
setError("");
// No await before signIn — keeps the popup allowed.
signIn({
clientId: PASPO_CLIENT_ID,
redirectUri: PASPO_REDIRECT_URI,
scope: "openid email",
onSuccess: (result) =>
exchangePaspoCode(result)
.then(onSignedIn)
.catch((e) => setError(e.message))
.finally(() => setBusy(false)),
onError: (e) => {
setError(e.error_description || e.error);
setBusy(false);
},
onClose: () => setBusy(false),
});
};
return (
<>
<button type="button" onClick={handleClick} disabled={busy}>
Sign in with Paspo
</button>
{error && <p role="alert">{error}</p>}
</>
);
}
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { exchangePaspoCode } from "./paspo";
// Route: /auth/paspo/callback
export function PaspoCallback() {
const navigate = useNavigate();
const started = useRef(false);
useEffect(() => {
// StrictMode runs effects twice; the verifier can be read only once.
if (started.current) return;
started.current = true;
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state") ?? "";
if (!code) {
navigate(`/login?error=${params.get("error") ?? "paspo"}`, { replace: true });
return;
}
exchangePaspoCode({ code, state })
.then(() => navigate("/", { replace: true }))
.catch(() => navigate("/login?error=paspo", { replace: true }));
}, [navigate]);
return <p>Signing you in…</p>;
}
App Router. The button and the callback are client components; the code exchange is a route handler.
npm install @pingocean/paspo-sdk
NEXT_PUBLIC_PASPO_CLIENT_ID=YOUR_CLIENT_ID
PASPO_CLIENT_SECRET=YOUR_CLIENT_SECRET # server only, no NEXT_PUBLIC_ prefix
PASPO_REDIRECT_URI=https://app.example.com/auth/paspo/callback
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { signIn, takeNonce, takeVerifier } from "@pingocean/paspo-sdk";
export async function exchangePaspoCode(code: string, state: string, via?: string) {
const response = await fetch("/api/auth/paspo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, code_verifier: takeVerifier(state), nonce: takeNonce(state), via }),
});
if (!response.ok) throw new Error("Paspo sign-in failed");
}
export function PaspoButton() {
const router = useRouter();
const [busy, setBusy] = useState(false);
return (
<button
type="button"
disabled={busy}
onClick={() => {
setBusy(true);
signIn({
clientId: process.env.NEXT_PUBLIC_PASPO_CLIENT_ID!,
redirectUri: `${window.location.origin}/auth/paspo/callback`,
scope: "openid email",
onSuccess: ({ code, state, via }) =>
exchangePaspoCode(code, state, via)
.then(() => router.refresh())
.finally(() => setBusy(false)),
onError: () => setBusy(false),
onClose: () => setBusy(false),
});
}}
>
Sign in with Paspo
</button>
);
}
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef } from "react";
import { exchangePaspoCode } from "@/app/components/paspo-button";
function Callback() {
const router = useRouter();
const params = useSearchParams();
const started = useRef(false);
useEffect(() => {
if (started.current) return;
started.current = true;
const code = params.get("code");
if (!code) return router.replace(`/login?error=${params.get("error") ?? "paspo"}`);
exchangePaspoCode(code, params.get("state") ?? "")
.then(() => router.replace("/"))
.catch(() => router.replace("/login?error=paspo"));
}, [params, router]);
return <p>Signing you in…</p>;
}
export default function Page() {
return (
<Suspense>
<Callback />
</Suspense>
);
}
Vite + Vue Router.
npm install @pingocean/paspo-sdk
import { ref } from "vue";
import { signIn, takeNonce, takeVerifier } from "@pingocean/paspo-sdk";
const CLIENT_ID = import.meta.env.VITE_PASPO_CLIENT_ID;
export async function exchangePaspoCode(code: string, state: string, via?: string) {
const response = await fetch("/auth/paspo", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ code, code_verifier: takeVerifier(state), nonce: takeNonce(state), via }),
});
if (!response.ok) throw new Error("Paspo sign-in failed");
return response.json();
}
export function usePaspo() {
const busy = ref(false);
const error = ref("");
function start(onSignedIn: (user: unknown) => void) {
busy.value = true;
error.value = "";
signIn({
clientId: CLIENT_ID,
redirectUri: `${window.location.origin}/auth/paspo/callback`,
scope: "openid email",
onSuccess: ({ code, state, via }) =>
exchangePaspoCode(code, state, via)
.then(onSignedIn)
.catch((e) => (error.value = e.message))
.finally(() => (busy.value = false)),
onError: (e) => {
error.value = e.error_description || e.error;
busy.value = false;
},
onClose: () => (busy.value = false),
});
}
return { busy, error, start };
}
<script setup lang="ts">
import { useRouter } from "vue-router";
import { usePaspo } from "@/composables/usePaspo";
const router = useRouter();
const { busy, error, start } = usePaspo();
</script>
<template>
<button type="button" :disabled="busy" @click="start(() => router.push('/'))">
Sign in with Paspo
</button>
<p v-if="error" role="alert">{{ error }}</p>
</template>
<script setup lang="ts">
// Route: /auth/paspo/callback
import { onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { exchangePaspoCode } from "@/composables/usePaspo";
const route = useRoute();
const router = useRouter();
onMounted(async () => {
const code = route.query.code as string | undefined;
if (!code) return router.replace({ path: "/login", query: { error: route.query.error ?? "paspo" } });
try {
await exchangePaspoCode(code, String(route.query.state ?? ""));
router.replace("/");
} catch {
router.replace({ path: "/login", query: { error: "paspo" } });
}
});
</script>
<template>
<p>Signing you in…</p>
</template>
Nuxt 3 / 4. The exchange runs in a Nuxt server route; the client part is the same as Vue.
npm install @pingocean/paspo-sdk
export default defineNuxtConfig({
runtimeConfig: {
paspoClientSecret: "", // NUXT_PASPO_CLIENT_SECRET — server only
paspoRedirectUri: "", // NUXT_PASPO_REDIRECT_URI
public: { paspoClientId: "" }, // NUXT_PUBLIC_PASPO_CLIENT_ID
},
});
<script setup lang="ts">
import { signIn, takeNonce, takeVerifier } from "@pingocean/paspo-sdk";
const { public: { paspoClientId } } = useRuntimeConfig();
const busy = ref(false);
function start() {
busy.value = true;
signIn({
clientId: paspoClientId,
redirectUri: `${window.location.origin}/auth/paspo/callback`,
scope: "openid email",
onSuccess: async ({ code, state, via }) => {
try {
await $fetch("/api/auth/paspo", {
method: "POST",
body: { code, code_verifier: takeVerifier(state), nonce: takeNonce(state), via },
});
await navigateTo("/");
} finally {
busy.value = false;
}
},
onError: () => (busy.value = false),
onClose: () => (busy.value = false),
});
}
</script>
<template>
<button type="button" :disabled="busy" @click="start">Sign in with Paspo</button>
</template>
<script setup lang="ts">
import { takeNonce, takeVerifier } from "@pingocean/paspo-sdk";
const route = useRoute();
onMounted(async () => {
const code = route.query.code as string | undefined;
const state = String(route.query.state ?? "");
if (!code) return navigateTo({ path: "/login", query: { error: route.query.error ?? "paspo" } }, { replace: true });
try {
await $fetch("/api/auth/paspo", {
method: "POST",
body: { code, code_verifier: takeVerifier(state), nonce: takeNonce(state) },
});
await navigateTo("/", { replace: true });
} catch {
await navigateTo({ path: "/login", query: { error: "paspo" } }, { replace: true });
}
});
</script>
<template>
<p>Signing you in…</p>
</template>
Angular 16+, standalone components.
npm install @pingocean/paspo-sdk
import { HttpClient } from "@angular/common/http";
import { Injectable, inject } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { signIn, takeNonce, takeVerifier, type PaspoSignInError } from "@pingocean/paspo-sdk";
import { environment } from "../environments/environment";
@Injectable({ providedIn: "root" })
export class PaspoService {
private http = inject(HttpClient);
/** Call from a (click) handler. */
signIn(handlers: { done: () => void; failed: (error: PaspoSignInError | Error) => void; closed: () => void }) {
signIn({
clientId: environment.paspoClientId,
redirectUri: `${window.location.origin}/auth/paspo/callback`,
scope: "openid email",
onSuccess: ({ code, state, via }) =>
this.exchange(code, state, via).then(handlers.done, handlers.failed),
onError: handlers.failed,
onClose: handlers.closed,
});
}
exchange(code: string, state: string, via?: string) {
return firstValueFrom(
this.http.post("/auth/paspo", { code, code_verifier: takeVerifier(state), nonce: takeNonce(state), via }),
);
}
}
import { Component, NgZone, inject, signal } from "@angular/core";
import { Router } from "@angular/router";
import { PaspoService } from "./paspo.service";
@Component({
selector: "app-paspo-button",
standalone: true,
template: `<button type="button" [disabled]="busy()" (click)="start()">Sign in with Paspo</button>`,
})
export class PaspoButtonComponent {
private paspo = inject(PaspoService);
private router = inject(Router);
private zone = inject(NgZone);
busy = signal(false);
start() {
this.busy.set(true);
this.paspo.signIn({
done: () => this.zone.run(() => this.router.navigateByUrl("/")),
failed: () => this.busy.set(false),
closed: () => this.busy.set(false),
});
}
}
import { Component, OnInit, inject } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { PaspoService } from "./paspo.service";
// Route: { path: "auth/paspo/callback", component: PaspoCallbackComponent }
@Component({ standalone: true, template: `<p>Signing you in…</p>` })
export class PaspoCallbackComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private paspo = inject(PaspoService);
async ngOnInit() {
const query = this.route.snapshot.queryParamMap;
const code = query.get("code");
if (!code) {
this.router.navigate(["/login"], { queryParams: { error: query.get("error") ?? "paspo" } });
return;
}
try {
await this.paspo.exchange(code, query.get("state") ?? "");
this.router.navigateByUrl("/");
} catch {
this.router.navigate(["/login"], { queryParams: { error: "paspo" } });
}
}
}
npm install @pingocean/paspo-sdk
import { PUBLIC_PASPO_CLIENT_ID } from "$env/static/public";
import { signIn, takeNonce, takeVerifier, type PaspoSignInOptions } from "@pingocean/paspo-sdk";
export async function exchangePaspoCode(code: string, state: string, via?: string) {
const response = await fetch("/auth/paspo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, code_verifier: takeVerifier(state), nonce: takeNonce(state), via }),
});
if (!response.ok) throw new Error("Paspo sign-in failed");
}
export function startPaspo(handlers: Pick<PaspoSignInOptions, "onSuccess" | "onError" | "onClose">) {
signIn({
clientId: PUBLIC_PASPO_CLIENT_ID,
redirectUri: `${location.origin}/auth/paspo/callback`,
scope: "openid email",
...handlers,
});
}
<script lang="ts">
import { goto } from "$app/navigation";
import { exchangePaspoCode, startPaspo } from "$lib/paspo";
let busy = false;
function start() {
busy = true;
startPaspo({
onSuccess: ({ code, state, via }) =>
exchangePaspoCode(code, state, via)
.then(() => goto("/"))
.finally(() => (busy = false)),
onError: () => (busy = false),
onClose: () => (busy = false),
});
}
</script>
<button type="button" disabled={busy} on:click={start}>Sign in with Paspo</button>
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { exchangePaspoCode } from "$lib/paspo";
onMount(async () => {
const query = $page.url.searchParams;
const code = query.get("code");
if (!code) return goto(`/login?error=${query.get("error") ?? "paspo"}`, { replaceState: true });
try {
await exchangePaspoCode(code, query.get("state") ?? "");
goto("/", { replaceState: true });
} catch {
goto("/login?error=paspo", { replaceState: true });
}
});
</script>
<p>Signing you in…</p>
The SDK is framework-agnostic: anything that can run a click handler in the browser works — Solid, Preact, Qwik, Astro islands, Alpine, jQuery, Blazor (via JS interop), Django / Rails / Laravel templates with the CDN script. Follow the Plain HTML example.
Prefer not to add a frontend dependency at all? Use a full-page redirect handled by your server — see Manual integration.