Heartank®Proof of Humanity ℠Log inENខ្មែរ

Developer Docs

Standard OpenID Connect, at a strict security profile (FAPI 2.0). Your server talks to login.333.eco; there is no password or shared secret to store.

Start here

1. Register your app and give the address of your server's public key (a JWKS). 2. We review it. 3. Use your client ID with the code below.

Everything your library needs is at /.well-known/openid-configuration.

Log in, or Connect?

Two button labels, one flow. Pick by where the button sits; your code and our page are the same either way, and our page always says Log in.

LabelUse it when
Log in with HeartBank®The person is entering your app, and HeartBank® is how they get in.
Connect HeartBank®They are already inside your app, on your own log-in, and are adding HeartBank® to that account, for example to show the verified-human badge.

With Connect you tie the sub to the account they're already in, and you keep your own log-in.

What your library must support

FeatureRequired value
FlowAuthorization code, scope openid (add poh for the verified-human status)
Pushed authorization requests (PAR)Required
PKCES256
Client authenticationprivate_key_jwt, ES256
Token binding (DPoP)Required, ES256
ID tokenES256
Refresh tokensNone: log the person in again, or keep your own session

Which libraries work

An ordinary OpenID library works if it has these features, and many older ones lack PAR or DPoP. We list only what we have run against the real provider:

StackLibraryResult
Node.jsopenid-client 6.8.9Works with no extra code
PythonAuthlib 1.9.2Works; you write PAR and DPoP by hand (full sample)
Firebase@333eco/firebase-bridgeTurns the ID token into your own custom token (below)

Node.js

import * as oc from "openid-client"; // openid-client 6.8.9: everything below is built in

const config = await oc.discovery(new URL("https://login.333.eco"), CLIENT_ID,
  { id_token_signed_response_alg: "ES256", token_endpoint_auth_signing_alg: "ES256" },
  oc.PrivateKeyJwt({ key: privateKey, kid: "your-key-id" }));

// 1. Send the person to log in. Keep verifier, nonce and state in your session.
const verifier = oc.randomPKCECodeVerifier(), nonce = oc.randomNonce(), state = oc.randomState();
const url = await oc.buildAuthorizationUrlWithPAR(config, {
  redirect_uri: REDIRECT_URI, scope: "openid", nonce, state,
  code_challenge: await oc.calculatePKCECodeChallenge(verifier), code_challenge_method: "S256",
});
redirect(url);

// 2. They come back to REDIRECT_URI.
const DPoP = oc.getDPoPHandle(config, await oc.randomDPoPKeyPair("ES256"));
const tokens = await oc.authorizationCodeGrant(config, callbackUrl,
  { pkceCodeVerifier: verifier, expectedNonce: nonce, expectedState: state, idTokenExpected: true },
  undefined, { DPoP });
const { sub, amr } = tokens.claims();

Python

#!/usr/bin/env python3
"""Log in with HeartBank® from Python, with Authlib (served at /developers/docs/python_authlib.py; test/e2e.test.mjs runs this exact file against login).
Authlib builds PKCE and the private_key_jwt assertion; it has no PAR or DPoP helper, so those are written out here.
Reads ISSUER, CLIENT_ID, REDIRECT_URI, CLIENT_JWK (private, JSON) from the environment. Prints the authorization URL, reads the
callback URL back on stdin, verifies the ID token, and prints its claims as JSON."""
import json, os, secrets, sys, time, uuid
from urllib.parse import parse_qs, urlparse
import requests
from authlib.common.security import generate_token
from authlib.jose import JsonWebKey, jwt
from authlib.oauth2.rfc7636 import create_s256_code_challenge

ISSUER, CLIENT_ID, REDIRECT = os.environ["ISSUER"], os.environ["CLIENT_ID"], os.environ["REDIRECT_URI"]
BASE = os.environ.get("BASE_URL", ISSUER)  # only the test harness sets this; in real use it is the issuer
HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))
client_key = json.loads(os.environ["CLIENT_JWK"])
here = lambda url: url.replace(ISSUER, BASE)

meta = requests.get(here(f"{ISSUER}/.well-known/openid-configuration"), headers=HEADERS).json()

def client_assertion():
    now = int(time.time())
    return jwt.encode({"alg": "ES256", "kid": client_key["kid"]},
                      {"iss": CLIENT_ID, "sub": CLIENT_ID, "aud": meta["issuer"], "jti": uuid.uuid4().hex, "iat": now, "exp": now + 60},
                      client_key).decode()

