267 lines
8.6 KiB
Go
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))
|
|
}
|