Obtenha um token
POST https://api.sdb.lbpay.com.br/v2/oauth/token
O corpo é application/x-www-form-urlencoded com três campos, mais um header DPoP. Não é JSON.
grant_type=client_credentials
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=<jwt>
DPoP: <jwt>
Onde entra cada chave
A privada nunca sai da sua infraestrutura. Ela só assina: a client_assertion e cada prova
DPoP. Não vai em header, nem em corpo, nem para nós.
A pública você registra uma vez no portal, e ela também viaja em toda requisição, dentro do
header DPoP, no campo jwk. Isso é exigência da RFC 9449 e não é vazamento: pública é pública.
Ela serve para verificarmos a prova, e conferimos que o thumbprint dela é igual ao cnf.jkt gravado
no token na emissão. Assinar com outra chave dá 401.
Você não precisa manter um arquivo separado com a pública em JWK: ela é derivada da privada em uma linha.
A client assertion
Um JWT assinado em ES256 com sua privada:
header: { "alg": "ES256", "kid": "<kid>" }
payload: { "iss": "<client_id>",
"sub": "<client_id>",
"aud": "https://api.sdb.lbpay.com.br/v2/oauth/token",
"iat": <agora>,
"exp": <agora + 60>,
"jti": "<uuid>" }
O aud precisa ser o endpoint de token, byte a byte. Uma assertion emitida para outro ambiente é
recusada.
A prova DPoP do endpoint de token
Mesmo formato da prova por requisição, sem ath, qh e bh, porque ainda não
existe token nem corpo para amarrar:
header: { "typ": "dpop+jwt", "alg": "ES256", "jwk": { ...sua chave pública... } }
payload: { "htm": "POST",
"htu": "https://api.sdb.lbpay.com.br/v2/oauth/token",
"iat": <agora>,
"jti": "<uuid>" }
Exemplo completo
- 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))
Cada aba é executada contra um servidor que confere as duas provas como a produção confere:
assinatura, typ, htu, ath, bh, a janela do iat e que nenhum componente privado vaze no
jwk. Documentação que ninguém rodou é documentação que não funciona.
Validade e renovação
O token vale no máximo 60 segundos, para qualquer perfil. Renove entre 35 e 45 s, uma renovação por vez (singleflight). Não dispare uma renovação por requisição.
O token carrega a credencial assinada inteira, então o servidor de recurso não precisa consultar banco no caminho quente. Sessenta segundos é o que impede uma revogação de ficar pendurada: passado esse tempo, o token acabou, aconteça o que acontecer.
jti de uso único
Cada jti é de uso único, tanto na assertion quanto na prova. Reaproveitar um vale
API_CREDENTIAL_PROOF_REPLAY, então gere um UUID novo por prova.