사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
552 lines
19 KiB
Go
552 lines
19 KiB
Go
package credentialstore
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Status values for principal tokens. These are the only values accepted by
|
|
// the CHECK constraint on tokens.status.
|
|
const (
|
|
StatusActive = "active"
|
|
StatusDisabled = "disabled"
|
|
StatusRevoked = "revoked"
|
|
)
|
|
|
|
// CreatePrincipalInput is the caller-provided payload for CreatePrincipalWithToken.
|
|
type CreatePrincipalInput struct {
|
|
// Alias is a human-readable name for the principal. It must be unique
|
|
// across all principals and is used for display and management.
|
|
Alias string
|
|
}
|
|
|
|
// IssuedPrincipal is the one-time response from CreatePrincipalWithToken.
|
|
// RawToken is returned exactly once to the issuer and must be delivered to
|
|
// the corresponding Edge configuration before the reference is lost. The
|
|
// TokenRecord embedded in the response contains only the digest; the raw
|
|
// value is never persisted.
|
|
type IssuedPrincipal struct {
|
|
Principal PrincipalRecord
|
|
Token TokenRecord
|
|
RawToken string
|
|
}
|
|
|
|
// IssuedToken is the one-time response when generating an additional token for an existing principal.
|
|
type IssuedToken struct {
|
|
Token TokenRecord
|
|
RawToken string
|
|
}
|
|
|
|
// PrincipalRecord is the persisted principal row returned by queries.
|
|
type PrincipalRecord struct {
|
|
ID string
|
|
Alias string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// TokenRecord is the persisted token row returned by queries. It never
|
|
// contains the raw token value; only the SHA-256 digest hex string.
|
|
type TokenRecord struct {
|
|
ID string
|
|
PrincipalID string
|
|
TokenRef string
|
|
Digest string
|
|
Status string
|
|
Revision int64
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
RevokedAt *time.Time
|
|
}
|
|
|
|
// ErrPrincipalAlreadyExists is returned when CreatePrincipalWithToken is
|
|
// called with an alias that is already in use.
|
|
var ErrPrincipalAlreadyExists = errors.New("credentialstore: principal alias already exists")
|
|
|
|
// ErrPrincipalNotFound is returned when a principal lookup finds no matching row.
|
|
var ErrPrincipalNotFound = errors.New("credentialstore: principal not found")
|
|
|
|
// ErrTokenNotFound is returned when a token lookup finds no matching row.
|
|
var ErrTokenNotFound = errors.New("credentialstore: token not found")
|
|
|
|
// ErrTokenNotActive is returned when an operation requires an active token.
|
|
var ErrTokenNotActive = errors.New("credentialstore: token is not active")
|
|
|
|
// ErrRevisionMismatch is returned by CAS operations when the provided
|
|
// revision does not match the current row revision.
|
|
var ErrRevisionMismatch = errors.New("credentialstore: revision mismatch")
|
|
|
|
// ErrTokenRevoked is returned by re-enable attempts on a revoked token.
|
|
var ErrTokenRevoked = errors.New("credentialstore: revoked token cannot be re-enabled")
|
|
|
|
// generateRawToken produces 32 random bytes encoded as a hex string.
|
|
// This is the only place the plaintext token exists; it is returned to the
|
|
// caller and never persisted.
|
|
func generateRawToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("credentialstore: generate random: %w", err)
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
// digestOf returns the SHA-256 hex digest of raw.
|
|
func digestOf(raw string) string {
|
|
sum := sha256.Sum256([]byte(raw))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// CreatePrincipalWithToken atomically creates a principal and an active
|
|
// token. The raw token is returned exactly once; the persisted TokenRecord
|
|
// contains only the SHA-256 digest.
|
|
//
|
|
// If the alias already exists, ErrPrincipalAlreadyExists is returned and no
|
|
// row is modified.
|
|
func (s *Store) CreatePrincipalWithToken(ctx context.Context, in CreatePrincipalInput) (*IssuedPrincipal, error) {
|
|
if in.Alias == "" {
|
|
return nil, fmt.Errorf("credentialstore: CreatePrincipalWithToken: alias is required")
|
|
}
|
|
now := time.Now().UTC()
|
|
raw, err := generateRawToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dig := digestOf(raw)
|
|
principalID := uuid.New().String()
|
|
tokenID := uuid.New().String()
|
|
|
|
var issued IssuedPrincipal
|
|
err = s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
// Check for duplicate alias inside the transaction.
|
|
var count int
|
|
if err := tx.QueryRowContext(ctx, s.bind(`SELECT COUNT(*) FROM principals WHERE alias=?`), in.Alias).Scan(&count); err != nil {
|
|
return fmt.Errorf("credentialstore: check alias: %w", err)
|
|
}
|
|
if count > 0 {
|
|
return ErrPrincipalAlreadyExists
|
|
}
|
|
|
|
// Insert principal.
|
|
if _, err := tx.ExecContext(ctx, s.bind(
|
|
`INSERT INTO principals (id, alias, created_at, updated_at) VALUES (?,?,?,?)`),
|
|
principalID, in.Alias, now, now,
|
|
); err != nil {
|
|
return fmt.Errorf("credentialstore: insert principal: %w", err)
|
|
}
|
|
|
|
// Insert token with digest only.
|
|
if _, err := tx.ExecContext(ctx, s.bind(
|
|
`INSERT INTO tokens (id, principal_id, token_ref, digest, status, revision, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`),
|
|
tokenID, principalID, tokenRefFor(dig), dig, StatusActive, 0, now, now,
|
|
); err != nil {
|
|
return fmt.Errorf("credentialstore: insert token: %w", err)
|
|
}
|
|
if err := s.bumpProjectionGenerationTx(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
|
|
issued = IssuedPrincipal{
|
|
Principal: PrincipalRecord{ID: principalID, Alias: in.Alias, CreatedAt: now, UpdatedAt: now},
|
|
Token: TokenRecord{
|
|
ID: tokenID, PrincipalID: principalID, TokenRef: tokenRefFor(dig),
|
|
Digest: dig, Status: StatusActive, Revision: 0, CreatedAt: now, UpdatedAt: now,
|
|
},
|
|
RawToken: raw,
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &issued, nil
|
|
}
|
|
|
|
// CreateFirstPrincipalWithToken atomically creates the first principal and its
|
|
// active token. It locks the projection state singleton row to serialize
|
|
// concurrent callers, verifies the principal store is currently empty, and
|
|
// rejects any non-empty store with ErrPrincipalAlreadyExists.
|
|
func (s *Store) CreateFirstPrincipalWithToken(ctx context.Context, in CreatePrincipalInput) (*IssuedPrincipal, error) {
|
|
if in.Alias == "" {
|
|
return nil, fmt.Errorf("credentialstore: CreateFirstPrincipalWithToken: alias is required")
|
|
}
|
|
now := time.Now().UTC()
|
|
raw, err := generateRawToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dig := digestOf(raw)
|
|
principalID := uuid.New().String()
|
|
tokenID := uuid.New().String()
|
|
|
|
var issued IssuedPrincipal
|
|
err = s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
res, err := tx.ExecContext(ctx, s.bind(`UPDATE principal_projection_state SET generation=generation WHERE singleton=1`))
|
|
if err != nil {
|
|
return fmt.Errorf("credentialstore: acquire first principal lock: %w", err)
|
|
}
|
|
affected, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("credentialstore: inspect first principal lock: %w", err)
|
|
}
|
|
if affected != 1 {
|
|
return ErrProjectionGenerationMissing
|
|
}
|
|
|
|
var count int
|
|
if err := tx.QueryRowContext(ctx, s.bind(`SELECT COUNT(*) FROM principals`)).Scan(&count); err != nil {
|
|
return fmt.Errorf("credentialstore: count principals: %w", err)
|
|
}
|
|
if count > 0 {
|
|
return ErrPrincipalAlreadyExists
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, s.bind(
|
|
`INSERT INTO principals (id, alias, created_at, updated_at) VALUES (?,?,?,?)`),
|
|
principalID, in.Alias, now, now,
|
|
); err != nil {
|
|
return fmt.Errorf("credentialstore: insert principal: %w", err)
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, s.bind(
|
|
`INSERT INTO tokens (id, principal_id, token_ref, digest, status, revision, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`),
|
|
tokenID, principalID, tokenRefFor(dig), dig, StatusActive, 0, now, now,
|
|
); err != nil {
|
|
return fmt.Errorf("credentialstore: insert token: %w", err)
|
|
}
|
|
if err := s.bumpProjectionGenerationTx(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
|
|
issued = IssuedPrincipal{
|
|
Principal: PrincipalRecord{ID: principalID, Alias: in.Alias, CreatedAt: now, UpdatedAt: now},
|
|
Token: TokenRecord{
|
|
ID: tokenID, PrincipalID: principalID, TokenRef: tokenRefFor(dig),
|
|
Digest: dig, Status: StatusActive, Revision: 0, CreatedAt: now, UpdatedAt: now,
|
|
},
|
|
RawToken: raw,
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &issued, nil
|
|
}
|
|
|
|
// CreateToken atomically generates and inserts an active token for an existing principal.
|
|
// The raw token is returned exactly once in IssuedToken; the persisted TokenRecord contains only the SHA-256 digest.
|
|
func (s *Store) CreateToken(ctx context.Context, principalID string) (*IssuedToken, error) {
|
|
if principalID == "" {
|
|
return nil, fmt.Errorf("credentialstore: CreateToken: principalID is required")
|
|
}
|
|
now := time.Now().UTC()
|
|
raw, err := generateRawToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dig := digestOf(raw)
|
|
tokenID := uuid.New().String()
|
|
|
|
var issued IssuedToken
|
|
err = s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
var count int
|
|
if err := tx.QueryRowContext(ctx, s.bind(`SELECT COUNT(*) FROM principals WHERE id=?`), principalID).Scan(&count); err != nil {
|
|
return fmt.Errorf("credentialstore: check principal: %w", err)
|
|
}
|
|
if count == 0 {
|
|
return ErrPrincipalNotFound
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, s.bind(
|
|
`INSERT INTO tokens (id, principal_id, token_ref, digest, status, revision, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`),
|
|
tokenID, principalID, tokenRefFor(dig), dig, StatusActive, 0, now, now,
|
|
); err != nil {
|
|
return fmt.Errorf("credentialstore: insert token: %w", err)
|
|
}
|
|
if err := s.bumpProjectionGenerationTx(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
|
|
issued = IssuedToken{
|
|
Token: TokenRecord{
|
|
ID: tokenID,
|
|
PrincipalID: principalID,
|
|
TokenRef: tokenRefFor(dig),
|
|
Digest: dig,
|
|
Status: StatusActive,
|
|
Revision: 0,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
RawToken: raw,
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &issued, nil
|
|
}
|
|
|
|
// ListTokens returns all tokens for a given principal.
|
|
func (s *Store) ListTokens(ctx context.Context, principalID string) ([]TokenRecord, error) {
|
|
rows, err := s.db.QueryContext(ctx, s.bind(`
|
|
SELECT id, principal_id, token_ref, digest, status, revision, created_at, updated_at, revoked_at
|
|
FROM tokens
|
|
WHERE principal_id=?
|
|
ORDER BY created_at ASC, id ASC
|
|
`), principalID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []TokenRecord
|
|
for rows.Next() {
|
|
var t TokenRecord
|
|
var createdAt, updatedAt string
|
|
var revokedAt sql.NullString
|
|
if err := rows.Scan(&t.ID, &t.PrincipalID, &t.TokenRef, &t.Digest, &t.Status, &t.Revision, &createdAt, &updatedAt, &revokedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
t.CreatedAt, _ = parseTime(createdAt)
|
|
t.UpdatedAt, _ = parseTime(updatedAt)
|
|
if revokedAt.Valid {
|
|
parsed, _ := parseTime(revokedAt.String)
|
|
t.RevokedAt = &parsed
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// tokenRefFor derives a stable, opaque token reference from a digest. It is a
|
|
// safe identifier for metadata and projections; callers authenticate with the
|
|
// one-time raw token, never with this reference or the digest itself.
|
|
func tokenRefFor(digest string) string {
|
|
return "tok_" + digest[:16]
|
|
}
|
|
|
|
// GetPrincipal returns the principal with the given alias, or (nil, nil) if
|
|
// no such principal exists.
|
|
func (s *Store) GetPrincipal(ctx context.Context, alias string) (*PrincipalRecord, error) {
|
|
var r PrincipalRecord
|
|
var createdAt, updatedAt string
|
|
err := s.db.QueryRowContext(ctx, s.bind(
|
|
`SELECT id, alias, created_at, updated_at FROM principals WHERE alias=?`), alias,
|
|
).Scan(&r.ID, &r.Alias, &createdAt, &updatedAt)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
r.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
|
return &r, nil
|
|
}
|
|
|
|
// ListPrincipals returns all principals with their active token metadata.
|
|
func (s *Store) ListPrincipals(ctx context.Context) ([]PrincipalWithToken, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT p.id, p.alias, p.created_at, p.updated_at,
|
|
t.id AS token_id, t.token_ref, t.digest, t.status, t.revision, t.created_at AS token_created_at, t.updated_at AS token_updated_at, t.revoked_at
|
|
FROM principals p
|
|
LEFT JOIN tokens t ON t.principal_id = p.id AND t.status = 'active'
|
|
ORDER BY p.alias
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []PrincipalWithToken
|
|
for rows.Next() {
|
|
var p PrincipalRecord
|
|
var t TokenRecord
|
|
var principalCreatedAt, principalUpdatedAt string
|
|
var tokenID, tokenRef, tokenDigest, tokenStatus sql.NullString
|
|
var tokenRevision sql.NullInt64
|
|
var tokenCreatedAt, tokenUpdatedAt, tokenRevokedAt sql.NullString
|
|
if err := rows.Scan(&p.ID, &p.Alias, &principalCreatedAt, &principalUpdatedAt,
|
|
&tokenID, &tokenRef, &tokenDigest, &tokenStatus, &tokenRevision,
|
|
&tokenCreatedAt, &tokenUpdatedAt, &tokenRevokedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
p.CreatedAt, _ = parseTime(principalCreatedAt)
|
|
p.UpdatedAt, _ = parseTime(principalUpdatedAt)
|
|
if tokenID.Valid {
|
|
t.ID = tokenID.String
|
|
t.PrincipalID = p.ID
|
|
t.TokenRef = tokenRef.String
|
|
t.Digest = tokenDigest.String
|
|
t.Status = tokenStatus.String
|
|
t.Revision = tokenRevision.Int64
|
|
t.CreatedAt, _ = time.Parse(time.RFC3339, tokenCreatedAt.String)
|
|
t.UpdatedAt, _ = time.Parse(time.RFC3339, tokenUpdatedAt.String)
|
|
if tokenRevokedAt.Valid {
|
|
timestamp := tokenRevokedAt.String
|
|
parsed, err := time.Parse(time.RFC3339, timestamp)
|
|
if err != nil {
|
|
parsed, _ = time.Parse("2006-01-02T15:04:05.999999999Z", timestamp)
|
|
}
|
|
t.RevokedAt = &parsed
|
|
}
|
|
}
|
|
out = append(out, PrincipalWithToken{Principal: p, Token: t})
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// PrincipalWithToken pairs a principal record with its active token metadata.
|
|
type PrincipalWithToken struct {
|
|
Principal PrincipalRecord
|
|
Token TokenRecord
|
|
}
|
|
|
|
// LookupTokenByDigest verifies a token digest against the store and returns
|
|
// the associated principal and token metadata. It is the authentication
|
|
// lookup path used by Edge projection validators.
|
|
//
|
|
// Only active tokens are returned; disabled and revoked tokens are hidden and
|
|
// yield ErrTokenNotFound just like an unknown digest.
|
|
func (s *Store) LookupTokenByDigest(ctx context.Context, digest string) (*PrincipalRecord, *TokenRecord, error) {
|
|
var p PrincipalRecord
|
|
var t TokenRecord
|
|
var principalCreatedAt, principalUpdatedAt, tokenCreatedAt, tokenUpdatedAt string
|
|
var revokedAt sql.NullString
|
|
err := s.db.QueryRowContext(ctx, s.bind(`
|
|
SELECT p.id, p.alias, p.created_at, p.updated_at,
|
|
t.id, t.token_ref, t.digest, t.status, t.revision, t.created_at, t.updated_at, t.revoked_at
|
|
FROM principals p
|
|
JOIN tokens t ON t.principal_id = p.id
|
|
WHERE t.digest = ? AND t.status = ?
|
|
`), digest, StatusActive).Scan(
|
|
&p.ID, &p.Alias, &principalCreatedAt, &principalUpdatedAt,
|
|
&t.ID, &t.TokenRef, &t.Digest, &t.Status, &t.Revision, &tokenCreatedAt, &tokenUpdatedAt, &revokedAt,
|
|
)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil, ErrTokenNotFound
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
p.CreatedAt, _ = parseTime(principalCreatedAt)
|
|
p.UpdatedAt, _ = parseTime(principalUpdatedAt)
|
|
t.CreatedAt, _ = parseTime(tokenCreatedAt)
|
|
t.UpdatedAt, _ = parseTime(tokenUpdatedAt)
|
|
if revokedAt.Valid {
|
|
parsed, _ := parseTime(revokedAt.String)
|
|
t.RevokedAt = &parsed
|
|
}
|
|
return &p, &t, nil
|
|
}
|
|
|
|
// DisableToken transitions an active token to disabled using CAS on revision.
|
|
// Disabled tokens cannot authenticate. Revoked tokens are terminal.
|
|
func (s *Store) DisableToken(ctx context.Context, principalID, tokenRef string, currentRevision int64) (*TokenRecord, error) {
|
|
return s.casStatus(ctx, principalID, tokenRef, currentRevision, []string{StatusActive}, StatusDisabled)
|
|
}
|
|
|
|
// RevokeToken transitions an active or disabled token to revoked. Revocation
|
|
// is irreversible: no method can return a revoked token to any other state.
|
|
func (s *Store) RevokeToken(ctx context.Context, principalID, tokenRef string, currentRevision int64) (*TokenRecord, error) {
|
|
return s.casStatus(ctx, principalID, tokenRef, currentRevision, []string{StatusActive, StatusDisabled}, StatusRevoked)
|
|
}
|
|
|
|
// casStatus performs a compare-and-swap on the token status. It requires
|
|
// currentRevision to match the row's current revision and transitions from
|
|
// fromStatus to toStatus. The revision is incremented atomically.
|
|
func (s *Store) casStatus(ctx context.Context, principalID, tokenRef string, currentRevision int64, fromStatuses []string, toStatus string) (*TokenRecord, error) {
|
|
var updated TokenRecord
|
|
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
now := time.Now().UTC()
|
|
placeholders := strings.TrimRight(strings.Repeat("?,", len(fromStatuses)), ",")
|
|
query := `UPDATE tokens SET status=?, revision=revision+1, updated_at=?, revoked_at=?
|
|
WHERE principal_id=? AND token_ref=? AND revision=? AND status IN (` + placeholders + `)`
|
|
args := []any{toStatus, now, nil, principalID, tokenRef, currentRevision}
|
|
if toStatus == StatusRevoked {
|
|
args[2] = now
|
|
}
|
|
for _, status := range fromStatuses {
|
|
args = append(args, status)
|
|
}
|
|
result, err := tx.ExecContext(ctx, s.bind(query), args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected != 1 {
|
|
return s.classifyTransitionMiss(ctx, tx, principalID, tokenRef, currentRevision, fromStatuses)
|
|
}
|
|
if err := s.loadToken(ctx, tx, principalID, tokenRef, &updated); err != nil {
|
|
return err
|
|
}
|
|
return s.bumpProjectionGenerationTx(ctx, tx)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &updated, nil
|
|
}
|
|
|
|
func (s *Store) classifyTransitionMiss(ctx context.Context, tx *sql.Tx, principalID, tokenRef string, currentRevision int64, fromStatuses []string) error {
|
|
var status string
|
|
var revision int64
|
|
err := tx.QueryRowContext(ctx, s.bind(
|
|
`SELECT status, revision FROM tokens WHERE principal_id=? AND token_ref=?`), principalID, tokenRef,
|
|
).Scan(&status, &revision)
|
|
if err == sql.ErrNoRows {
|
|
return ErrTokenNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if revision != currentRevision {
|
|
return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, revision, currentRevision)
|
|
}
|
|
if status == StatusRevoked {
|
|
return ErrTokenRevoked
|
|
}
|
|
return fmt.Errorf("%w: have %s want one of %s", ErrTokenNotActive, status, strings.Join(fromStatuses, ","))
|
|
}
|
|
|
|
func (s *Store) loadToken(ctx context.Context, tx *sql.Tx, principalID, tokenRef string, target *TokenRecord) error {
|
|
var createdAt, updatedAt string
|
|
var revokedAt sql.NullString
|
|
err := tx.QueryRowContext(ctx, s.bind(
|
|
`SELECT id, principal_id, token_ref, digest, status, revision, created_at, updated_at, revoked_at FROM tokens WHERE principal_id=? AND token_ref=?`),
|
|
principalID, tokenRef,
|
|
).Scan(&target.ID, &target.PrincipalID, &target.TokenRef, &target.Digest, &target.Status, &target.Revision, &createdAt, &updatedAt, &revokedAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target.CreatedAt, _ = parseTime(createdAt)
|
|
target.UpdatedAt, _ = parseTime(updatedAt)
|
|
if revokedAt.Valid {
|
|
parsed, _ := parseTime(revokedAt.String)
|
|
target.RevokedAt = &parsed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseTime attempts RFC3339 first, then falls back to microsecond-precision
|
|
// SQLite datetime format.
|
|
func parseTime(s string) (time.Time, error) {
|
|
t, err := time.Parse(time.RFC3339, s)
|
|
if err != nil {
|
|
t, err = time.Parse("2006-01-02T15:04:05.999999999Z", s)
|
|
}
|
|
return t, err
|
|
}
|