Heartank®Developersអ្នកអភិវឌ្ឍន៍Log inចូល

Log in with HeartBank®

Passkeys only. Each app sees its own ID for a person, never one another app can match.មានតែ passkey ប៉ុណ្ណោះ។ កម្មវិធីនីមួយៗឃើញ ID ផ្ទាល់ខ្លួនសម្រាប់មនុស្សម្នាក់ មិនដែលជា ID ដែលកម្មវិធីផ្សេងអាចផ្គូផ្គងបានទេ។

Log in with HeartBank®Connect HeartBank®

The second button is for people adding HeartBank® to an account they already have in your app.ប៊ូតុងទីពីរគឺសម្រាប់មនុស្សដែលបន្ថែម HeartBank® ទៅគណនីដែលពួកគេមានរួចហើយក្នុងកម្មវិធីរបស់អ្នក។

Register your appចុះឈ្មោះកម្មវិធីរបស់អ្នក

Add it at developers.333.eco/dashboard, under a business. It works once it is approved. OpenID Connect on the FAPI 2.0 profile: pushed requests, private-key client assertions and sender-bound tokens.បន្ថែមវានៅ developers.333.eco/dashboard ក្រោមអាជីវកម្មមួយ។ វាដំណើរការបន្ទាប់ពីត្រូវបានអនុម័ត។ OpenID Connect តាមទម្រង់ FAPI 2.0។

What an app may ask forអ្វីដែលកម្មវិធីអាចស្នើសុំ

Log in
Once your app is approvedនៅពេលកម្មវិធីរបស់អ្នកត្រូវបានអនុម័ត
Proof-of-personhood layers (poh)ស្រទាប់ភស្តុតាងថាជាមនុស្ស (poh)
After reviewបន្ទាប់ពីការពិនិត្យ
The ledgerបញ្ជី
After reviewបន្ទាប់ពីការពិនិត្យ

Log in authenticates; Sign attests. A log-in session can never sign.Log in បញ្ជាក់អត្តសញ្ញាណ Sign បញ្ជាក់ខ្លឹមសារ។ វគ្គនៃការចូលមិនអាចចុះហត្ថលេខាបានទេ។

ការណែនាំខាងក្រោមមានជាភាសាអង់គ្លេសតែប៉ុណ្ណោះ។

The guide

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. Add your app under a business, 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 login.333.eco/.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 passkey mark.

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 proof-of-personhood layers)
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 https://login.333.eco/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

ឯកសារយោងខាងក្រោមមានជាភាសាអង់គ្លេសតែប៉ុណ្ណោះ។

Log in with HeartBank®: reference

Prove who someone is, with a passkey. OpenID Connect on the FAPI 2.0 profile.

login.333.eco (repo 333/333.eco/login.333.eco; its live discovery document is the authority: /.well-known/openid-configuration). Passkeys only, no passwords. ⭐ Each app sees its own ID for a person (pairwise sub), never one another app can match.

The buttons: Log in with HeartBank®, and Connect HeartBank® for a person adding HeartBank® to an account they already have in your app. Same flow, two labels. ⛔ Never "sign in": Log in authenticates, Sign with HeartBank® attests, and a log-in session can never sign.

The flow: push the request (POST /request, PAR, with PKCE S256 and your private_key_jwt), send the browser to /auth, exchange the code at /token (DPoP), read the claims. Log out ends the app's session AND the HeartBank® one: after your own sign-out, send the browser to /session/end.

Scopes: openid (sub, amr, locale) · poh, the verified-human badge (poh, poh_updated_at) · ledger (pid_token, for Thank with HeartBank®). Registration is at developers.333.eco/dashboard (an app under a business), reviewed by hand; self-serve registration by a document on your own site (log-in only, openid) was ruled 2026-11-11 and is not built yet.

GET https://login.333.eco/.well-known/openid-configuration

The discovery document: every endpoint, scope and algorithm

Who may call it: anyone, no key

Answers

  • 200 OpenID Provider Metadata. object