def auth_fields():
    return {"client_id": CLIENT_ID, "client_assertion": client_assertion(),
            "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"}

# PAR: the request goes server to server, the browser carries only a reference.
verifier, nonce, state = generate_token(64), secrets.token_urlsafe(16), secrets.token_urlsafe(16)
par = requests.post(here(meta["pushed_authorization_request_endpoint"]), headers=HEADERS, data={
    **auth_fields(), "response_type": "code", "scope": "openid", "redirect_uri": REDIRECT, "nonce": nonce, "state": state,
    "code_challenge": create_s256_code_challenge(verifier), "code_challenge_method": "S256"})
par.raise_for_status()
print(f'{meta["authorization_endpoint"]}?client_id={CLIENT_ID}&request_uri={requests.utils.quote(par.json()["request_uri"], safe="")}', flush=True)

# ...the person logs in, and the browser comes back to REDIRECT with code, state and iss.
back = parse_qs(urlparse(sys.stdin.readline().strip()).query)
assert back["state"][0] == state and back["iss"][0] == meta["issuer"], "mix-up or forgery"

# DPoP: a proof signed by a key this app holds, naming this request.
dpop_key = JsonWebKey.generate_key("EC", "P-256", is_private=True)
dpop = jwt.encode({"alg": "ES256", "typ": "dpop+jwt", "jwk": dpop_key.as_dict(is_private=False)},
                  {"htm": "POST", "htu": meta["token_endpoint"], "jti": uuid.uuid4().hex, "iat": int(time.time())}, dpop_key).decode()
tok = requests.post(here(meta["token_endpoint"]), headers={**HEADERS, "DPoP": dpop}, data={
    **auth_fields(), "grant_type": "authorization_code", "code": back["code"][0], "redirect_uri": REDIRECT, "code_verifier": verifier})
tok.raise_for_status()

keys = JsonWebKey.import_key_set(requests.get(here(meta["jwks_uri"]), headers=HEADERS).json())
claims = jwt.decode(tok.json()["id_token"], keys, claims_options={"iss": {"essential": True, "value": meta["issuer"]},
                                                                  "aud": {"essential": True, "value": CLIENT_ID},
                                                                  "nonce": {"essential": True, "value": nonce}})
claims.validate()
print(json.dumps({"sub": claims["sub"], "amr": claims["amr"], "token_type": tok.json()["token_type"]}), flush=True)

Firebase

Only Firebase apps need this. After any library above gives you the ID token, your function exchanges it for a custom token in your project, with your service account.

import { getAuth } from "firebase-admin/auth";
import { createBridge } from "@333eco/firebase-bridge";

const bridge = createBridge({ issuer: "https://login.333.eco", clientId: CLIENT_ID, auth: getAuth(), requirePasskey: false });
const { customToken, passkey } = await bridge.exchange(idToken, { nonce });
// the browser calls signInWithCustomToken(auth, customToken)

Your rules get hb_amr and hb_passkey. The Firebase uid is hb:<sub>.

What the ID token tells you

ClaimMeaning
subThis person, at your app only. Another app gets a different value, so two apps can't link a person through it.
amrHow this log-in happened: swk or hwk = a passkey · otp, email = an email code · sms = a text code. A first-time log-in carries the passkey they just made.
auth_timeWhen they last actually logged in, which can be long ago with single sign-on. If you need a recent log-in, send max_age.
pohOnly with the poh scope and the person's consent: the layers they released, today ["passkey"]. Absent otherwise, never false. What it means.

Require a passkey where it matters by checking amr. A person can link email, phone and passkeys into one account, so one sub can log in several ways; poh describes the account and amr describes this log-in.

A sub can stop appearing. If someone joins two accounts, the joined account's sub ends, and they reach you with the other one as a new user. We can't tell you the two are one person, because that is what per-app identifiers prevent. Offer your own way to move their data.

Logging out

Register a backchannel_logout_uri and we POST a signed logout token when their HeartBank® session ends. To log them out from your app, send them to /session/end.

Checking the status later

Layers can change after log-in. Register a change address and we POST a signed event (application/secevent+jwt) that says only that something changed. Then ask:

POST https://login.333.eco/poh
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=<a private_key_jwt with aud https://login.333.eco/poh>
sub=<the person's sub>

200 {"poh":["passkey"],"poh_updated_at":1793000000}
404 no grant from this person to your app
429 once per person per 24 hours

The person can see every lookup, and can disconnect your app at any time.

Rules

Developer terms · Badge kit · The verified-human badge