zorealoauth2

package module
v0.1.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: MIT Imports: 21 Imported by: 0

README

zoreal-oauth2-go

Go Reference Go Report Card CI

Login with ZOREAL for Go backends: the relying-party half of the flow that @zoreal/oauth2-react starts in the browser.

The browser SDK runs the pairing (QR or app link), and hands your frontend an authorization code plus the code_verifier and nonce it generated. Your frontend posts all three to your backend, and this module does the rest: the code exchange with your client authentication, ES256 verification of the ID token against the provider's JWKS, and the /userinfo read for personal claims.

zoreal-oauth2-go (this module)   your backend: exchange, verify, userinfo
@zoreal/oauth2-react             your frontend: the button, the QR, the polling

Install

go get github.com/Bynn-Intelligence/zoreal-oauth2-go

Go >= 1.22. One dependency: github.com/golang-jwt/jwt/v5. The JWKS is parsed with the standard library — the provider signs with EC P-256 keys and nothing else, so that is the whole grammar.

Getting your credentials

Everything Config needs comes from a ZOREAL asset.

  1. Create an account at https://zoreal.com and open Assets.
  2. Create an asset — a website (a domain you own) or an app bundle (a reverse-DNS bundle id). An asset is the thing users log in to; its token is your ClientID and it looks like ast_....
  3. On the asset, open the OAuth2 tab and set:
    • the redirect URIs and JavaScript origins your app uses (requests from anything not registered are rejected — this is the core control),
    • the scopes the client is allowed to request (see the catalogue below),
    • your client authentication: generate a client secret (client_secret_basic, the ClientSecret field), or register a JWKS for private_key_jwt (the PrivateKey / PrivateKeyPEM fields). A public client authenticates with PKCE alone and no secret.
  4. A website asset must verify its domain (a DNS or meta-tag proof, shown in the dashboard) before it can request personal-data scopes or sign users in; the verified domain is what your users' sub is pairwise against.

The ClientID is public (it ships in your frontend). The client secret is not — keep it in your server's secret store (an env var, a secrets manager), never in the browser.

There is no test-identity sandbox — and that is deliberate

ZOREAL never issues fake or sandbox humans: a pool of test identities would be a fraud vector against the exact thing the product proves. So you always authenticate real ZOREAL IDs.

To develop and test, create a free ZOREAL ID for yourself (enrol in the ZOREAL ID app) and sign in with it. Mark your asset's environment sandbox in the dashboard while building — a sandbox asset may register http://localhost origins and redirect URIs that a production asset may not — and flip it to production when you ship. The identities are real either way; only the allowed origins differ.

Quick start

Build one client at boot and share it; it is safe for concurrent use.

import zorealoauth2 "github.com/Bynn-Intelligence/zoreal-oauth2-go"

zoreal, err := zorealoauth2.NewClient(zorealoauth2.Config{
	ClientID:     os.Getenv("ZOREAL_CLIENT_ID"), // ast_...
	ClientSecret: os.Getenv("ZOREAL_CLIENT_SECRET"),
	// Issuer defaults to https://id.zoreal.com
})

The endpoint your frontend posts to:

func handleZorealLogin(w http.ResponseWriter, r *http.Request) {
	login, err := zoreal.Authenticate(r.Context(),
		r.PostFormValue("code"),
		r.PostFormValue("code_verifier"), // PKCE is mandatory; the SDK hands it over
		r.PostFormValue("nonce"),         // binds the ID token to this login
	)
	if err != nil {
		http.Error(w, "login failed", http.StatusUnauthorized)
		return
	}

	login.Sub()       // "TC5X-JN7G-YTSE-6E63" — pairwise, stable for YOUR domain
	login.ACR()       // "zoreal.live" | "zoreal.device" | "zoreal.session"
	login.Assurance() // uniqueness basis, verification month, chip liveness, trust tier

	email, err := login.Email(r.Context()) // from /userinfo, when your client has the email scope
	verified, _ := login.EmailVerified(r.Context())
	over18, registered := login.AgeOver(18) // zoreal.age scope; registered says the claim exists at all
	_ = over18
	_, _, _ = email, verified, registered
}

Account matching, the shape that works: look the user up by ("zoreal", login.Sub()) first; only when that misses, and only when login.EmailVerified(ctx) is true, claim an existing account by its email — then store the provider and Sub on it. Claim, don't collide.

Assurance levels — acr, and requiring a liveness check

What acr is

acr is an OpenID Connect standard claim — Authentication Context Class Reference. It is a single string in the ID token that says how strongly this particular login was authenticated. Every ZOREAL login carries one, and it is the difference between "someone who once enrolled this identity is behind this request" and "a live human, verified to be the right one, is behind this request right now". Read it with login.ACR().

