diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4d4be4d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +All notable changes to this SDK are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). A Go module carries no +version in `go.mod`; each version is released as a `vX.Y.Z` tag. + +## [1.0.0] - Unreleased + +**BREAKING.** Requires API contract 2.0.0. + +### Added +- `Faces.CreateLivenessSession(ctx, collectionID)` returns a `LivenessSession` + (`SessionID`, ordered `Challenges` of type `blink`, `turn_left` or + `turn_right`, `ExpiresAt`). +- `Faces.CompleteLivenessSession(ctx, collectionID, sessionID, frames, mirrored)` + returns a `LivenessSessionResult`: the active liveness verdict, `Steps` + (type and passed) and, on a pass, `LivenessToken` and `LivenessTokenExpiresAt`. + It is retried only on 429, never on a network error or a 5xx such as 503, + since the session is used up by then. +- The error code `LIVENESS_SESSION_INVALID` (422), returned as `*APIError`. + +### Changed +- Pinned API contract 2.0.0 (`ContractVersion`, `CONTRACT_VERSION`, + `contract/openapi-2.0.0.json`). + +### Removed +- **BREAKING:** `ActiveLivenessResult.LivenessToken` and + `ActiveLivenessResult.LivenessTokenExpiresAt`. The stateless active liveness + check no longer issues a token. + +### Migration +`ActiveLiveness` no longer returns a token. Create a session, show its +challenges, complete it with the frames: + +```go +session, err := client.Faces.CreateLivenessSession(ctx, collID) +// show session.Challenges in order and capture frames while the person performs them +result, err := client.Faces.CompleteLivenessSession(ctx, collID, session.SessionID, frames, mirrored) +// enrol with RegisterInput{LivenessToken: result.LivenessToken} when result.IsLive +``` + +Keep `ActiveLiveness` only where a verdict without a token is enough. diff --git a/CONTRACT_VERSION b/CONTRACT_VERSION index 3eefcb9..227cea2 100644 --- a/CONTRACT_VERSION +++ b/CONTRACT_VERSION @@ -1 +1 @@ -1.0.0 +2.0.0 diff --git a/README.md b/README.md index 78d1c1f..64dc7f7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ go get github.com/livexface/livexface-go Requires Go 1.22 or later. -Validated against API contract 1.0.0 (`/openapi.json` `info.version`), exposed as `livexface.ContractVersion`. The test suite calls every client method against the pinned contract in `contract/` and fails if a method or path is missing from it or a required field is not sent; to move to a new contract, copy the release asset `openapi-.json` into `contract/` and update `CONTRACT_VERSION` and the `ContractVersion` constant. +This is the 1.0.0 release (see [CHANGELOG.md](CHANGELOG.md)); a Go module has no version in `go.mod`, so the version is the `v1.0.0` release tag. + +Validated against API contract 2.0.0 (`/openapi.json` `info.version`), exposed as `livexface.ContractVersion`. The test suite calls every client method against the pinned contract in `contract/` and fails if a method or path is missing from it or a required field is not sent; to move to a new contract, copy the release asset `openapi-.json` into `contract/` and update `CONTRACT_VERSION` and the `ContractVersion` constant. ## Quick Start @@ -93,7 +95,9 @@ client := livexface.New( | `Faces.Verify(ctx, collectionID, VerifyInput)` | 1:1 verification against a stored face | | `Faces.Identify(ctx, collectionID, IdentifyInput)` | 1:N search — return top-K matches | | `Faces.Liveness(ctx, collectionID, image, filename)` | Passive liveness detection | -| `Faces.ActiveLiveness(ctx, collectionID, []LivenessFrame)` | Active liveness over 5–50 frames; returns a liveness token when passed | +| `Faces.ActiveLiveness(ctx, collectionID, []LivenessFrame)` | Stateless active liveness over 5–50 frames; a verdict only, no token | +| `Faces.CreateLivenessSession(ctx, collectionID)` | Start a liveness session; returns the steps to perform and its expiry | +| `Faces.CompleteLivenessSession(ctx, collectionID, sessionID, []LivenessFrame, mirrored)` | Submit 5–50 frames for a session; returns the verdict, per-step results and, on a pass, a liveness token | | `Faces.Compare(ctx, CompareInput)` | Compare two images without enrolling | | `Faces.BatchRegister(ctx, collectionID, []BatchItem, ...CallOption)` | Enroll up to 20 faces in one request | | `Faces.BatchRegisterAsync(ctx, collectionID, []BatchItem, ...CallOption)` | Queue up to 100 faces; returns a job to poll with `GetBatchJob` | @@ -101,32 +105,66 @@ client := livexface.New( ### Liveness-gated enrolment Collections that require liveness reject enrolment without a token from a -passed active liveness check (`LIVENESS_TOKEN_REQUIRED`). Tokens are -single-use, expire after 5 minutes, and are bound to the organization and -collection. A token that is expired, reused or for another collection returns -`LIVENESS_TOKEN_INVALID`; a token whose face does not match the enrolled image -returns `LIVENESS_FACE_MISMATCH`. +passed liveness session (`LIVENESS_TOKEN_REQUIRED`). The server picks the steps +of each session (one blink and one or two head turns, in random order), so +frames prepared in advance from a photo do not pass. `ActiveLiveness` is a +stateless verdict and issues no token. + +1. Create a session. It expires 60 seconds later by default. +2. Show its challenges to the person in order. `turn_left` and `turn_right` + are the person's own left and right. +3. Capture 5–50 frames while they perform the steps. +4. Complete the session with the frames, once. Pass `mirrored: true` when the + frames are horizontally mirrored, as a selfie preview is. +5. Enrol with the token. ```go +session, err := client.Faces.CreateLivenessSession(ctx, collID) +if err != nil { + log.Fatal(err) +} +prompts := map[livexface.LivenessStepType]string{ + livexface.LivenessStepBlink: "Blink", + livexface.LivenessStepTurnLeft: "Turn your head to your left", + livexface.LivenessStepTurnRight: "Turn your head to your right", +} +for _, ch := range session.Challenges { + showPrompt(prompts[ch.Type]) // your UI, while the camera captures frames +} + frames := make([]livexface.LivenessFrame, 0, len(jpegFrames)) -for _, f := range jpegFrames { // at least 5 frames captured while the user blinks and turns +for _, f := range jpegFrames { frames = append(frames, livexface.LivenessFrame{Image: f}) } -check, err := client.Faces.ActiveLiveness(ctx, collID, frames) +result, err := client.Faces.CompleteLivenessSession(ctx, collID, session.SessionID, frames, false) if err != nil { - log.Fatal(err) + log.Fatal(err) // on LIVENESS_SESSION_INVALID or SERVICE_BUSY, create a new session } -if !check.IsLive { +if !result.IsLive { + for _, st := range result.Steps { + fmt.Printf("%s passed=%v\n", st.Type, st.Passed) + } log.Fatal("liveness check failed") } face, err := client.Faces.Register(ctx, collID, livexface.RegisterInput{ ExternalID: "user_42", Image: jpegFrames[0], - LivenessToken: check.LivenessToken, + LivenessToken: result.LivenessToken, }) ``` +A session is judged at most once. Any submission uses it up except one with +fewer than 5 frames (`IMAGE_REQUIRED`, 400), which you may resubmit. A session +that is unknown, expired, already submitted or for another collection fails with +`LIVENESS_SESSION_INVALID` (422); create a new session. A pass whose token the +server could not store has `IsLive` true and an empty `LivenessToken`. + +Liveness tokens are single-use, expire after 5 minutes and are bound to the +organization and collection. A token that is expired, reused or for another +collection returns `LIVENESS_TOKEN_INVALID`; a token whose face does not match +the enrolled image returns `LIVENESS_FACE_MISMATCH`. + `BatchItem` has the same optional `LivenessToken` field for `BatchRegister` and `BatchRegisterAsync`. @@ -157,6 +195,10 @@ if err != nil { } ``` +`CompleteLivenessSession` fails with `LIVENESS_SESSION_INVALID` (HTTP 422) +when the session is unknown, expired, already submitted or bound to another +collection. Check `apiErr.Code` and start a new session. + ## Idempotent requests `Register`, `BatchRegister` and `BatchRegisterAsync` accept @@ -187,6 +229,11 @@ first: and for calls with an idempotency key, never for other POSTs such as `Identify` or `Verify`. - Other 4xx responses are never retried. +- `CompleteLivenessSession` is retried only on 429, which the server sends + before it touches the session. It is not retried on a network error or any + 5xx, including 503 `SERVICE_BUSY`: the server uses the session up before the + engine can answer busy, so a retry would only fail with + `LIVENESS_SESSION_INVALID` and hide the cause. On a 503, create a new session. - The enrolment methods send the same idempotency key on every attempt, and generate one when you pass none. diff --git a/client.go b/client.go index 70df154..33b702b 100644 --- a/client.go +++ b/client.go @@ -23,7 +23,7 @@ const defaultBaseURL = "https://api.livexface.com/api/v1" // ContractVersion is the API contract version (info.version of /openapi.json) // this SDK release is validated against. The contract is pinned in // contract/openapi-.json and checked by the test suite. -const ContractVersion = "1.0.0" +const ContractVersion = "2.0.0" // ─── Error ──────────────────────────────────────────────────────────────────── @@ -96,6 +96,8 @@ func WithHTTPClient(hc *http.Client) Option { // - network errors and other 5xx responses are retried only for GET, PATCH // and DELETE requests and for requests that carry an idempotency key; // - other 4xx responses are never retried; +// - CompleteLivenessSession is retried only on 429: the server uses the +// session up before it can answer 503; // - Register, BatchRegister and BatchRegisterAsync send one idempotency key // on every attempt of a call, generating one when the caller gave none. // @@ -195,6 +197,13 @@ type apiEnvelope struct { // response it returns a *APIError. body is sent whole on every attempt; a // non-empty idempotencyKey is sent as the Idempotency-Key header. func (c *Client) do(ctx context.Context, method, path string, body []byte, contentType, idempotencyKey string) (json.RawMessage, error) { + return c.doRetry(ctx, method, path, body, contentType, idempotencyKey, true) +} + +// doRetry is do with a choice about 503: retryBusy false leaves a 503 to the +// rules for other 5xx, for a request the server may have acted on before +// answering 503 (completing a liveness session uses the session up first). +func (c *Client) doRetry(ctx context.Context, method, path string, body []byte, contentType, idempotencyKey string, retryBusy bool) (json.RawMessage, error) { for attempt := 0; ; attempt++ { req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body)) if err != nil { @@ -214,7 +223,7 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte, conte } safe := idempotencyKey != "" || method == http.MethodGet || method == http.MethodPatch || method == http.MethodDelete - delay, ok := c.retryDelay(err, attempt, safe) + delay, ok := c.retryDelay(err, attempt, safe, retryBusy) if !ok { return nil, err } @@ -288,11 +297,12 @@ func (c *Client) send(req *http.Request) (json.RawMessage, error) { // retryDelay reports whether a failed attempt may be retried and how long to // wait first. safe means repeating the request is harmless: a read, a // metadata update, a deletion or a request with an idempotency key. -func (c *Client) retryDelay(err error, attempt int, safe bool) (time.Duration, bool) { +// retryBusy false treats 503 like any other 5xx. +func (c *Client) retryDelay(err error, attempt int, safe, retryBusy bool) (time.Duration, bool) { var apiErr *APIError if errors.As(err, &apiErr) { switch s := apiErr.StatusCode; { - case s == http.StatusTooManyRequests || s == http.StatusServiceUnavailable: + case s == http.StatusTooManyRequests || (s == http.StatusServiceUnavailable && retryBusy): if ra := apiErr.RetryAfter; ra != nil { if float64(*ra) >= c.maxRetryDelay.Seconds() { return c.maxRetryDelay, true @@ -570,22 +580,28 @@ func (r *FacesResource) Liveness(ctx context.Context, collectionID string, image return &result, nil } -// ActiveLiveness runs an active liveness check (blink, head turn and passive -// anti-spoofing) over a sequence of 5 to 50 frames. When the check passes, the -// result carries a single-use LivenessToken (valid for 5 minutes, bound to the -// organization and collection) that can be passed to Register or BatchItem to -// enrol into a collection that requires liveness. -func (r *FacesResource) ActiveLiveness(ctx context.Context, collectionID string, frames []LivenessFrame) (*ActiveLivenessResult, error) { - var buf bytes.Buffer - mw := multipart.NewWriter(&buf) - +// writeFrames adds frames as the parts frame_0, frame_1, …. +func writeFrames(mw *multipart.Writer, frames []LivenessFrame) error { for i, frame := range frames { field := fmt.Sprintf("frame_%d", i) fname := imageFilename(frame.Filename, field+".jpg") if err := writeImagePart(mw, field, fname, frame.Image); err != nil { - return nil, fmt.Errorf("livexface: write %s: %w", field, err) + return fmt.Errorf("livexface: write %s: %w", field, err) } } + return nil +} + +// ActiveLiveness runs a stateless active liveness check (blink, head turn and +// passive anti-spoofing) over a sequence of 5 to 50 frames. It returns a +// verdict only and issues no liveness token; to enrol into a collection that +// requires liveness, use CreateLivenessSession and CompleteLivenessSession. +func (r *FacesResource) ActiveLiveness(ctx context.Context, collectionID string, frames []LivenessFrame) (*ActiveLivenessResult, error) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + if err := writeFrames(mw, frames); err != nil { + return nil, err + } mw.Close() data, err := r.c.do(ctx, http.MethodPost, @@ -601,6 +617,60 @@ func (r *FacesResource) ActiveLiveness(ctx context.Context, collectionID string, return &result, nil } +// CreateLivenessSession starts a liveness session bound to the collection. +// The server picks the steps (one blink and one or two head turns, in random +// order); show them to the person in order, capture frames while they perform +// them, and pass the frames to CompleteLivenessSession before ExpiresAt +// (60 seconds by default). +func (r *FacesResource) CreateLivenessSession(ctx context.Context, collectionID string) (*LivenessSession, error) { + data, err := r.c.do(ctx, http.MethodPost, + "/collections/"+collectionID+"/liveness-sessions", nil, "", "") + if err != nil { + return nil, err + } + var session LivenessSession + if err := json.Unmarshal(data, &session); err != nil { + return nil, fmt.Errorf("livexface: decode liveness session: %w", err) + } + return &session, nil +} + +// CompleteLivenessSession submits 5 to 50 frames for a liveness session. Set +// mirrored when the frames are horizontally mirrored, as a selfie preview is. +// The session passes only when every step appears in the frames in order; a +// pass carries a single-use LivenessToken (valid for 5 minutes, bound to the +// collection) for Register or BatchItem. +// +// A session is judged at most once: any submission other than one with fewer +// than 5 frames (IMAGE_REQUIRED) uses it up. A reused, expired or unknown +// session fails with LIVENESS_SESSION_INVALID (422). The call is therefore +// never retried automatically except on 429, which the server answers before +// touching the session; after a network error or a 5xx such as SERVICE_BUSY, +// create a new session. +func (r *FacesResource) CompleteLivenessSession(ctx context.Context, collectionID, sessionID string, frames []LivenessFrame, mirrored bool) (*LivenessSessionResult, error) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + if err := writeFrames(mw, frames); err != nil { + return nil, err + } + if err := mw.WriteField("mirrored", strconv.FormatBool(mirrored)); err != nil { + return nil, err + } + mw.Close() + + data, err := r.c.doRetry(ctx, http.MethodPost, + "/collections/"+collectionID+"/liveness-sessions/"+sessionID, + buf.Bytes(), mw.FormDataContentType(), "", false) + if err != nil { + return nil, err + } + var result LivenessSessionResult + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("livexface: decode liveness session result: %w", err) + } + return &result, nil +} + // Compare performs a pairwise comparison of two face images without enrolling // either into a collection. func (r *FacesResource) Compare(ctx context.Context, input CompareInput) (*VerifyResult, error) { diff --git a/client_test.go b/client_test.go index c70c877..542f7b5 100644 --- a/client_test.go +++ b/client_test.go @@ -8,7 +8,9 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "regexp" + "strings" "sync" "testing" "time" @@ -27,7 +29,7 @@ func newTestServer(t *testing.T, data string) (*Client, *capturedRequest) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got.Method = r.Method got.Path = r.URL.Path - if err := r.ParseMultipartForm(32 << 20); err != nil { + if err := r.ParseMultipartForm(32 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { t.Errorf("parse multipart: %v", err) } got.Files = map[string]int{} @@ -62,9 +64,7 @@ func TestActiveLivenessPassed(t *testing.T) { "blink": {"passed": true, "available": true, "blinkCount": 2}, "headTurn": {"passed": null, "available": false}, "passiveAntispoof": {"passed": true, "available": true, "score": 0.97} - }, - "livenessToken": "lvt_abc", - "livenessTokenExpiresAt": "2026-09-28T10:05:00Z" + } }`) res, err := c.Faces.ActiveLiveness(context.Background(), "col_1", frames(6)) @@ -82,13 +82,9 @@ func TestActiveLivenessPassed(t *testing.T) { if len(got.Files) != 6 { t.Errorf("got %d file parts, want 6", len(got.Files)) } - if !res.IsLive || res.LivenessToken != "lvt_abc" || res.FramesWithFace != 6 { + if !res.IsLive || res.FramesWithFace != 6 { t.Errorf("unexpected result: %+v", res) } - want := time.Date(2026, 9, 28, 10, 5, 0, 0, time.UTC) - if res.LivenessTokenExpiresAt == nil || !res.LivenessTokenExpiresAt.Equal(want) { - t.Errorf("expiresAt = %v, want %v", res.LivenessTokenExpiresAt, want) - } if b := res.Challenges.Blink; b.Passed == nil || !*b.Passed || !b.Available || b.Metrics["blinkCount"] != float64(2) { t.Errorf("blink = %+v", b) } @@ -97,7 +93,7 @@ func TestActiveLivenessPassed(t *testing.T) { } } -func TestActiveLivenessFailedHasNoToken(t *testing.T) { +func TestActiveLivenessFailed(t *testing.T) { c, _ := newTestServer(t, `{ "isLive": false, "overallScore": 0.21, "framesAnalyzed": 5, "framesWithFace": 5, "challenges": { @@ -111,7 +107,7 @@ func TestActiveLivenessFailedHasNoToken(t *testing.T) { if err != nil { t.Fatal(err) } - if res.IsLive || res.LivenessToken != "" || res.LivenessTokenExpiresAt != nil { + if res.IsLive { t.Errorf("unexpected result: %+v", res) } if p := res.Challenges.Blink.Passed; p == nil || *p { @@ -119,6 +115,178 @@ func TestActiveLivenessFailedHasNoToken(t *testing.T) { } } +// The stateless check issues no token since contract 2.0.0; only a completed +// liveness session does. +func TestActiveLivenessResultHasNoToken(t *testing.T) { + typ := reflect.TypeOf(ActiveLivenessResult{}) + for i := 0; i < typ.NumField(); i++ { + if f := typ.Field(i); strings.Contains(f.Name, "Token") || strings.Contains(f.Tag.Get("json"), "Token") { + t.Errorf("ActiveLivenessResult has token field %s", f.Name) + } + } +} + +// ─── Liveness sessions ──────────────────────────────────────────────────────── + +func TestCreateLivenessSession(t *testing.T) { + c, got := newTestServer(t, `{ + "sessionId": "lvs_abc", + "challenges": [{"type": "turn_left"}, {"type": "blink"}, {"type": "turn_right"}], + "expiresAt": "2026-10-03T10:01:00Z" + }`) + + s, err := c.Faces.CreateLivenessSession(context.Background(), "col_1") + if err != nil { + t.Fatal(err) + } + if got.Method != http.MethodPost || got.Path != "/api/v1/collections/col_1/liveness-sessions" { + t.Errorf("request = %s %s", got.Method, got.Path) + } + if len(got.Files)+len(got.Fields) != 0 { + t.Errorf("sent a body: files %v, fields %v", got.Files, got.Fields) + } + want := []LivenessStepType{LivenessStepTurnLeft, LivenessStepBlink, LivenessStepTurnRight} + if s.SessionID != "lvs_abc" || len(s.Challenges) != len(want) { + t.Fatalf("session = %+v", s) + } + for i, ch := range s.Challenges { + if ch.Type != want[i] { + t.Errorf("challenge %d = %q, want %q", i, ch.Type, want[i]) + } + } + if !s.ExpiresAt.Equal(time.Date(2026, 10, 3, 10, 1, 0, 0, time.UTC)) { + t.Errorf("expiresAt = %v", s.ExpiresAt) + } +} + +func TestCompleteLivenessSessionPassed(t *testing.T) { + c, got := newTestServer(t, `{ + "isLive": true, "overallScore": 0.91, "framesAnalyzed": 25, "framesWithFace": 25, + "challenges": { + "blink": {"passed": true, "available": true}, + "headTurn": {"passed": true, "available": true}, + "passiveAntispoof": {"passed": true, "available": true, "score": 0.95} + }, + "steps": [{"type": "turn_left", "passed": true}, {"type": "blink", "passed": true}], + "livenessToken": "lvt_abc", + "livenessTokenExpiresAt": "2026-10-03T10:06:00Z" + }`) + + res, err := c.Faces.CompleteLivenessSession(context.Background(), "col_1", "lvs_abc", frames(25), true) + if err != nil { + t.Fatal(err) + } + if got.Method != http.MethodPost || got.Path != "/api/v1/collections/col_1/liveness-sessions/lvs_abc" { + t.Errorf("request = %s %s", got.Method, got.Path) + } + for i := 0; i < 25; i++ { + if got.Files[fmt.Sprintf("frame_%d", i)] != 1 { + t.Errorf("missing part frame_%d (files: %v)", i, got.Files) + } + } + if len(got.Files) != 25 || got.Fields["mirrored"] != "true" { + t.Errorf("files %d, mirrored %q; want 25 and true", len(got.Files), got.Fields["mirrored"]) + } + if !res.IsLive || res.FramesAnalyzed != 25 || res.LivenessToken != "lvt_abc" { + t.Errorf("unexpected result: %+v", res) + } + if want := time.Date(2026, 10, 3, 10, 6, 0, 0, time.UTC); res.LivenessTokenExpiresAt == nil || !res.LivenessTokenExpiresAt.Equal(want) { + t.Errorf("token expiresAt = %v, want %v", res.LivenessTokenExpiresAt, want) + } + wantSteps := []LivenessStep{{LivenessStepTurnLeft, true}, {LivenessStepBlink, true}} + if !reflect.DeepEqual(res.Steps, wantSteps) { + t.Errorf("steps = %+v, want %+v", res.Steps, wantSteps) + } + if p := res.Challenges.HeadTurn.Passed; p == nil || !*p { + t.Errorf("headTurn.passed = %v, want true", p) + } +} + +func TestCompleteLivenessSessionFailedHasNoToken(t *testing.T) { + c, got := newTestServer(t, `{ + "isLive": false, "overallScore": 0.4, "framesAnalyzed": 20, "framesWithFace": 20, + "challenges": { + "blink": {"passed": true, "available": true}, + "headTurn": {"passed": true, "available": true}, + "passiveAntispoof": {"passed": true, "available": true} + }, + "steps": [{"type": "blink", "passed": true}, {"type": "turn_right", "passed": false}] + }`) + + res, err := c.Faces.CompleteLivenessSession(context.Background(), "col_1", "lvs_abc", frames(20), false) + if err != nil { + t.Fatal(err) + } + if got.Fields["mirrored"] != "false" { + t.Errorf("mirrored = %q, want false", got.Fields["mirrored"]) + } + if res.IsLive || res.LivenessToken != "" || res.LivenessTokenExpiresAt != nil { + t.Errorf("unexpected result: %+v", res) + } + if len(res.Steps) != 2 || res.Steps[1] != (LivenessStep{LivenessStepTurnRight, false}) { + t.Errorf("steps = %+v", res.Steps) + } +} + +func TestCompleteLivenessSessionInvalid(t *testing.T) { + c, s := scripted(t, []Option{WithRetries(2)}, + reply(http.StatusUnprocessableEntity, apiError(422, "LIVENESS_SESSION_INVALID"))) + + _, err := c.Faces.CompleteLivenessSession(context.Background(), "col_1", "lvs_used", frames(5), false) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("want *APIError, got %v", err) + } + if apiErr.Code != "LIVENESS_SESSION_INVALID" || apiErr.StatusCode != 422 || apiErr.RequestID != "req_422" { + t.Errorf("error = %+v", apiErr) + } + if n := len(s.requests()); n != 1 { + t.Errorf("%d requests, want 1", n) + } +} + +// A retry would find the session used up, so completion is not repeated after +// a dropped connection or a 503 (the server consumes the session first), but +// is after a 429, which the server sends before touching the session. +func TestCompleteLivenessSessionRetries(t *testing.T) { + complete := func(c *Client) error { + _, err := c.Faces.CompleteLivenessSession(context.Background(), "col_1", "lvs_abc", frames(5), false) + return err + } + t.Run("not retried on a dropped connection", func(t *testing.T) { + c, s := scripted(t, []Option{WithRetries(3)}, drop) + var apiErr *APIError + if err := complete(c); err == nil || errors.As(err, &apiErr) { + t.Fatalf("want a network error, got %v", err) + } + if keys := s.requests(); len(keys) != 1 || keys[0] != "" { + t.Errorf("requests = %q, want one without a key", keys) + } + }) + t.Run("not retried on 503", func(t *testing.T) { + c, s := scripted(t, []Option{WithRetries(3)}, + reply(http.StatusServiceUnavailable, apiError(503, "SERVICE_BUSY"), "Retry-After", "1")) + var apiErr *APIError + if err := complete(c); !errors.As(err, &apiErr) || apiErr.Code != "SERVICE_BUSY" { + t.Fatalf("want SERVICE_BUSY, got %v", err) + } + if n := len(s.requests()); n != 1 { + t.Errorf("%d requests, want 1", n) + } + }) + t.Run("retried on 429", func(t *testing.T) { + c, s := scripted(t, []Option{WithRetries(3)}, + reply(http.StatusTooManyRequests, apiError(429, "RATE_LIMIT_EXCEEDED"), "Retry-After", "1"), + reply(http.StatusOK, `{"success":true,"data":{"isLive":false,"steps":[]}}`)) + if err := complete(c); err != nil { + t.Fatal(err) + } + if n := len(s.requests()); n != 2 { + t.Errorf("%d requests, want 2", n) + } + }) +} + func TestRegisterLivenessToken(t *testing.T) { face := `{"id":"face_1","externalId":"u1"}` for _, tc := range []struct { diff --git a/contract/openapi-1.0.0.json b/contract/openapi-2.0.0.json similarity index 91% rename from contract/openapi-1.0.0.json rename to contract/openapi-2.0.0.json index 3743d0d..7cdb4e6 100644 --- a/contract/openapi-1.0.0.json +++ b/contract/openapi-2.0.0.json @@ -1,37 +1,6 @@ { "components": { "schemas": { - "github_com_livexface_backend_internal_domain_entity.ActiveLivenessResult": { - "properties": { - "challenges": { - "additionalProperties": { - "additionalProperties": true, - "type": "object" - }, - "type": "object" - }, - "framesAnalyzed": { - "type": "integer" - }, - "framesWithFace": { - "type": "integer" - }, - "isLive": { - "type": "boolean" - }, - "livenessToken": { - "description": "Set only when the check passed and the token could be stored.", - "type": "string" - }, - "livenessTokenExpiresAt": { - "type": "string" - }, - "overallScore": { - "type": "number" - } - }, - "type": "object" - }, "github_com_livexface_backend_internal_domain_entity.BatchJobResult": { "properties": { "error": { @@ -88,6 +57,17 @@ }, "type": "object" }, + "github_com_livexface_backend_internal_domain_entity.LivenessStep": { + "properties": { + "passed": { + "type": "boolean" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, "github_com_livexface_backend_internal_domain_entity.PlanFeature": { "properties": { "included": { @@ -334,6 +314,30 @@ }, "type": "object" }, + "internal_delivery_http_handler.activeLivenessResponse": { + "properties": { + "challenges": { + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "type": "object" + }, + "framesAnalyzed": { + "type": "integer" + }, + "framesWithFace": { + "type": "integer" + }, + "isLive": { + "type": "boolean" + }, + "overallScore": { + "type": "number" + } + }, + "type": "object" + }, "internal_delivery_http_handler.apiKeyDTO": { "properties": { "allowedIps": { @@ -1291,6 +1295,15 @@ ], "type": "object" }, + "internal_delivery_http_handler.livenessChallengeDTO": { + "properties": { + "type": { + "example": "turn_left", + "type": "string" + } + }, + "type": "object" + }, "internal_delivery_http_handler.livenessResponse": { "properties": { "faceCount": { @@ -1308,6 +1321,60 @@ }, "type": "object" }, + "internal_delivery_http_handler.livenessSessionResponse": { + "properties": { + "challenges": { + "items": { + "$ref": "#/components/schemas/internal_delivery_http_handler.livenessChallengeDTO" + }, + "type": "array" + }, + "expiresAt": { + "type": "string" + }, + "sessionId": { + "example": "lvs_3q2-…", + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.livenessSessionResultResponse": { + "properties": { + "challenges": { + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "type": "object" + }, + "framesAnalyzed": { + "type": "integer" + }, + "framesWithFace": { + "type": "integer" + }, + "isLive": { + "type": "boolean" + }, + "livenessToken": { + "type": "string" + }, + "livenessTokenExpiresAt": { + "type": "string" + }, + "overallScore": { + "type": "number" + }, + "steps": { + "items": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.LivenessStep" + }, + "type": "array" + } + }, + "type": "object" + }, "internal_delivery_http_handler.loginRequest": { "properties": { "email": { @@ -1964,13 +2031,13 @@ }, "termsOfService": "http://swagger.io/terms/", "title": "LiveXFace — Face Recognition API as a Service", - "version": "1.0.0" + "version": "2.0.0" }, "openapi": "3.0.3", "paths": { "/collections/{collection_id}/active-liveness": { "post": { - "description": "Judge a sequence of frames (blink, head turn and the passive model). Send the frames in order as frame_0, frame_1, … up to frame_49; at least 5 are required. A passed check returns a single-use livenessToken, valid for 5 minutes and bound to this collection, for enrolling into a collection that requires liveness.", + "description": "Judge a sequence of frames (blink, head turn and the passive model). Send the frames in order as frame_0, frame_1, … up to frame_49; at least 5 are required. This stateless check returns a verdict only and issues no liveness token; to enrol into a collection that requires liveness, complete a liveness session instead.", "parameters": [ { "description": "Collection ID (UUID)", @@ -2014,7 +2081,7 @@ { "properties": { "data": { - "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.ActiveLivenessResult" + "$ref": "#/components/schemas/internal_delivery_http_handler.activeLivenessResponse" } }, "type": "object" @@ -2163,7 +2230,7 @@ "APIKeyAuth": [] } ], - "summary": "Active liveness check", + "summary": "Active liveness check (verdict only)", "tags": [ "Face Recognition" ] @@ -4660,6 +4727,381 @@ ] } }, + "/collections/{collection_id}/liveness-sessions": { + "post": { + "description": "Start a liveness session: the server picks the steps the person must perform, in order (one blink and one or two head turns, turn_left and turn_right meaning the person's own left and right). Show them to the person, capture frames while they perform them, and submit the frames to the session before expiresAt (60 seconds by default). The session is bound to this collection.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.livenessSessionResponse" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Created" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Create a liveness session", + "tags": [ + "Face Recognition" + ] + } + }, + "/collections/{collection_id}/liveness-sessions/{session_id}": { + "post": { + "description": "Submit the frames for a liveness session, once, before it expires. Every step must appear in the frames in the order given, and the blink and passive rules of the active check must hold. A pass returns a single-use livenessToken, valid for 5 minutes and bound to this collection, for enrolling with liveness_token. The session is used up by any submission except one with fewer than 5 frames.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Session ID from creating the session", + "in": "path", + "name": "session_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "frame_0": { + "description": "First frame; continue with frame_1 … frame_49 (at least 5 frames)", + "format": "binary", + "type": "string", + "x-formData-name": "frame_0" + }, + "mirrored": { + "default": false, + "description": "true when the frames are horizontally mirrored, as a selfie preview is", + "type": "boolean", + "x-formData-name": "mirrored" + } + }, + "required": [ + "frame_0" + ], + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.livenessSessionResultResponse" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "IMAGE_REQUIRED: fewer than 5 readable frames (the session is kept); BAD_REQUEST: mirrored is not true or false" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND" + }, + "422": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "LIVENESS_SESSION_INVALID: unknown, expired, already submitted, or bound to another organization or collection" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + }, + "503": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "SERVICE_BUSY: the recognition engine is saturated; create a new session and retry" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Complete a liveness session", + "tags": [ + "Face Recognition" + ] + } + }, "/collections/{collection_id}/verify": { "post": { "description": "Compare an uploaded image against a stored face (by face_id) or a second uploaded image (reference_image). Returns match/no-match with confidence score.", diff --git a/contract_test.go b/contract_test.go index b6ab102..9ef3f31 100644 --- a/contract_test.go +++ b/contract_test.go @@ -122,6 +122,14 @@ var contractCalls = map[string]func(context.Context, *Client) error{ _, err := c.Faces.ActiveLiveness(ctx, "col_1", frames(5)) return err }, + "FacesResource.CreateLivenessSession": func(ctx context.Context, c *Client) error { + _, err := c.Faces.CreateLivenessSession(ctx, "col_1") + return err + }, + "FacesResource.CompleteLivenessSession": func(ctx context.Context, c *Client) error { + _, err := c.Faces.CompleteLivenessSession(ctx, "col_1", "lvs_1", frames(5), true) + return err + }, "FacesResource.Compare": func(ctx context.Context, c *Client) error { _, err := c.Faces.Compare(ctx, CompareInput{Image1: []byte{0xff, 0xd8}, Image2: []byte{0xff, 0xd8}, Threshold: 0.5}) return err diff --git a/types.go b/types.go index ffa4f46..270ee5a 100644 --- a/types.go +++ b/types.go @@ -45,7 +45,8 @@ type LivenessResult struct { FaceCount int `json:"faceCount"` } -// LivenessFrame is a single frame (JPEG or PNG) submitted to ActiveLiveness. +// LivenessFrame is a single frame (JPEG or PNG) submitted to ActiveLiveness +// or CompleteLivenessSession. type LivenessFrame struct { Image []byte // Filename is optional; defaults to "frame_.jpg". @@ -101,15 +102,55 @@ type LivenessChallenges struct { } // ActiveLivenessResult is returned by an active (multi-frame) liveness check. -// LivenessToken and LivenessTokenExpiresAt are set only when IsLive is true. +// It is a verdict only: liveness tokens come from CompleteLivenessSession. type ActiveLivenessResult struct { - IsLive bool `json:"isLive"` - OverallScore float64 `json:"overallScore"` - FramesAnalyzed int `json:"framesAnalyzed"` - FramesWithFace int `json:"framesWithFace"` - Challenges LivenessChallenges `json:"challenges"` - LivenessToken string `json:"livenessToken,omitempty"` - LivenessTokenExpiresAt *time.Time `json:"livenessTokenExpiresAt,omitempty"` + IsLive bool `json:"isLive"` + OverallScore float64 `json:"overallScore"` + FramesAnalyzed int `json:"framesAnalyzed"` + FramesWithFace int `json:"framesWithFace"` + Challenges LivenessChallenges `json:"challenges"` +} + +// LivenessStepType is one step a liveness session asks the person to perform. +// Turns are in the person's own left and right. +type LivenessStepType string + +// The step types a liveness session can ask for. +const ( + LivenessStepBlink LivenessStepType = "blink" + LivenessStepTurnLeft LivenessStepType = "turn_left" + LivenessStepTurnRight LivenessStepType = "turn_right" +) + +// LivenessSessionChallenge is one step of a liveness session. +type LivenessSessionChallenge struct { + Type LivenessStepType `json:"type"` +} + +// LivenessSession is returned by CreateLivenessSession. Show its Challenges +// to the person in order, capture frames while they perform them and submit +// the frames with CompleteLivenessSession before ExpiresAt. +type LivenessSession struct { + SessionID string `json:"sessionId"` + Challenges []LivenessSessionChallenge `json:"challenges"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// LivenessStep reports whether one session step was performed, in its turn. +type LivenessStep struct { + Type LivenessStepType `json:"type"` + Passed bool `json:"passed"` +} + +// LivenessSessionResult is returned by CompleteLivenessSession: the active +// liveness verdict plus the session's steps in order. LivenessToken and +// LivenessTokenExpiresAt are set only when the session passed (and the server +// could store the token). +type LivenessSessionResult struct { + ActiveLivenessResult + Steps []LivenessStep `json:"steps"` + LivenessToken string `json:"livenessToken,omitempty"` + LivenessTokenExpiresAt *time.Time `json:"livenessTokenExpiresAt,omitempty"` } // BatchFaceResult holds the outcome of a single face in a batch register request. @@ -135,8 +176,8 @@ type RegisterInput struct { // Filename is optional; defaults to "image.jpg". Filename string Metadata map[string]interface{} - // LivenessToken is optional; a token from a passed ActiveLiveness check. - // Required when the collection requires liveness. + // LivenessToken is optional; a token from a passed liveness session + // (CompleteLivenessSession). Required when the collection requires liveness. LivenessToken string } @@ -179,8 +220,8 @@ type BatchItem struct { Image []byte Filename string Metadata map[string]interface{} - // LivenessToken is optional; a token from a passed ActiveLiveness check. - // Required when the collection requires liveness. + // LivenessToken is optional; a token from a passed liveness session + // (CompleteLivenessSession). Required when the collection requires liveness. LivenessToken string }