Files
sherlock/internal/authn/login.go
T
amacocian 1c2108dd2b
Release / tag (push) Successful in 2s
CI / build (push) Successful in 3m5s
Fix fmt
2026-06-13 13:40:24 +02:00

267 lines
8.6 KiB
Go

package authn
import (
"context"
"crypto/tls"
"errors"
"fmt"
"html"
"net"
"net/http"
"os"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
)
// ErrNotConfigured is returned by Login when Config.Configured() is
// false. The CLI surfaces it with the env vars to set.
var ErrNotConfigured = errors.New("authn: Authentik issuer/client ID not configured")
// Result is everything Login returns on success.
type Result struct {
Tokens keyring.TokenSet
}
// LoginOptions tunes Login for tests. Production code passes zero.
type LoginOptions struct {
// Discoverer overrides OIDC well-known discovery. Default is
// DefaultDiscoverer{}.
Discoverer Discoverer
// HTTPClient is plumbed into oauth2.HTTPClient context. Default
// is &http.Client{} with TLS using the system cert pool.
HTTPClient *http.Client
// OnAuthURL is called once with the URL the operator must visit
// (after the listener has bound, before blocking on the callback).
// Tests set this to drive the flow programmatically; the CLI
// opens it in the browser.
OnAuthURL func(url string)
}
// Login runs the full OAuth PKCE flow synchronously: bind a loopback
// listener on cfg.LoopbackAddr (default :6990), generate state +
// PKCE, build the auth URL and hand it to OnAuthURL, then block
// until the browser redirects to /callback (or ctx is cancelled).
// On success it verifies the ID token and returns the populated
// TokenSet.
//
// Login does not persist the result. Callers that want a stored,
// kept-fresh token should use Ensure instead.
//
// Login is safe to call from multiple processes only one at a time —
// the loopback port is a global resource. A second concurrent Login
// will fail with EADDRINUSE; we surface that verbatim so the
// operator (or the second MCP) can retry.
func Login(ctx context.Context, cfg Config, opts LoginOptions) (Result, error) {
if !cfg.Configured() {
return Result{}, ErrNotConfigured
}
if opts.Discoverer == nil {
opts.Discoverer = DefaultDiscoverer{}
}
if opts.HTTPClient == nil {
opts.HTTPClient = &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}}
}
if opts.OnAuthURL == nil {
opts.OnAuthURL = func(string) {}
}
ln, err := net.Listen("tcp", cfg.loopbackAddr())
if err != nil {
return Result{}, fmt.Errorf("authn: listen on %s: %w", cfg.loopbackAddr(), err)
}
defer func() { _ = ln.Close() }()
// The redirect URL must match what we registered with Authentik
// exactly. We use the listener's reported address so tests can
// bind :0 — production always binds the canonical LoopbackAddr.
redirectURL := "http://" + ln.Addr().String() + "/callback"
ctx = oidc.ClientContext(ctx, opts.HTTPClient)
provider, err := opts.Discoverer.Discover(ctx, cfg.Issuer)
if err != nil {
return Result{}, err
}
pkce, err := NewPKCE()
if err != nil {
return Result{}, err
}
state, err := newState()
if err != nil {
return Result{}, err
}
oauthCfg := &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: redirectURL,
Scopes: cfg.Scopes,
}
authURL := oauthCfg.AuthCodeURL(state,
oauth2.SetAuthURLParam("code_challenge", pkce.Challenge),
oauth2.SetAuthURLParam("code_challenge_method", pkce.Method),
)
cb := make(chan callbackOutcome, 1)
srv := &http.Server{
Handler: callbackHandler(state, cb),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
// Serve returns once Shutdown is called from below.
_ = srv.Serve(ln)
}()
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
// Open the browser asynchronously so callers can do blocking work
// here (e.g. test stubs that drive the redirect themselves).
go opts.OnAuthURL(authURL)
select {
case <-ctx.Done():
return Result{}, ctx.Err()
case out := <-cb:
if out.err != nil {
return Result{}, out.err
}
return exchangeAndVerify(ctx, cfg, provider, oauthCfg, pkce, out.code)
}
}
type callbackOutcome struct {
code string
err error
}
func callbackHandler(expectedState string, out chan<- callbackOutcome) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/callback" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if e := q.Get("error"); e != "" {
desc := q.Get("error_description")
renderHTML(w, "Login failed", fmt.Sprintf("%s: %s", e, desc), http.StatusBadRequest)
out <- callbackOutcome{err: fmt.Errorf("authn: callback error: %s: %s", e, desc)}
return
}
gotState := q.Get("state")
if gotState != expectedState {
renderHTML(w, "Login failed", "State mismatch (possible CSRF).", http.StatusBadRequest)
out <- callbackOutcome{err: errors.New("authn: callback state mismatch")}
return
}
code := q.Get("code")
if code == "" {
renderHTML(w, "Login failed", "Authorization server returned no code.", http.StatusBadRequest)
out <- callbackOutcome{err: errors.New("authn: callback missing code")}
return
}
renderHTML(w, "Logged in", "You may close this tab and return to your terminal.", http.StatusOK)
out <- callbackOutcome{code: code}
})
}
func exchangeAndVerify(ctx context.Context, cfg Config, provider *oidc.Provider, oauthCfg *oauth2.Config, pkce PKCE, code string) (Result, error) {
tok, err := oauthCfg.Exchange(ctx, code,
oauth2.SetAuthURLParam("code_verifier", pkce.Verifier),
)
if err != nil {
return Result{}, fmt.Errorf("authn: token exchange: %w", err)
}
if os.Getenv("SHERLOCK_DEBUG_TOKEN_RESPONSE") != "" {
fmt.Fprintf(os.Stderr, "authn: token response extras: scope=%q token_type=%q expires_in=%v refresh_expires_in=%v\n",
tok.Extra("scope"), tok.Extra("token_type"),
tok.Extra("expires_in"), tok.Extra("refresh_expires_in"))
}
rawID, _ := tok.Extra("id_token").(string)
if rawID == "" {
return Result{}, errors.New("authn: token response missing id_token")
}
verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
idTok, err := verifier.Verify(ctx, rawID)
if err != nil {
return Result{}, fmt.Errorf("authn: id_token verify: %w", err)
}
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
if err := idTok.Claims(&claims); err != nil {
return Result{}, fmt.Errorf("authn: id_token claims: %w", err)
}
ts := keyring.TokenSet{
IDToken: rawID,
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
IDExpiresAt: idTok.Expiry,
RefreshExpAt: refreshExpiry(tok),
Issuer: cfg.Issuer,
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Scopes: grantedScopes(tok, cfg.Scopes),
Subject: claims.Sub,
Email: claims.Email,
Name: claims.Name,
}
return Result{Tokens: ts}, nil
}
// grantedScopes returns the scopes the auth server actually granted,
// per RFC 6749 §5.1. If the server includes a "scope" field in the
// token response we trust that (it's the authoritative list); if it
// doesn't, we fall back to what we asked for. Persisting what was
// granted (not what was requested) lets `sherlock status` reflect
// reality — useful for diagnosing "why does this endpoint 403?" when
// the OAuth server silently narrows our scope set.
func grantedScopes(tok *oauth2.Token, requested []string) []string {
if s, ok := tok.Extra("scope").(string); ok && s != "" {
return splitScope(s)
}
return requested
}
func splitScope(s string) []string {
out := make([]string, 0, 8)
start := 0
for i := 0; i <= len(s); i++ {
if i == len(s) || s[i] == ' ' {
if i > start {
out = append(out, s[start:i])
}
start = i + 1
}
}
return out
}
// refreshExpiry pulls Authentik's `refresh_expires_in` (an extension
// to RFC 6749). If absent we conservatively assume 30 days, matching
// Authentik's default.
func refreshExpiry(tok *oauth2.Token) time.Time {
if v, ok := tok.Extra("refresh_expires_in").(float64); ok && v > 0 {
return time.Now().Add(time.Duration(v) * time.Second)
}
return time.Now().Add(30 * 24 * time.Hour)
}
func renderHTML(w http.ResponseWriter, title, body string, status int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
const tmpl = `<!doctype html><html><head><meta charset="utf-8"><title>%s — sherlock</title>` +
`<style>body{font-family:system-ui,sans-serif;max-width:30em;margin:6em auto;color:#111}` +
`h1{font-size:1.4em}p{color:#444}</style></head>` +
`<body><h1>%s</h1><p>%s</p></body></html>`
_, _ = fmt.Fprintf(w, tmpl, html.EscapeString(title), html.EscapeString(title), html.EscapeString(body))
}