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
5 changes: 4 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,12 @@ func main() {
}
mgr.AddProvider(provider)

// Wire session store into UserService so DeleteUser purges active sessions
// Wire session store into UserService so DeleteUser purges active sessions,
// and into the wallet lifecycle service so suspending or revoking a
// wallet instance drops the user's live sessions (SID-AUTH-06).
if backendProvider != nil {
backendProvider.Services().User.SetSessionCleaner(provider.SessionStore())
backendProvider.Services().WalletLifecycle.SetSessionCleaner(provider.SessionStore())
}
}

Expand Down
49 changes: 49 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,55 @@ Finish WebAuthn registration.

---

#### Wallet instance lifecycle (SID-AUTH-06)

A wallet instance is one wallet installation, identified by the JWK thumbprint of
its instance key and registered when it first obtains a Wallet Instance
Attestation. A user can inspect and manage their own instances; a provider
manages them through the admin API (`/admin/tenants/{id}/instances`). Both paths
share one lifecycle: `active` → `suspended` (reversible) or `revoked`
(terminal), `suspended` → `active` or `revoked`. Any change away from `active`
drops the user's live sessions and refuses new WIAs for that instance. Login with
the passkey linked to a suspended or revoked instance is refused with `403
WALLET_SUSPENDED` / `WALLET_REVOKED`. Revoking the last non-revoked instance
deactivates the wallet: the encrypted private data, server-side credentials,
presentations and pending challenges are erased, every passkey of the user is
refused at login, and a new enrollment is required.

The passkey link is recorded when the wallet passes its passkey's base64url
credential id as `credential_id` to `POST /wallet-provider/wia/generate`.

##### GET /user/session/instances

List the caller's wallet instances in the current tenant.

**Response:**
```json
{ "instances": [ { "id": "<jkt>", "status": "active", "wscd_type": "native_android", "last_attested_at": "..." } ] }
```

##### PUT /user/session/instances/{instance_id}/status

Change the status of one of the caller's instances.

**Request:**
```json
{ "status": "suspended", "reason": "lost phone" }
```

**Response:** `200 {"id": "<jkt>", "status": "suspended"}`; `404` if the instance
is not the caller's; `409` for an invalid transition (e.g. reactivating a
revoked instance).

##### POST /user/session/instances/revoke-all

Deactivate the wallet: revoke every instance of the caller and erase the wallet
data.

**Request (optional):** `{ "reason": "device stolen" }`

**Response:** `200 {"revoked": 2}`

### Credential Management

All credential endpoints require authentication.
Expand Down
7 changes: 7 additions & 0 deletions internal/api/admin_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/sirosfoundation/go-siros-set/set"
"github.com/sirosfoundation/go-wallet-backend/internal/domain"
"github.com/sirosfoundation/go-wallet-backend/internal/service"
"github.com/sirosfoundation/go-wallet-backend/internal/storage"
"github.com/sirosfoundation/go-wallet-backend/pkg/audit"
)
Expand All @@ -20,8 +21,14 @@ type AdminHandlers struct {
store storage.Store
logger *zap.Logger
audit *audit.Emitter
// lifecycle, when set, handles wallet instance status changes so the
// admin path shares the self-service cascade (SID-AUTH-06).
lifecycle *service.WalletLifecycleService
}

// SetLifecycle wires the shared wallet lifecycle service.
func (h *AdminHandlers) SetLifecycle(l *service.WalletLifecycleService) { h.lifecycle = l }

// NewAdminHandlers creates a new AdminHandlers instance
func NewAdminHandlers(store storage.Store, logger *zap.Logger, auditor *audit.Emitter) *AdminHandlers {
return &AdminHandlers{
Expand Down
20 changes: 20 additions & 0 deletions internal/api/admin_instance_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

"github.com/sirosfoundation/go-siros-set/set"
"github.com/sirosfoundation/go-wallet-backend/internal/domain"
"github.com/sirosfoundation/go-wallet-backend/internal/service"
"github.com/sirosfoundation/go-wallet-backend/internal/storage"
)

Expand Down Expand Up @@ -76,7 +77,7 @@
return
}
h.logger.Error("failed to get wallet instance", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update wallet instance"})

Check failure on line 80 in internal/api/admin_instance_handlers.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "failed to update wallet instance" 3 times.

See more on https://sonarcloud.io/project/issues?id=sirosfoundation_go-wallet-backend&issues=AaCBJWuhA0JDft1RDbQ5&open=AaCBJWuhA0JDft1RDbQ5&pullRequest=319
return
}
if instance.TenantID != tenantID {
Expand All @@ -86,10 +87,29 @@

status := domain.InstanceStatus(req.Status)
if err := domain.ValidateStatusTransition(instance.Status, status); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "invalid status transition", "current": string(instance.Status), "target": string(status)})