On the MCP server: the tool discovery

GET https://login.333.eco/.well-known/jwks.json

The keys login signs ID tokens with

Who may call it: anyone, no key

Answers

  • 200 A JWK set (ES256). object

On the MCP server: the tool jwks

POST https://login.333.eco/request

Push the log-in request (PAR, required)

Required before /auth. Add max_age=0 when a change needs a FRESH passkey log-in, and ui_locales (en or km) with the language the person chose in your app.

Who may call it: Sent as FORM fields, not a header: client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion = an ES256 JWT, iss = sub = your client id, aud = the endpoint's own address, one-use jti. Your keys live at your registered key URL.

Body application/x-www-form-urlencoded

FieldTypeWhat it is
client_idstring
client_assertion_typestring
client_assertionstring
response_type"code"
scopestringopenid, plus poh or ledger if your app was approved for them.
redirect_uristring
statestring
noncestring
code_challengestring
code_challenge_method"S256"
max_age optionalinteger
ui_locales optional"en" | "km"

Answers

  • 201 The request is held. { request_uri, expires_in }
  • 400 A bad request. { error, error_description }
  • 401 Your client assertion was refused. { error, error_description }

What a success answer holds

FieldTypeWhat it is
request_uristring
expires_ininteger

On the MCP server: not a tool: needs the app's own private key on its server; ⛔ the MCP server never holds or passes a developer's key

GET https://login.333.eco/auth

Send the browser here (one tap from where the person is)

The person presses their passkey (straight to it when the last log-in in this browser was a passkey; "Other ways to log in" brings the list back). login then returns the browser to your redirect_uri with code, state and iss.

Who may call it: anyone, no key

Parameters

NameTypeWhat it is
client_id (query)string
request_uri (query)string

Answers

  • 303 Back to your redirect_uri with code, state and iss.

On the MCP server: not a tool: a browser step: the person's own passkey

POST https://login.333.eco/token

Exchange the code

Who may call it: Sent as FORM fields, not a header: client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion = an ES256 JWT, iss = sub = your client id, aud = the endpoint's own address, one-use jti. Your keys live at your registered key URL. · A DPoP proof (ES256), with the access token as Authorization: DPoP <token>.

Body application/x-www-form-urlencoded

FieldTypeWhat it is
grant_type"authorization_code"
codestring
redirect_uristring
code_verifierstring
client_assertion_typestring
client_assertionstring

Answers

  • 200 Tokens. { access_token, token_type, id_token, expires_in }
  • 400 Refused. { error, error_description }

What a success answer holds

FieldTypeWhat it is
access_tokenstring
token_type"DPoP"
id_tokenstringES256; check iss, aud, nonce, auth_time (for a fresh log-in, on your server).
expires_in optionalinteger

On the MCP server: not a tool: needs the app's own private key on its server; ⛔ the MCP server never holds or passes a developer's key

GET https://login.333.eco/me

The person's claims

Who may call it: A DPoP proof (ES256), with the access token as Authorization: DPoP <token>.

Answers

  • 200 Claims for the scopes granted. { sub, amr, locale, poh, poh_updated_at, pid_token }

What a success answer holds

FieldTypeWhat it is
sub optionalstringPairwise: this person's ID in YOUR app only.
amr optionalstring[]How they logged in, for example a passkey with the user verified.
locale optional"en" | "km"
poh optionalstring[]Scope poh: the verified-human layers this person has, when they choose to show them.
poh_updated_at optionalinteger
pid_token optionalstringScope ledger: the person's token for Thank with HeartBank®, opaque to your app.

On the MCP server: not a tool: needs a person's token; ⛔ an agent never holds one

POST https://login.333.eco/token/revocation

Revoke a token

Who may call it: Sent as FORM fields, not a header: client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion = an ES256 JWT, iss = sub = your client id, aud = the endpoint's own address, one-use jti. Your keys live at your registered key URL.

Body application/x-www-form-urlencoded

FieldTypeWhat it is
tokenstring

