The browser hands your backend a one-time code. Your backend:
client_secret and the PKCE code_verifier.id_token (signature, iss, aud, exp, nonce).sub and starts its own session.The code is single-use and lives 60 seconds: exchange it right away and never retry with the same code.
POST https://paspo.id/api/w/v1/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=…
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&redirect_uri=https://app.example.com/auth/paspo/callback
&code_verifier=…
| Parameter | |
|---|---|
grant_type | authorization_code |
code | From onSuccess or the callback query string |
client_id, client_secret | Your client. HTTP Basic auth (client_secret_basic) works too |
redirect_uri | Exactly the value used in signIn. Omit it when the SDK returned via: "fedcm" |
code_verifier | takeVerifier(state) from the browser |
Response 200:
{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1NiIs…",
"scope": "openid email"
}
Errors follow RFC 6749: { "error": "invalid_grant", "error_description": "…" }.
The id_token is a JWT signed with RS256. Check:
https://paspo.id/api/w/v1/oauth/jwks (cache the key set);iss = https://paspo.id;aud = your client_id;exp is in the future;nonce = the value from takeNonce(state), if you sent one.Use the claims only after these checks pass. See Reference → Claims.
npm install jose express express-session
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
const ISSUER = "https://paspo.id";
const TOKEN_URL = "https://paspo.id/api/w/v1/oauth/token";
const JWKS = createRemoteJWKSet(new URL("https://paspo.id/api/w/v1/oauth/jwks"));
const CLIENT_ID = process.env.PASPO_CLIENT_ID!;
const CLIENT_SECRET = process.env.PASPO_CLIENT_SECRET!;
const REDIRECT_URI = process.env.PASPO_REDIRECT_URI!;
export type PaspoClaims = JWTPayload & {
email?: string;
email_verified?: boolean;
phone_number?: string;
phone_number_verified?: boolean;
name?: string;
nonce?: string;
};
export async function exchangePaspoCode(input: {
code: string;
code_verifier: string;
nonce?: string;
via?: string;
}): Promise<PaspoClaims> {
const body = new URLSearchParams({
grant_type: "authorization_code",
code: input.code,
code_verifier: input.code_verifier,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
});
if (input.via !== "fedcm") body.set("redirect_uri", REDIRECT_URI);
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const tokens = await response.json();
if (!response.ok) throw new Error(tokens.error ?? "token_request_failed");
const { payload } = await jwtVerify<PaspoClaims>(tokens.id_token, JWKS, {
issuer: ISSUER,
audience: CLIENT_ID,
algorithms: ["RS256"],
});
if (input.nonce && payload.nonce !== input.nonce) throw new Error("nonce_mismatch");
return payload;
}
import express from "express";
import session from "express-session";
import { exchangePaspoCode } from "./paspo";
const app = express();
app.use(express.json());
app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: "lax" } }));
app.post("/auth/paspo", async (req, res) => {
const { code, code_verifier, nonce, via } = req.body ?? {};
if (typeof code !== "string" || typeof code_verifier !== "string") {
return res.status(400).json({ error: "invalid_request" });
}
try {
const claims = await exchangePaspoCode({ code, code_verifier, nonce, via });
const user = await upsertUserByPaspoSub(claims.sub!, claims); // your DB
req.session.regenerate(() => {
req.session.userId = user.id;
res.json({ id: user.id, email: user.email });
});
} catch (error) {
res.status(401).json({ error: (error as Error).message });
}
});
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { exchangePaspoCode } from "@/server/paspo"; // the Node.js helper above
export async function POST(request: Request) {
const { code, code_verifier, nonce, via } = await request.json();
if (typeof code !== "string" || typeof code_verifier !== "string") {
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
}
try {
const claims = await exchangePaspoCode({ code, code_verifier, nonce, via });
const user = await upsertUserByPaspoSub(claims.sub!, claims);
(await cookies()).set("session", await createSession(user.id), {
httpOnly: true, secure: true, sameSite: "lax", path: "/",
});
return NextResponse.json({ id: user.id });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 401 });
}
}
Using Auth.js / NextAuth instead? Register Paspo as an OIDC provider — see Manual integration.
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://paspo.id/api/w/v1/oauth/jwks"));
export default defineEventHandler(async (event) => {
const { code, code_verifier, nonce, via } = await readBody(event);
if (typeof code !== "string" || typeof code_verifier !== "string") {
throw createError({ statusCode: 400, statusMessage: "invalid_request" });
}
const config = useRuntimeConfig(event);
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier,
client_id: config.public.paspoClientId,
client_secret: config.paspoClientSecret,
});
if (via !== "fedcm") body.set("redirect_uri", config.paspoRedirectUri);
const tokens = await $fetch<{ id_token: string }>("https://paspo.id/api/w/v1/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
}).catch(() => {
throw createError({ statusCode: 401, statusMessage: "invalid_grant" });
});
const { payload } = await jwtVerify(tokens.id_token, JWKS, {
issuer: "https://paspo.id",
audience: config.public.paspoClientId,
algorithms: ["RS256"],
});
if (nonce && payload.nonce !== nonce) {
throw createError({ statusCode: 401, statusMessage: "nonce_mismatch" });
}
// Find or create the user by payload.sub, then start your session
// (e.g. nuxt-auth-utils: await setUserSession(event, { user: { id } })).
return { sub: payload.sub, email: payload.email };
});
pip install fastapi httpx "pyjwt[crypto]"
import os
import httpx
import jwt
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel
ISSUER = "https://paspo.id"
TOKEN_URL = "https://paspo.id/api/w/v1/oauth/token"
CLIENT_ID = os.environ["PASPO_CLIENT_ID"]
CLIENT_SECRET = os.environ["PASPO_CLIENT_SECRET"]
REDIRECT_URI = os.environ["PASPO_REDIRECT_URI"]
jwks = jwt.PyJWKClient("https://paspo.id/api/w/v1/oauth/jwks", cache_keys=True)
app = FastAPI()
class PaspoCode(BaseModel):
code: str
code_verifier: str
nonce: str | None = None
via: str | None = None
def exchange_paspo_code(data: PaspoCode) -> dict:
form = {
"grant_type": "authorization_code",
"code": data.code,
"code_verifier": data.code_verifier,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
}
if data.via != "fedcm":
form["redirect_uri"] = REDIRECT_URI
response = httpx.post(TOKEN_URL, data=form, timeout=10)
tokens = response.json()
if response.status_code != 200:
raise HTTPException(401, tokens.get("error", "token_request_failed"))
key = jwks.get_signing_key_from_jwt(tokens["id_token"])
claims = jwt.decode(
tokens["id_token"], key.key, algorithms=["RS256"], audience=CLIENT_ID, issuer=ISSUER
)
if data.nonce and claims.get("nonce") != data.nonce:
raise HTTPException(401, "nonce_mismatch")
return claims
@app.post("/auth/paspo")
def paspo_sign_in(data: PaspoCode, request: Request):
claims = exchange_paspo_code(data)
user = upsert_user_by_paspo_sub(claims["sub"], claims) # your DB
# Start your session here (cookie, JWT, starlette SessionMiddleware…)
return {"id": user.id, "email": claims.get("email")}
composer require firebase/php-jwt
<?php
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
function exchange_paspo_code(string $code, string $verifier, ?string $nonce, ?string $via): array
{
$form = [
'grant_type' => 'authorization_code',
'code' => $code,
'code_verifier' => $verifier,
'client_id' => getenv('PASPO_CLIENT_ID'),
'client_secret' => getenv('PASPO_CLIENT_SECRET'),
];
if ($via !== 'fedcm') {
$form['redirect_uri'] = getenv('PASPO_REDIRECT_URI');
}
$ch = curl_init('https://paspo.id/api/w/v1/oauth/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($form),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$tokens = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException($tokens['error'] ?? 'token_request_failed');
}
// Cache this JSON (e.g. 1 hour) instead of fetching it on every sign-in.
$jwks = json_decode(file_get_contents('https://paspo.id/api/w/v1/oauth/jwks'), true);
$claims = (array) JWT::decode($tokens['id_token'], JWK::parseKeySet($jwks, 'RS256'));
// php-jwt checks signature and exp; iss / aud / nonce are on you.
$aud = (array) $claims['aud'];
if ($claims['iss'] !== 'https://paspo.id' || !in_array(getenv('PASPO_CLIENT_ID'), $aud, true)) {
throw new RuntimeException('invalid_id_token');
}
if ($nonce && ($claims['nonce'] ?? null) !== $nonce) {
throw new RuntimeException('nonce_mismatch');
}
return $claims;
}
// POST /auth/paspo
$input = json_decode(file_get_contents('php://input'), true);
$claims = exchange_paspo_code($input['code'], $input['code_verifier'], $input['nonce'] ?? null, $input['via'] ?? null);
session_regenerate_id(true);
$_SESSION['paspo_sub'] = $claims['sub'];
go get github.com/coreos/go-oidc/v3/oidc golang.org/x/oauth2
package auth
import (
"context"
"errors"
"os"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type PaspoClaims struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Phone string `json:"phone_number"`
Name string `json:"name"`
Nonce string `json:"nonce"`
}
func ExchangePaspoCode(ctx context.Context, code, verifier, nonce, via string) (*PaspoClaims, error) {
provider, err := oidc.NewProvider(ctx, "https://paspo.id") // cache it in production
if err != nil {
return nil, err
}
conf := oauth2.Config{
ClientID: os.Getenv("PASPO_CLIENT_ID"),
ClientSecret: os.Getenv("PASPO_CLIENT_SECRET"),
Endpoint: provider.Endpoint(),
}
if via != "fedcm" {
conf.RedirectURL = os.Getenv("PASPO_REDIRECT_URI")
}
token, err := conf.Exchange(ctx, code, oauth2.VerifierOption(verifier))
if err != nil {
return nil, err
}
rawIDToken, _ := token.Extra("id_token").(string)
idToken, err := provider.Verifier(&oidc.Config{ClientID: conf.ClientID}).Verify(ctx, rawIDToken)
if err != nil {
return nil, err
}
var claims PaspoClaims
if err := idToken.Claims(&claims); err != nil {
return nil, err
}
if nonce != "" && claims.Nonce != nonce {
return nil, errors.New("nonce_mismatch")
}
return &claims, nil
}
For a quick manual test (see Testing):
curl -X POST https://paspo.id/api/w/v1/oauth/token \
-d grant_type=authorization_code \
-d code=CODE \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d redirect_uri=https://app.example.com/auth/paspo/callback \
-d code_verifier=VERIFIER
client_secret lives only in server env variables and secret storage.sub, not by e-mail: the user may share a different address next time.HttpOnly; Secure; SameSite=Lax.code, code_verifier, tokens or client_secret.access_token or id_token to the browser — issue your own session.access_token only if you call UserInfo; the id_token already carries the requested claims.