Check failure on line 90 in internal/api/admin_instance_handlers.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "invalid status transition" 3 times.

See more on https://sonarcloud.io/project/issues?id=sirosfoundation_go-wallet-backend&issues=AaCBJWuhA0JDft1RDbQ6&open=AaCBJWuhA0JDft1RDbQ6&pullRequest=319
return
}

if h.lifecycle != nil {
// Shared lifecycle service: same transition rules, audit and cascade
// (session drop, wallet erasure on last revocation) as self-service.
if _, err := h.lifecycle.ChangeStatus(c.Request.Context(), service.LifecycleActor{Kind: "provider"}, tenantID, instanceID, status, req.Reason); err != nil {
switch {
case errors.Is(err, storage.ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "wallet instance not found"})
case errors.Is(err, domain.ErrInvalidStatusTransition):
c.JSON(http.StatusConflict, gin.H{"error": "invalid status transition"})
default:
h.logger.Error("failed to update wallet instance status", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update wallet instance"})
}
return
}
c.JSON(http.StatusOK, gin.H{"id": instanceID, "status": req.Status})
return
}

if err := h.store.WalletInstances().UpdateStatus(c.Request.Context(), instanceID, status, req.Reason); err != nil {
if errors.Is(err, storage.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "wallet instance not found"})
Expand Down
45 changes: 45 additions & 0 deletions internal/api/admin_instance_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"go.uber.org/zap"

"github.com/sirosfoundation/go-wallet-backend/internal/domain"
"github.com/sirosfoundation/go-wallet-backend/internal/service"
"github.com/sirosfoundation/go-wallet-backend/internal/storage/memory"
"github.com/sirosfoundation/go-wallet-backend/pkg/audit"
)
Expand Down Expand Up @@ -371,3 +372,47 @@ func TestDeleteWalletInstance_WithAudit(t *testing.T) {
t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String())
}
}

// With the shared lifecycle service wired (as BackendProvider does), an admin
// revocation of the user's last instance runs the SID-AUTH-06 cascade.
func TestUpdateWalletInstanceStatus_LifecycleCascade(t *testing.T) {
gin.SetMode(gin.TestMode)
store := memory.NewStore()
h := NewAdminHandlers(store, zap.NewNop(), testAuditEmitter(t))
h.SetLifecycle(service.NewWalletLifecycleService(store, zap.NewNop(), nil))
userID := domain.NewUserID()
if err := store.Users().Create(context.Background(), &domain.User{UUID: userID, PrivateData: []byte("vault")}); err != nil {
t.Fatal(err)
}
seedInstance(t, h, "inst-1", "acme", &userID)

r := gin.New()
r.PUT("/admin/tenants/:id/instances/:instance_id/status", h.UpdateWalletInstanceStatus)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/admin/tenants/acme/instances/inst-1/status", strings.NewReader(`{"status":"revoked","reason":"compromised"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String())
}
inst, err := store.WalletInstances().GetByID(context.Background(), "inst-1")
if err != nil || inst.Status != domain.InstanceStatusRevoked {
t.Fatalf("expected revoked, got %v %v", err, inst)
}
user, err := store.Users().GetByID(context.Background(), userID)
if err != nil {
t.Fatal(err)
}
if user.PrivateData != nil {
t.Errorf("revoking the last instance must erase the wallet's private data")
}

