Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions .github/workflows/contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@ name: Contract
#
# One job, one server instance, all four languages run their contract tests
# against it sequentially -- avoids the cross-job networking complexity of
# splitting into a matrix, and /system/init's idempotency (a second call
# reports already_initialized rather than erroring) makes sequential re-use
# safe: each language bootstraps the same "contract-admin" account (a no-op
# after the first) and only ever creates its OWN uniquely-named
# project/secret, so there is no cross-language collision.
# splitting into a matrix. The server keeps its secure default
# (security.require_mfa, ADR-112): a password-only session is confined to MFA
# enrolment, so one "Bootstrap contract admin" step creates the
# "contract-admin" account, enrols TOTP, completes the real MFA login and mints
# a short-lived PAT (scripts/contract_bootstrap.py), exported as
# KEYORIX_CONTRACT_ADMIN_TOKEN (masked, never printed). Every language then
# authenticates with that token and only ever creates its OWN uniquely-named
# project/secret, so there is no cross-language collision. Each language also
# asserts that a password-only login still gets an MFA challenge.
on:
workflow_call:

Expand Down Expand Up @@ -103,6 +107,9 @@ jobs:
cat "$RUNNER_TEMP/keyorix-server.log"
exit 1

- name: Bootstrap contract admin (MFA stays on)
run: python3 sdks/scripts/contract_bootstrap.py --env-file "$GITHUB_ENV"

- name: Go contract test
working-directory: sdks/go
run: go test -run TestContract -v ./...
Expand Down
6 changes: 6 additions & 0 deletions go/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to the Go SDK are documented here.

## Unreleased

### Added

- `APIError.Code` carries the server's stable error code, and a value read the secret's own state refuses (server ERRMAP-1) is typed: 409 `SecretExpired` / `SecretSuspended` / `SecretMaxReadsExhausted` returns `*SecretUnavailableError` (unwraps to `*APIError`); a read outside the secret's access schedule stays `*ForbiddenError` (Code `SecretAccessOutsideSchedule`) with an accurate message. Error text is local, never the server's body. Against an older server these reads still return the generic `*APIError` (500).

## v0.3.0 — 2026-10-02

