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.
| Label | Use 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
| Feature | Required value |
|---|---|
| Flow | Authorization code, scope openid (add poh for the verified-human status) |
| Pushed authorization requests (PAR) | Required |
| PKCE | S256 |
| Client authentication | private_key_jwt, ES256 |
| Token binding (DPoP) | Required, ES256 |
| ID token | ES256 |
| Refresh tokens | None: 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:
| Stack | Library | Result |
|---|---|---|
| Node.js | openid-client 6.8.9 | Works with no extra code |
| Python | Authlib 1.9.2 | Works; you write PAR and DPoP by hand (full sample) |
| Firebase | @333eco/firebase-bridge | Turns 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
| Claim | Meaning |
|---|---|
sub | This person, at your app only. Another app gets a different value, so two apps can't link a person through it. |
amr | How 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_time | When they last actually logged in, which can be long ago with single sign-on. If you need a recent log-in, send max_age. |
poh | Only 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.