package identity import ( "context" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "os" "testing" "time" ) // mockOIDCProvider starts a minimal OIDC provider that serves // discovery + JWKS + token endpoint, signing self-signed ID tokens. // It returns the issuer URL + a cleanup function. func mockOIDCProvider(t *testing.T, clientID string) (issuer string, privateKey any, cleanup func()) { t.Helper() // We use a very minimal mock: discovery returns a JWKS URL + // token URL pointing to the same test server. The token // endpoint returns a fake ID token. For full verification // we'd need RSA signing, but for the client logic tests we // verify the flow wiring, not the crypto (the verifier is // tested via integration in P26). var srv *httptest.Server srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/.well-known/openid-configuration": w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "issuer": srvURL(srv), "authorization_endpoint": srvURL(srv) + "/auth", "token_endpoint": srvURL(srv) + "/token", "jwks_uri": srvURL(srv) + "/jwks", "device_authorization_endpoint": srvURL(srv) + "/device", "response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"none"}, }) case "/jwks": w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"keys": []any{}}) case "/token": w.Header().Set("Content-Type", "application/json") // Return a minimal unsigned ID token (header.payload.sig // with empty sig). The verifier in production validates // against JWKS; for tests we only check the flow wiring. payload := map[string]any{ "iss": srvURL(srv), "sub": "test-user-123", "aud": clientID, "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "groups": []string{"orca-admins"}, "email": "test@example.com", } payloadBytes, _ := json.Marshal(payload) enc := base64Raw(payloadBytes) idToken := "eyJhbGciOiJub25lIn0." + enc + "." json.NewEncoder(w).Encode(map[string]any{ "access_token": "at-123", "refresh_token": "rt-456", "id_token": idToken, "expires_in": 3600, "token_type": "Bearer", }) case "/device": w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "device_code": "dc-123", "user_code": "ORCA-CODE", "verification_uri": srvURL(srv) + "/device-verify", "interval": 1, "expires_in": 300, }) default: http.NotFound(w, r) } })) return srvURL(srv), nil, srv.Close } func srvURL(srv *httptest.Server) string { return "http://" + srv.Listener.Addr().String() } func base64Raw(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } // TestOIDCClientDiscovery verifies NewOIDCClient discovers the issuer. func TestOIDCClientDiscovery(t *testing.T) { issuer, _, cleanup := mockOIDCProvider(t, "test-client") defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() client, err := NewOIDCClient(ctx, OIDCConfig{ Issuer: issuer, ClientID: "test-client", }) if err != nil { t.Fatalf("NewOIDCClient: %v", err) } if client.Issuer() != issuer { t.Errorf("issuer = %q, want %q", client.Issuer(), issuer) } } // TestCredentialsRoundTrip verifies Save + Load credentials round-trip // at 0600. func TestCredentialsRoundTrip(t *testing.T) { dir := t.TempDir() t.Setenv("ORCA_HOME", dir) creds := &Credentials{ IDToken: "id-123", AccessToken: "at-456", RefreshToken: "rt-789", Expiry: time.Now().Add(time.Hour), Issuer: "https://idp.example", Subject: "user-1", Groups: []string{"admins"}, } if err := SaveCredentials(creds); err != nil { t.Fatalf("Save: %v", err) } loaded, err := LoadCredentials() if err != nil { t.Fatalf("Load: %v", err) } if loaded.Subject != "user-1" { t.Errorf("subject = %q, want user-1", loaded.Subject) } if len(loaded.Groups) != 1 || loaded.Groups[0] != "admins" { t.Errorf("groups = %v, want [admins]", loaded.Groups) } } // TestCredentialsModeEnforced verifies LoadCredentials rejects looser // than 0600. func TestCredentialsModeEnforced(t *testing.T) { dir := t.TempDir() t.Setenv("ORCA_HOME", dir) creds := &Credentials{IDToken: "x", Issuer: "x", Subject: "x"} if err := SaveCredentials(creds); err != nil { t.Fatalf("Save: %v", err) } // Loosen to 0644. path := dir + "/credentials.json" if err := os.Chmod(path, 0o644); err != nil { t.Fatalf("chmod: %v", err) } _, err := LoadCredentials() if err == nil { t.Error("LoadCredentials should reject 0644") } } // TestClearCredentials verifies logout removes the file. func TestClearCredentials(t *testing.T) { dir := t.TempDir() t.Setenv("ORCA_HOME", dir) creds := &Credentials{IDToken: "x", Issuer: "x", Subject: "x"} _ = SaveCredentials(creds) if err := ClearCredentials(); err != nil { t.Fatalf("Clear: %v", err) } if _, err := LoadCredentials(); err == nil { t.Error("Load after clear should fail") } } // TestDefaultScopes verifies the scopes include openid. func TestDefaultScopes(t *testing.T) { scopes := DefaultScopes() found := false for _, s := range scopes { if s == "openid" { found = true } } if !found { t.Error("DefaultScopes missing openid") } }