Files
sherlock/internal/authn/refresh.go
T
amacocian f5109abfad
CI / build (push) Successful in 19s
Gitea mcp pre-release
2026-05-25 15:22:30 +02:00

157 lines
4.7 KiB
Go

//go:build unix
package authn
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
)
// RefreshSkew is how much headroom EnsureFresh leaves before the ID
// token actually expires. Refreshing 30s ahead of expiry avoids
// shipping a token that will be rejected by clock-skewed resource
// servers.
const RefreshSkew = 30 * time.Second
// ErrNoRefreshToken is returned by EnsureFresh when the stored
// TokenSet has no refresh token to swap.
var ErrNoRefreshToken = errors.New("authn: no refresh token stored")
// EnsureFreshOptions tunes EnsureFresh for tests.
type EnsureFreshOptions struct {
// LockPath is the absolute path to the cross-process flock file.
// Required.
LockPath string
// Discoverer overrides OIDC discovery for the refresh round-trip.
Discoverer Discoverer
// Clock is injected for time-warp tests.
Clock func() time.Time
}
// EnsureFresh returns the stored TokenSet for service, refreshing it
// against its OAuth issuer if the ID token has < RefreshSkew left.
// Cross-process safe: takes an exclusive flock on opts.LockPath,
// re-reads the keyring after acquiring the lock, and skips the
// refresh if a concurrent process already rotated the tokens.
//
// The lockfile is created with mode 0600 and never removed — its
// only role is to anchor the flock; multiple sherlock invocations
// across an operator's lifetime share it.
//
// Callers that want full "ensure I have a token, even if it means
// starting an OAuth flow" semantics should use Ensure instead.
func EnsureFresh(ctx context.Context, store keyring.Store, service string, opts EnsureFreshOptions) (keyring.TokenSet, error) {
if opts.LockPath == "" {
return keyring.TokenSet{}, errors.New("authn: EnsureFresh requires LockPath")
}
if opts.Discoverer == nil {
opts.Discoverer = DefaultDiscoverer{}
}
if opts.Clock == nil {
opts.Clock = time.Now
}
ts, err := store.Get(service)
if err != nil {
return keyring.TokenSet{}, err
}
if ts.Empty() {
return ts, keyring.ErrNoTokens
}
if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) {
return ts, nil
}
// Slow path: take the lock, double-check, refresh under the lock
// so concurrent processes serialise.
if err := os.MkdirAll(filepath.Dir(opts.LockPath), 0o700); err != nil {
return ts, fmt.Errorf("authn: mkdir lockdir: %w", err)
}
f, err := os.OpenFile(opts.LockPath, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return ts, fmt.Errorf("authn: open lockfile: %w", err)
}
defer func() { _ = f.Close() }()
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
return ts, fmt.Errorf("authn: flock lockfile: %w", err)
}
defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }()
// Re-read after acquiring the lock. A concurrent process may have
// refreshed for us while we were waiting on flock.
ts, err = store.Get(service)
if err != nil {
return ts, err
}
if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) {
return ts, nil
}
if ts.RefreshToken == "" {
return ts, ErrNoRefreshToken
}
fresh, err := refreshOnce(ctx, ts, opts.Discoverer)
if err != nil {
return ts, err
}
if err := store.Set(service, fresh); err != nil {
return ts, fmt.Errorf("authn: persist refreshed tokens: %w", err)
}
return fresh, nil
}
func refreshOnce(ctx context.Context, ts keyring.TokenSet, disc Discoverer) (keyring.TokenSet, error) {
if ts.Issuer == "" || ts.ClientID == "" {
return ts, errors.New("authn: stored tokens missing issuer or client_id (re-login)")
}
provider, err := disc.Discover(ctx, ts.Issuer)
if err != nil {
return ts, err
}
cfg := &oauth2.Config{
ClientID: ts.ClientID,
ClientSecret: ts.ClientSecret,
Endpoint: provider.Endpoint(),
Scopes: ts.Scopes,
}
src := cfg.TokenSource(ctx, &oauth2.Token{
AccessToken: ts.AccessToken,
RefreshToken: ts.RefreshToken,
// Force the source to refresh by claiming we expired in the past.
Expiry: time.Now().Add(-1 * time.Minute),
})
tok, err := src.Token()
if err != nil {
return ts, fmt.Errorf("authn: refresh: %w", err)
}
rawID, _ := tok.Extra("id_token").(string)
if rawID == "" {
// Some IdPs omit id_token on refresh; keep the old one if so.
rawID = ts.IDToken
}
verifier := provider.Verifier(&oidc.Config{ClientID: ts.ClientID})
idTok, err := verifier.Verify(ctx, rawID)
if err != nil {
return ts, fmt.Errorf("authn: refreshed id_token verify: %w", err)
}
out := ts
out.IDToken = rawID
out.AccessToken = tok.AccessToken
if tok.RefreshToken != "" {
out.RefreshToken = tok.RefreshToken
}
out.IDExpiresAt = idTok.Expiry
out.RefreshExpAt = refreshExpiry(tok)
return out, nil
}