### Added
Expand Down
97 changes: 58 additions & 39 deletions go/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ import (
// image (see .github/workflows/contract.yml) and a local run can provide via
// `go run ./server` from a keyorix checkout (see CONTRIBUTING.md).
//
// The server is expected to be freshly bootstrapped (no admin yet) so this
// test can claim the bootstrap token itself and know the exact resulting
// state (one "default" project, three environments) -- it does not assume
// any other seeding. KEYORIX_CONTRACT_BOOTSTRAP_TOKEN must match the
// server's configured KEYORIX_BOOTSTRAP_TOKEN.
// The server is expected to be freshly bootstrapped by
// scripts/contract_bootstrap.py (which claims the bootstrap token, enrols the
// admin in MFA and exports KEYORIX_CONTRACT_ADMIN_TOKEN), so the test knows the
// exact resulting state (one "default" project, three environments) -- it does
// not assume any other seeding. The server keeps MFA enforced.
func contractServerURL(t *testing.T) string {
t.Helper()
url := os.Getenv("KEYORIX_CONTRACT_SERVER_URL")
Expand All @@ -44,43 +44,54 @@ func contractServerURL(t *testing.T) string {
return url
}

// seedAdminSession bootstraps a fresh server (idempotent -- a second run
// against an already-initialized server just logs in instead) and returns a
// valid session token, via raw HTTP: /system/init and /auth/login are not
// part of this SDK's own public surface (Login is, and is used for the
// second call), so bootstrap must go directly over HTTP.
func seedAdminSession(t *testing.T, serverURL string) string {
// seedAdminSession returns the admin credential for the contract suite. The
// server enforces MFA by default (security.require_mfa, ADR-112), so a
// password-only login cannot authenticate: scripts/contract_bootstrap.py
// bootstraps the admin, enrols TOTP, completes the real MFA step and mints a
// PAT, exported as KEYORIX_CONTRACT_ADMIN_TOKEN. MFA is never turned off.
func seedAdminSession(t *testing.T, _ string) string {
t.Helper()
bootstrapToken := os.Getenv("KEYORIX_CONTRACT_BOOTSTRAP_TOKEN")
if bootstrapToken == "" {
t.Fatal("KEYORIX_CONTRACT_BOOTSTRAP_TOKEN must be set alongside KEYORIX_CONTRACT_SERVER_URL")
token := os.Getenv("KEYORIX_CONTRACT_ADMIN_TOKEN")
if token == "" {
t.Fatal("KEYORIX_CONTRACT_ADMIN_TOKEN must be set alongside KEYORIX_CONTRACT_SERVER_URL (run scripts/contract_bootstrap.py)")
}
return token
}

body, _ := json.Marshal(map[string]string{
"username": "contract-admin",
"email": "contract-admin@example.com",
"password": "ContractTestPassw0rd!",
"bootstrap_token": bootstrapToken,
})
resp, err := http.Post(serverURL+"/system/init", "application/json", bytes.NewReader(body)) //nolint:gosec // serverURL is the operator-supplied contract-test target, not attacker input
// TestContract_PasswordLoginReturnsMFAChallenge keeps the MFA-by-default
// behaviour covered: a user with no token who logs in with only a password gets
// an MFA challenge, not a session. A challenge login keeps its rate-limit slot
// until the second factor completes (10 per 15 min per IP), so this makes
// exactly one login call, on the raw response, rather than also calling Login.
func TestContract_PasswordLoginReturnsMFAChallenge(t *testing.T) {
serverURL := contractServerURL(t)
seedAdminSession(t, serverURL) // the bootstrap has enrolled the admin in MFA

body, _ := json.Marshal(map[string]string{"username": "contract-admin", "password": "ContractTestPassw0rd!"})
resp, err := http.Post(serverURL+"/auth/login", "application/json", bytes.NewReader(body)) //nolint:gosec // serverURL is the operator-supplied contract-test target, not attacker input
if err != nil {
t.Fatalf("POST /system/init: %v", err)
t.Fatalf("POST /auth/login: %v", err)
}
defer resp.Body.Close()
// 200/201 (first boot) or 200 with already_initialized=true (idempotent
// re-run) are both fine; anything else (e.g. a rejected bootstrap token)
// is not.
if resp.StatusCode >= 300 {
t.Fatalf("POST /system/init: unexpected status %d", resp.StatusCode)
if resp.StatusCode != http.StatusOK {
t.Fatalf("POST /auth/login: unexpected status %d", resp.StatusCode)
}

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
token, err := Login(ctx, serverURL, "contract-admin", "ContractTestPassw0rd!")
if err != nil {
t.Fatalf("Login after bootstrap: %v", err)
var result struct {
Data struct {
Token string `json:"token"`
MFARequired bool `json:"mfa_required"`
MFAChallenge string `json:"mfa_challenge"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("decode login response: %v", err)
}
if !result.Data.MFARequired || result.Data.MFAChallenge == "" {
t.Fatal("password-only login did not return an MFA challenge")
}
if result.Data.Token != "" {
t.Fatal("password-only login returned a session token despite MFA")
}
return token
}

// seedSecret creates a secret directly over HTTP (CreateSecret is not part of
Expand Down Expand Up @@ -363,12 +374,20 @@ func TestContract_FullClientSurface(t *testing.T) {
}
// A brand-new machine identity holds no roles, so this must
// authenticate (not AuthError) even though it can't yet be
// authorized for anything (ForbiddenError) -- proving the token is
// recognized as valid, distinct from being permitted.
_, err = machineClient.ListProjects(ctx)
// authorized for anything -- proving the token is recognized as
// valid, distinct from being permitted. Current keyorix main answers
// a role-less machine's project listing with an empty,
// permission-filtered list; older servers answered 403
// (ForbiddenError). Either proves "authenticated but not
// authorized"; seeing projects, or an AuthError, does not.
projects, err := machineClient.ListProjects(ctx)
var forbidden *ForbiddenError
if !errors.As(err, &forbidden) {
t.Errorf("ListProjects with a fresh machine token: got %T (%v), want *ForbiddenError", err, err)
switch {
case errors.As(err, &forbidden):
case err != nil:
t.Errorf("ListProjects with a fresh machine token: got %T (%v), want an empty list or *ForbiddenError", err, err)
case len(projects) != 0:
t.Errorf("ListProjects with a fresh machine token returned %d projects, want none (the identity holds no roles)", len(projects))
}
})

Expand Down
56 changes: 54 additions & 2 deletions go/keyorix.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ type SecretValue struct {
type APIError struct {
StatusCode int
Body string
// Code is the server's stable error code (the error envelope's "error"
// field, e.g. "SecretSuspended"), or "" when the body carries none.
Code string
}

func (e *APIError) Error() string {
Expand All @@ -102,6 +105,9 @@ func (e *AuthError) Unwrap() error { return e.APIError }
type ForbiddenError struct{ *APIError }

func (e *ForbiddenError) Error() string {
if e.Code == CodeSecretAccessOutsideSchedule {
return "keyorix: forbidden — the secret is outside its access schedule"
}
return "keyorix: forbidden — token lacks permission for this request"
}
func (e *ForbiddenError) Unwrap() error { return e.APIError }
Expand All @@ -115,13 +121,59 @@ type NotFoundError struct{ *APIError }
func (e *NotFoundError) Error() string { return "keyorix: resource not found" }
func (e *NotFoundError) Unwrap() error { return e.APIError }

// Stable error codes the server sends when a secret's own state refuses a value
// read. A read outside the secret's access schedule is a 403 (ForbiddenError with
// this Code); the other three are a 409 (SecretUnavailableError).
const (
CodeSecretAccessOutsideSchedule = "SecretAccessOutsideSchedule"
CodeSecretExpired = "SecretExpired"
CodeSecretSuspended = "SecretSuspended"
CodeSecretMaxReadsExhausted = "SecretMaxReadsExhausted"
)

// SecretUnavailableError indicates the token may read the secret but the
// secret's own state refuses the value read (HTTP 409): it has expired, is
// suspended, or has used up its max-reads budget. Code says which. Each is
// reversible by an operator. Unwraps to *APIError.
type SecretUnavailableError struct{ *APIError }

func (e *SecretUnavailableError) Error() string {
switch e.Code {
case CodeSecretExpired:
return "keyorix: the secret has expired"
case CodeSecretSuspended:
return "keyorix: the secret is suspended"
default:
return "keyorix: the secret has reached its maximum number of reads"
}
}
func (e *SecretUnavailableError) Unwrap() error { return e.APIError }

// errorCode reads the error envelope's "error" field, or "".
func errorCode(body string) string {
var env struct {
Error string `json:"error"`
}
if json.Unmarshal([]byte(body), &env) != nil {
return ""
}
return env.Error
}

// errorForResponse maps a non-2xx HTTP status to a typed error: AuthError
// (401), ForbiddenError (403), NotFoundError (404), or the generic APIError
// (401), ForbiddenError (403), NotFoundError (404), SecretUnavailableError (409
// with a secret-state code), or the generic APIError
// for anything else. Every request path returns through this so callers can
// rely on errors.As matching consistently, regardless of which method failed.
func errorForResponse(statusCode int, body string) error {
base := &APIError{StatusCode: statusCode, Body: body}
base := &APIError{StatusCode: statusCode, Body: body, Code: errorCode(body)}
switch statusCode {
case http.StatusConflict:
switch base.Code {
case CodeSecretExpired, CodeSecretSuspended, CodeSecretMaxReadsExhausted:
return &SecretUnavailableError{base}
}
return base
case http.StatusUnauthorized:
return &AuthError{base}
case http.StatusForbidden:
Expand Down
49 changes: 49 additions & 0 deletions go/keyorix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package keyorix
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -236,3 +237,51 @@ func TestErrorForResponse_MapsStatusToTypedError(t *testing.T) {
})
}
}

// A value read the secret's own state refuses (server ERRMAP-1): 409 with a
// secret-state code is a *SecretUnavailableError carrying that Code; a schedule
// refusal stays a *ForbiddenError (callers already handle it) but says what it is.
// Neither error text echoes the server's body.
func TestErrorForResponse_SecretReadRefusals(t *testing.T) {
for _, tc := range []struct {
status int
code, want string
unavailable bool
}{
{http.StatusConflict, CodeSecretExpired, "has expired", true},
{http.StatusConflict, CodeSecretSuspended, "is suspended", true},
{http.StatusConflict, CodeSecretMaxReadsExhausted, "maximum number of reads", true},
{http.StatusForbidden, CodeSecretAccessOutsideSchedule, "outside its access schedule", false},
{http.StatusConflict, "ConflictError", "server returned 409", false},
} {
t.Run(tc.code, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.status)
_, _ = fmt.Fprintf(w, `{"success":false,"code":%d,"error":%q,"message":"SERVER-TEXT"}`, tc.status, tc.code)
}))
defer srv.Close()
c, err := New(srv.URL, "test-token")
if err != nil {
t.Fatal(err)
}
_, err = c.GetSecretByRef(context.Background(), "p/e/n")
if err == nil || !strings.Contains(err.Error(), tc.want) || strings.Contains(err.Error(), "SERVER-TEXT") {
t.Fatalf("error %v: want text %q and no server text", err, tc.want)
}
var unavailable *SecretUnavailableError
if errors.As(err, &unavailable) != tc.unavailable {
t.Fatalf("errors.As(*SecretUnavailableError) = %v, want %v (%T)", !tc.unavailable, tc.unavailable, err)
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Code != tc.code {
t.Fatalf("APIError.Code = %+v, want %q", apiErr, tc.code)
}
if tc.status == http.StatusForbidden {
var forbidden *ForbiddenError
if !errors.As(err, &forbidden) {
t.Fatalf("a schedule refusal must stay a *ForbiddenError, got %T", err)
}
}
})
}
}
6 changes: 6 additions & 0 deletions java/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to the Java SDK are documented here.

## Unreleased

### Added

- `KeyorixException.getErrorCode()` carries the server's stable error code, and a value read the secret's own state refuses (server ERRMAP-1) is typed: 409 `SecretExpired` / `SecretSuspended` / `SecretMaxReadsExhausted` throws `SecretUnavailableException`; a read outside the secret's access schedule stays `ForbiddenException` (code `SecretAccessOutsideSchedule`) with an accurate message. Messages are local, never the server's body.

## v0.3.0 (unreleased)

### Added
Expand Down
1 change: 1 addition & 0 deletions java/src/main/java/com/keyorix/AuthException.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ public class AuthException extends KeyorixException {
private static final long serialVersionUID = 1L;
public AuthException(String message) { super(message); }
public AuthException(String message, int statusCode, String responseBody) { super(message, statusCode, responseBody); }
public AuthException(String message, int statusCode, String responseBody, String errorCode) { super(message, statusCode, responseBody, errorCode); }
}
1 change: 1 addition & 0 deletions java/src/main/java/com/keyorix/ForbiddenException.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ public class ForbiddenException extends KeyorixException {
private static final long serialVersionUID = 1L;
public ForbiddenException(String message) { super(message); }
public ForbiddenException(String message, int statusCode, String responseBody) { super(message, statusCode, responseBody); }
public ForbiddenException(String message, int statusCode, String responseBody, String errorCode) { super(message, statusCode, responseBody, errorCode); }
}
37 changes: 32 additions & 5 deletions java/src/main/java/com/keyorix/KeyorixClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -407,24 +407,51 @@ private String get(String path) throws KeyorixException {

/**
* Maps a non-2xx HTTP status to a typed exception: AuthException (401),
* ForbiddenException (403), NotFoundException (404), or the generic
* ForbiddenException (403), NotFoundException (404),
* SecretUnavailableException (409 with a secret-state code), or the generic
* KeyorixException for anything else. Every request path throws through
* this so callers can catch the specific type they need, regardless of
* which method failed.
*/
private static KeyorixException mapError(int status, String body) {
String code = body == null ? null : JsonParser.extractString(body, "error");
switch (status) {
case 401:
return new AuthException("Unauthorized — check your token", status, body);
return new AuthException("Unauthorized — check your token", status, body, code);
case 403:
return new ForbiddenException("Forbidden — token lacks permission for this request", status, body);
if (CODE_SECRET_ACCESS_OUTSIDE_SCHEDULE.equals(code)) {
return new ForbiddenException("Forbidden — the secret is outside its access schedule", status, body, code);
}
return new ForbiddenException("Forbidden — token lacks permission for this request", status, body, code);
case 404:
return new NotFoundException("Resource not found", status, body);
return new NotFoundException("Resource not found", status, body, code);
case 409:
if (CODE_SECRET_EXPIRED.equals(code)) {
return new SecretUnavailableException("The secret has expired", status, body, code);
}
if (CODE_SECRET_SUSPENDED.equals(code)) {
return new SecretUnavailableException("The secret is suspended", status, body, code);
}
if (CODE_SECRET_MAX_READS_EXHAUSTED.equals(code)) {
return new SecretUnavailableException("The secret has reached its maximum number of reads", status, body, code);
}
return new KeyorixException("Server returned " + status, status, body, code);
default:
return new KeyorixException("Server returned " + status, status, body);
return new KeyorixException("Server returned " + status, status, body, code);
}
}

/**
* Stable error codes the server sends when a secret's own state refuses a
* value read. A read outside the access schedule is a 403
* ({@link ForbiddenException} with this code); the other three are a 409
* ({@link SecretUnavailableException}).
*/
public static final String CODE_SECRET_ACCESS_OUTSIDE_SCHEDULE = "SecretAccessOutsideSchedule";
public static final String CODE_SECRET_EXPIRED = "SecretExpired";
public static final String CODE_SECRET_SUSPENDED = "SecretSuspended";
public static final String CODE_SECRET_MAX_READS_EXHAUSTED = "SecretMaxReadsExhausted";

private HttpURLConnection openConnection(String url, String method, boolean auth) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
if (sslSocketFactory != null && conn instanceof HttpsURLConnection) {
Expand Down
Loading
Loading