Implements PLAN.md Phase 7: the server half of end-to-end encryption.
Devices upload identity, one-time and fallback keys; other devices query
and claim them, across servers too, and every one-time key is handed out
exactly once. Cross-signing, signature uploads, device list tracking and
server-side key backup complete the set.
internal/keyserver is the component. Keys are stored in canonical JSON
so that re-uploading the same key is a byte comparison, and signatures
uploaded later are kept apart from the key they sign and merged in on
read, so a key's own JSON stays what its owner uploaded. Device keys must
be for the caller's own device; cross-signing keys must be the
uploader's, carry the right usage and, for self- and user-signing keys,
be signed by the master key. Signatures are verified against the key as
stored. Replacing a master key asks for the password; a first upload or
an upload of the keys already stored does not, as the spec allows. A
signature of another user's master key is shown only to the user who
made it and never federated.
Every write that changes what /keys/query answers moves the device list
stream in the same transaction, one position per device, and wakes the
syncs of everyone sharing a room. The stream is the sixth field of a sync
token; older tokens still parse. The syncapi works out device_lists
changed and left from the stream and from membership moves in the
window, skipping invites and knocks, and reports
device_one_time_keys_count and device_unused_fallback_key_types, the
latter as [] when empty. GET /keys/changes uses the same logic.
Over federation: /user/keys/query, /user/keys/claim and
/user/devices/{userId}, and m.device_list_update and m.signing_key_update
both ways. Remote keys are not cached; an update about a remote user
sharing a room moves them in the stream so local clients re-query.
Device display names are not sent to other servers. With federation off,
remote users are reported in failures at once.
Deleting a device deletes its keys, through a new userapi
DeviceObserver. Key backup keeps the better copy of each session by the
spec's rule, never reuses a deleted version's number, and answers writes
to a stale version with M_WRONG_ROOM_KEYS_VERSION.
Storage is migration 0008 for sqlite and postgres, with a PocketBase
stub. The Complement target sets gain the key, device list, fallback,
cross-signing and backup tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
566 lines
18 KiB
Go
566 lines
18 KiB
Go
// Package userapi owns accounts, passwords, devices, tokens and account
|
|
// data: everything that identifies a local user and their sessions.
|
|
//
|
|
// It is the component the Client-Server API handlers call for anything
|
|
// to do with who is making a request. The HTTP shapes live in clientapi;
|
|
// what is here is the part that would be the same behind any transport —
|
|
// which is also what makes `warp create-account` able to create an account
|
|
// without starting a server.
|
|
//
|
|
// Nothing in this package knows about rooms. A device's encryption keys
|
|
// are the keyserver's, which hears about a device going away through the
|
|
// DeviceObserver; its push rules arrive in Phase 8.
|
|
package userapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
)
|
|
|
|
// Errors callers have to tell apart. The storage sentinels are re-exported
|
|
// rather than translated: there is one error value with two names, so
|
|
// errors.Is works whichever package a caller reaches for.
|
|
var (
|
|
ErrUserExists = storage.ErrUserExists
|
|
ErrNoSuchUser = storage.ErrNoSuchUser
|
|
ErrNoSuchDevice = storage.ErrNoSuchDevice
|
|
ErrNoSuchToken = storage.ErrNoSuchToken
|
|
ErrNoSuchAccountData = storage.ErrNoSuchAccountData
|
|
|
|
// ErrTokenExpired is an access token that was valid and has timed out.
|
|
// It is distinct from ErrNoSuchToken because the spec answers it with a
|
|
// soft logout: the client may keep its encryption state and log in
|
|
// again.
|
|
ErrTokenExpired = errors.New("access token has expired")
|
|
|
|
// ErrAccountDeactivated is a token belonging to an account that has
|
|
// been deactivated.
|
|
ErrAccountDeactivated = errors.New("account has been deactivated")
|
|
|
|
// ErrWrongPassword is a failed password check. It is deliberately the
|
|
// same error whether the account exists or not, so that callers cannot
|
|
// turn it into a user-enumeration oracle by accident.
|
|
ErrWrongPassword = errors.New("invalid password")
|
|
|
|
// ErrNoPassword is an account that has no password set and so cannot
|
|
// use m.login.password.
|
|
ErrNoPassword = errors.New("account has no password")
|
|
|
|
// ErrInvalidDeviceID is a client-supplied device ID that is empty, too
|
|
// long or contains control characters.
|
|
ErrInvalidDeviceID = errors.New("invalid device ID")
|
|
)
|
|
|
|
// API is the userapi component.
|
|
type API struct {
|
|
serverName string
|
|
store storage.UserStore
|
|
log *slog.Logger
|
|
now func() time.Time
|
|
|
|
accessTokenLifetime time.Duration
|
|
refreshTokenLifetime time.Duration
|
|
|
|
// lastSeen throttles the last-seen bookkeeping; see RecordActivity.
|
|
lastSeen sync.Map
|
|
|
|
// observer hears about deleted and renamed devices; nil hears nothing.
|
|
observer DeviceObserver
|
|
}
|
|
|
|
// DeviceObserver is told about changes to devices that something else
|
|
// keeps state for: the keyserver, whose keys for a deleted device have to
|
|
// go with it. *keyserver.API satisfies it.
|
|
//
|
|
// It is called after the change has been made, on the request path, and
|
|
// must not fail it: the device is already gone.
|
|
type DeviceObserver interface {
|
|
// DevicesDeleted is called with the deleted device IDs, or with none
|
|
// when every device of the user was deleted.
|
|
DevicesDeleted(ctx context.Context, localpart string, deviceIDs []string)
|
|
|
|
// DeviceRenamed is called when a device's display name changes.
|
|
DeviceRenamed(ctx context.Context, localpart, deviceID string)
|
|
}
|
|
|
|
// SetDeviceObserver connects the observer. It is a setter because the
|
|
// keyserver is built after the userapi it reads devices from.
|
|
func (a *API) SetDeviceObserver(o DeviceObserver) { a.observer = o }
|
|
|
|
// Options configure the component.
|
|
type Options struct {
|
|
// ServerName is the Matrix server name; it becomes the domain part of
|
|
// every user ID this component hands out.
|
|
ServerName string
|
|
|
|
// Store is the persistence layer.
|
|
Store storage.UserStore
|
|
|
|
// AccessTokenLifetime expires issued access tokens. Zero issues tokens
|
|
// that never expire.
|
|
AccessTokenLifetime time.Duration
|
|
|
|
// RefreshTokenLifetime bounds how long a refresh token stays usable.
|
|
// Zero means no expiry.
|
|
RefreshTokenLifetime time.Duration
|
|
|
|
Logger *slog.Logger
|
|
|
|
// Now is the clock, so tests can control token expiry. Nil means
|
|
// time.Now.
|
|
Now func() time.Time
|
|
}
|
|
|
|
// New builds the component.
|
|
func New(opts Options) (*API, error) {
|
|
if opts.ServerName == "" {
|
|
return nil, errors.New("userapi: ServerName must not be empty")
|
|
}
|
|
if opts.Store == nil {
|
|
return nil, errors.New("userapi: Store must not be nil")
|
|
}
|
|
if opts.Logger == nil {
|
|
opts.Logger = slog.Default()
|
|
}
|
|
if opts.Now == nil {
|
|
opts.Now = time.Now
|
|
}
|
|
return &API{
|
|
serverName: opts.ServerName,
|
|
store: opts.Store,
|
|
log: opts.Logger,
|
|
now: opts.Now,
|
|
accessTokenLifetime: opts.AccessTokenLifetime,
|
|
refreshTokenLifetime: opts.RefreshTokenLifetime,
|
|
}, nil
|
|
}
|
|
|
|
// ServerName returns the server name user IDs are built with.
|
|
func (a *API) ServerName() string { return a.serverName }
|
|
|
|
// nowMS is the current time in the unit the store uses.
|
|
func (a *API) nowMS() int64 { return a.now().UnixMilli() }
|
|
|
|
// NewAccountRequest describes an account to create.
|
|
type NewAccountRequest struct {
|
|
// Localpart is normalised and validated by CreateAccount.
|
|
Localpart string
|
|
|
|
// Password may be empty, which creates an account that cannot log in
|
|
// with a password. `warp create-account` uses that for a placeholder
|
|
// account; the admin API does not offer it.
|
|
Password string
|
|
|
|
DisplayName string
|
|
Admin bool
|
|
}
|
|
|
|
// CreateAccount validates the request, hashes the password and stores the
|
|
// account. It returns ErrUserExists if the name is taken and
|
|
// ErrInvalidLocalpart if the name is not usable.
|
|
func (a *API) CreateAccount(ctx context.Context, req NewAccountRequest) (storage.Account, error) {
|
|
localpart := NormaliseLocalpart(req.Localpart)
|
|
if err := ValidateLocalpart(localpart, a.serverName); err != nil {
|
|
return storage.Account{}, err
|
|
}
|
|
|
|
var hash string
|
|
if req.Password != "" {
|
|
var err error
|
|
if hash, err = HashPassword(req.Password); err != nil {
|
|
return storage.Account{}, err
|
|
}
|
|
}
|
|
|
|
acc, err := a.store.CreateAccount(ctx, storage.NewAccount{
|
|
Localpart: localpart,
|
|
PasswordHash: hash,
|
|
DisplayName: req.DisplayName,
|
|
Admin: req.Admin,
|
|
CreatedAtMS: a.nowMS(),
|
|
})
|
|
if err != nil {
|
|
return storage.Account{}, err
|
|
}
|
|
a.log.Info("created account", "user_id", a.UserID(localpart), "admin", req.Admin)
|
|
return acc, nil
|
|
}
|
|
|
|
// Account reads an account by normalised localpart.
|
|
func (a *API) Account(ctx context.Context, localpart string) (storage.Account, error) {
|
|
return a.store.Account(ctx, localpart)
|
|
}
|
|
|
|
// AccountExists reports whether a localpart is taken. It is the answer
|
|
// behind /register/available, and deliberately does not distinguish a
|
|
// deactivated account: the name stays reserved.
|
|
func (a *API) AccountExists(ctx context.Context, localpart string) (bool, error) {
|
|
switch _, err := a.store.Account(ctx, localpart); {
|
|
case err == nil:
|
|
return true, nil
|
|
case errors.Is(err, ErrNoSuchUser):
|
|
return false, nil
|
|
default:
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
// SetAdmin grants or revokes the admin flag.
|
|
func (a *API) SetAdmin(ctx context.Context, localpart string, admin bool) error {
|
|
return a.store.SetAdmin(ctx, localpart, admin)
|
|
}
|
|
|
|
// SetPassword replaces an account's password.
|
|
func (a *API) SetPassword(ctx context.Context, localpart, password string) error {
|
|
hash, err := HashPassword(password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.store.SetPasswordHash(ctx, localpart, hash)
|
|
}
|
|
|
|
// CheckPassword verifies a password against an account.
|
|
//
|
|
// Every failure path costs one argon2id hash, including an account that does
|
|
// not exist or has no password, so the time a rejection takes says nothing
|
|
// about which user names are real.
|
|
func (a *API) CheckPassword(ctx context.Context, localpart, password string) error {
|
|
hash, err := a.store.PasswordHash(ctx, localpart)
|
|
switch {
|
|
case errors.Is(err, ErrNoSuchUser):
|
|
_, _ = VerifyPassword(dummyPasswordHash, password)
|
|
return ErrWrongPassword
|
|
case err != nil:
|
|
return err
|
|
}
|
|
if hash == "" {
|
|
_, _ = VerifyPassword(dummyPasswordHash, password)
|
|
return ErrNoPassword
|
|
}
|
|
|
|
ok, err := VerifyPassword(hash, password)
|
|
switch {
|
|
case errors.Is(err, ErrPasswordTooLong):
|
|
return ErrWrongPassword
|
|
case err != nil:
|
|
// A malformed hash is an operational problem, not a wrong password.
|
|
a.log.Error("stored password hash is unusable", "localpart", localpart, "error", err)
|
|
return err
|
|
case !ok:
|
|
return ErrWrongPassword
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SessionRequest asks for a new device and its tokens.
|
|
type SessionRequest struct {
|
|
// Localpart must already be normalised and must exist.
|
|
Localpart string
|
|
|
|
// DeviceID is the client's choice. Empty means generate one. Naming a
|
|
// device that already exists re-uses it and revokes its old tokens.
|
|
DeviceID string
|
|
|
|
// DisplayName is initial_device_display_name: used only when the device
|
|
// is created.
|
|
DisplayName string
|
|
|
|
// WithRefreshToken issues a refresh token alongside the access token.
|
|
WithRefreshToken bool
|
|
|
|
IP string
|
|
UserAgent string
|
|
}
|
|
|
|
// Session is the result of a login, a registration or a token refresh: what
|
|
// the client needs to start making requests.
|
|
type Session struct {
|
|
UserID string
|
|
Localpart string
|
|
DeviceID string
|
|
|
|
AccessToken string
|
|
|
|
// RefreshToken is empty unless the client asked for one.
|
|
RefreshToken string
|
|
|
|
// ExpiresInMS is how long the access token is good for, or 0 when it
|
|
// does not expire.
|
|
ExpiresInMS int64
|
|
}
|
|
|
|
// CreateSession issues a device and tokens for an existing account.
|
|
func (a *API) CreateSession(ctx context.Context, req SessionRequest) (Session, error) {
|
|
deviceID := req.DeviceID
|
|
if deviceID == "" {
|
|
var err error
|
|
if deviceID, err = GenerateDeviceID(); err != nil {
|
|
return Session{}, err
|
|
}
|
|
} else if err := ValidateDeviceID(deviceID); err != nil {
|
|
return Session{}, err
|
|
}
|
|
|
|
accessToken, err := GenerateAccessToken()
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
var refreshToken string
|
|
if req.WithRefreshToken {
|
|
if refreshToken, err = GenerateRefreshToken(); err != nil {
|
|
return Session{}, err
|
|
}
|
|
}
|
|
|
|
now := a.nowMS()
|
|
session := storage.NewSession{
|
|
Localpart: req.Localpart,
|
|
DeviceID: deviceID,
|
|
DisplayName: req.DisplayName,
|
|
CreatedAtMS: now,
|
|
AccessTokenHash: HashToken(accessToken),
|
|
AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime),
|
|
RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime),
|
|
LastSeenIP: req.IP,
|
|
UserAgent: req.UserAgent,
|
|
}
|
|
if refreshToken != "" {
|
|
session.RefreshTokenHash = HashToken(refreshToken)
|
|
}
|
|
|
|
device, err := a.store.CreateSession(ctx, session)
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
return Session{
|
|
UserID: a.UserID(req.Localpart),
|
|
Localpart: req.Localpart,
|
|
DeviceID: device.ID,
|
|
AccessToken: accessToken,
|
|
RefreshToken: refreshToken,
|
|
ExpiresInMS: lifetimeMS(a.accessTokenLifetime),
|
|
}, nil
|
|
}
|
|
|
|
// RefreshSession spends a refresh token and issues a new pair. It returns
|
|
// ErrNoSuchToken if the token is unknown or already spent.
|
|
func (a *API) RefreshSession(ctx context.Context, refreshToken string) (Session, error) {
|
|
accessToken, err := GenerateAccessToken()
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
newRefreshToken, err := GenerateRefreshToken()
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
|
|
now := a.nowMS()
|
|
info, err := a.store.RefreshSession(ctx, storage.RefreshedSession{
|
|
RefreshTokenHash: HashToken(refreshToken),
|
|
NewAccessTokenHash: HashToken(accessToken),
|
|
AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime),
|
|
NewRefreshTokenHash: HashToken(newRefreshToken),
|
|
RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime),
|
|
RefreshedAtMS: now,
|
|
})
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
if info.Deactivated {
|
|
return Session{}, ErrAccountDeactivated
|
|
}
|
|
return Session{
|
|
UserID: a.UserID(info.Localpart),
|
|
Localpart: info.Localpart,
|
|
DeviceID: info.DeviceID,
|
|
AccessToken: accessToken,
|
|
RefreshToken: newRefreshToken,
|
|
ExpiresInMS: lifetimeMS(a.accessTokenLifetime),
|
|
}, nil
|
|
}
|
|
|
|
// Authenticated is the identity behind a request: what the auth middleware
|
|
// resolves an access token into and what every authenticated handler is
|
|
// given.
|
|
type Authenticated struct {
|
|
UserID string
|
|
Localpart string
|
|
DeviceID string
|
|
Admin bool
|
|
}
|
|
|
|
// Authenticate resolves an access token.
|
|
//
|
|
// It returns ErrNoSuchToken for an unknown token, ErrTokenExpired for one
|
|
// that has timed out, and ErrAccountDeactivated for a token whose account is
|
|
// gone. The caller turns those into M_UNKNOWN_TOKEN, a soft logout, and
|
|
// M_USER_DEACTIVATED respectively.
|
|
func (a *API) Authenticate(ctx context.Context, accessToken string) (Authenticated, error) {
|
|
if accessToken == "" {
|
|
return Authenticated{}, ErrNoSuchToken
|
|
}
|
|
info, err := a.store.TokenInfo(ctx, HashToken(accessToken))
|
|
if err != nil {
|
|
return Authenticated{}, err
|
|
}
|
|
if info.ExpiresAtMS != 0 && info.ExpiresAtMS <= a.nowMS() {
|
|
return Authenticated{}, ErrTokenExpired
|
|
}
|
|
if info.Deactivated {
|
|
return Authenticated{}, ErrAccountDeactivated
|
|
}
|
|
return Authenticated{
|
|
UserID: a.UserID(info.Localpart),
|
|
Localpart: info.Localpart,
|
|
DeviceID: info.DeviceID,
|
|
Admin: info.Admin,
|
|
}, nil
|
|
}
|
|
|
|
// lastSeenInterval is how stale the last-seen record of a device may get.
|
|
// Recording it on every request would double the write load of the whole C-S
|
|
// API to keep a field nothing reads more often than a user opens their
|
|
// device list.
|
|
const lastSeenInterval = time.Minute
|
|
|
|
// RecordActivity updates a device's last-seen timestamp, address and user
|
|
// agent, at most once per lastSeenInterval per device. A failure is logged
|
|
// and swallowed: the request it belongs to has already been authorised, and
|
|
// bookkeeping must not turn into a 500.
|
|
func (a *API) RecordActivity(ctx context.Context, auth Authenticated, ip, userAgent string) {
|
|
key := auth.Localpart + "\x00" + auth.DeviceID
|
|
now := a.now()
|
|
|
|
if previous, ok := a.lastSeen.Load(key); ok {
|
|
if at, ok := previous.(time.Time); ok && now.Sub(at) < lastSeenInterval {
|
|
return
|
|
}
|
|
}
|
|
a.lastSeen.Store(key, now)
|
|
|
|
if err := a.store.TouchDevice(ctx, auth.Localpart, auth.DeviceID, ip, userAgent, now.UnixMilli()); err != nil {
|
|
a.log.Warn("recording device activity failed",
|
|
"user_id", auth.UserID, "device_id", auth.DeviceID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Device reads one of a user's devices.
|
|
func (a *API) Device(ctx context.Context, localpart, deviceID string) (storage.Device, error) {
|
|
return a.store.Device(ctx, localpart, deviceID)
|
|
}
|
|
|
|
// Devices lists a user's devices.
|
|
func (a *API) Devices(ctx context.Context, localpart string) ([]storage.Device, error) {
|
|
return a.store.Devices(ctx, localpart)
|
|
}
|
|
|
|
// SetDeviceDisplayName renames a device.
|
|
func (a *API) SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error {
|
|
if err := a.store.SetDeviceDisplayName(ctx, localpart, deviceID, displayName); err != nil {
|
|
return err
|
|
}
|
|
if a.observer != nil {
|
|
a.observer.DeviceRenamed(ctx, localpart, deviceID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteDevices revokes devices and their tokens. It is what /logout,
|
|
// /delete_devices and DELETE /devices/{id} all end up calling.
|
|
func (a *API) DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) error {
|
|
if len(deviceIDs) == 0 {
|
|
return nil
|
|
}
|
|
if _, err := a.store.DeleteDevices(ctx, localpart, deviceIDs); err != nil {
|
|
return err
|
|
}
|
|
a.forgetLastSeen(localpart, deviceIDs...)
|
|
if a.observer != nil {
|
|
a.observer.DevicesDeleted(ctx, localpart, deviceIDs)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteAllDevices revokes every device of a user: /logout/all.
|
|
func (a *API) DeleteAllDevices(ctx context.Context, localpart string) error {
|
|
if _, err := a.store.DeleteAllDevices(ctx, localpart); err != nil {
|
|
return err
|
|
}
|
|
a.forgetLastSeen(localpart)
|
|
if a.observer != nil {
|
|
a.observer.DevicesDeleted(ctx, localpart, nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// forgetLastSeen drops the throttle entries for devices that no longer
|
|
// exist, so the map does not grow with every logout. With no device IDs it
|
|
// clears every entry of the user.
|
|
func (a *API) forgetLastSeen(localpart string, deviceIDs ...string) {
|
|
if len(deviceIDs) > 0 {
|
|
for _, id := range deviceIDs {
|
|
a.lastSeen.Delete(localpart + "\x00" + id)
|
|
}
|
|
return
|
|
}
|
|
prefix := localpart + "\x00"
|
|
a.lastSeen.Range(func(key, _ any) bool {
|
|
if s, ok := key.(string); ok && len(s) > len(prefix) && s[:len(prefix)] == prefix {
|
|
a.lastSeen.Delete(key)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
// SetDisplayName updates a user's profile display name.
|
|
func (a *API) SetDisplayName(ctx context.Context, localpart, displayName string) error {
|
|
return a.store.SetDisplayName(ctx, localpart, displayName)
|
|
}
|
|
|
|
// SetAvatarURL updates a user's profile avatar.
|
|
func (a *API) SetAvatarURL(ctx context.Context, localpart, avatarURL string) error {
|
|
return a.store.SetAvatarURL(ctx, localpart, avatarURL)
|
|
}
|
|
|
|
// AccountData reads one account data blob; roomID is empty for global data.
|
|
func (a *API) AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error) {
|
|
return a.store.AccountData(ctx, localpart, roomID, dataType)
|
|
}
|
|
|
|
// SetAccountData writes one account data blob.
|
|
func (a *API) SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error {
|
|
return a.store.SetAccountData(ctx, localpart, roomID, dataType, content)
|
|
}
|
|
|
|
// ListAccounts returns a page of accounts for the admin API.
|
|
func (a *API) ListAccounts(ctx context.Context, filter storage.AccountFilter) ([]storage.Account, int, error) {
|
|
return a.store.ListAccounts(ctx, filter)
|
|
}
|
|
|
|
// Stats returns the aggregate user counts for the admin API.
|
|
func (a *API) Stats(ctx context.Context) (storage.Stats, error) {
|
|
return a.store.Stats(ctx)
|
|
}
|
|
|
|
// expiryMS turns a lifetime into an absolute deadline in the store's unit,
|
|
// or 0 for "never".
|
|
func expiryMS(nowMS int64, lifetime time.Duration) int64 {
|
|
if lifetime <= 0 {
|
|
return 0
|
|
}
|
|
return nowMS + lifetime.Milliseconds()
|
|
}
|
|
|
|
// lifetimeMS is the expires_in_ms a client is told, or 0 for "never".
|
|
func lifetimeMS(lifetime time.Duration) int64 {
|
|
if lifetime <= 0 {
|
|
return 0
|
|
}
|
|
return lifetime.Milliseconds()
|
|
}
|