It answers a question Sub cannot. login.Sub() tells you who (a stable, pairwise identifier for this person at your site). login.ACR() tells you how sure ZOREAL is that the person is really there for this login. A stolen, unlocked phone can still produce a sub; it cannot produce a fresh zoreal.live.

The three levels

Ordered weakest to strongest. Each is what actually happened, never what was requested — a login that could only reach a weaker level says so honestly rather than claiming the level you asked for.

acr What the holder did amr What it proves What it does not prove
zoreal.session Nothing — a returning holder at a site they have used before, resumed silently from an existing ZOREAL session, no phone interaction [] Continuity: the same browser/session ZOREAL already knew That the holder is present, or even awake
zoreal.device Approved the login on their enrolled phone: a signature from a key in the phone's secure element, released by a local biometric or passcode unlock ["hwk","user"] Possession of the enrolled device and a local unlock on it That a live face was captured for this login — an unlocked phone in the wrong hands still signs
zoreal.live All of the above plus a fresh face capture this login: a flash-plus-zoom video scored for presentation attacks and screen replay (moire), matched 1:1 against the government document read at enrolment ["hwk","face","user"] A live, real, unique human, verified to be the enrolled person, at the moment of this login — (this is the strongest level)

amr (Authentication Methods References, read with login.AMR()) is the companion claim listing the factors used: hwk a hardware key, user a user-presence/unlock gesture, face a face biometric. zoreal.live is exactly zoreal.device with face added, because a live login is a device approval with a capture on top.

