Paspo is a standard OpenID Connect provider, so you don't need the SDK. Use it when:
The trade-off: a full-page redirect instead of the popup, and no FedCM prompt. Need the popup? Use the SDK — the window protocol isn't a public API.
Browser ──GET /auth/paspo/login──▶ Your server
• state, nonce, code_verifier → session
Browser ◀──302 paspo.id/oauth/authorize?…──
Browser ──user confirms in Paspo──▶ paspo.id
Browser ◀──302 <redirect_uri>?code=…&state=…──
Browser ──GET <redirect_uri>?code&state──▶ Your server
• check state
• POST /api/w/v1/oauth/token
• verify id_token, check nonce
• find user by sub, start session
Browser ◀──302 /app──
Redirect the browser to https://paspo.id/oauth/authorize with:
| Parameter | Required | |
|---|---|---|
client_id | yes | Your client ID |
redirect_uri | yes | Exactly one of the registered URIs |
response_type | yes | code — the only supported value |
scope | yes | Space-separated: openid plus any of profile, email, phone |
state | yes | Random, ≥ 128 bits. CSRF protection |
code_challenge | yes | BASE64URL(SHA256(code_verifier)), no padding |
code_challenge_method | yes | S256 |
nonce | recommended | Random. Returned in the id_token |
https://paspo.id/oauth/authorize
?client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fpaspo%2Fcallback
&response_type=code
&scope=openid%20email
&state=Q2s9…
&nonce=bX4r…
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
Store state, nonce and code_verifier server-side (session, or an encrypted HttpOnly; Secure; SameSite=Lax cookie with a 10-minute lifetime).
import { createHash, randomBytes } from "node:crypto";
const codeVerifier = randomBytes(32).toString("base64url");
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
const state = randomBytes(16).toString("base64url");
const nonce = randomBytes(16).toString("base64url");
import base64, hashlib, secrets
code_verifier = secrets.token_urlsafe(32)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)
nonce = secrets.token_urlsafe(16)
$b64url = fn($b) => rtrim(strtr(base64_encode($b), '+/', '-_'), '=');
$codeVerifier = $b64url(random_bytes(32));
$codeChallenge = $b64url(hash('sha256', $codeVerifier, true));
$state = $b64url(random_bytes(16));
$nonce = $b64url(random_bytes(16));
const b64url = (bytes: Uint8Array) =>
btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const codeVerifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
const codeChallenge = b64url(new Uint8Array(digest));
Paspo redirects to redirect_uri with either:
?code=…&state=… — success;?error=access_denied&state=… — the user cancelled; other values are standard OAuth errors such as invalid_request or invalid_scope.On success:
state with the stored value; reject on mismatch. Delete the stored values — one attempt per login.client_secret, the same redirect_uri and the stored code_verifier.nonce with the stored one.sub, start your session, redirect into the app.openid-client handles discovery, PKCE, state, nonce and id_token validation.
npm install openid-client express express-session
import express from "express";
import session from "express-session";
import * as oidc from "openid-client";
const REDIRECT_URI = process.env.PASPO_REDIRECT_URI!; // https://app.example.com/auth/paspo/callback
const config = await oidc.discovery(
new URL("https://paspo.id"),
process.env.PASPO_CLIENT_ID!,
process.env.PASPO_CLIENT_SECRET!,
);
const app = express();
app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: "lax" } }));
app.get("/auth/paspo/login", async (req, res) => {
const codeVerifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const nonce = oidc.randomNonce();
req.session.paspo = { codeVerifier, state, nonce };
const url = oidc.buildAuthorizationUrl(config, {
redirect_uri: REDIRECT_URI,
scope: "openid email",
code_challenge: await oidc.calculatePKCECodeChallenge(codeVerifier),
code_challenge_method: "S256",
state,
nonce,
});
res.redirect(url.href);
});
app.get("/auth/paspo/callback", async (req, res) => {
const pending = req.session.paspo;
delete req.session.paspo;
if (!pending) return res.redirect("/login?error=paspo");
try {
const tokens = await oidc.authorizationCodeGrant(
config,
new URL(req.originalUrl, REDIRECT_URI),
{ pkceCodeVerifier: pending.codeVerifier, expectedState: pending.state, expectedNonce: pending.nonce },
);
const claims = tokens.claims()!;
const user = await upsertUserByPaspoSub(claims.sub, claims); // your DB
req.session.regenerate(() => {
req.session.userId = user.id;
res.redirect("/");
});
} catch {
res.redirect("/login?error=paspo");
}
});
import NextAuth from "next-auth";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
{
id: "paspo",
name: "Paspo",
type: "oidc",
issuer: "https://paspo.id",
clientId: process.env.AUTH_PASPO_ID,
clientSecret: process.env.AUTH_PASPO_SECRET,
authorization: { params: { scope: "openid email" } },
checks: ["pkce", "state", "nonce"],
},
],
});
Register the redirect URI https://app.example.com/api/auth/callback/paspo. Start sign-in with signIn("paspo").
INSTALLED_APPS += ["allauth.socialaccount.providers.openid_connect"]
SOCIALACCOUNT_PROVIDERS = {
"openid_connect": {
"OAUTH_PKCE_ENABLED": True,
"APPS": [
{
"provider_id": "paspo",
"name": "Paspo",
"client_id": os.environ["PASPO_CLIENT_ID"],
"secret": os.environ["PASPO_CLIENT_SECRET"],
"settings": {"server_url": "https://paspo.id", "scope": ["openid", "email"]},
}
],
}
}
Redirect URI: https://app.example.com/accounts/oidc/paspo/login/callback/.
spring:
security:
oauth2:
client:
registration:
paspo:
client-id: ${PASPO_CLIENT_ID}
client-secret: ${PASPO_CLIENT_SECRET}
authorization-grant-type: authorization_code
scope: openid,email
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
paspo:
issuer-uri: https://paspo.id
Spring adds PKCE automatically only for public clients. For a confidential client, enable it with OAuth2AuthorizationRequestCustomizers.withPkce() on the authorization request resolver. Redirect URI: https://app.example.com/login/oauth2/code/paspo.
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = "paspo";
})
.AddCookie()
.AddOpenIdConnect("paspo", options =>
{
options.Authority = "https://paspo.id";
options.ClientId = builder.Configuration["Paspo:ClientId"];
options.ClientSecret = builder.Configuration["Paspo:ClientSecret"];
options.ResponseType = "code";
options.UsePkce = true;
options.CallbackPath = "/auth/paspo/callback";
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("email");
options.MapInboundClaims = false;
});
Any certified OpenID Connect client works: point it at the issuer https://paspo.id, enable PKCE S256, and use client_secret_post or client_secret_basic. Not sure how? Use the manual AI prompt.
response_type=token (implicit flow) — not supported.iframe for the authorize page — the Paspo session cookie is not sent to third-party frames.client_secret in frontend code or mobile apps.