// Revoked is terminal, also via the lifecycle path.
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPut, "/admin/tenants/acme/instances/inst-1/status", strings.NewReader(`{"status":"active"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", w.Code)
}
}
4 changes: 4 additions & 0 deletions internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,10 @@ func (h *Handlers) FinishWebAuthnLogin(c *gin.Context) {
c.JSON(404, gin.H{"error": "Credential not found"})
case errors.Is(err, service.ErrVerificationFailed):
c.JSON(401, gin.H{"error": "Authentication failed"})
case errors.Is(err, service.ErrWalletInstanceSuspended):
c.JSON(403, gin.H{"error": "WALLET_SUSPENDED", "message": "This wallet instance has been suspended"})
case errors.Is(err, service.ErrWalletInstanceRevoked):
c.JSON(403, gin.H{"error": "WALLET_REVOKED", "message": "This wallet has been deactivated; a new enrollment is required"})
Comment on lines +285 to +288
case errors.Is(err, service.ErrTenantAccessDenied):
c.JSON(403, gin.H{"error": "Tenant user must use tenant-scoped login endpoint"})
case errors.Is(err, service.ErrIdentityNotBound):
Expand Down
112 changes: 112 additions & 0 deletions internal/api/instance_handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package api

import (
"errors"
"net/http"

"github.com/gin-gonic/gin"
"go.uber.org/zap"

"github.com/sirosfoundation/go-wallet-backend/internal/domain"
"github.com/sirosfoundation/go-wallet-backend/internal/service"
"github.com/sirosfoundation/go-wallet-backend/internal/storage"
)

// Self-service wallet instance lifecycle (SID-AUTH-06, go-wallet-backend#195):
// a user can see their own wallet instances and suspend, reactivate or revoke
// them; revoking all deactivates the wallet. Provider-side changes go through
// the admin API (admin_instance_handlers.go); both share
// service.WalletLifecycleService, so the cascade is the same.

type updateMyInstanceStatusRequest struct {
Status string `json:"status" binding:"required,oneof=active suspended revoked"`
Reason string `json:"reason"`
}

type revokeAllInstancesRequest struct {
Reason string `json:"reason"`
}

func (h *Handlers) lifecycleActor(c *gin.Context) (service.LifecycleActor, domain.TenantID, bool) {
uid, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return service.LifecycleActor{}, "", false
}
userID := domain.UserIDFromString(uid.(string))
tenantID, _ := h.getTenantID(c)
return service.LifecycleActor{Kind: "user", UserID: &userID}, tenantID, true
}

// ListMyWalletInstances handles GET /user/session/instances.
func (h *Handlers) ListMyWalletInstances(c *gin.Context) {
if h.services.WalletLifecycle == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "LIFECYCLE_NOT_SUPPORTED"})
return
}
actor, tenantID, ok := h.lifecycleActor(c)
if !ok {
return
}
instances, err := h.services.WalletLifecycle.ListForUser(c.Request.Context(), tenantID, *actor.UserID)
if err != nil {
h.logger.Error("failed to list wallet instances", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list wallet instances"})
return
}
c.JSON(http.StatusOK, gin.H{"instances": instances})
}

// UpdateMyWalletInstanceStatus handles PUT /user/session/instances/:instance_id/status.
func (h *Handlers) UpdateMyWalletInstanceStatus(c *gin.Context) {
if h.services.WalletLifecycle == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "LIFECYCLE_NOT_SUPPORTED"})
return
}
actor, tenantID, ok := h.lifecycleActor(c)
if !ok {
return
}
var req updateMyInstanceStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: status must be active, suspended, or revoked"})
return
}
inst, err := h.services.WalletLifecycle.ChangeStatus(c.Request.Context(), actor, tenantID, c.Param("instance_id"), domain.InstanceStatus(req.Status), req.Reason)
if err != nil {
switch {
case errors.Is(err, storage.ErrNotFound), errors.Is(err, service.ErrWalletInstanceNotOwned):
c.JSON(http.StatusNotFound, gin.H{"error": "wallet instance not found"})
case errors.Is(err, domain.ErrInvalidStatusTransition):
c.JSON(http.StatusConflict, gin.H{"error": "invalid status transition"})
default:
h.logger.Error("failed to update wallet instance status", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update wallet instance"})
}
return
}
c.JSON(http.StatusOK, gin.H{"id": inst.ID, "status": string(inst.Status)})
}

// RevokeAllMyWalletInstances handles POST /user/session/instances/revoke-all:
// deactivate the wallet. Every instance is revoked and the wallet data erased;
// a new enrollment is required afterwards.
func (h *Handlers) RevokeAllMyWalletInstances(c *gin.Context) {
if h.services.WalletLifecycle == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "LIFECYCLE_NOT_SUPPORTED"})
return
}
actor, tenantID, ok := h.lifecycleActor(c)
if !ok {
return
}
var req revokeAllInstancesRequest
_ = c.ShouldBindJSON(&req) // body is optional
n, err := h.services.WalletLifecycle.RevokeAllForUser(c.Request.Context(), actor, tenantID, *actor.UserID, req.Reason)
if err != nil {
h.logger.Error("failed to revoke wallet instances", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke wallet instances"})
return
}
c.JSON(http.StatusOK, gin.H{"revoked": n})
}
Loading
Loading