390 lines
13 KiB
Go
390 lines
13 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
|
"github.com/go-chi/chi/v5"
|
|
"google.golang.org/protobuf/types/known/structpb"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/clientip"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
type fakeOAuthClient struct {
|
|
initResp *pluginv1.InitAuthorizeResponse
|
|
initErr error
|
|
exchangeResp *pluginv1.AuthenticateResponse
|
|
exchangeErr error
|
|
|
|
gotInit *pluginv1.InitAuthorizeRequest
|
|
gotExchange *pluginv1.ExchangeCodeRequest
|
|
}
|
|
|
|
func (f *fakeOAuthClient) InitAuthorize(_ context.Context, in *pluginv1.InitAuthorizeRequest) (*pluginv1.InitAuthorizeResponse, error) {
|
|
f.gotInit = in
|
|
return f.initResp, f.initErr
|
|
}
|
|
|
|
func (f *fakeOAuthClient) ExchangeCode(_ context.Context, in *pluginv1.ExchangeCodeRequest) (*pluginv1.AuthenticateResponse, error) {
|
|
f.gotExchange = in
|
|
return f.exchangeResp, f.exchangeErr
|
|
}
|
|
|
|
type fakeCompleter struct {
|
|
gotInput OAuthLoginInput
|
|
pair *TokenPair
|
|
user *models.User
|
|
err error
|
|
}
|
|
|
|
func (f *fakeCompleter) CompleteOAuthLogin(_ context.Context, in OAuthLoginInput) (*TokenPair, *models.User, error) {
|
|
f.gotInput = in
|
|
return f.pair, f.user, f.err
|
|
}
|
|
|
|
func newOAuthHandlerForTest(_ *testing.T, fc *fakeOAuthClient, fcomp *fakeCompleter) (*OAuthHandler, *InMemoryOAuthStore) {
|
|
store := NewInMemoryOAuthStore()
|
|
h := NewOAuthHandler(OAuthHandlerDeps{
|
|
Store: store,
|
|
StateSecret: []byte("test-secret"),
|
|
ResolveClient: func(_ context.Context, _ int) (OAuthClient, string, error) {
|
|
return fc, "whmcs", nil
|
|
},
|
|
LoginCompleter: fcomp,
|
|
HostBaseURL: "https://silo.test",
|
|
StateTTL: 10 * time.Minute,
|
|
FrontendCompletePath: "/login/oauth-complete",
|
|
})
|
|
return h, store
|
|
}
|
|
|
|
func withInstallID(r *http.Request, id string) *http.Request {
|
|
rc := chi.NewRouteContext()
|
|
rc.URLParams.Add("install_id", id)
|
|
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rc))
|
|
}
|
|
|
|
func TestOAuthInit_Redirects302WithAuthorizeURL(t *testing.T) {
|
|
authorizeURL := "https://billing.example/oauth/authorize.php?client_id=x"
|
|
pState, _ := structpb.NewStruct(map[string]any{"pkce_verifier": "abc"})
|
|
fc := &fakeOAuthClient{initResp: &pluginv1.InitAuthorizeResponse{AuthorizeUrl: authorizeURL, ProviderState: pState}}
|
|
fcomp := &fakeCompleter{}
|
|
h, store := newOAuthHandlerForTest(t, fc, fcomp)
|
|
|
|
r := withInstallID(httptest.NewRequest("POST", "/api/v1/auth/oauth/42/init?next=/me", nil), "42")
|
|
w := httptest.NewRecorder()
|
|
h.HandleInit(w, r)
|
|
|
|
if w.Code != http.StatusFound {
|
|
t.Fatalf("code = %d body = %s", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Header().Get("Location"); got != authorizeURL {
|
|
t.Errorf("Location = %q", got)
|
|
}
|
|
|
|
// Exactly one row inserted; next_url preserved.
|
|
if got := len(store.rows); got != 1 {
|
|
t.Errorf("rows = %d, want 1", got)
|
|
}
|
|
for _, row := range store.rows {
|
|
if row.InstallID != "42" {
|
|
t.Errorf("InstallID = %q", row.InstallID)
|
|
}
|
|
if row.NextURL != "/me" {
|
|
t.Errorf("NextURL = %q", row.NextURL)
|
|
}
|
|
if !strings.Contains(string(row.ProviderState), "pkce_verifier") {
|
|
t.Errorf("ProviderState missing PKCE: %s", row.ProviderState)
|
|
}
|
|
}
|
|
|
|
// Plugin received our state.
|
|
if fc.gotInit == nil || fc.gotInit.GetState() == "" {
|
|
t.Error("plugin not invoked or empty state")
|
|
}
|
|
}
|
|
|
|
func TestOAuthInit_NormalizesUnsafeNextURL(t *testing.T) {
|
|
authorizeURL := "https://billing.example/oauth/authorize.php?client_id=x"
|
|
fc := &fakeOAuthClient{initResp: &pluginv1.InitAuthorizeResponse{AuthorizeUrl: authorizeURL}}
|
|
h, store := newOAuthHandlerForTest(t, fc, &fakeCompleter{})
|
|
|
|
r := withInstallID(httptest.NewRequest("POST", "/api/v1/auth/oauth/42/init?next=//evil.example/path", nil), "42")
|
|
w := httptest.NewRecorder()
|
|
h.HandleInit(w, r)
|
|
|
|
if w.Code != http.StatusFound {
|
|
t.Fatalf("code = %d body = %s", w.Code, w.Body.String())
|
|
}
|
|
for _, row := range store.rows {
|
|
if row.NextURL != "/" {
|
|
t.Fatalf("NextURL = %q, want /", row.NextURL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOAuthInit_RejectsBadInstallID(t *testing.T) {
|
|
h, _ := newOAuthHandlerForTest(t, &fakeOAuthClient{}, &fakeCompleter{})
|
|
cases := []string{"", "abc", "-1", "0"}
|
|
for _, c := range cases {
|
|
r := withInstallID(httptest.NewRequest("POST", "/init", nil), c)
|
|
w := httptest.NewRecorder()
|
|
h.HandleInit(w, r)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("install_id=%q → code %d, want 400", c, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOAuthInit_PluginErrorReturns502(t *testing.T) {
|
|
fc := &fakeOAuthClient{initErr: errors.New("upstream db dsn leaked")}
|
|
h, _ := newOAuthHandlerForTest(t, fc, &fakeCompleter{})
|
|
r := withInstallID(httptest.NewRequest("POST", "/init", nil), "42")
|
|
w := httptest.NewRecorder()
|
|
h.HandleInit(w, r)
|
|
if w.Code != http.StatusBadGateway {
|
|
t.Errorf("code = %d, want 502", w.Code)
|
|
}
|
|
if strings.Contains(w.Body.String(), "upstream db dsn leaked") {
|
|
t.Errorf("response leaked internal error: %q", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestOAuthInit_EmptyAuthorizeURLIs502(t *testing.T) {
|
|
fc := &fakeOAuthClient{initResp: &pluginv1.InitAuthorizeResponse{AuthorizeUrl: ""}}
|
|
h, _ := newOAuthHandlerForTest(t, fc, &fakeCompleter{})
|
|
r := withInstallID(httptest.NewRequest("POST", "/init", nil), "42")
|
|
w := httptest.NewRecorder()
|
|
h.HandleInit(w, r)
|
|
if w.Code != http.StatusBadGateway {
|
|
t.Errorf("code = %d, want 502", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestOAuthCallback_HappyPath_DeliversOneTimeCompletionCode(t *testing.T) {
|
|
pState, _ := structpb.NewStruct(map[string]any{"pkce_verifier": "abc"})
|
|
fc := &fakeOAuthClient{
|
|
initResp: &pluginv1.InitAuthorizeResponse{AuthorizeUrl: "https://a", ProviderState: pState},
|
|
exchangeResp: &pluginv1.AuthenticateResponse{ExternalSubject: "ws-1", Email: "u@x.com", DisplayName: "U"},
|
|
}
|
|
fcomp := &fakeCompleter{
|
|
pair: &TokenPair{AccessToken: "acc.tok", RefreshToken: "ref.tok", ExpiresIn: 900},
|
|
user: &models.User{ID: 7},
|
|
}
|
|
h, _ := newOAuthHandlerForTest(t, fc, fcomp)
|
|
|
|
// Init first to populate the store + capture the signed state.
|
|
rInit := withInstallID(httptest.NewRequest("POST", "/api/v1/auth/oauth/42/init?next=/me", nil), "42")
|
|
wInit := httptest.NewRecorder()
|
|
h.HandleInit(wInit, rInit)
|
|
if wInit.Code != http.StatusFound {
|
|
t.Fatalf("init code = %d body=%s", wInit.Code, wInit.Body.String())
|
|
}
|
|
state := fc.gotInit.GetState()
|
|
|
|
// Callback with that state.
|
|
rCb := withInstallID(httptest.NewRequest("GET", "/api/v1/auth/oauth/42/callback?code=auth-code&state="+url.QueryEscape(state), nil), "42")
|
|
wCb := httptest.NewRecorder()
|
|
h.HandleCallback(wCb, rCb)
|
|
|
|
if wCb.Code != http.StatusFound {
|
|
t.Fatalf("callback code = %d body = %s", wCb.Code, wCb.Body.String())
|
|
}
|
|
loc := wCb.Header().Get("Location")
|
|
if !strings.HasPrefix(loc, "https://silo.test/login/oauth-complete?") {
|
|
t.Fatalf("redirect = %q", loc)
|
|
}
|
|
if strings.Contains(loc, "acc.tok") || strings.Contains(loc, "ref.tok") {
|
|
t.Fatalf("redirect leaked tokens: %q", loc)
|
|
}
|
|
parsed, err := url.Parse(loc)
|
|
if err != nil {
|
|
t.Fatalf("parse redirect: %v", err)
|
|
}
|
|
code := parsed.Query().Get("code")
|
|
if code == "" {
|
|
t.Fatalf("completion code missing from redirect: %q", loc)
|
|
}
|
|
|
|
rComplete := httptest.NewRequest("POST", "/api/v1/auth/oauth/complete", strings.NewReader(`{"code":"`+code+`"}`))
|
|
wComplete := httptest.NewRecorder()
|
|
h.HandleComplete(wComplete, rComplete)
|
|
if wComplete.Code != http.StatusOK {
|
|
t.Fatalf("complete code = %d body=%s", wComplete.Code, wComplete.Body.String())
|
|
}
|
|
var completed OAuthCompleteResponse
|
|
if err := json.NewDecoder(wComplete.Body).Decode(&completed); err != nil {
|
|
t.Fatalf("decode complete response: %v", err)
|
|
}
|
|
if completed.AccessToken != "acc.tok" {
|
|
t.Errorf("access_token = %q", completed.AccessToken)
|
|
}
|
|
if completed.RefreshToken != "ref.tok" {
|
|
t.Errorf("refresh_token = %q", completed.RefreshToken)
|
|
}
|
|
if completed.NextURL != "/me" {
|
|
t.Errorf("next = %q", completed.NextURL)
|
|
}
|
|
|
|
// Completer received the right inputs.
|
|
if fcomp.gotInput.InstallationID != 42 {
|
|
t.Errorf("InstallationID = %d", fcomp.gotInput.InstallationID)
|
|
}
|
|
if fcomp.gotInput.Response.GetExternalSubject() != "ws-1" {
|
|
t.Errorf("ExternalSubject = %q", fcomp.gotInput.Response.GetExternalSubject())
|
|
}
|
|
if fcomp.gotInput.CapabilityID != "whmcs" {
|
|
t.Errorf("CapabilityID = %q", fcomp.gotInput.CapabilityID)
|
|
}
|
|
}
|
|
|
|
func TestOAuthCallback_RejectsTamperedState(t *testing.T) {
|
|
h, _ := newOAuthHandlerForTest(t, &fakeOAuthClient{}, &fakeCompleter{})
|
|
r := withInstallID(httptest.NewRequest("GET", "/cb?code=x&state=tampered.junk", nil), "42")
|
|
w := httptest.NewRecorder()
|
|
h.HandleCallback(w, r)
|
|
if w.Code != http.StatusFound {
|
|
t.Fatalf("code = %d", w.Code)
|
|
}
|
|
if got := w.Header().Get("Location"); !strings.Contains(got, "error=oauth_failed") {
|
|
t.Errorf("Location = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestOAuthCallback_RejectsMissingCodeOrState(t *testing.T) {
|
|
h, _ := newOAuthHandlerForTest(t, &fakeOAuthClient{}, &fakeCompleter{})
|
|
r := withInstallID(httptest.NewRequest("GET", "/cb", nil), "42")
|
|
w := httptest.NewRecorder()
|
|
h.HandleCallback(w, r)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("code = %d, want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestOAuthCallback_InstallMismatchRedirectsToLoginError(t *testing.T) {
|
|
pState, _ := structpb.NewStruct(map[string]any{})
|
|
fc := &fakeOAuthClient{initResp: &pluginv1.InitAuthorizeResponse{AuthorizeUrl: "https://a", ProviderState: pState}}
|
|
h, _ := newOAuthHandlerForTest(t, fc, &fakeCompleter{})
|
|
|
|
// Init for install 42.
|
|
rInit := withInstallID(httptest.NewRequest("POST", "/init", nil), "42")
|
|
wInit := httptest.NewRecorder()
|
|
h.HandleInit(wInit, rInit)
|
|
state := fc.gotInit.GetState()
|
|
|
|
// Callback against install 99 with state signed for 42 → mismatch.
|
|
rCb := withInstallID(httptest.NewRequest("GET", "/cb?code=c&state="+url.QueryEscape(state), nil), "99")
|
|
wCb := httptest.NewRecorder()
|
|
h.HandleCallback(wCb, rCb)
|
|
if wCb.Code != http.StatusFound {
|
|
t.Errorf("code = %d", wCb.Code)
|
|
}
|
|
if got := wCb.Header().Get("Location"); !strings.Contains(got, "install_mismatch") {
|
|
t.Errorf("Location = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestOAuthCallback_ExchangeFailRedirectsToLoginError(t *testing.T) {
|
|
pState, _ := structpb.NewStruct(map[string]any{})
|
|
fc := &fakeOAuthClient{
|
|
initResp: &pluginv1.InitAuthorizeResponse{AuthorizeUrl: "https://a", ProviderState: pState},
|
|
exchangeErr: errors.New("token endpoint returned 400 with secret context"),
|
|
}
|
|
h, _ := newOAuthHandlerForTest(t, fc, &fakeCompleter{})
|
|
|
|
rInit := withInstallID(httptest.NewRequest("POST", "/init", nil), "42")
|
|
h.HandleInit(httptest.NewRecorder(), rInit)
|
|
state := fc.gotInit.GetState()
|
|
|
|
rCb := withInstallID(httptest.NewRequest("GET", "/cb?code=c&state="+url.QueryEscape(state), nil), "42")
|
|
wCb := httptest.NewRecorder()
|
|
h.HandleCallback(wCb, rCb)
|
|
if wCb.Code != http.StatusFound {
|
|
t.Errorf("code = %d", wCb.Code)
|
|
}
|
|
if got := wCb.Header().Get("Location"); !strings.Contains(got, "reason=exchange_failed") {
|
|
t.Errorf("Location = %q", got)
|
|
} else if strings.Contains(got, "token endpoint") || strings.Contains(got, "secret context") {
|
|
t.Errorf("Location leaked internal error: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestOAuthCallback_CompleteLoginErrorRedirectsWithGenericReason(t *testing.T) {
|
|
pState, _ := structpb.NewStruct(map[string]any{})
|
|
fc := &fakeOAuthClient{
|
|
initResp: &pluginv1.InitAuthorizeResponse{AuthorizeUrl: "https://a", ProviderState: pState},
|
|
exchangeResp: &pluginv1.AuthenticateResponse{ExternalSubject: "ws-1"},
|
|
}
|
|
fcomp := &fakeCompleter{err: errors.New("insert users failed: private db detail")}
|
|
h, _ := newOAuthHandlerForTest(t, fc, fcomp)
|
|
|
|
rInit := withInstallID(httptest.NewRequest("POST", "/init", nil), "42")
|
|
h.HandleInit(httptest.NewRecorder(), rInit)
|
|
state := fc.gotInit.GetState()
|
|
|
|
rCb := withInstallID(httptest.NewRequest("GET", "/cb?code=c&state="+url.QueryEscape(state), nil), "42")
|
|
wCb := httptest.NewRecorder()
|
|
h.HandleCallback(wCb, rCb)
|
|
if wCb.Code != http.StatusFound {
|
|
t.Errorf("code = %d", wCb.Code)
|
|
}
|
|
if got := wCb.Header().Get("Location"); !strings.Contains(got, "reason=login_failed") {
|
|
t.Errorf("Location = %q", got)
|
|
} else if strings.Contains(got, "private db detail") {
|
|
t.Errorf("Location leaked internal error: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestClientIPUsesResolvedContextAndPreservesIPv6(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
contextIP string
|
|
remoteAddr string
|
|
want string
|
|
}{
|
|
{
|
|
name: "middleware context wins",
|
|
contextIP: "2001:db8::7",
|
|
remoteAddr: "10.0.0.1:1234",
|
|
want: "2001:db8::7",
|
|
},
|
|
{
|
|
name: "bracketed ipv6 with port",
|
|
remoteAddr: "[::1]:8080",
|
|
want: "::1",
|
|
},
|
|
{
|
|
name: "bare ipv6 from middleware remote addr",
|
|
remoteAddr: "::1",
|
|
want: "::1",
|
|
},
|
|
{
|
|
name: "ipv4 with port",
|
|
remoteAddr: "192.0.2.10:8080",
|
|
want: "192.0.2.10",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.RemoteAddr = tt.remoteAddr
|
|
if tt.contextIP != "" {
|
|
r = r.WithContext(clientip.SetContext(r.Context(), tt.contextIP))
|
|
}
|
|
if got := clientIP(r); got != tt.want {
|
|
t.Fatalf("clientIP() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|