Answers

  • 200 Revoked (or already gone).

On the MCP server: not a tool: needs the app's own private key on its server; ⛔ the MCP server never holds or passes a developer's key

GET https://login.333.eco/session/end

Log out of HeartBank® too

After your app's own sign-out, send the browser here. ⛔ Ending only your app's session leaves the HeartBank® one alive: the next Log in would come straight back with no passkey press, and on a shared phone the next person to tap it becomes the last one. login tells every app by back-channel, then returns to your root.

Who may call it: anyone, no key

Parameters

NameTypeWhat it is
client_id (query)string
post_logout_redirect_uri (query)stringYour app's root, as registered.

Answers

  • 303 Back to your app's front page.

On the MCP server: not a tool: a browser step

GET https://login.333.eco/session/state

Is this browser logged in at HeartBank®? (display only)

For a header's Log in / Log out button, from listed origins only (CORS). ⛔ Display only: never trust it for anything.

Who may call it: anyone, no key

Answers

  • 200 One yes-or-no. { loggedIn }
  • 403 Not a listed origin.

What a success answer holds

FieldTypeWhat it is
loggedInboolean

On the MCP server: not a tool: a browser cookie, display only

POST https://login.333.eco/notice

Announce a security change before making it

Your app sends only the person's pairwise sub and a kind (⛔ never a name); login emails each address the person linked, in their language, or shows it at their next log-in. ⚠️ If the notice can't go out, the change is not made. First-party apps only today. aud = https://login.333.eco/notice.

Who may call it: Sent as FORM fields, not a header: client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion = an ES256 JWT, iss = sub = your client id, aud = the endpoint's own address, one-use jti. Your keys live at your registered key URL.

Body application/x-www-form-urlencoded

FieldTypeWhat it is
client_assertion_typestring
client_assertionstring
substring
kind"rails"rails: the person's ways to receive money changed.
lang optional"en" | "km"

Answers

  • 200 Sent. { ok }
  • 400 A bad request. { error, error_description }
  • 401 Your client assertion was refused. { error, error_description }
  • 403 Your app may not send notices. { error, error_description }
  • 502 Not delivered: do not make the change. { error, error_description }

What a success answer holds

FieldTypeWhat it is
ok optionaltrue

On the MCP server: not a tool: needs the app's own private key on its server; ⛔ the MCP server never holds or passes a developer's key

POST https://login.333.eco/poh

Read a person's verified-human layers again

For a person who granted your app poh. ⛔ A person who didn't, and a person who doesn't exist, get the SAME 404: no answer tells you which. aud = https://login.333.eco/poh.

Who may call it: Sent as FORM fields, not a header: client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion = an ES256 JWT, iss = sub = your client id, aud = the endpoint's own address, one-use jti. Your keys live at your registered key URL.

Body application/x-www-form-urlencoded

FieldTypeWhat it is
client_assertion_typestring
client_assertionstring
substring

Answers

  • 200 The layers, when there are any. { poh, poh_updated_at }
  • 401 Your client assertion was refused.
  • 404 Not found (see above).
  • 429 Too often: try again tomorrow.

What a success answer holds

FieldTypeWhat it is
poh optionalstring[]
poh_updated_atinteger

On the MCP server: not a tool: needs the app's own private key on its server; ⛔ the MCP server never holds or passes a developer's key

POST https://login.333.eco/press/wrap

Thank with HeartBank®: exchange a press for a pressToken

Step 3 of a thank: the press result the browser brought back from sign.333.eco, exchanged by your server. ⛔ No log-in history: a press is not a log-in. Ledger writers only.

Who may call it: anyone, no key

Body application/x-www-form-urlencoded

FieldTypeWhat it is
resultstring

Answers

  • 200 The pressToken for POST /v1/commit. { pressToken }
  • 400 The result was refused. { error, error_description }
  • 403 Not a ledger writer. { error, error_description }

What a success answer holds

FieldTypeWhat it is
pressTokenstring

On the MCP server: not a tool: the app's server, after the person's own press