feat(oidc): prepare one-shot upstream issuer proof without token disclosure
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
5c7db26b7c
commit
6f33abddcf
7 changed files with 836 additions and 0 deletions
|
|
@ -36,6 +36,14 @@ import (
|
|||
const version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "probe-upstream-issuer" {
|
||||
if err := authelia.RunIssuerProbe(context.Background(), os.Args[2:], os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(os.Args) > 1 && (os.Args[1] == "login" || os.Args[1] == "service-token" || os.Args[1] == "verify-client") {
|
||||
if err := authclient.Run(context.Background(), os.Args[1:], os.Stderr); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
|
|
|
|||
286
src/internal/adapters/authelia/issuer_probe.go
Normal file
286
src/internal/adapters/authelia/issuer_probe.go
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
package authelia
|
||||
|
||||
// This one-shot diagnostic uses the existing upstream confidential client and
|
||||
// callback URI. It issues no downstream credential and returns no user claims.
|
||||
// Deployment requires a route matching ONLY its random state, never all callbacks.
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"keycape/internal/domain"
|
||||
)
|
||||
|
||||
// IssuerProbeOptions contains deployment-owned metadata. AllowedIssuers is a
|
||||
// finite reviewed set, not a source of URLs to fetch from an untrusted token.
|
||||
type IssuerProbeOptions struct {
|
||||
State string
|
||||
AllowedIssuers []string
|
||||
Lifetime time.Duration
|
||||
}
|
||||
|
||||
// IssuerProbe owns one browser-bound flow and emits one metadata-only receipt.
|
||||
// Tokens and authorization codes never leave the adapter package.
|
||||
type IssuerProbe struct {
|
||||
adapter *AutheliaAdapter
|
||||
opts IssuerProbeOptions
|
||||
host string
|
||||
callback string
|
||||
nonce string
|
||||
expires time.Time
|
||||
mu sync.Mutex
|
||||
started bool
|
||||
consumed bool
|
||||
done chan map[string]any
|
||||
}
|
||||
|
||||
func NewIssuerProbe(cfg Config, client HTTPClient, opts IssuerProbeOptions) (*IssuerProbe, error) {
|
||||
state, err := base64.RawURLEncoding.DecodeString(opts.State)
|
||||
if err != nil || len(state) != 32 || base64.RawURLEncoding.EncodeToString(state) != opts.State {
|
||||
return nil, errors.New("probe state must be a canonical 32-byte base64url value")
|
||||
}
|
||||
redirect, err := url.Parse(cfg.RedirectURI)
|
||||
if err != nil || redirect.Scheme != "https" || redirect.Host == "" || redirect.User != nil || redirect.RawQuery != "" || redirect.Fragment != "" || redirect.Path != "/authorize/callback" {
|
||||
return nil, errors.New("probe requires the existing HTTPS /authorize/callback registration")
|
||||
}
|
||||
if cfg.ClientID == "" || cfg.ClientSecret == "" || len(opts.AllowedIssuers) == 0 || opts.Lifetime <= 0 || opts.Lifetime > 10*time.Minute {
|
||||
return nil, errors.New("probe requires the existing client, reviewed issuers and a lifetime of at most ten minutes")
|
||||
}
|
||||
for _, issuer := range opts.AllowedIssuers {
|
||||
u, err := url.Parse(issuer)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return nil, errors.New("invalid reviewed issuer")
|
||||
}
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
}
|
||||
nonce := make([]byte, 32)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, errors.New("probe entropy unavailable")
|
||||
}
|
||||
return &IssuerProbe{adapter: New(cfg, client), opts: opts, host: redirect.Host, callback: redirect.Path, nonce: base64.RawURLEncoding.EncodeToString(nonce), expires: time.Now().Add(opts.Lifetime), done: make(chan map[string]any, 1)}, nil
|
||||
}
|
||||
|
||||
// Done receives only allowlisted verification metadata, never a token or user.
|
||||
func (p *IssuerProbe) Done() <-chan map[string]any { return p.done }
|
||||
|
||||
func (p *IssuerProbe) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method refused", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/healthz" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.Host != p.host {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if time.Now().After(p.expires) || p.consumed {
|
||||
http.Error(w, "probe expired or consumed", http.StatusGone)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/upstream-issuer-proof/"+p.opts.State {
|
||||
if p.started {
|
||||
http.Error(w, "probe already started", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
target, err := p.adapter.AuthorizeURL(r.Context(), domain.AuthRequest{State: p.opts.State})
|
||||
if err != nil {
|
||||
http.Error(w, "probe unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
u, err := url.Parse(target)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
http.Error(w, "probe unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("nonce", p.nonce)
|
||||
u.RawQuery = q.Encode()
|
||||
p.started = true
|
||||
http.SetCookie(w, &http.Cookie{Name: "__Host-keycape-issuer-probe", Value: p.nonce, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int(p.opts.Lifetime.Seconds())})
|
||||
http.Redirect(w, r, u.String(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != p.callback {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
q, err := url.ParseQuery(r.URL.RawQuery)
|
||||
cookie, cerr := r.Cookie("__Host-keycape-issuer-probe")
|
||||
if err != nil || len(q["state"]) != 1 || subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(p.opts.State)) != 1 || !p.started || cerr != nil || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(p.nonce)) != 1 {
|
||||
http.Error(w, "probe binding refused", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
p.consumed = true
|
||||
http.SetCookie(w, &http.Cookie{Name: "__Host-keycape-issuer-probe", Value: "", Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1})
|
||||
receipt := map[string]any{"schema": "keycape.upstream-issuer-proof.v1", "observed_at": time.Now().UTC().Format(time.RFC3339), "status": "failed", "tokens_retained": false, "downstream_credential_issued": false}
|
||||
if len(q["code"]) != 1 || q.Get("code") == "" || len(q["error"]) > 0 {
|
||||
receipt["failure"] = "authorization_callback_refused"
|
||||
} else {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
if issuer, reason := p.verifyCode(ctx, q.Get("code")); reason != "" {
|
||||
receipt["failure"] = reason
|
||||
} else {
|
||||
receipt["status"] = "verified"
|
||||
receipt["issuer"] = issuer
|
||||
receipt["signature_verified"] = true
|
||||
receipt["audience_verified"] = true
|
||||
receipt["validity_window_verified"] = true
|
||||
receipt["nonce_verified"] = true
|
||||
}
|
||||
}
|
||||
p.done <- receipt
|
||||
// The browser learns only completion. Read the metadata receipt through the
|
||||
// deployment owner's contained log collection, after cleaning up the route.
|
||||
if receipt["status"] != "verified" {
|
||||
http.Error(w, "Issuer proof failed. The operator has the diagnostic result.", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = io.WriteString(w, "Issuer proof complete. You may close this tab. No application login or downstream credential was issued.\n")
|
||||
}
|
||||
|
||||
func (p *IssuerProbe) verifyCode(ctx context.Context, code string) (string, string) {
|
||||
// Exchange under the same tokenBaseURL/client/redirect as the production
|
||||
// adapter. Use a bounded reader and cancellation; never relay error bodies.
|
||||
tokenURL := strings.TrimRight(p.adapter.tokenBaseURL(), "/") + "/api/oidc/token"
|
||||
form := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {p.adapter.cfg.RedirectURI}, "client_id": {p.adapter.cfg.ClientID}}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", "token_exchange_error"
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(p.adapter.cfg.ClientID, p.adapter.cfg.ClientSecret)
|
||||
response, err := p.adapter.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "token_exchange_error"
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", "token_exchange_refused"
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
|
||||
if err != nil || len(raw) > 1<<20 {
|
||||
return "", "token_response_invalid"
|
||||
}
|
||||
var tokens tokenResponse
|
||||
if json.Unmarshal(raw, &tokens) != nil {
|
||||
return "", "token_response_invalid"
|
||||
}
|
||||
// Parsing chooses among operator-reviewed strings only. Acceptance still
|
||||
// requires independent signature, audience and time verification below.
|
||||
untrusted, err := parseIDTokenClaims(tokens.IDToken)
|
||||
if err != nil {
|
||||
return "", "id_token_verification_error"
|
||||
}
|
||||
issuer := stringClaim(untrusted, "iss")
|
||||
allowed := false
|
||||
for _, candidate := range p.opts.AllowedIssuers {
|
||||
if issuer == candidate {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return "", "issuer_outside_reviewed_set"
|
||||
}
|
||||
cfg := p.adapter.cfg
|
||||
cfg.Issuer = issuer
|
||||
verified, err := newIDTokenVerifier(cfg, p.adapter.client, p.adapter.tokenBaseURL()).Verify(ctx, tokens.IDToken)
|
||||
if err != nil {
|
||||
return "", FailureReason(err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(stringClaim(verified, "nonce")), []byte(p.nonce)) != 1 {
|
||||
return "", "id_token_nonce_mismatch"
|
||||
}
|
||||
return issuer, ""
|
||||
}
|
||||
|
||||
// RunIssuerProbe starts only this diagnostic, not the issuer, user directory or
|
||||
// MFA adapters. It decodes only the Authelia section of the existing config.
|
||||
func RunIssuerProbe(ctx context.Context, args []string, out io.Writer) error {
|
||||
fs := flag.NewFlagSet("probe-upstream-issuer", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
configPath := fs.String("config", "", "existing config file")
|
||||
listen := fs.String("listen", ":8081", "private probe listener")
|
||||
issuers := fs.String("allowed-issuers", "", "comma-separated reviewed issuer strings")
|
||||
stateEnv := fs.String("state-env", "KEYCAPE_ISSUER_PROBE_STATE", "variable holding random probe state")
|
||||
lifetime := fs.Duration("lifetime", 10*time.Minute, "one-shot lifetime, at most ten minutes")
|
||||
if fs.Parse(args) != nil || fs.NArg() != 0 {
|
||||
return errors.New("invalid issuer probe arguments")
|
||||
}
|
||||
raw, err := os.ReadFile(*configPath)
|
||||
if err != nil {
|
||||
return errors.New("issuer probe config unavailable")
|
||||
}
|
||||
var sections map[string]yaml.Node
|
||||
if yaml.Unmarshal(raw, §ions) != nil {
|
||||
return errors.New("issuer probe config invalid")
|
||||
}
|
||||
node, ok := sections["authelia"]
|
||||
if !ok {
|
||||
return errors.New("issuer probe upstream config absent")
|
||||
}
|
||||
var cfg Config
|
||||
if node.Decode(&cfg) != nil {
|
||||
return errors.New("issuer probe upstream config invalid")
|
||||
}
|
||||
cfg.ClientSecret = os.ExpandEnv(cfg.ClientSecret)
|
||||
probe, err := NewIssuerProbe(cfg, nil, IssuerProbeOptions{State: os.Getenv(*stateEnv), AllowedIssuers: strings.Split(*issuers, ","), Lifetime: *lifetime})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("tcp", *listen)
|
||||
if err != nil {
|
||||
return errors.New("issuer probe listener unavailable")
|
||||
}
|
||||
server := &http.Server{Handler: probe, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 40 * time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 16384, ErrorLog: log.New(io.Discard, "", 0)}
|
||||
runErr := make(chan error, 1)
|
||||
go func() { runErr <- server.Serve(listener) }()
|
||||
bounded, cancel := context.WithTimeout(ctx, *lifetime)
|
||||
defer cancel()
|
||||
var receipt map[string]any
|
||||
select {
|
||||
case receipt = <-probe.Done():
|
||||
case <-bounded.Done():
|
||||
receipt = map[string]any{"schema": "keycape.upstream-issuer-proof.v1", "status": "failed", "failure": "probe_deadline", "tokens_retained": false, "downstream_credential_issued": false}
|
||||
case <-runErr:
|
||||
receipt = map[string]any{"schema": "keycape.upstream-issuer-proof.v1", "status": "failed", "failure": "probe_listener_stopped", "tokens_retained": false, "downstream_credential_issued": false}
|
||||
}
|
||||
shutdown, stop := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer stop()
|
||||
if server.Shutdown(shutdown) != nil {
|
||||
_ = server.Close()
|
||||
}
|
||||
if json.NewEncoder(out).Encode(receipt) != nil {
|
||||
return errors.New("issuer proof receipt could not be written")
|
||||
}
|
||||
if receipt["status"] != "verified" {
|
||||
return errors.New("issuer proof failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
258
src/internal/adapters/authelia/issuer_probe_test.go
Normal file
258
src/internal/adapters/authelia/issuer_probe_test.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
package authelia_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"keycape/internal/adapters/authelia"
|
||||
)
|
||||
|
||||
const probeState = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
|
||||
type probeProvider struct {
|
||||
provider
|
||||
calls int
|
||||
tokenStatus int
|
||||
tokenBody string
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (p *probeProvider) Do(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/api/oidc/token" {
|
||||
p.calls++
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != testConfig().ClientID || pass != testConfig().ClientSecret {
|
||||
p.t.Fatal("wrong client authentication")
|
||||
}
|
||||
if r.Context().Err() != nil {
|
||||
return nil, r.Context().Err()
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
p.t.Fatal(err)
|
||||
}
|
||||
if r.Form.Get("redirect_uri") != "https://kc.example.com/authorize/callback" || r.Form.Get("code") != "private-auth-code" {
|
||||
p.t.Fatal("wrong exchange binding")
|
||||
}
|
||||
if p.tokenStatus != 0 {
|
||||
return &http.Response{StatusCode: p.tokenStatus, Body: io.NopCloser(strings.NewReader(p.tokenBody))}, nil
|
||||
}
|
||||
}
|
||||
return p.provider.Do(r)
|
||||
}
|
||||
|
||||
func newProbe(t *testing.T, p *probeProvider, lifetime time.Duration) *authelia.IssuerProbe {
|
||||
t.Helper()
|
||||
p.t = t
|
||||
cfg := testConfig()
|
||||
cfg.RedirectURI = "https://kc.example.com/authorize/callback"
|
||||
cfg.BrowserBaseURL = "https://auth.example.com"
|
||||
probe, err := authelia.NewIssuerProbe(cfg, p, authelia.IssuerProbeOptions{State: probeState, AllowedIssuers: []string{testIssuer, "http://authelia.sso.svc.cluster.local:9091"}, Lifetime: lifetime})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return probe
|
||||
}
|
||||
func startProbe(t *testing.T, probe *authelia.IssuerProbe) (*http.Cookie, string) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "https://kc.example.com/upstream-issuer-proof/"+probeState, nil)
|
||||
probe.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("start status %d", w.Code)
|
||||
}
|
||||
target, err := url.Parse(w.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if target.Host != "auth.example.com" || target.Query().Get("state") != probeState || target.Query().Get("redirect_uri") != "https://kc.example.com/authorize/callback" {
|
||||
t.Fatal("wrong upstream authorization binding")
|
||||
}
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 || !cookies[0].Secure || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteLaxMode {
|
||||
t.Fatal("browser binding cookie missing")
|
||||
}
|
||||
return cookies[0], target.Query().Get("nonce")
|
||||
}
|
||||
func completeProbe(probe *authelia.IssuerProbe, cookie *http.Cookie, query string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "https://kc.example.com/authorize/callback?"+query, nil)
|
||||
if cookie != nil {
|
||||
r.AddCookie(cookie)
|
||||
}
|
||||
probe.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestIssuerProbeVerifiesSignedIssuerWithoutDisclosingTokens(t *testing.T) {
|
||||
for _, issuer := range []string{testIssuer, "http://authelia.sso.svc.cluster.local:9091"} {
|
||||
t.Run(issuer, func(t *testing.T) {
|
||||
p := &probeProvider{provider: provider{jwks: testJWKS(nil)}}
|
||||
probe := newProbe(t, p, time.Minute)
|
||||
cookie, nonce := startProbe(t, probe)
|
||||
p.idToken = signIDToken(map[string]interface{}{"iss": issuer, "nonce": nonce, "preferred_username": "private-user", "email": "private-email"}, testKeyID, nil)
|
||||
w := completeProbe(probe, cookie, "state="+probeState+"&code=private-auth-code")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
receipt := <-probe.Done()
|
||||
if receipt["status"] != "verified" || receipt["issuer"] != issuer || receipt["nonce_verified"] != true {
|
||||
t.Fatal("missing verified issuer receipt")
|
||||
}
|
||||
raw, _ := json.Marshal(receipt)
|
||||
for _, secret := range []string{p.idToken, "private-user", "private-email", "private-auth-code", testConfig().ClientSecret, nonce} {
|
||||
if strings.Contains(string(raw)+w.Body.String(), secret) {
|
||||
t.Fatal("diagnostic disclosed private material")
|
||||
}
|
||||
}
|
||||
if p.calls != 1 {
|
||||
t.Fatal("wrong exchange count")
|
||||
}
|
||||
if completeProbe(probe, cookie, "state="+probeState+"&code=private-auth-code").Code != http.StatusGone || p.calls != 1 {
|
||||
t.Fatal("callback was replayed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerProbeRefusesUnboundCallbacksBeforeExchange(t *testing.T) {
|
||||
p := &probeProvider{provider: provider{jwks: testJWKS(nil)}}
|
||||
probe := newProbe(t, p, time.Minute)
|
||||
if completeProbe(probe, nil, "state="+probeState+"&code=private-auth-code").Code != http.StatusForbidden {
|
||||
t.Fatal("callback before start accepted")
|
||||
}
|
||||
cookie, nonce := startProbe(t, probe)
|
||||
p.idToken = signIDToken(map[string]interface{}{"nonce": nonce}, testKeyID, nil)
|
||||
for _, query := range []string{"state=wrong&code=private-auth-code", "state=" + probeState + "&state=other&code=private-auth-code", "state=%xx&code=private-auth-code"} {
|
||||
if completeProbe(probe, cookie, query).Code != http.StatusForbidden {
|
||||
t.Fatal("invalid state accepted")
|
||||
}
|
||||
}
|
||||
if completeProbe(probe, nil, "state="+probeState+"&code=private-auth-code").Code != http.StatusForbidden {
|
||||
t.Fatal("missing browser cookie accepted")
|
||||
}
|
||||
if p.calls != 0 {
|
||||
t.Fatal("unbound callback exchanged a code")
|
||||
}
|
||||
if completeProbe(probe, cookie, "state="+probeState+"&code=private-auth-code").Code != http.StatusOK {
|
||||
t.Fatal("invalid callback consumed valid flow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerProbeRejectsTokensAndWritesOnlyFailureCategory(t *testing.T) {
|
||||
other, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
claims map[string]interface{}
|
||||
key *rsa.PrivateKey
|
||||
status int
|
||||
body string
|
||||
jwksErr error
|
||||
}{
|
||||
{name: "unreviewed issuer", claims: map[string]interface{}{"iss": "https://private-attacker.example"}},
|
||||
{name: "wrong audience", claims: map[string]interface{}{"aud": "private-wrong-client"}},
|
||||
{name: "expired", claims: map[string]interface{}{"iat": time.Now().Add(-2 * time.Hour).Unix(), "exp": time.Now().Add(-time.Hour).Unix()}},
|
||||
{name: "wrong nonce", claims: map[string]interface{}{"nonce": "private-wrong-nonce"}},
|
||||
{name: "forged signature", key: other},
|
||||
{name: "unavailable keys", jwksErr: errors.New("private-provider-body")},
|
||||
{name: "provider refused", status: 401, body: "private-provider-body"},
|
||||
{name: "provider malformed", status: 200, body: "private-provider-body"},
|
||||
{name: "oversize", status: 200, body: strings.Repeat("x", (1<<20)+1)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := &probeProvider{provider: provider{jwks: testJWKS(nil), jwksErr: tc.jwksErr}, tokenStatus: tc.status, tokenBody: tc.body}
|
||||
probe := newProbe(t, p, time.Minute)
|
||||
cookie, nonce := startProbe(t, probe)
|
||||
claims := map[string]interface{}{"nonce": nonce}
|
||||
for k, v := range tc.claims {
|
||||
claims[k] = v
|
||||
}
|
||||
p.idToken = signIDToken(claims, testKeyID, tc.key)
|
||||
w := completeProbe(probe, cookie, "state="+probeState+"&code=private-auth-code")
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bad token accepted: %d", w.Code)
|
||||
}
|
||||
receipt := <-probe.Done()
|
||||
if receipt["status"] != "failed" || receipt["issuer"] != nil {
|
||||
t.Fatal("unverified issuer receipt")
|
||||
}
|
||||
raw, _ := json.Marshal(receipt)
|
||||
if strings.Contains(string(raw)+w.Body.String(), "private-") || strings.Contains(string(raw), p.idToken) {
|
||||
t.Fatal("failure disclosed material")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerProbeBoundsLifetimeAndConfiguration(t *testing.T) {
|
||||
p := &probeProvider{}
|
||||
probe := newProbe(t, p, time.Nanosecond)
|
||||
time.Sleep(time.Millisecond)
|
||||
if completeProbe(probe, nil, "state="+probeState).Code != http.StatusGone {
|
||||
t.Fatal("expired probe accepted")
|
||||
}
|
||||
cfg := testConfig()
|
||||
cfg.RedirectURI = "https://kc.example.com/authorize/callback"
|
||||
for _, state := range []string{"", base64.RawURLEncoding.EncodeToString(make([]byte, 16)), probeState + "="} {
|
||||
if _, err := authelia.NewIssuerProbe(cfg, nil, authelia.IssuerProbeOptions{State: state, AllowedIssuers: []string{testIssuer}, Lifetime: time.Minute}); err == nil {
|
||||
t.Fatal("weak state accepted")
|
||||
}
|
||||
}
|
||||
if _, err := authelia.NewIssuerProbe(cfg, nil, authelia.IssuerProbeOptions{State: probeState, AllowedIssuers: []string{testIssuer}, Lifetime: 11 * time.Minute}); err == nil {
|
||||
t.Fatal("unbounded lifetime accepted")
|
||||
}
|
||||
for _, uri := range []string{"http://kc.example.com/authorize/callback", "https://kc.example.com/new-callback", "https://kc.example.com/authorize/callback?code=secret"} {
|
||||
cfg.RedirectURI = uri
|
||||
if _, err := authelia.NewIssuerProbe(cfg, nil, authelia.IssuerProbeOptions{State: probeState, AllowedIssuers: []string{testIssuer}, Lifetime: time.Minute}); err == nil {
|
||||
t.Fatal("changed callback accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerProbeErrorsDoNotPrintArguments(t *testing.T) {
|
||||
var out strings.Builder
|
||||
err := authelia.RunIssuerProbe(context.Background(), []string{"--private-secret=never-print"}, &out)
|
||||
if err == nil || strings.Contains(fmt.Sprint(err)+out.String(), "never-print") {
|
||||
t.Fatal("argument disclosure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerProbeCLIReadsOnlyUpstreamAndBoundsIdleRun(t *testing.T) {
|
||||
cfg := filepath.Join(t.TempDir(), "config.yaml")
|
||||
content := "authelia:\n baseURL: https://auth.example.com\n clientId: keycape\n clientSecret: private-config-secret\n redirectURI: https://kc.example.com/authorize/callback\nprivateKeyPEM: /must-not-be-read\nlldap:\n password: private-unrelated-password\n"
|
||||
if err := os.WriteFile(cfg, []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PROBE_TEST_STATE", probeState)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
var out strings.Builder
|
||||
err := authelia.RunIssuerProbe(ctx, []string{"--config=" + cfg, "--state-env=PROBE_TEST_STATE", "--allowed-issuers=https://auth.example.com", "--listen=127.0.0.1:0", "--lifetime=1s"}, &out)
|
||||
if err == nil {
|
||||
t.Fatal("cancelled probe succeeded")
|
||||
}
|
||||
var receipt map[string]interface{}
|
||||
if json.Unmarshal([]byte(out.String()), &receipt) != nil || receipt["failure"] != "probe_deadline" {
|
||||
t.Fatal("expected bounded diagnostic receipt")
|
||||
}
|
||||
if strings.Contains(out.String()+err.Error(), "private-") {
|
||||
t.Fatal("config material disclosed")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue