mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-29 13:35:35 +00:00
100 lines
3.4 KiB
Go
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
|
|
}
|