203 lines
7.5 KiB
Go
203 lines
7.5 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type oauthFlowMemoryStore struct {
|
|
flows map[string]*AuthFlow
|
|
}
|
|
|
|
func (s *oauthFlowMemoryStore) CreateAuthFlow(_ context.Context, flow *AuthFlow) error {
|
|
if s.flows == nil {
|
|
s.flows = make(map[string]*AuthFlow)
|
|
}
|
|
copy := *flow
|
|
s.flows[flow.StateHash] = ©
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthFlowMemoryStore) ConsumeAuthFlow(
|
|
_ context.Context,
|
|
stateHash, registrationID string,
|
|
kind AuthFlowKind,
|
|
now time.Time,
|
|
) (*AuthFlow, error) {
|
|
flow := s.flows[stateHash]
|
|
if flow == nil || flow.AppRegistrationID != registrationID || flow.Kind != kind ||
|
|
flow.ConsumedAt != nil || !flow.ExpiresAt.After(now) {
|
|
return nil, ErrAuthFlowUnavailable
|
|
}
|
|
copy := *flow
|
|
copy.ConsumedAt = &now
|
|
flow.ConsumedAt = &now
|
|
return ©, nil
|
|
}
|
|
|
|
func TestOAuthFlowStartPersistsOnlyStateDigestAndPKCEVerifier(t *testing.T) {
|
|
store := &oauthFlowMemoryStore{}
|
|
manager := NewOAuthFlowManager(store)
|
|
manager.now = func() time.Time { return time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) }
|
|
manager.random = strings.NewReader(strings.Repeat("a", oauthRandomBytes*2))
|
|
|
|
installationID := int64(42)
|
|
started, err := manager.Start(context.Background(), OAuthFlowRequest{
|
|
WorkspaceID: "workspace-1",
|
|
UserID: "user-1",
|
|
AppRegistrationID: "registration-test",
|
|
Kind: AuthFlowKindPersonal,
|
|
ExpectedWorkspaceSource: ConnectionSourceGitHubAppInstallation,
|
|
ExpectedWorkspaceGeneration: 3,
|
|
ExpectedInstallationID: &installationID,
|
|
ExpectedWorkspaceAppRegistrationID: "registration-test",
|
|
ExpectedPersonalGeneration: 7,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
if started.State == "" || started.PKCEChallenge == "" {
|
|
t.Fatalf("Start() = %+v, want state and PKCE challenge", started)
|
|
}
|
|
digest := sha256.Sum256([]byte(started.State))
|
|
persisted := store.flows[stateDigestString(digest)]
|
|
if persisted == nil {
|
|
t.Fatal("state digest was not persisted")
|
|
}
|
|
if strings.Contains(persisted.StateHash, started.State) || persisted.PKCEVerifier == "" {
|
|
t.Fatalf("persisted flow leaked state or omitted verifier: %+v", persisted)
|
|
}
|
|
if got, want := persisted.ExpiresAt.Sub(manager.now()), personalOAuthStateTTL; got != want {
|
|
t.Fatalf("flow TTL = %v, want %v", got, want)
|
|
}
|
|
if persisted.ExpectedWorkspaceSource != ConnectionSourceGitHubAppInstallation ||
|
|
persisted.ExpectedWorkspaceGeneration != 3 || persisted.ExpectedInstallationID == nil ||
|
|
*persisted.ExpectedInstallationID != 42 || persisted.ExpectedPersonalGeneration != 7 {
|
|
t.Fatalf("persisted flow expectation = %+v", persisted)
|
|
}
|
|
}
|
|
|
|
func TestOAuthFlowConsumeRejectsMismatchAndReplay(t *testing.T) {
|
|
store := &oauthFlowMemoryStore{}
|
|
manager := NewOAuthFlowManager(store)
|
|
manager.now = func() time.Time { return time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) }
|
|
manager.random = io.LimitReader(strings.NewReader(strings.Repeat("b", oauthRandomBytes)), oauthRandomBytes)
|
|
|
|
started, err := manager.Start(context.Background(), OAuthFlowRequest{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test",
|
|
Kind: AuthFlowKindAppInstallation,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
_, err = manager.Consume(context.Background(), started.State, OAuthFlowExpectation{
|
|
WorkspaceID: "other-workspace", UserID: "user-1", AppRegistrationID: "registration-test",
|
|
Kind: AuthFlowKindAppInstallation,
|
|
})
|
|
if !errors.Is(err, ErrOAuthStateMismatch) {
|
|
t.Fatalf("Consume() mismatch error = %v, want %v", err, ErrOAuthStateMismatch)
|
|
}
|
|
_, err = manager.Consume(context.Background(), started.State, OAuthFlowExpectation{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test",
|
|
Kind: AuthFlowKindAppInstallation,
|
|
})
|
|
if !errors.Is(err, ErrOAuthStateInvalid) {
|
|
t.Fatalf("Consume() replay error = %v, want %v", err, ErrOAuthStateInvalid)
|
|
}
|
|
}
|
|
|
|
func TestOAuthFlowConsumeRejectsExpiredState(t *testing.T) {
|
|
store := &oauthFlowMemoryStore{}
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
manager := NewOAuthFlowManager(store)
|
|
manager.now = func() time.Time { return now }
|
|
manager.random = strings.NewReader(strings.Repeat("c", oauthRandomBytes))
|
|
started, err := manager.Start(context.Background(), OAuthFlowRequest{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test",
|
|
Kind: AuthFlowKindAppInstallation,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
manager.now = func() time.Time { return now.Add(appInstallationStateTTL + time.Second) }
|
|
_, err = manager.Consume(context.Background(), started.State, OAuthFlowExpectation{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test",
|
|
Kind: AuthFlowKindAppInstallation,
|
|
})
|
|
if !errors.Is(err, ErrOAuthStateInvalid) {
|
|
t.Fatalf("Consume() expired error = %v, want %v", err, ErrOAuthStateInvalid)
|
|
}
|
|
}
|
|
|
|
func TestOAuthFlowGitHubClientExchangesRefreshesAndVerifiesUser(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/login/oauth/access_token":
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Fatalf("ParseForm() error = %v", err)
|
|
}
|
|
if r.Form.Get("client_id") != "client-id" || r.Form.Get("client_secret") != "client-secret" {
|
|
t.Fatalf("OAuth client credentials = %v", r.Form)
|
|
}
|
|
if r.Form.Get("grant_type") == "refresh_token" {
|
|
if r.Form.Get("refresh_token") != "old-refresh" {
|
|
t.Fatalf("refresh form = %v", r.Form)
|
|
}
|
|
} else if r.Form.Get("code") != "code" || r.Form.Get("code_verifier") != "verifier" {
|
|
t.Fatalf("exchange form = %v", r.Form)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"access_token": "access", "refresh_token": "refresh",
|
|
"expires_in": 28800, "refresh_token_expires_in": 15552000,
|
|
})
|
|
case "/user":
|
|
if r.Header.Get("Authorization") != "Bearer access" {
|
|
t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": 11, "login": "octocat"})
|
|
case "/user/installations/42":
|
|
w.WriteHeader(http.StatusOK)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewGitHubOAuthClient("client-id", "client-secret")
|
|
client.webBaseURL = server.URL
|
|
client.apiBaseURL = server.URL
|
|
client.httpClient = server.Client()
|
|
client.now = func() time.Time { return now }
|
|
tokens, err := client.ExchangeUserCode(context.Background(), "code", "verifier", "https://kandev.example/callback")
|
|
if err != nil {
|
|
t.Fatalf("ExchangeUserCode() error = %v", err)
|
|
}
|
|
if tokens.AccessExpiresAt != now.Add(8*time.Hour) || tokens.RefreshExpiresAt == nil {
|
|
t.Fatalf("ExchangeUserCode() = %+v", tokens)
|
|
}
|
|
if _, err := client.RefreshUserToken(context.Background(), "old-refresh"); err != nil {
|
|
t.Fatalf("RefreshUserToken() error = %v", err)
|
|
}
|
|
user, err := client.GetOAuthUser(context.Background(), tokens.AccessToken)
|
|
if err != nil || user.ID != 11 || user.Login != "octocat" {
|
|
t.Fatalf("GetOAuthUser() = %+v, %v", user, err)
|
|
}
|
|
accessible, err := client.UserCanAccessInstallation(context.Background(), tokens.AccessToken, 42)
|
|
if err != nil || !accessible {
|
|
t.Fatalf("UserCanAccessInstallation() = %v, %v", accessible, err)
|
|
}
|
|
accessible, err = client.UserCanAccessInstallation(context.Background(), tokens.AccessToken, 99)
|
|
if err != nil || accessible {
|
|
t.Fatalf("missing UserCanAccessInstallation() = %v, %v", accessible, err)
|
|
}
|
|
}
|