Files

168 lines
5.4 KiB
Go

package onetimeaccess
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/italypaleale/francis/actor"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// One-time access tokens are stored entirely in the actor state store.
// Each token is its own actor, whose actor ID is the token value itself.
// The state is persisted with a TTL equal to the token's lifetime, so it's purged automatically when the token expires (there's no separate cleanup job).
// TokenActorType is the actor type for the one-time access token actor
const TokenActorType = "OneTimeAccessToken"
// Methods exposed by the one-time access token actor
// Because we cannot invoke an actor while a DB transaction is open (that would deadlock on SQLite), consuming a token is done by invoking the actor first (which atomically validates and deletes the token), and only afterwards performing the remaining work.
// On failure, the caller compensates by restoring the token via the "restore" method as best-effort.
const (
// TokenMethodRestore stores a token's state, and is also how a consumed token is put back
TokenMethodRestore = "restore"
tokenMethodConsume = "consume"
)
// tokenConsumeStatus is the outcome of a "consume" invocation.
type tokenConsumeStatus string
const (
// tokenConsumeOK indicates the token was valid and has been consumed
tokenConsumeOK tokenConsumeStatus = "ok"
// tokenConsumeNotFound indicates the token doesn't exist (or has expired)
tokenConsumeNotFound tokenConsumeStatus = "not_found"
// tokenConsumeDeviceMismatch indicates the provided device token doesn't match
tokenConsumeDeviceMismatch tokenConsumeStatus = "device_mismatch"
)
// TokenState is the persisted state of a one-time access token actor.
// The token value itself is the actor's ID, so it isn't repeated here.
type TokenState struct {
UserID string
DeviceToken *string
ExpiresAt time.Time
}
// tokenConsumeRequest is the payload for the "consume" method
type tokenConsumeRequest struct {
DeviceToken string
}
// tokenConsumeResponse is the response of the "consume" method
type tokenConsumeResponse struct {
Status tokenConsumeStatus
// State is included only when Status is "ok", so the caller can restore it if a later step fails
State TokenState
}
// tokenActor is the actor that manages a single one-time access token
type tokenActor struct {
log *slog.Logger
client actor.Client[TokenState]
}
// NewTokenActor allocates a new one-time access token actor
// It satisfies actor.Factory
func NewTokenActor(actorID string, service *actor.Service) actor.Actor {
return &tokenActor{
log: slog.With(
slog.String("scope", "actor"),
slog.String("actorType", TokenActorType),
),
client: actor.NewActorClient[TokenState](TokenActorType, actorID, service),
}
}
// Invoke implements actor.ActorInvoke
func (a *tokenActor) Invoke(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
switch method {
case tokenMethodConsume:
return a.consume(parentCtx, data)
case TokenMethodRestore:
return nil, a.restore(parentCtx, data)
default:
return nil, common.ErrUnsupportedActorMethod{Method: method}
}
}
// consume atomically validates the token and, if valid, deletes it.
func (a *tokenActor) consume(parentCtx context.Context, data actor.Envelope) (tokenConsumeResponse, error) {
var req tokenConsumeRequest
if data != nil {
err := data.Decode(&req)
if err != nil {
return tokenConsumeResponse{}, fmt.Errorf("request body is not valid for method '%s': %w", tokenMethodConsume, err)
}
}
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return tokenConsumeResponse{}, fmt.Errorf("error retrieving actor state: %w", err)
}
// An empty UserID means there's no state: the token doesn't exist (or its state already expired and was purged)
if state.UserID == "" || state.ExpiresAt.Before(time.Now()) {
return tokenConsumeResponse{
Status: tokenConsumeNotFound,
}, nil
}
// If the token requires a device token, it must match
// A mismatch leaves the token untouched, mirroring the pre-actor behavior
if state.DeviceToken != nil && req.DeviceToken != *state.DeviceToken {
return tokenConsumeResponse{
Status: tokenConsumeDeviceMismatch,
}, nil
}
// The token is valid: delete the state (one-time use)
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err = a.client.DeleteState(ctx)
if err != nil {
return tokenConsumeResponse{}, fmt.Errorf("error deleting actor state: %w", err)
}
return tokenConsumeResponse{
Status: tokenConsumeOK,
State: state,
}, nil
}
// restore re-creates the token state, used to compensate when a step after consuming the token fails.
func (a *tokenActor) restore(parentCtx context.Context, data actor.Envelope) error {
if data == nil {
return fmt.Errorf("request body is empty for method '%s'", TokenMethodRestore)
}
var state TokenState
err := data.Decode(&state)
if err != nil {
return fmt.Errorf("request body is not valid for method '%s': %w", TokenMethodRestore, err)
}
// If the token has meanwhile expired, there's nothing to restore
ttl := time.Until(state.ExpiresAt)
if ttl <= 0 {
return nil
}
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err = a.client.SetState(ctx, state, &actor.SetStateOpts{
TTL: ttl,
})
if err != nil {
return fmt.Errorf("error saving actor state: %w", err)
}
return nil
}