The default is zoreal.device, never zoreal.session: a login that asks for nothing still requires the enrolled phone and a local unlock. Silence has to be explicitly asked for (the SDK's prompt=none), and it succeeds only for a returning holder at a site whose consent they have already given.

The vocabulary is exported as ACRSession, ACRDevice and ACRLive if you prefer a compile-time spelling to a string literal.

When to require which
  • ACRSession — you never require this; it is what a returning holder gets for a low-stakes convenience re-auth when they ask for the silent path.
  • ACRDevice (the default) — a forum, a community, a normal account login. Possession of the enrolled phone plus a local unlock is a high bar already; most sites want exactly this and should pass no acr option at all.
  • ACRLive — a bank onboarding, a high-value transaction, an age-gated purchase, a first login, a "confirm it is really you" step before a sensitive action. Anywhere a fresh, unforgeable proof of the live, right human is worth the few seconds a face capture costs.
Requesting versus verifying — the one rule that matters

Requesting a level and verifying it are two separate steps, and only the second is security:

  1. Request it on the wire, in the frontend, with the SDK's acr_values: 'zoreal.live'. This is what makes the holder's ZOREAL ID app run the face capture before it will approve. It is advisory — it shapes what the holder is asked to do, nothing more. A browser is attacker-controlled; a value that only travels through it proves nothing.
  2. Verify it here, at token exchange, by passing WithRequiredACR. The signed acr claim in the ID token — minted by ZOREAL, not by the browser — is the proof.
login, err := zoreal.Authenticate(r.Context(), code, codeVerifier, nonce,
	zorealoauth2.WithRequiredACR(zorealoauth2.ACRLive), // *VerificationError unless the signed token says so
)

login.ACR()                                 // "zoreal.live" — what actually happened
login.Live()                                // convenience: ACR() == ACRLive
login.SatisfiesACR(zorealoauth2.ACRDevice)  // true — live is stronger than device

An RP that requests zoreal.live on the wire but never passes WithRequiredACR here has checked nothing — it has only asked the holder nicely and then trusted a value it never validated.

How the check behaves

Verification satisfies upward: ACRSession < ACRDevice < ACRLive, so a requirement of ACRDevice accepts a zoreal.live token (the holder gave you more assurance than you demanded). A token whose acr is below the requirement, missing entirely, or outside the vocabulary is refused with a *VerificationError (test with errors.As). An unknown required value — a typo like "zoreal.liveness" — wraps ErrConfiguration (test with errors.Is) instead, because that is a bug in your code, not a bad token, and failing every login silently is worse than saying so.

login, err := zoreal.Authenticate(r.Context(), code, codeVerifier, nonce,
	zorealoauth2.WithRequiredACR(zorealoauth2.ACRLive),
)
if err != nil {
	var verr *zorealoauth2.VerificationError
	switch {
	case errors.As(err, &verr):
		// the token fell short of the floor — refuse this login
	case errors.Is(err, zorealoauth2.ErrConfiguration):
		// the required value is a typo — your bug, not the holder's
	}
}

If you prefer to branch rather than have verification refuse the token, omit the option and inspect the result with the predicate:

login, err := zoreal.Authenticate(r.Context(), code, codeVerifier, nonce)
if err != nil {
	// handle the exchange/verification failure
}
if !login.SatisfiesACR(zorealoauth2.ACRLive) {
	// step the user up, or refuse the sensitive action
}
acr versus the assurance block

Do not confuse login.ACR() with login.Assurance(). acr grades this login event. The assurance block (login.Assurance()) describes the identity behind it — how the person was verified at enrolment (uniqueness basis, verification month, whether chip liveness was proven, the trust tier, the device's key protection). One is about now; the other is about who they are. A high-value flow usually wants both: WithRequiredACR(zorealoauth2.ACRLive) for presence, and the assurance block for the strength of the underlying identity proofing. The block's full schema is below.

What each call does

Call What happens
Authenticate(ctx, code, codeVerifier, nonce, opts...) Exchange + VerifyIDToken, returns a *Login
Exchange(ctx, code, codeVerifier) POST {issuer}/token with your client authentication
VerifyIDToken(ctx, jwt, nonce, opts...) ES256 against {issuer}/jwks, checks iss, aud, exp, nonce when given ("" skips it), and the WithRequiredACR floor
Userinfo(ctx, accessToken) GET {issuer}/userinfo with the Bearer token
Login.Userinfo(ctx) the above, once, memoized; an empty map when there is no access token

Tier A claims read straight off the *LoginSub(), ACR(), AMR(), Assurance(), AgeOver(n), Nationality(). The Tier B/C accessors take a context.Context and read /userinfo on first use: Email(ctx), EmailVerified(ctx), Name(ctx), GivenName(ctx), FamilyName(ctx), Birthdate(ctx), DocumentType(ctx), DocumentNumber(ctx), IssuingCountry(ctx), DocumentExpiresOn(ctx) and Portrait(ctx). See the scope catalogue for which scope grants each, and the error reference for the error types every call returns.

Client authentication

Set exactly one of these on Config; leaving all unset is the fourth method.

Method Config Notes
none nothing Public client: PKCE is the only proof, Tier A scopes only
client_secret_basic ClientSecret The secret travels as HTTP Basic, never as a form field
private_key_jwt PrivateKey or PrivateKeyPEM, optional KeyID The module builds and signs the RFC 7523 assertion: ES256 for a P-256 key, RS256 for an RSA key, 60-second lifetime, fresh single-use jti per request
tls_client_auth TLSCertificate The certificate rides the TLS handshake on every request. The provider accepts the method at registration but does not implement it at the token endpoint yet and answers 501, which surfaces as the *ExchangeError it is

PrivateKeyPEM understands EC PRIVATE KEY, RSA PRIVATE KEY and PKCS #8 PRIVATE KEY blocks. An EC key must be on P-256.

Scopes and claims

Scopes are requested in the frontend (the SDK's scope string, always starting with openid), consented to by the holder, and pre-authorized on your asset. What each grants and where it is delivered:

Scope Claims Delivered in Tier Requires
openid sub, iss, aud, exp, iat, nonce, auth_time, acr, amr, and the assurance block ID token A any client
zoreal.age age_over_13/16/18/21/65 booleans — only the thresholds you registered, never an age or birthdate ID token A any client
zoreal.nationality nationality (ISO 3166-1 alpha-3) ID token A any client
email email, email_verified /userinfo B confidential client + verified domain
profile.name name, given_name, family_name /userinfo B confidential client + verified domain
profile.birthdate birthdate (full ISO 8601 date) /userinfo B confidential client + verified domain
profile.document document_type, document_number, issuing_country, document_expires_on /userinfo B confidential client + verified domain
profile.portrait portrait (the chip's facial image; GDPR Article 9 data) /userinfo C confidential client + verified domain — registrable but not served yet
  • Tier A rides in the ID token and is available to every client, so the no-backend browser button can use it. Read it straight off the *Login: Sub(), ACR(), AMR(), Assurance(), AgeOver(n), Nationality().
  • Tier B and C are personal data, served only from /userinfo to a confidential client on a domain you have verified, and never placed in a browser token. Their accessors take a context.Context and may fetch — Email(ctx), Name(ctx), Birthdate(ctx), DocumentNumber(ctx), and so on.
  • Age thresholds are a fixed set — 13, 16, 18, 21, 65 — that you register on the asset. login.AgeOver(n) returns ok == false for a threshold you did not register (no claim was minted), which is a different fact from over == false.

Error reference

Exchange / Authenticate return an *ExchangeError, which carries the provider's own OAuth error code and reason, verbatim, plus the HTTP status. Test for it with errors.As. What you will actually see:

OAuthError Cause Retryable?
invalid_grant The code is spent — unknown, expired (60s), already used, PKCE mismatch, or the asset's domain verification lapsed mid-flow No. Start a new login; the code cannot be reused
invalid_request Client authentication failed — wrong secret, a bad private_key_jwt assertion, or tls_client_auth (not accepted at /token yet) No. Fix your client configuration
unsupported_grant_type Something other than authorization_code reached /token No. A bug

Errors that surface in the frontend instead, before your backend is involved (from the SDK's onError / onNonOAuthError callbacks), so handle them there:

Where Code Meaning
/pair invalid_scope A scope not on the asset's allowed list, or a Tier B scope from a public client
/pair invalid_request Missing PKCE/nonce, an unverified sector, an unregistered redirect_uri, or an unknown acr_values
/pair login_required prompt=none with no silent session to resume — the expected quiet outcome, not a failure
pairing request_denied The holder declined in their ZOREAL ID app — not an error to alarm on; offer to try again
pairing request_expired The pairing window elapsed, or a required liveness the device could not meet — offer to try again

This package's own error types, and what each means:

Type Test with Means
ErrConfiguration errors.Is You built the client wrong, or passed WithRequiredACR a value outside the vocabulary — a bug in your code, not a bad token
*ExchangeError errors.As The code exchange at /token failed; OAuthError, Description and Status carry the provider's verdict (table above). Status is 0 when the request never completed, and Unwrap then carries the transport error, so errors.Is(err, context.DeadlineExceeded) keeps working
*VerificationError errors.As The ID token did not verify: signature, iss, aud, exp, the nonce, or the WithRequiredACR floor. A JWKS that could not be fetched surfaces here too, because a token that cannot be checked is a token that did not verify
*UserinfoError errors.As The /userinfo read failed. A returning user matched on Sub can survive a tolerated one; a signup that needs the email cannot

Token values never appear in an error message.

The assurance block

login.Assurance() is the ID token's zoreal claim — a map[string]any describing the strength of the identity behind this login (distinct from acr, which grades the login event). Its keys and their value sets:

Key Values Meaning
uniqueness personal_number | document | none The anchor the holder is deduplicated on. personal_number (a national number from the chip) is strongest; none means no reliable anchor
verified_on "YYYY-MM" The month the underlying document was verified. Quantised to a month on purpose — a day-precision date is a cross-site correlator
chip_liveness_proven true | false Whether the passport chip's active-authentication challenge was proven (a genuine chip, not a clone)
trust_tier high | standard high when chip_liveness_proven, else standard
key_protection secure_enclave | strongbox | tee | software How the holder's device key is protected. software means no hardware attestation

A high-value flow usually pairs WithRequiredACR(zorealoauth2.ACRLive) (fresh presence) with a check on the assurance block (identity strength) — e.g. requiring uniqueness == "personal_number" and trust_tier == "high":

a := login.Assurance()
if a["uniqueness"] != "personal_number" || a["trust_tier"] != "high" {
	// not a strongly-anchored identity — step up or refuse the sensitive action
}

A complete example

A full net/http handler, end to end — the shape a real integration takes.

package main

import (
	"errors"
	"log"
	"net/http"
	"os"

	zorealoauth2 "github.com/Bynn-Intelligence/zoreal-oauth2-go"
)

var zoreal *zorealoauth2.Client

func init() {
	var err error
	zoreal, err = zorealoauth2.NewClient(zorealoauth2.Config{
		ClientID:     os.Getenv("ZOREAL_CLIENT_ID"),     // ast_...
		ClientSecret: os.Getenv("ZOREAL_CLIENT_SECRET"), // server-side secret, never in the browser
	})
	if err != nil {
		log.Fatal(err)
	}
}

// handleZorealLogin is where your frontend's ZorealLogin onSuccess POSTs
// { code, code_verifier, nonce } over your own TLS. Protect this route with
// your normal CSRF / same-origin controls, exactly as you would any login
// endpoint — the ZOREAL nonce protects the token, not your route.
func handleZorealLogin(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	login, err := zoreal.Authenticate(ctx,
		r.PostFormValue("code"),
		r.PostFormValue("code_verifier"),
		r.PostFormValue("nonce"),
		// zorealoauth2.WithRequiredACR(zorealoauth2.ACRLive), // for a step-up / high-value login
	)
	if err != nil {
		var exchErr *zorealoauth2.ExchangeError
		var verifyErr *zorealoauth2.VerificationError
		switch {
		case errors.As(err, &exchErr), errors.As(err, &verifyErr):
			// A spent code or a token that did not verify: the login must be restarted.
			log.Printf("ZOREAL login failed: %v", err)
		case errors.Is(err, zorealoauth2.ErrConfiguration):
			// A bug in this server's own configuration, not the holder's problem.
			log.Printf("ZOREAL client misconfigured: %v", err)
		}
		http.Error(w, "sign in failed", http.StatusUnauthorized)
		return
	}

	// Personal data lives at /userinfo, not in the ID token. EmailVerified
	// fetches it. A *UserinfoError is fine for a returning user matched on
	// Sub, and fatal only for a signup that needs the email.
	emailVerified, err := login.EmailVerified(ctx)
	if err != nil {
		var uErr *zorealoauth2.UserinfoError
		if !errors.As(err, &uErr) {
			http.Error(w, "sign in failed", http.StatusUnauthorized)
			return
		}
	}

	// find/create/claim against your own store. Match on (provider, Sub)
	// first; only then, and only for a verified email, claim an existing
	// account. Claim, don't collide.
	user, err := findUserByProvider("zoreal", login.Sub())
	if err != nil {
		http.Error(w, "sign in failed", http.StatusInternalServerError)
		return
	}
	if user == nil {
		email, _ := login.Email(ctx)
		if emailVerified {
			user, _ = findUserByEmail(email)
		}
		if user == nil {
			name, _ := login.Name(ctx)
			user = createUser(email, name)
		}
		linkProvider(user, "zoreal", login.Sub())
	}

	establishSession(w, r, user) // your framework's session, rotated to defend against fixation
	w.WriteHeader(http.StatusNoContent)
}

The findUserByProvider, findUserByEmail, createUser, linkProvider and establishSession helpers are your application's own; the package's job ends at a verified *Login.

Things worth knowing before you integrate

  • The ID token never carries personal data. sub, timing, acr/amr, the assurance block, and — if registered — age_over_* booleans and nationality. Email, names, birthdate and document fields come only from /userinfo, which is why Authenticate alone is not enough for a signup.
  • The access token lives 10 minutes. Read /userinfo while handling the login; do not store the token for later.
  • Sub is pairwise per verified domain. It is the right account key and it is derived from your registered sector: changing your asset's domain rotates every sub you have stored. Plan domain changes as a migration.
  • ES256 only. The provider signs ID tokens with nothing else, and this module refuses other algorithms rather than negotiating.
  • Always pass the nonce through, and protect your own endpoint too. The SDK generates the nonce and gives it to your frontend in onSuccess; passing it here lets the package confirm the ID token was minted for this login rather than substituted. Two things it does not do: it is not your endpoint's CSRF token (protect your login route with your framework's normal CSRF / same-origin defence), and PKCE — not the nonce — is what proves whoever exchanges the code is whoever started the flow.
  • Email is a deliberate choice. It is a Tier B scope precisely because a shared email defeats the unlinkability the pairwise sub provides. Request it because you need it, not because the checkbox is familiar.
  • Pick the client authentication your registration names. A public client configures nothing; a confidential client sets its secret or its private key. private_key_jwt is the strongest of the shipped methods: the key never travels, only a 60-second single-use assertion does.
  • profile.portrait is registrable but not served yet. Login.Portrait exists so the shape is stable, and returns "" until the provider ships the claim.
  • The Issuer must match the token's iss exactly — it is compared, not normalized. Production is https://id.zoreal.com (the default); set Issuer only when you were given a non-production provider to point at.

The ZOREAL OAuth2 library family

Repository Package Role
zoreal-oauth2-react @zoreal/oauth2-react (npm) React frontend: the button, the QR, the polling
zoreal-oauth2-js @zoreal/oauth2-js (npm) Framework-free browser core
zoreal-oauth2-react-native @zoreal/oauth2-react-native (npm) React Native frontend
zoreal-oauth2-node @zoreal/oauth2-node (npm) Node.js backend
zoreal-oauth2-ruby zoreal-oauth2 (RubyGems) Ruby backend
zoreal-oauth2-python zoreal-oauth2 (PyPI) Python backend
zoreal-oauth2-php zoreal/oauth2 (Packagist) PHP backend
zoreal-oauth2-go github.com/Bynn-Intelligence/zoreal-oauth2-go Go backend
zoreal-oauth2-java com.zoreal:oauth2 (Maven Central) JVM backend
zoreal-oauth2-dotnet Zoreal.OAuth2 (NuGet) .NET backend

License

MIT.

Documentation

Overview

Package zorealoauth2 is Login with ZOREAL for Go backends: the relying-party half of the flow that the ZOREAL browser SDKs start in the frontend.

The browser SDK runs the pairing (QR or app link) and hands your frontend an authorization code plus the PKCE code_verifier and the nonce it generated. Your frontend posts all three to your backend, and this package does the rest: the code exchange with your client authentication, ES256 verification of the ID token against the provider's JWKS, and the /userinfo read for personal claims.

Build one *Client at boot and share it; it is safe for concurrent use.

zoreal, err := zorealoauth2.NewClient(zorealoauth2.Config{
	ClientID:     os.Getenv("ZOREAL_CLIENT_ID"), // ast_...
	ClientSecret: os.Getenv("ZOREAL_CLIENT_SECRET"),
})

login, err := zoreal.Authenticate(ctx, code, codeVerifier, nonce)
login.Sub()               // the pairwise subject: your stable user key
login.Email(ctx)          // from /userinfo, when your client has the email scope

Index

Constants

View Source
const (
	ACRSession = "zoreal.session"
	ACRDevice  = "zoreal.device"
	ACRLive    = "zoreal.live"
)

The assurance vocabulary the acr claim speaks, weakest to strongest. Verification accepts equal or stronger: a relying party requiring ACRDevice is satisfied by a zoreal.live token, never the reverse.

View Source
const DefaultIssuer = "https://id.zoreal.com"

DefaultIssuer is the production ZOREAL OpenID Provider. Every endpoint this package calls is relative to the issuer, and the configured value must match the iss inside the tokens exactly — it is compared, not normalized.

View Source
const Version = "0.1.0"

Version is the package version. The module is tagged v<Version>.

Variables

View Source
var ErrConfiguration = errors.New("zorealoauth2: configuration error")

ErrConfiguration is wrapped by every error this package returns for a mistake in YOUR code rather than in a token: a client built without something it cannot work without, or a WithRequiredACR value outside the assurance vocabulary. Test for it with errors.Is(err, zorealoauth2.ErrConfiguration).

Functions

This section is empty.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is the relying-party client: one instance per registered ZOREAL client, safe for concurrent use, so build it once at boot and share it.

func NewClient

func NewClient(cfg Config) (*Client, error)

NewClient validates the configuration and builds a Client. Every error it returns wraps ErrConfiguration.

func (*Client) Authenticate

func (c *Client) Authenticate(ctx context.Context, code, codeVerifier, nonce string, opts ...VerifyOption) (*Login, error)

Authenticate is the whole login, in order: exchange the code (with the PKCE verifier the browser SDK handed over), verify the ID token against the JWKS, check the nonce when the caller has one (pass "" when it does not, and know that without the nonce the backend cannot tell a substituted ID token from the real one), and — when the caller passes WithRequiredACR — refuse a token whose assurance is below it. Returns a Login; personal data is NOT fetched here, because the ID token never carries it and not every caller wants it — Login.Userinfo fetches on first use.

func (*Client) ClientID

func (c *Client) ClientID() string

ClientID returns the configured client_id.

func (*Client) Exchange

func (c *Client) Exchange(ctx context.Context, code, codeVerifier string) (*TokenResponse, error)

Exchange posts the authorization code to {issuer}/token. The verifier is mandatory: PKCE is required for every ZOREAL client, and the browser SDK that generated it hands it to your frontend precisely so your backend can present it here. Client authentication rides along per the configured method. Every failure is an *ExchangeError.

func (*Client) Issuer

func (c *Client) Issuer() string

Issuer returns the configured issuer, without a trailing slash.

func (*Client) Userinfo

func (c *Client) Userinfo(ctx context.Context, accessToken string) (map[string]any, error)

Userinfo reads {issuer}/userinfo with the Bearer access token from the exchange. This is the only place personal claims (email, profile.*) are served, and the access token lives ten minutes, so call it as part of handling the login rather than storing the token for later. Every failure is an *UserinfoError.

func (*Client) VerifyIDToken

func (c *Client) VerifyIDToken(ctx context.Context, idToken, nonce string, opts ...VerifyOption) (map[string]any, error)

VerifyIDToken checks the compact JWT against the provider JWKS: ES256 signature, exact iss, aud == client_id, exp — and, when the caller passes the nonce the SDK generated, the nonce binding, and, with WithRequiredACR, the assurance floor. Returns the claims. There is no RS256 fallback on purpose: ZOREAL signs ID tokens with nothing else, and accepting a second algorithm is how algorithm confusion starts.

type Config

type Config struct {
	// ClientID is the asset token from the ZOREAL dashboard (ast_...). It is
	// also the OAuth client_id — one value, not two.
	ClientID string

	// Issuer defaults to DefaultIssuer. A trailing slash is trimmed.
	Issuer string

	// ClientSecret selects client_secret_basic.
	ClientSecret string

	// PrivateKey selects private_key_jwt: an *ecdsa.PrivateKey on P-256 or
	// an *rsa.PrivateKey.
	PrivateKey crypto.PrivateKey
	// PrivateKeyPEM is the same key as PEM ("EC PRIVATE KEY", "RSA PRIVATE
	// KEY" or PKCS #8 "PRIVATE KEY" blocks are understood). Set it or
	// PrivateKey, not both.
	PrivateKeyPEM []byte
	// KeyID is the kid the client assertion advertises, when your registered
	// JWKS names one. Optional.
	KeyID string

	// TLSCertificate selects tls_client_auth (mutual TLS).
	TLSCertificate *tls.Certificate

	// HTTPClient replaces the built one. When set, Timeout and
	// TLSCertificate are yours to configure on it; setting TLSCertificate
	// alongside a custom HTTPClient is a configuration error rather than a
	// certificate that silently never rides a handshake.
	HTTPClient *http.Client

	// Timeout bounds every request the built HTTP client makes. Defaults to
	// 10 seconds. Ignored when HTTPClient is set.
	Timeout time.Duration

	// JWKSTTL is how long the fetched provider JWKS is held before it is
	// re-fetched. Defaults to 10 minutes, matching the provider's own cache
	// header. An unknown kid invalidates the cache early, once per
	// verification, so a key rotation never strands a login for the TTL.
	JWKSTTL time.Duration
}

Config configures a Client. ClientID is required; everything else has a default or is one of the four client authentication postures:

  • none: leave ClientSecret, PrivateKey/PrivateKeyPEM and TLSCertificate unset. A public client authenticates with PKCE alone and can only ever have been granted Tier A scopes.
  • client_secret_basic: set ClientSecret. The secret travels as HTTP Basic, never as a form field.
  • private_key_jwt: set PrivateKey (an *ecdsa.PrivateKey on P-256, signed ES256, or an *rsa.PrivateKey, signed RS256) or PrivateKeyPEM. KeyID sets the kid header on the assertion when your registered JWKS names one.
  • tls_client_auth: set TLSCertificate. The certificate and its key ride the TLS handshake on every request this client makes. The provider accepts the method at registration but does not implement it at the token endpoint yet and answers 501; that surfaces as the *ExchangeError it is rather than being papered over.

Set exactly one of the three. Setting more than one is a configuration error, because a client that authenticates two ways is a client whose registration this package cannot guess.

type ExchangeError

type ExchangeError struct {
	OAuthError  string
	Description string
	Status      int
	// contains filtered or unexported fields
}

ExchangeError is returned when the code exchange at the token endpoint fails. OAuthError is the RFC 6749 error code and Description the provider's own reason, verbatim: the provider's words are the only signal that says WHY (a consumed code, a PKCE mismatch, a lapsed sector), and rewriting them would hide it. Status is the HTTP status, or 0 when the request never completed; in that case Unwrap carries the transport error, so errors.Is(err, context.DeadlineExceeded) and friends keep working.

Token values never appear in the message.

func (*ExchangeError) Error

func (e *ExchangeError) Error() string

func (*ExchangeError) Unwrap

func (e *ExchangeError) Unwrap() error

type Login

type Login struct {
	// Claims are the verified ID token claims.
	Claims map[string]any
	// IDToken is the raw compact JWT the claims came from.
	IDToken string
	// AccessToken is from the token response and lives ten minutes.
	AccessToken string
	// Scope is the granted scope from the token response.
	Scope string
	// contains filtered or unexported fields
}

Login is one verified login. The ID token claims are already checked when this exists; userinfo is fetched on first use, because the ID token never carries personal data and not every login needs any.

func (*Login) ACR

func (l *Login) ACR() string

ACR is how the login was authenticated: zoreal.live, zoreal.device or zoreal.session. It describes what happened, never what was requested.

func (*Login) AMR

func (l *Login) AMR() []string

AMR is the authentication methods reference, as the provider sent it.

func (*Login) AgeOver

func (l *Login) AgeOver(threshold int) (over, ok bool)

AgeOver reads the age_over_<threshold> boolean the zoreal.age scope delivers. Only the thresholds registered for your client appear, never an age; ok reports whether the claim is present at all, which is a different fact from a false.

func (*Login) Assurance

func (l *Login) Assurance() map[string]any

Assurance is the zoreal claim block: uniqueness basis, verification month, chip liveness, trust tier, key protection.

func (*Login) Birthdate

func (l *Login) Birthdate(ctx context.Context) (string, error)

Birthdate is ISO 8601, from the profile.birthdate scope.

func (*Login) DocumentExpiresOn

func (l *Login) DocumentExpiresOn(ctx context.Context) (string, error)

DocumentExpiresOn is ISO 8601, from the profile.document scope.

func (*Login) DocumentNumber

func (l *Login) DocumentNumber(ctx context.Context) (string, error)

DocumentNumber is from the profile.document scope.

func (*Login) DocumentType

func (l *Login) DocumentType(ctx context.Context) (string, error)

DocumentType is from the profile.document scope.

func (*Login) Email

func (l *Login) Email(ctx context.Context) (string, error)

Email is the address the holder verified with ZOREAL. From /userinfo, with the email scope.

func (*Login) EmailVerified

func (l *Login) EmailVerified(ctx context.Context) (bool, error)

EmailVerified reports whether the provider has verified the email.

func (*Login) FamilyName

func (l *Login) FamilyName(ctx context.Context) (string, error)

FamilyName is from /userinfo, profile.name scope.

func (*Login) GivenName

func (l *Login) GivenName(ctx context.Context) (string, error)

GivenName is from /userinfo, profile.name scope.

func (*Login) IssuingCountry

func (l *Login) IssuingCountry(ctx context.Context) (string, error)

IssuingCountry is from the profile.document scope.

func (*Login) Live added in v0.1.3

func (l *Login) Live() bool

Live reports whether a fresh liveness capture backed this login — the convenience spelling of ACR() == ACRLive. For enforcement, pass WithRequiredACR to Authenticate and let verification refuse the token instead of checking after the fact.

func (*Login) Name

func (l *Login) Name(ctx context.Context) (string, error)

Name is the document display name. From /userinfo, profile.name scope.

func (*Login) Nationality

func (l *Login) Nationality() string

Nationality is the zoreal.nationality scope's claim: ISO 3166-1 alpha-3, read from the document chip. Empty without the scope.

func (*Login) Portrait

func (l *Login) Portrait(ctx context.Context) (string, error)

Portrait is the profile.portrait scope's claim. The scope is registrable, but the provider does not serve the claim yet, so this returns "" today; the accessor exists so the shape is stable when it ships.

func (*Login) SatisfiesACR added in v0.1.3

func (l *Login) SatisfiesACR(required string) bool

SatisfiesACR reports whether this login's assurance is required or stronger, on the vocabulary's ordering (ACRSession < ACRDevice < ACRLive). A value outside the vocabulary, on either side, satisfies nothing.

func (*Login) Sub

func (l *Login) Sub() string

Sub is the pairwise subject: stable for your verified domain, meaningless to anyone else. This is the value to key accounts on — and it is derived from YOUR registered sector, so changing your asset's domain rotates every sub you have stored.

func (*Login) Userinfo

func (l *Login) Userinfo(ctx context.Context) (map[string]any, error)

Userinfo returns the personal claims from /userinfo, fetched once and memoized. It returns an *UserinfoError when the endpoint refuses — treat it as non-fatal if your flow can continue without personal data, as a returning user matched on Sub can. A failed fetch is not memoized, so a later call may retry. Returns an empty map, and never fetches, when the exchange carried no access token.

type TokenResponse

type TokenResponse struct {
	IDToken     string `json:"id_token"`
	AccessToken string `json:"access_token"`
	TokenType   string `json:"token_type"`
	ExpiresIn   int    `json:"expires_in"`
	Scope       string `json:"scope"`
}

TokenResponse is the provider's answer to a successful code exchange. The access token lives ten minutes: read /userinfo while handling the login, do not store it for later.

type UserinfoError

type UserinfoError struct {
	Description string
	Status      int
	// contains filtered or unexported fields
}

UserinfoError is returned when /userinfo answered with anything but the claims. Callers that can live without personal data (a returning user matched by sub) may treat it as non-fatal and continue; callers that need the email should not. Status is the HTTP status, or 0 when the request never completed.

func (*UserinfoError) Error

func (e *UserinfoError) Error() string

func (*UserinfoError) Unwrap

func (e *UserinfoError) Unwrap() error

type VerificationError

type VerificationError struct {
	Reason string
	// contains filtered or unexported fields
}

VerificationError is returned when the ID token did not verify: bad signature, wrong issuer or audience, expired, an algorithm that is not ES256, a key the provider JWKS does not hold, a nonce that was not the one this login started with, or an acr below the assurance floor the caller required. A JWKS that could not be fetched surfaces here too, because a token that cannot be checked is a token that did not verify.

func (*VerificationError) Error

func (e *VerificationError) Error() string

func (*VerificationError) Unwrap

func (e *VerificationError) Unwrap() error

type VerifyOption added in v0.1.3

type VerifyOption func(*verifyConfig)

VerifyOption tightens what Authenticate and VerifyIDToken require of the ID token beyond the always-on checks.

func WithRequiredACR added in v0.1.3

func WithRequiredACR(required string) VerifyOption

WithRequiredACR sets the assurance floor: the token's acr claim must be the required value or a stronger one (ACRSession < ACRDevice < ACRLive), or verification refuses the token with a *VerificationError. A required value outside the vocabulary is a typo in YOUR code, not a bad token, and wraps ErrConfiguration instead of failing every login.

REQUESTING an assurance on the wire (the browser SDK's acr_values) is advisory; the signed acr claim is the proof, and this option is where a relying party that asked for a liveness check verifies it actually happened. An RP that requires zoreal.live and never passes this option has checked nothing.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL