Get a token
POST https://api.sdb.lbpay.com.br/v2/oauth/token
The body is application/x-www-form-urlencoded with three fields, plus a DPoP header. It is not
JSON.
grant_type=client_credentials
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=<jwt>
DPoP: <jwt>
Where each key comes in
The private key never leaves your infrastructure. It only signs: the client_assertion and
every DPoP proof. It never goes in a header, in a body, or to us.
The public key you register once in the portal, and it also travels in every request, inside
the DPoP header, in the jwk field. That is required by RFC 9449 and is not a leak: public is
public. It lets us verify the proof, and we check that its thumbprint equals the cnf.jkt recorded
in the token at issuance. Signing with a different key gives 401.
You do not need to keep a separate file with the public key in JWK form. It is derived from the
private key in one line (createPublicKey(privateKey).export({ format: 'jwk' }) in Node).
The client assertion
A JWT signed ES256 with your private key:
header: { "alg": "ES256", "kid": "<kid>" }
payload: { "iss": "<client_id>",
"sub": "<client_id>",
"aud": "https://api.sdb.lbpay.com.br/v2/oauth/token",
"iat": <now>,
"exp": <now + 60>,
"jti": "<uuid>" }
aud must be the token endpoint, byte for byte. An assertion minted for another environment is
rejected.
The DPoP proof for the token endpoint
Same shape as the per-request proof, without ath, qh and bh,
because there is no token yet and no body to bind:
header: { "typ": "dpop+jwt", "alg": "ES256", "jwk": { ...your public key... } }
payload: { "htm": "POST",
"htu": "https://api.sdb.lbpay.com.br/v2/oauth/token",
"iat": <now>,
"jti": "<uuid>" }
Complete example
- Node
- Python
- PHP
- Java
- C#
- Go
- Ruby
- Elixir
- Clojure
// Fluxo completo: obtém o token e faz uma chamada assinada. Só biblioteca padrão.
import { createHash, createPrivateKey, createPublicKey, createSign, randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
const BASE = process.env.LBPAY_BASE ?? 'https://seamless-v2.sdb.lbpay.com.br'
const CLIENT_ID = process.env.LBPAY_CLIENT_ID ?? 'cli-demo'
const KID = process.env.LBPAY_KID ?? 'kid-demo'
const privateKey = createPrivateKey(readFileSync('private.pem', 'utf8'))
// A pública sai da privada: você não precisa manter um segundo arquivo.
const publicJwk = createPublicKey(privateKey).export({ format: 'jwk' })
const b64u = (b) => Buffer.from(b).toString('base64url')
const sha = (s) => b64u(createHash('sha256').update(s).digest())
const now = () => Math.floor(Date.now() / 1000)
function jws(header, payload) {
const h = b64u(JSON.stringify(header))
const p = b64u(JSON.stringify(payload))
// A saída default do Node (DER) é aceita; R‖S também. Use o que sua lib emitir.
const signature = createSign('sha256').update(`${h}.${p}`).sign(privateKey)
return `${h}.${p}.${b64u(signature)}`
}
// ── 1. token ───────────────────────────────────────────────────────────────────────
async function getToken() {
const url = `${BASE}/oauth/token`
const iat = now()
const clientAssertion = jws(
{ alg: 'ES256', kid: KID },
{ iss: CLIENT_ID, sub: CLIENT_ID, aud: url, iat, exp: iat + 60, jti: randomUUID() },
)
// Versão curta: aqui ainda não há token, query nem corpo para amarrar.
const proof = jws(
{ typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk },
{ htm: 'POST', htu: url, iat, jti: randomUUID() },
)
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', dpop: proof },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: clientAssertion,
}),
})
if (!res.ok) throw new Error(`token ${res.status}: ${await res.text()}`)
return (await res.json()).access_token
}
# Fluxo completo: obtém o token e faz uma chamada assinada. Só `cryptography` + stdlib.
import base64, hashlib, json, os, time, urllib.parse, urllib.request, uuid
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
BASE = os.environ.get("LBPAY_BASE", "https://seamless-v2.sdb.lbpay.com.br")
CLIENT_ID = os.environ.get("LBPAY_CLIENT_ID", "cli-demo")
KID = os.environ.get("LBPAY_KID", "kid-demo")
with open("private.pem", "rb") as f:
key = serialization.load_pem_private_key(f.read(), password=None)
def b64u(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def sha(text: str) -> str:
return b64u(hashlib.sha256(text.encode()).digest())
# O default do json.dumps poe espacos; sem separators o JWS fica maior a toa.
def compact(obj) -> bytes:
return json.dumps(obj, separators=(",", ":")).encode()
def jws(header: dict, payload: dict) -> str:
signing_input = b64u(compact(header)) + "." + b64u(compact(payload))
# A saida default (DER) e aceita; R‖S tambem. Use o que sua lib emitir.
signature = key.sign(signing_input.encode(), ec.ECDSA(hashes.SHA256()))
return signing_input + "." + b64u(signature)
def post(url: str, headers: dict, body: bytes):
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req) as res:
return res.status, res.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
# A publica sai da privada: voce nao precisa manter um segundo arquivo.
numbers = key.public_key().public_numbers()
public_jwk = {
"crv": "P-256",
"kty": "EC",
"x": b64u(numbers.x.to_bytes(32, "big")),
"y": b64u(numbers.y.to_bytes(32, "big")),
}
# ── 1. token ──────────────────────────────────────────────────────────────────
token_url = f"{BASE}/oauth/token"
iat = int(time.time())
client_assertion = jws(
{"alg": "ES256", "kid": KID},
{"iss": CLIENT_ID, "sub": CLIENT_ID, "aud": token_url,
"iat": iat, "exp": iat + 60, "jti": str(uuid.uuid4())},
)
# Versao curta: aqui ainda nao ha token, query nem corpo para amarrar.
token_proof = jws(
{"typ": "dpop+jwt", "alg": "ES256", "jwk": public_jwk},
{"htm": "POST", "htu": token_url, "iat": iat, "jti": str(uuid.uuid4())},
)
status, resp = post(
token_url,
{"Content-Type": "application/x-www-form-urlencoded", "DPoP": token_proof},
urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": client_assertion,
}).encode(),
)
if status != 200:
raise SystemExit(f"token {status}: {resp}")
access_token = json.loads(resp)["access_token"]
<?php
// Fluxo completo: obtém o token e faz uma chamada assinada. Só a extensão openssl.
$BASE = getenv('LBPAY_BASE') ?: 'https://seamless-v2.sdb.lbpay.com.br';
$CLIENT_ID = getenv('LBPAY_CLIENT_ID') ?: 'cli-demo';
$KID = getenv('LBPAY_KID') ?: 'kid-demo';
$key = openssl_pkey_get_private(file_get_contents('private.pem'));
function b64u(string $raw): string {
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
function sha(string $text): string {
return b64u(hash('sha256', $text, true));
}
function jws(array $header, array $payload): string {
global $key;
$signingInput = b64u(json_encode($header)) . '.' . b64u(json_encode($payload));
// A saída do openssl_sign (DER) é aceita; R‖S também. Use o que sua lib emitir.
openssl_sign($signingInput, $signature, $key, OPENSSL_ALGO_SHA256);
return $signingInput . '.' . b64u($signature);
}
function post(string $url, array $headers, string $body): array {
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => $body,
'ignore_errors' => true,
]]);
$resp = file_get_contents($url, false, $ctx);
preg_match('/ (\d{3}) /', $http_response_header[0], $m);
return [(int) $m[1], $resp];
}
// A pública sai da privada: você não precisa manter um segundo arquivo.
$d = openssl_pkey_get_details($key);
$publicJwk = [
'crv' => 'P-256',
'kty' => 'EC',
'x' => b64u($d['ec']['x']),
'y' => b64u($d['ec']['y']),
];
// ── 1. token ──────────────────────────────────────────────────────────────────
$tokenUrl = "$BASE/oauth/token";
$iat = time();
$clientAssertion = jws(
['alg' => 'ES256', 'kid' => $KID],
['iss' => $CLIENT_ID, 'sub' => $CLIENT_ID, 'aud' => $tokenUrl,
'iat' => $iat, 'exp' => $iat + 60, 'jti' => bin2hex(random_bytes(16))],
);
// Versão curta: aqui ainda não há token, query nem corpo para amarrar.
$tokenProof = jws(
['typ' => 'dpop+jwt', 'alg' => 'ES256', 'jwk' => $publicJwk],
['htm' => 'POST', 'htu' => $tokenUrl, 'iat' => $iat, 'jti' => bin2hex(random_bytes(16))],
);
[$status, $resp] = post($tokenUrl, [
'Content-Type: application/x-www-form-urlencoded',
"DPoP: $tokenProof",
], http_build_query([
'grant_type' => 'client_credentials',
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'client_assertion' => $clientAssertion,
]));
if ($status !== 200) { fwrite(STDERR, "token $status: $resp\n"); exit(1); }
$accessToken = json_decode($resp, true)['access_token'];
// Fluxo completo: obtém o token e faz uma chamada assinada. Só JDK, sem dependência.
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.*;
import java.security.interfaces.ECPublicKey;
import java.security.spec.*;
import java.util.Base64;
import java.util.UUID;
public class Fluxo {
static PrivateKey priv;
static String publicJwk;
static final HttpClient HTTP = HttpClient.newHttpClient();
static String env(String name, String fallback) {
String v = System.getenv(name);
return (v == null || v.isEmpty()) ? fallback : v;
}
static String b64u(byte[] raw) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
}
static String sha(String text) throws Exception {
return b64u(MessageDigest.getInstance("SHA-256")
.digest(text.getBytes(StandardCharsets.UTF_8)));
}
static byte[] pem(String path) throws IOException {
String body = Files.readString(Path.of(path))
.replaceAll("-----[A-Z ]+-----", "")
.replaceAll("\\s", "");
return Base64.getDecoder().decode(body);
}
// x e y do JWK sao 32 bytes com padding a esquerda. toByteArray() devolve 33 (byte de
// sinal) ou menos de 32 — os dois quebram o thumbprint, e o cnf.jkt deixa de bater.
static byte[] fixed32(BigInteger v) {
byte[] raw = v.toByteArray();
byte[] out = new byte[32];
int n = Math.min(raw.length, 32);
System.arraycopy(raw, raw.length - n, out, 32 - n, n);
return out;
}
static String jws(String header, String payload) throws Exception {
String signingInput = b64u(header.getBytes(StandardCharsets.UTF_8)) + "."
+ b64u(payload.getBytes(StandardCharsets.UTF_8));
// A saída do SHA256withECDSA comum (DER) é aceita; R‖S também. Use o que sua lib emitir.
Signature sig = Signature.getInstance("SHA256withECDSA");
sig.initSign(priv);
sig.update(signingInput.getBytes(StandardCharsets.UTF_8));
return signingInput + "." + b64u(sig.sign());
}
static HttpResponse<String> post(String url, String body, String... headers)
throws IOException, InterruptedException {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url))
.POST(HttpRequest.BodyPublishers.ofString(body));
for (int i = 0; i < headers.length; i += 2) b.header(headers[i], headers[i + 1]);
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
}
public static void main(String[] args) throws Exception {
String base = env("LBPAY_BASE", "https://seamless-v2.sdb.lbpay.com.br");
String clientId = env("LBPAY_CLIENT_ID", "cli-demo");
String kid = env("LBPAY_KID", "kid-demo");
KeyFactory ecFactory = KeyFactory.getInstance("EC");
priv = ecFactory.generatePrivate(new PKCS8EncodedKeySpec(pem("private.pem")));
// O JDK nao deriva a publica a partir da privada pela API padrao: carregue o SPKI.
ECPublicKey pub = (ECPublicKey) ecFactory
.generatePublic(new X509EncodedKeySpec(pem("public.pem")));
publicJwk = String.format("{\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\"%s\",\"y\":\"%s\"}",
b64u(fixed32(pub.getW().getAffineX())), b64u(fixed32(pub.getW().getAffineY())));
// ── 1. token ──────────────────────────────────────────────────────────
String tokenUrl = base + "/oauth/token";
long iat = System.currentTimeMillis() / 1000;
String clientAssertion = jws(
String.format("{\"alg\":\"ES256\",\"kid\":\"%s\"}", kid),
String.format("{\"iss\":\"%s\",\"sub\":\"%s\",\"aud\":\"%s\","
+ "\"iat\":%d,\"exp\":%d,\"jti\":\"%s\"}",
clientId, clientId, tokenUrl, iat, iat + 60, UUID.randomUUID()));
// Versao curta: aqui ainda nao ha token, query nem corpo para amarrar.
String tokenProof = jws(
"{\"typ\":\"dpop+jwt\",\"alg\":\"ES256\",\"jwk\":" + publicJwk + "}",
String.format("{\"htm\":\"POST\",\"htu\":\"%s\",\"iat\":%d,\"jti\":\"%s\"}",
tokenUrl, iat, UUID.randomUUID()));
String form = "grant_type=client_credentials"
+ "&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer"
+ "&client_assertion=" + clientAssertion;
HttpResponse<String> tokenRes = post(tokenUrl, form,
"Content-Type", "application/x-www-form-urlencoded", "DPoP", tokenProof);
if (tokenRes.statusCode() != 200) {
System.err.println("token " + tokenRes.statusCode() + ": " + tokenRes.body());
System.exit(1);
}
String accessToken = tokenRes.body().replaceAll(".*\"access_token\"\\s*:\\s*\"([^\"]+)\".*", "$1");
// Fluxo completo: obtém o token e faz uma chamada assinada. Só biblioteca padrão do .NET.
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
var BASE = Environment.GetEnvironmentVariable("LBPAY_BASE") ?? "https://seamless-v2.sdb.lbpay.com.br";
var CLIENT_ID = Environment.GetEnvironmentVariable("LBPAY_CLIENT_ID") ?? "cli-demo";
var KID = Environment.GetEnvironmentVariable("LBPAY_KID") ?? "kid-demo";
var ec = ECDsa.Create();
ec.ImportFromPem(File.ReadAllText("../private.pem"));
// Sem isto o JsonSerializer escapa "+" como +, e o typ vira "dpop+jwt". Funciona,
// porque todo parser JSON desescapa — mas quem for depurar a própria prova vai achar que quebrou.
var json = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
string B64u(byte[] raw) =>
Convert.ToBase64String(raw).TrimEnd('=').Replace('+', '-').Replace('/', '_');
string Sha(string text) => B64u(SHA256.HashData(Encoding.UTF8.GetBytes(text)));
string Jws(object header, object payload)
{
var signingInput =
B64u(JsonSerializer.SerializeToUtf8Bytes(header, json)) + "." +
B64u(JsonSerializer.SerializeToUtf8Bytes(payload, json));
// SignData ja devolve R‖S cru (IEEE P1363), que e o que o JOSE exige.
// Diferente de Java, PHP, Python, Ruby e Go, aqui NAO existe conversao de DER a fazer.
var signature = ec.SignData(Encoding.UTF8.GetBytes(signingInput), HashAlgorithmName.SHA256);
return signingInput + "." + B64u(signature);
}
// A pública sai da privada: você não precisa manter um segundo arquivo.
var p = ec.ExportParameters(false);
// A ordem dos campos importa para o thumbprint (RFC 7638): crv, kty, x, y.
var publicJwk = new Dictionary<string, string>
{
["crv"] = "P-256",
["kty"] = "EC",
["x"] = B64u(p.Q.X!),
["y"] = B64u(p.Q.Y!),
};
var http = new HttpClient();
var iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
// ── 1. token ──────────────────────────────────────────────────────────────────
var tokenUrl = $"{BASE}/oauth/token";
var clientAssertion = Jws(
new Dictionary<string, object> { ["alg"] = "ES256", ["kid"] = KID },
new Dictionary<string, object>
{
["iss"] = CLIENT_ID, ["sub"] = CLIENT_ID, ["aud"] = tokenUrl,
["iat"] = iat, ["exp"] = iat + 60, ["jti"] = Guid.NewGuid().ToString(),
});
// Versao curta: aqui ainda nao ha token, query nem corpo para amarrar.
var tokenProof = Jws(
new Dictionary<string, object> { ["typ"] = "dpop+jwt", ["alg"] = "ES256", ["jwk"] = publicJwk },
new Dictionary<string, object>
{
["htm"] = "POST", ["htu"] = tokenUrl, ["iat"] = iat,
["jti"] = Guid.NewGuid().ToString(),
});
var tokenReq = new HttpRequestMessage(HttpMethod.Post, tokenUrl)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
["client_assertion"] = clientAssertion,
}),
};
tokenReq.Headers.Add("DPoP", tokenProof);
var tokenRes = await http.SendAsync(tokenReq);
if (!tokenRes.IsSuccessStatusCode)
{
Console.Error.WriteLine($"token {(int)tokenRes.StatusCode}: {await tokenRes.Content.ReadAsStringAsync()}");
return 1;
}
var accessToken = JsonDocument.Parse(await tokenRes.Content.ReadAsStringAsync())
.RootElement.GetProperty("access_token").GetString()!;
// Fluxo completo: obtém o token e faz uma chamada assinada. Só biblioteca padrão.
package main
import (
"bytes"
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
var key *ecdsa.PrivateKey
func env(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}
func b64u(raw []byte) string { return base64.RawURLEncoding.EncodeToString(raw) }
func sha(text string) string {
sum := sha256.Sum256([]byte(text))
return b64u(sum[:])
}
func mustJSON(v any) []byte {
out, err := json.Marshal(v)
if err != nil {
panic(err)
}
return out
}
func jws(header, payload any) string {
signingInput := b64u(mustJSON(header)) + "." + b64u(mustJSON(payload))
digest := sha256.Sum256([]byte(signingInput))
// A saída do SignASN1 (DER) é aceita; R‖S também. Use o que sua lib emitir.
signature, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
if err != nil {
panic(err)
}
return signingInput + "." + b64u(signature)
}
func post(target string, headers map[string]string, body []byte) (int, string) {
req, _ := http.NewRequest("POST", target, bytes.NewReader(body))
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
return res.StatusCode, string(out)
}
func main() {
base := env("LBPAY_BASE", "https://seamless-v2.sdb.lbpay.com.br")
clientID := env("LBPAY_CLIENT_ID", "cli-demo")
kid := env("LBPAY_KID", "kid-demo")
raw, _ := os.ReadFile("private.pem")
block, _ := pem.Decode(raw)
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
panic(err)
}
key = parsed.(*ecdsa.PrivateKey)
// A pública sai da privada: você não precisa manter um segundo arquivo.
publicJWK := map[string]string{
"crv": "P-256",
"kty": "EC",
"x": b64u(key.X.FillBytes(make([]byte, 32))),
"y": b64u(key.Y.FillBytes(make([]byte, 32))),
}
jti := func() string {
b := make([]byte, 16)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
// ── 1. token ──────────────────────────────────────────────────────────────
tokenURL := base + "/oauth/token"
iat := time.Now().Unix()
clientAssertion := jws(
map[string]any{"alg": "ES256", "kid": kid},
map[string]any{"iss": clientID, "sub": clientID, "aud": tokenURL,
"iat": iat, "exp": iat + 60, "jti": jti()},
)
// Versão curta: aqui ainda não há token, query nem corpo para amarrar.
tokenProof := jws(
map[string]any{"typ": "dpop+jwt", "alg": "ES256", "jwk": publicJWK},
map[string]any{"htm": "POST", "htu": tokenURL, "iat": iat, "jti": jti()},
)
form := url.Values{
"grant_type": {"client_credentials"},
"client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
"client_assertion": {clientAssertion},
}
status, resp := post(tokenURL, map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"DPoP": tokenProof,
}, []byte(form.Encode()))
if status != 200 {
fmt.Fprintf(os.Stderr, "token %d: %s\n", status, resp)
os.Exit(1)
}
var token struct {
AccessToken string `json:"access_token"`
}
json.NewDecoder(strings.NewReader(resp)).Decode(&token)
# Fluxo completo: obtém o token e faz uma chamada assinada. Só biblioteca padrão.
require 'openssl'
require 'json'
require 'base64'
require 'securerandom'
require 'net/http'
require 'uri'
BASE = ENV.fetch('LBPAY_BASE', 'https://seamless-v2.sdb.lbpay.com.br')
CLIENT_ID = ENV.fetch('LBPAY_CLIENT_ID', 'cli-demo')
KID = ENV.fetch('LBPAY_KID', 'kid-demo')
KEY = OpenSSL::PKey::EC.new(File.read('private.pem'))
def b64u(raw)
Base64.urlsafe_encode64(raw, padding: false)
end
def sha(text)
b64u(OpenSSL::Digest::SHA256.digest(text))
end
def jws(header, payload)
signing_input = "#{b64u(JSON.generate(header))}.#{b64u(JSON.generate(payload))}"
# A saída do key.sign (DER) é aceita; R‖S também. Use o que sua lib emitir.
signature = KEY.sign(OpenSSL::Digest::SHA256.new, signing_input)
"#{signing_input}.#{b64u(signature)}"
end
def post(url, headers, body)
uri = URI(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
res = http.post(uri.request_uri, body, headers)
[res.code.to_i, res.body]
end
# A pública sai da privada: você não precisa manter um segundo arquivo.
point = KEY.public_key.to_bn.to_s(2)
PUBLIC_JWK = { crv: 'P-256', kty: 'EC', x: b64u(point[1, 32]), y: b64u(point[33, 32]) }.freeze
# ── 1. token ──────────────────────────────────────────────────────────────────
token_url = "#{BASE}/oauth/token"
iat = Time.now.to_i
client_assertion = jws(
{ alg: 'ES256', kid: KID },
{ iss: CLIENT_ID, sub: CLIENT_ID, aud: token_url,
iat: iat, exp: iat + 60, jti: SecureRandom.uuid }
)
# Versão curta: aqui ainda não há token, query nem corpo para amarrar.
token_proof = jws(
{ typ: 'dpop+jwt', alg: 'ES256', jwk: PUBLIC_JWK },
{ htm: 'POST', htu: token_url, iat: iat, jti: SecureRandom.uuid }
)
status, resp = post(
token_url,
{ 'Content-Type' => 'application/x-www-form-urlencoded', 'DPoP' => token_proof },
URI.encode_www_form(
grant_type: 'client_credentials',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: client_assertion
)
)
abort("token #{status}: #{resp}") unless status == 200
access_token = JSON.parse(resp)['access_token']
# Fluxo completo: obtém o token e faz uma chamada assinada. Só :public_key, :crypto e :httpc.
defmodule LBPay do
def b64u(raw), do: Base.url_encode64(raw, padding: false)
def sha(text), do: b64u(:crypto.hash(:sha256, text))
def jws(key, header, payload) do
signing_input = b64u(JSON.encode!(header)) <> "." <> b64u(JSON.encode!(payload))
# A saída do :public_key.sign (DER) é aceita; R‖S também. Use o que sua lib emitir.
signature = :public_key.sign(signing_input, :sha256, key)
signing_input <> "." <> b64u(signature)
end
def post(url, headers, content_type, body) do
request = {String.to_charlist(url), headers, String.to_charlist(content_type),
String.to_charlist(body)}
case :httpc.request(:post, request, [], body_format: :binary) do
{:ok, {{_, status, _}, _headers, resp}} -> {status, resp}
{:error, reason} -> {0, inspect(reason)}
end
end
def jti, do: Base.encode16(:crypto.strong_rand_bytes(16), case: :lower)
end
:inets.start()
:ssl.start()
base = System.get_env("LBPAY_BASE") || "https://seamless-v2.sdb.lbpay.com.br"
client_id = System.get_env("LBPAY_CLIENT_ID") || "cli-demo"
kid = System.get_env("LBPAY_KID") || "kid-demo"
[entry] = :public_key.pem_decode(File.read!("private.pem"))
key = :public_key.pem_entry_decode(entry)
# A pública sai da privada: o ponto não comprimido é 0x04 || X (32) || Y (32).
{:ECPrivateKey, _v, _priv, _params, <<4, x::binary-size(32), y::binary-size(32)>>, _} = key
public_jwk = %{"crv" => "P-256", "kty" => "EC", "x" => LBPay.b64u(x), "y" => LBPay.b64u(y)}
# ── 1. token ──────────────────────────────────────────────────────────────────
token_url = "#{base}/oauth/token"
iat = System.system_time(:second)
client_assertion =
LBPay.jws(key, %{"alg" => "ES256", "kid" => kid}, %{
"iss" => client_id, "sub" => client_id, "aud" => token_url,
"iat" => iat, "exp" => iat + 60, "jti" => LBPay.jti()
})
# Versão curta: aqui ainda não há token, query nem corpo para amarrar.
token_proof =
LBPay.jws(key, %{"typ" => "dpop+jwt", "alg" => "ES256", "jwk" => public_jwk}, %{
"htm" => "POST", "htu" => token_url, "iat" => iat, "jti" => LBPay.jti()
})
form =
URI.encode_query(%{
"grant_type" => "client_credentials",
"client_assertion_type" => "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion" => client_assertion
})
{status, resp} =
LBPay.post(token_url, [{~c"DPoP", String.to_charlist(token_proof)}],
"application/x-www-form-urlencoded", form)
if status != 200 do
IO.puts(:stderr, "token #{status}: #{resp}")
System.halt(1)
end
access_token = JSON.decode!(resp)["access_token"]
;; Fluxo completo: obtém o token e faz uma chamada assinada. Só interop com o JDK.
(import '[java.security KeyFactory Signature MessageDigest]
'[java.security.spec PKCS8EncodedKeySpec X509EncodedKeySpec]
'[java.net URI]
'[java.net.http HttpClient HttpRequest HttpRequest$BodyPublishers HttpResponse$BodyHandlers]
'[java.util Base64 UUID])
(require '[clojure.string :as str])
(defn env [name fallback]
(or (not-empty (System/getenv name)) fallback))
(defn b64u [^bytes raw]
(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) raw))
(defn sha [^String text]
(b64u (.digest (MessageDigest/getInstance "SHA-256") (.getBytes text "UTF-8"))))
(defn pem->der [path]
(-> (slurp path)
(str/replace #"-----[A-Z ]+-----" "")
(str/replace #"\s" "")
(->> (.decode (Base64/getDecoder)))))
;; x e y do JWK sao 32 bytes com padding a esquerda. toByteArray() devolve 33 (byte de
;; sinal) ou menos de 32 — os dois quebram o thumbprint, e o cnf.jkt deixa de bater.
(defn fixed32 [^java.math.BigInteger v]
(let [raw (.toByteArray v)
out (byte-array 32)
n (min (alength raw) 32)]
(System/arraycopy raw (- (alength raw) n) out (- 32 n) n)
out))
(def ec-factory (KeyFactory/getInstance "EC"))
(def priv (.generatePrivate ec-factory (PKCS8EncodedKeySpec. (pem->der "private.pem"))))
;; O JDK nao deriva a publica a partir da privada pela API padrao: carregue o SPKI.
(def pub (.generatePublic ec-factory (X509EncodedKeySpec. (pem->der "public.pem"))))
(def public-jwk
(let [w (.getW pub)]
(format "{\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\"%s\",\"y\":\"%s\"}"
(b64u (fixed32 (.getAffineX w))) (b64u (fixed32 (.getAffineY w))))))
(defn jws [header payload]
(let [signing-input (str (b64u (.getBytes header "UTF-8")) "."
(b64u (.getBytes payload "UTF-8")))
;; A saida do SHA256withECDSA comum (DER) e aceita; R‖S tambem. Use o que sua lib emitir.
sig (doto (Signature/getInstance "SHA256withECDSA")
(.initSign priv)
(.update (.getBytes signing-input "UTF-8")))]
(str signing-input "." (b64u (.sign sig)))))
(def http (HttpClient/newHttpClient))
(defn post [url body headers]
(let [b (reduce (fn [acc [k v]] (.header acc k v))
(-> (HttpRequest/newBuilder (URI/create url))
(.POST (HttpRequest$BodyPublishers/ofString body)))
headers)
res (.send http (.build b) (HttpResponse$BodyHandlers/ofString))]
[(.statusCode res) (.body res)]))
;; ── 1. token ───────────────────────────────────────────────────────────────────
(let [base (env "LBPAY_BASE" "https://seamless-v2.sdb.lbpay.com.br")
client-id (env "LBPAY_CLIENT_ID" "cli-demo")
kid (env "LBPAY_KID" "kid-demo")
token-url (str base "/oauth/token")
iat (quot (System/currentTimeMillis) 1000)
client-assertion
(jws (format "{\"alg\":\"ES256\",\"kid\":\"%s\"}" kid)
(format (str "{\"iss\":\"%s\",\"sub\":\"%s\",\"aud\":\"%s\","
"\"iat\":%d,\"exp\":%d,\"jti\":\"%s\"}")
client-id client-id token-url iat (+ iat 60) (UUID/randomUUID)))
;; Versao curta: aqui ainda nao ha token, query nem corpo para amarrar.
token-proof
(jws (str "{\"typ\":\"dpop+jwt\",\"alg\":\"ES256\",\"jwk\":" public-jwk "}")
(format "{\"htm\":\"POST\",\"htu\":\"%s\",\"iat\":%d,\"jti\":\"%s\"}"
token-url iat (UUID/randomUUID)))
form (str "grant_type=client_credentials"
"&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer"
"&client_assertion=" client-assertion)
[status resp] (post token-url form
[["Content-Type" "application/x-www-form-urlencoded"]
["DPoP" token-proof]])]
(when (not= status 200)
(binding [*out* *err*] (println "token" status resp))
(System/exit 1))
Every tab is executed against a server that verifies both proofs exactly as production does:
signature, typ, htu, ath, bh, the iat window, and that no private component leaks into
the jwk. Documentation nobody ran is documentation that does not work.
Lifetime and renewal
The token is valid for at most 60 seconds, for any profile. Renew between 35 and 45 seconds, one renewal at a time (singleflight). Do not fire a renewal per request.
let cached = { token: null, exp: 0, inflight: null };
async function token() {
const now = Math.floor(Date.now() / 1000);
if (cached.token && now < cached.exp - 20) return cached.token;
cached.inflight ??= requestToken().then((t) => {
cached = { token: t.access_token, exp: now + t.expires_in, inflight: null };
return cached.token;
});
return cached.inflight;
}
The token carries the whole signed credential, so the resource server needs no database lookup on the hot path. Sixty seconds is what keeps a revocation from lingering: after that, the token is gone no matter what.
One-shot jti
Each jti is single use, on both the assertion and the proof. Reusing one gives
API_CREDENTIAL_PROOF_REPLAY, so generate a fresh UUID per proof.