157 lines
4.7 KiB
Go
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
|
|
}
|