Files
at-container-registry/pkg/appview/authgate/service_auth_fetcher.go
T

100 lines
3.4 KiB
Go

package authgate
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/auth/token"
)
// ServiceAuthFetcher pre-mints the AppView↔hold service-auth at /auth/token
// time so the registry JWT can be bound to its lifetime.
//
// Flow: token handler calls Fetch with the requester's DID and chosen auth
// method, we resolve their hold, call the appropriate service-token fetcher
// (which caches the result with the PDS-granted exp), and read the cached
// expiry back. The token handler stamps the JWT's exp from that value, so
// the JWT and service-auth expire concurrently.
type ServiceAuthFetcher struct {
holdResolver
refresher *oauth.Refresher
cache *auth.Cache
}
// ServiceAuthOption configures a ServiceAuthFetcher.
type ServiceAuthOption func(*ServiceAuthFetcher)
// WithCache routes expiry read-back through the supplied cache instead of
// the package default. GetOrFetchServiceToken* still writes to the default
// cache today; injecting a fresh Cache is currently only useful for tests
// that pre-seed entries and want to verify the read-back path.
func WithCache(c *auth.Cache) ServiceAuthOption {
return func(f *ServiceAuthFetcher) { f.cache = c }
}
// NewServiceAuthFetcher constructs a ServiceAuthFetcher. defaultHoldDID is
// the AppView fallback used when a user has no sailor profile yet. The
// refresher is required for OAuth-flow service-token minting; pass nil if
// only the app-password flow needs to work.
func NewServiceAuthFetcher(db *sql.DB, refresher *oauth.Refresher, defaultHoldDID string, opts ...ServiceAuthOption) *ServiceAuthFetcher {
f := &ServiceAuthFetcher{
holdResolver: holdResolver{db: db, defaultHoldDID: defaultHoldDID},
refresher: refresher,
cache: auth.DefaultCache(),
}
for _, opt := range opts {
opt(f)
}
return f
}
// Fetch satisfies token.ServiceAuthFetcher. Returns the granted expiry of
// the cached service-auth, or zero time when the user has no hold (caller
// falls back to the issuer's default).
func (f *ServiceAuthFetcher) Fetch(ctx context.Context, did, authMethod string) (time.Time, error) {
holdDID, err := f.resolveHoldDID(ctx, did)
if err != nil {
return time.Time{}, err
}
if holdDID == "" {
// No hold configured anywhere — graceful degradation.
return time.Time{}, nil
}
_, _, pdsEndpoint, err := atproto.ResolveIdentity(ctx, did)
if err != nil {
return time.Time{}, fmt.Errorf("resolve PDS for %s: %w", did, err)
}
switch authMethod {
case token.AuthMethodOAuth:
if f.refresher == nil {
return time.Time{}, errors.New("OAuth flow requires a refresher")
}
if _, err := auth.GetOrFetchServiceToken(ctx, f.refresher, did, holdDID, pdsEndpoint); err != nil {
return time.Time{}, fmt.Errorf("oauth service-auth fetch: %w", err)
}
case token.AuthMethodAppPassword:
if _, err := auth.GetOrFetchServiceTokenWithAppPassword(ctx, did, holdDID, pdsEndpoint); err != nil {
return time.Time{}, fmt.Errorf("app-password service-auth fetch: %w", err)
}
default:
return time.Time{}, fmt.Errorf("unknown auth method: %q", authMethod)
}
// GetOrFetchServiceToken* caches as a side effect — read the granted
// expiry back. The cached value already has the 10s safety margin from
// pkg/auth/cache.go applied.
_, expiresAt := f.cache.Get(did, holdDID)
if expiresAt.IsZero() {
return time.Time{}, errors.New("service-auth fetched but cache miss")
}
return expiresAt, nil
}