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
- Variables
- type Client
- func (c *Client) Authenticate(ctx context.Context, code, codeVerifier, nonce string, opts ...VerifyOption) (*Login, error)
- func (c *Client) ClientID() string
- func (c *Client) Exchange(ctx context.Context, code, codeVerifier string) (*TokenResponse, error)
- func (c *Client) Issuer() string
- func (c *Client) Userinfo(ctx context.Context, accessToken string) (map[string]any, error)
- func (c *Client) VerifyIDToken(ctx context.Context, idToken, nonce string, opts ...VerifyOption) (map[string]any, error)
- type Config
- type ExchangeError
- type Login
- func (l *Login) ACR() string
- func (l *Login) AMR() []string
- func (l *Login) AgeOver(threshold int) (over, ok bool)
- func (l *Login) Assurance() map[string]any
- func (l *Login) Birthdate(ctx context.Context) (string, error)
- func (l *Login) DocumentExpiresOn(ctx context.Context) (string, error)
- func (l *Login) DocumentNumber(ctx context.Context) (string, error)
- func (l *Login) DocumentType(ctx context.Context) (string, error)
- func (l *Login) Email(ctx context.Context) (string, error)
- func (l *Login) EmailVerified(ctx context.Context) (bool, error)
- func (l *Login) FamilyName(ctx context.Context) (string, error)
- func (l *Login) GivenName(ctx context.Context) (string, error)
- func (l *Login) IssuingCountry(ctx context.Context) (string, error)
- func (l *Login) Live() bool
- func (l *Login) Name(ctx context.Context) (string, error)
- func (l *Login) Nationality() string
- func (l *Login) Portrait(ctx context.Context) (string, error)
- func (l *Login) SatisfiesACR(required string) bool
- func (l *Login) Sub() string
- func (l *Login) Userinfo(ctx context.Context) (map[string]any, error)
- type TokenResponse
- type UserinfoError
- type VerificationError
- type VerifyOption
Constants ¶
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.
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.
const Version = "0.1.0"
Version is the package version. The module is tagged v<Version>.
Variables ¶
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 ¶
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) Exchange ¶
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) Userinfo ¶
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 ¶
ACR is how the login was authenticated: zoreal.live, zoreal.device or zoreal.session. It describes what happened, never what was requested.
func (*Login) AgeOver ¶
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 ¶
Assurance is the zoreal claim block: uniqueness basis, verification month, chip liveness, trust tier, key protection.
func (*Login) DocumentExpiresOn ¶
DocumentExpiresOn is ISO 8601, from the profile.document scope.
func (*Login) DocumentNumber ¶
DocumentNumber is from the profile.document scope.
func (*Login) DocumentType ¶
DocumentType is from the profile.document scope.
func (*Login) Email ¶
Email is the address the holder verified with ZOREAL. From /userinfo, with the email scope.
func (*Login) EmailVerified ¶
EmailVerified reports whether the provider has verified the email.
func (*Login) FamilyName ¶
FamilyName is from /userinfo, profile.name scope.
func (*Login) IssuingCountry ¶
IssuingCountry is from the profile.document scope.
func (*Login) Live ¶ added in v0.1.3
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) Nationality ¶
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 ¶
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
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 ¶
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 ¶
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.